ramses-rf 0.51.9__py3-none-any.whl → 0.52.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ramses_cli/client.py +21 -9
- ramses_cli/discovery.py +1 -1
- ramses_rf/database.py +113 -58
- ramses_rf/device/base.py +10 -5
- ramses_rf/device/heat.py +15 -7
- ramses_rf/device/hvac.py +31 -7
- ramses_rf/dispatcher.py +4 -4
- ramses_rf/entity_base.py +483 -123
- ramses_rf/gateway.py +17 -6
- ramses_rf/version.py +1 -1
- {ramses_rf-0.51.9.dist-info → ramses_rf-0.52.1.dist-info}/METADATA +1 -1
- {ramses_rf-0.51.9.dist-info → ramses_rf-0.52.1.dist-info}/RECORD +21 -21
- ramses_tx/gateway.py +2 -0
- ramses_tx/message.py +3 -1
- ramses_tx/parsers.py +2 -0
- ramses_tx/ramses.py +11 -3
- ramses_tx/schemas.py +8 -2
- ramses_tx/version.py +1 -1
- {ramses_rf-0.51.9.dist-info → ramses_rf-0.52.1.dist-info}/WHEEL +0 -0
- {ramses_rf-0.51.9.dist-info → ramses_rf-0.52.1.dist-info}/entry_points.txt +0 -0
- {ramses_rf-0.51.9.dist-info → ramses_rf-0.52.1.dist-info}/licenses/LICENSE +0 -0
ramses_cli/client.py
CHANGED
|
@@ -438,17 +438,29 @@ def print_summary(gwy: Gateway, **kwargs: Any) -> None:
|
|
|
438
438
|
|
|
439
439
|
if kwargs.get("show_crazys"):
|
|
440
440
|
for device in [d for d in gwy.devices if d.type == DEV_TYPE_MAP.CTL]:
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
441
|
+
if gwy.msg_db:
|
|
442
|
+
for msg in gwy.msg_db.get(device=device.id, code=Code._0005):
|
|
443
|
+
print(f"{msg._pkt}")
|
|
444
|
+
for msg in gwy.msg_db.get(device=device.id, code=Code._000C):
|
|
445
|
+
print(f"{msg._pkt}")
|
|
446
|
+
else: # TODO(eb): replace next block by
|
|
447
|
+
# raise NotImplementedError
|
|
448
|
+
for code, verbs in device._msgz.items():
|
|
449
|
+
if code in (Code._0005, Code._000C):
|
|
450
|
+
for verb in verbs.values():
|
|
451
|
+
for pkt in verb.values():
|
|
452
|
+
print(f"{pkt}")
|
|
446
453
|
print()
|
|
447
454
|
for device in [d for d in gwy.devices if d.type == DEV_TYPE_MAP.UFC]:
|
|
448
|
-
|
|
449
|
-
for
|
|
450
|
-
|
|
451
|
-
|
|
455
|
+
if gwy.msg_db:
|
|
456
|
+
for msg in gwy.msg_db.get(device=device.id):
|
|
457
|
+
print(f"{msg._pkt}")
|
|
458
|
+
else: # TODO(eb): replace next block by
|
|
459
|
+
# raise NotImplementedError
|
|
460
|
+
for code in device._msgz.values():
|
|
461
|
+
for verb in code.values():
|
|
462
|
+
for pkt in verb.values():
|
|
463
|
+
print(f"{pkt}")
|
|
452
464
|
print()
|
|
453
465
|
|
|
454
466
|
|
ramses_cli/discovery.py
CHANGED
|
@@ -394,7 +394,7 @@ async def script_scan_otb_ramses(
|
|
|
394
394
|
Code._3223,
|
|
395
395
|
Code._3EF0, # rel. modulation level / RelativeModulationLevel (also, below)
|
|
396
396
|
Code._3EF1, # rel. modulation level / RelativeModulationLevel
|
|
397
|
-
) # excl. 3220
|
|
397
|
+
) # excl. 3150, 3220
|
|
398
398
|
|
|
399
399
|
for c in _CODES:
|
|
400
400
|
gwy.send_cmd(Command.from_attrs(RQ, dev_id, c, "00"), priority=Priority.LOW)
|
ramses_rf/database.py
CHANGED
|
@@ -8,24 +8,13 @@ import logging
|
|
|
8
8
|
import sqlite3
|
|
9
9
|
from collections import OrderedDict
|
|
10
10
|
from datetime import datetime as dt, timedelta as td
|
|
11
|
-
from typing import Any, NewType
|
|
11
|
+
from typing import TYPE_CHECKING, Any, NewType
|
|
12
12
|
|
|
13
|
-
from ramses_tx import Message
|
|
14
|
-
|
|
15
|
-
DtmStrT = NewType("DtmStrT", str)
|
|
16
|
-
MsgDdT = OrderedDict[DtmStrT, Message]
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
class Params(TypedDict):
|
|
20
|
-
dtm: dt | str | None
|
|
21
|
-
verb: str | None
|
|
22
|
-
src: str | None
|
|
23
|
-
dst: str | None
|
|
24
|
-
code: str | None
|
|
25
|
-
ctx: str | None
|
|
26
|
-
hdr: str | None
|
|
27
|
-
plk: str | None
|
|
13
|
+
from ramses_tx import CODES_SCHEMA, Code, Message
|
|
28
14
|
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
DtmStrT = NewType("DtmStrT", str)
|
|
17
|
+
MsgDdT = OrderedDict[DtmStrT, Message]
|
|
29
18
|
|
|
30
19
|
_LOGGER = logging.getLogger(__name__)
|
|
31
20
|
|
|
@@ -34,7 +23,7 @@ def _setup_db_adapters() -> None:
|
|
|
34
23
|
"""Set up the database adapters and converters."""
|
|
35
24
|
|
|
36
25
|
def adapt_datetime_iso(val: dt) -> str:
|
|
37
|
-
"""Adapt datetime.datetime to timezone-naive ISO 8601 datetime to match
|
|
26
|
+
"""Adapt datetime.datetime to timezone-naive ISO 8601 datetime to match _msgs dtm keys."""
|
|
38
27
|
return val.isoformat(timespec="microseconds")
|
|
39
28
|
|
|
40
29
|
sqlite3.register_adapter(dt, adapt_datetime_iso)
|
|
@@ -77,13 +66,13 @@ class MessageIndex:
|
|
|
77
66
|
Index holds the latest message to & from all devices by header
|
|
78
67
|
(example of a hdr: 000C|RP|01:223036|0208)."""
|
|
79
68
|
|
|
69
|
+
_housekeeping_task: asyncio.Task[None]
|
|
70
|
+
|
|
80
71
|
def __init__(self, maintain: bool = True) -> None:
|
|
81
72
|
"""Instantiate a message database/index."""
|
|
82
73
|
|
|
83
74
|
self.maintain = maintain
|
|
84
|
-
self._msgs: MsgDdT = (
|
|
85
|
-
OrderedDict()
|
|
86
|
-
) # stores all messages for retrieval. Filled in housekeeping loop.
|
|
75
|
+
self._msgs: MsgDdT = OrderedDict() # stores all messages for retrieval. Filled & cleaned up in housekeeping_loop.
|
|
87
76
|
|
|
88
77
|
# Connect to a SQLite DB in memory
|
|
89
78
|
self._cx = sqlite3.connect(
|
|
@@ -98,7 +87,7 @@ class MessageIndex:
|
|
|
98
87
|
if self.maintain:
|
|
99
88
|
self._lock = asyncio.Lock()
|
|
100
89
|
self._last_housekeeping: dt = None # type: ignore[assignment]
|
|
101
|
-
self._housekeeping_task
|
|
90
|
+
self._housekeeping_task = None # type: ignore[assignment]
|
|
102
91
|
|
|
103
92
|
self.start()
|
|
104
93
|
|
|
@@ -109,7 +98,7 @@ class MessageIndex:
|
|
|
109
98
|
"""Start the housekeeper loop."""
|
|
110
99
|
|
|
111
100
|
if self.maintain:
|
|
112
|
-
if self._housekeeping_task and not self._housekeeping_task.done():
|
|
101
|
+
if self._housekeeping_task and (not self._housekeeping_task.done()):
|
|
113
102
|
return
|
|
114
103
|
|
|
115
104
|
self._housekeeping_task = asyncio.create_task(
|
|
@@ -119,7 +108,7 @@ class MessageIndex:
|
|
|
119
108
|
def stop(self) -> None:
|
|
120
109
|
"""Stop the housekeeper loop."""
|
|
121
110
|
|
|
122
|
-
if self._housekeeping_task and not self._housekeeping_task.done():
|
|
111
|
+
if self._housekeeping_task and (not self._housekeeping_task.done()):
|
|
123
112
|
self._housekeeping_task.cancel() # stop the housekeeper
|
|
124
113
|
|
|
125
114
|
self._cx.commit() # just in case
|
|
@@ -150,8 +139,8 @@ class MessageIndex:
|
|
|
150
139
|
CREATE TABLE messages (
|
|
151
140
|
dtm DTM NOT NULL PRIMARY KEY,
|
|
152
141
|
verb TEXT(2) NOT NULL,
|
|
153
|
-
src TEXT(
|
|
154
|
-
dst TEXT(
|
|
142
|
+
src TEXT(12) NOT NULL,
|
|
143
|
+
dst TEXT(12) NOT NULL,
|
|
155
144
|
code TEXT(4) NOT NULL,
|
|
156
145
|
ctx TEXT,
|
|
157
146
|
hdr TEXT NOT NULL UNIQUE,
|
|
@@ -175,14 +164,14 @@ class MessageIndex:
|
|
|
175
164
|
|
|
176
165
|
async def housekeeping(dt_now: dt, _cutoff: td = td(days=1)) -> None:
|
|
177
166
|
"""
|
|
178
|
-
|
|
167
|
+
Deletes all messages older than a given delta from the dict using the MessageIndex.
|
|
179
168
|
:param dt_now: current timestamp
|
|
180
169
|
:param _cutoff: the oldest timestamp to retain, default is 24 hours ago
|
|
181
170
|
"""
|
|
182
171
|
dtm = dt_now - _cutoff # .isoformat(timespec="microseconds") < needed?
|
|
183
172
|
|
|
184
173
|
self._cu.execute("SELECT dtm FROM messages WHERE dtm => ?", (dtm,))
|
|
185
|
-
rows = self._cu.fetchall()
|
|
174
|
+
rows = self._cu.fetchall() # fetch dtm of current messages to retain
|
|
186
175
|
|
|
187
176
|
try: # make this operation atomic, i.e. update self._msgs only on success
|
|
188
177
|
await self._lock.acquire()
|
|
@@ -234,15 +223,17 @@ class MessageIndex:
|
|
|
234
223
|
finally:
|
|
235
224
|
pass # self._lock.release()
|
|
236
225
|
|
|
237
|
-
if
|
|
238
|
-
|
|
226
|
+
if (
|
|
227
|
+
dup and msg.src is not msg.dst and not msg.dst.id.startswith("18:") # HGI
|
|
228
|
+
): # when src==dst, expect to add duplicate, don't warn
|
|
229
|
+
_LOGGER.debug(
|
|
239
230
|
"Overwrote dtm (%s) for %s: %s (contrived log?)",
|
|
240
231
|
msg.dtm,
|
|
241
232
|
msg._pkt._hdr,
|
|
242
233
|
dup[0]._pkt,
|
|
243
234
|
)
|
|
244
235
|
if old is not None:
|
|
245
|
-
_LOGGER.
|
|
236
|
+
_LOGGER.debug("Old msg replaced: %s", old)
|
|
246
237
|
|
|
247
238
|
return old
|
|
248
239
|
|
|
@@ -251,7 +242,7 @@ class MessageIndex:
|
|
|
251
242
|
Add a single record to the MessageIndex with timestamp now() and no Message contents.
|
|
252
243
|
"""
|
|
253
244
|
# Used by OtbGateway init, via entity_base.py
|
|
254
|
-
dtm: DtmStrT =
|
|
245
|
+
dtm: DtmStrT = dt.strftime(dt.now(), "%Y-%m-%dT%H:%M:%S") # type: ignore[assignment]
|
|
255
246
|
hdr = f"{code}|{verb}|{src}|00" # dummy record has no contents
|
|
256
247
|
|
|
257
248
|
dup = self._delete_from(hdr=hdr)
|
|
@@ -314,7 +305,7 @@ class MessageIndex:
|
|
|
314
305
|
payload_keys(msg.payload),
|
|
315
306
|
),
|
|
316
307
|
)
|
|
317
|
-
_LOGGER.
|
|
308
|
+
_LOGGER.debug(f"Added {msg} to gwy.msg_db")
|
|
318
309
|
|
|
319
310
|
return _old_msgs[0] if _old_msgs else None
|
|
320
311
|
|
|
@@ -368,10 +359,36 @@ class MessageIndex:
|
|
|
368
359
|
|
|
369
360
|
return msgs
|
|
370
361
|
|
|
362
|
+
# MessageIndex msg_db query methods > copy to docs/source/ramses_rf.rst
|
|
363
|
+
# (ex = entity_base.py query methods
|
|
364
|
+
#
|
|
365
|
+
# +----+--------------+-------------+------------+------+--------------------------+
|
|
366
|
+
# | ix |method name | args | returns | uses | used by |
|
|
367
|
+
# +====+==============+=============+============+======+==========================+
|
|
368
|
+
# | i1 | get | Msg/kwargs | tuple[Msg] | i3 | |
|
|
369
|
+
# +----+--------------+-------------+------------+------+--------------------------+
|
|
370
|
+
# | i2 | contains | kwargs | bool | i4 | |
|
|
371
|
+
# +----+--------------+-------------+------------+------+--------------------------+
|
|
372
|
+
# | i3 | _select_from | kwargs | tuple[Msg] | i4 | |
|
|
373
|
+
# +----+--------------+-------------+------------+------+--------------------------+
|
|
374
|
+
# | i4 | qry_dtms | kwargs | list(dtm) | | |
|
|
375
|
+
# +----+--------------+-------------+------------+------+--------------------------+
|
|
376
|
+
# | i5 | qry | sql, kwargs | tuple[Msg] | | _msgs() |
|
|
377
|
+
# +----+--------------+-------------+------------+------+--------------------------+
|
|
378
|
+
# | i6 | qry_field | sql, kwargs | tuple[fld] | | e4, e5 |
|
|
379
|
+
# +----+--------------+-------------+------------+------+--------------------------+
|
|
380
|
+
# | i7 | get_rp_codes | src, dst | list[Code] | | Discovery#supported_cmds |
|
|
381
|
+
# +----+--------------+-------------+------------+------+--------------------------+
|
|
382
|
+
|
|
371
383
|
def get(
|
|
372
384
|
self, msg: Message | None = None, **kwargs: bool | dt | str
|
|
373
385
|
) -> tuple[Message, ...]:
|
|
374
|
-
"""
|
|
386
|
+
"""
|
|
387
|
+
Public method to get a set of message(s) from the index.
|
|
388
|
+
:param msg: Message to return, by dtm (expect a single result as dtm is unique key)
|
|
389
|
+
:param kwargs: data table field names and criteria, e.g. (hdr=...)
|
|
390
|
+
:return: tuple of matching Messages
|
|
391
|
+
"""
|
|
375
392
|
|
|
376
393
|
if not (bool(msg) ^ bool(kwargs)):
|
|
377
394
|
raise ValueError("Either a Message or kwargs should be provided, not both")
|
|
@@ -381,7 +398,33 @@ class MessageIndex:
|
|
|
381
398
|
|
|
382
399
|
return self._select_from(**kwargs)
|
|
383
400
|
|
|
401
|
+
def contains(self, **kwargs: bool | dt | str) -> bool:
|
|
402
|
+
"""
|
|
403
|
+
Check if the MessageIndex contains at least 1 record that matches the provided fields.
|
|
404
|
+
:param kwargs: (exact) SQLite table field_name: required_value pairs
|
|
405
|
+
:return: True if at least one message fitting the given conditions is present, False when qry returned empty
|
|
406
|
+
"""
|
|
407
|
+
|
|
408
|
+
return len(self.qry_dtms(**kwargs)) > 0
|
|
409
|
+
|
|
410
|
+
def _select_from(self, **kwargs: bool | dt | str) -> tuple[Message, ...]:
|
|
411
|
+
"""
|
|
412
|
+
Select message(s) using the MessageIndex.
|
|
413
|
+
:param kwargs: (exact) SQLite table field_name: required_value pairs
|
|
414
|
+
:returns: a tuple of qualifying messages
|
|
415
|
+
"""
|
|
416
|
+
|
|
417
|
+
return tuple(
|
|
418
|
+
self._msgs[row[0].isoformat(timespec="microseconds")]
|
|
419
|
+
for row in self.qry_dtms(**kwargs)
|
|
420
|
+
)
|
|
421
|
+
|
|
384
422
|
def qry_dtms(self, **kwargs: bool | dt | str) -> list[Any]:
|
|
423
|
+
"""
|
|
424
|
+
Select from the ImageIndex a list of dtms that match the provided arguments.
|
|
425
|
+
:param kwargs: data table field names and criteria
|
|
426
|
+
:return: list of unformatted dtms that match, useful for msg lookup, or an empty list if 0 matches
|
|
427
|
+
"""
|
|
385
428
|
# tweak kwargs as stored in SQLite, inverse from _insert_into():
|
|
386
429
|
kw = {key: value for key, value in kwargs.items() if key != "ctx"}
|
|
387
430
|
if "ctx" in kwargs:
|
|
@@ -398,28 +441,13 @@ class MessageIndex:
|
|
|
398
441
|
self._cu.execute(sql, tuple(kw.values()))
|
|
399
442
|
return self._cu.fetchall()
|
|
400
443
|
|
|
401
|
-
def
|
|
444
|
+
def qry(self, sql: str, parameters: tuple[str, ...]) -> tuple[Message, ...]:
|
|
402
445
|
"""
|
|
403
|
-
|
|
404
|
-
:param
|
|
405
|
-
:
|
|
446
|
+
Get a tuple of messages from _msgs using the index, given sql and parameters.
|
|
447
|
+
:param sql: a bespoke SQL query SELECT string that should return dtm as first field
|
|
448
|
+
:param parameters: tuple of kwargs with the selection filter
|
|
449
|
+
:return: a tuple of qualifying messages
|
|
406
450
|
"""
|
|
407
|
-
# adapted from _select_from()
|
|
408
|
-
|
|
409
|
-
return len(self.qry_dtms(**kwargs)) > 0
|
|
410
|
-
|
|
411
|
-
def _select_from(self, **kwargs: bool | dt | str) -> tuple[Message, ...]:
|
|
412
|
-
"""Select message(s) using the MessageIndex.
|
|
413
|
-
:param kwargs: (exact) SQLite table field_name: required_value pairs
|
|
414
|
-
:returns: a tuple of qualifying messages"""
|
|
415
|
-
|
|
416
|
-
return tuple(
|
|
417
|
-
self._msgs[row[0].isoformat(timespec="microseconds")]
|
|
418
|
-
for row in self.qry_dtms(**kwargs)
|
|
419
|
-
)
|
|
420
|
-
|
|
421
|
-
def qry(self, sql: str, parameters: tuple[str, ...]) -> tuple[Message, ...]:
|
|
422
|
-
"""Get a tuple of messages from the index, given sql and parameters."""
|
|
423
451
|
|
|
424
452
|
if "SELECT" not in sql:
|
|
425
453
|
raise ValueError(f"{self}: Only SELECT queries are allowed")
|
|
@@ -429,7 +457,9 @@ class MessageIndex:
|
|
|
429
457
|
lst: list[Message] = []
|
|
430
458
|
# stamp = list(self._msgs)[0] if len(self._msgs) > 0 else "N/A" # for debug
|
|
431
459
|
for row in self._cu.fetchall():
|
|
432
|
-
ts: DtmStrT = row[0].isoformat(
|
|
460
|
+
ts: DtmStrT = row[0].isoformat(
|
|
461
|
+
timespec="microseconds"
|
|
462
|
+
) # must reformat from DTM
|
|
433
463
|
# _LOGGER.debug(
|
|
434
464
|
# f"QRY Msg key raw: {row[0]} Reformatted: {ts} _msgs stamp format: {stamp}"
|
|
435
465
|
# )
|
|
@@ -438,21 +468,46 @@ class MessageIndex:
|
|
|
438
468
|
if ts in self._msgs:
|
|
439
469
|
lst.append(self._msgs[ts])
|
|
440
470
|
else: # happens in tests with artificial msg from heat
|
|
441
|
-
_LOGGER.
|
|
471
|
+
_LOGGER.info("MessageIndex timestamp %s not in device messages", ts)
|
|
442
472
|
return tuple(lst)
|
|
443
473
|
|
|
474
|
+
def get_rp_codes(self, parameters: tuple[str, ...]) -> list[Code]:
|
|
475
|
+
"""
|
|
476
|
+
Get a list of Codes from the index, given parameters.
|
|
477
|
+
:param parameters: tuple of additional kwargs
|
|
478
|
+
:return: list of Code: value pairs
|
|
479
|
+
"""
|
|
480
|
+
|
|
481
|
+
def get_code(code: str) -> Code:
|
|
482
|
+
for Cd in CODES_SCHEMA:
|
|
483
|
+
if code == Cd:
|
|
484
|
+
return Cd
|
|
485
|
+
raise LookupError(f"Failed to find matching code for {code}")
|
|
486
|
+
|
|
487
|
+
sql = """
|
|
488
|
+
SELECT code from messages WHERE verb is 'RP' AND (src = ? OR dst = ?)
|
|
489
|
+
"""
|
|
490
|
+
if "SELECT" not in sql:
|
|
491
|
+
raise ValueError(f"{self}: Only SELECT queries are allowed")
|
|
492
|
+
|
|
493
|
+
self._cu.execute(sql, parameters)
|
|
494
|
+
res = self._cu.fetchall()
|
|
495
|
+
return [get_code(res[0]) for res[0] in self._cu.fetchall()]
|
|
496
|
+
|
|
444
497
|
def qry_field(
|
|
445
498
|
self, sql: str, parameters: tuple[str, ...]
|
|
446
499
|
) -> list[tuple[dt | str, str]]:
|
|
447
500
|
"""
|
|
448
|
-
Get a list of
|
|
501
|
+
Get a list of fields from the index, given select sql and parameters.
|
|
502
|
+
:param sql: a bespoke SQL query SELECT string
|
|
503
|
+
:param parameters: tuple of additional kwargs
|
|
504
|
+
:return: list of key: value pairs as defined in sql
|
|
449
505
|
"""
|
|
450
506
|
|
|
451
507
|
if "SELECT" not in sql:
|
|
452
508
|
raise ValueError(f"{self}: Only SELECT queries are allowed")
|
|
453
509
|
|
|
454
510
|
self._cu.execute(sql, parameters)
|
|
455
|
-
|
|
456
511
|
return self._cu.fetchall()
|
|
457
512
|
|
|
458
513
|
def all(self, include_expired: bool = False) -> tuple[Message, ...]:
|
|
@@ -474,7 +529,7 @@ class MessageIndex:
|
|
|
474
529
|
# if include_expired or not self._msgs[ts].HAS_EXPIRED: # not working
|
|
475
530
|
lst.append(self._msgs[ts])
|
|
476
531
|
else: # happens in tests with dummy msg from heat init
|
|
477
|
-
_LOGGER.
|
|
532
|
+
_LOGGER.info("MessageIndex ts %s not in device messages", ts)
|
|
478
533
|
return tuple(lst)
|
|
479
534
|
|
|
480
535
|
def clr(self) -> None:
|
ramses_rf/device/base.py
CHANGED
|
@@ -60,7 +60,7 @@ class DeviceBase(Entity):
|
|
|
60
60
|
def __init__(self, gwy: Gateway, dev_addr: Address, **kwargs: Any) -> None:
|
|
61
61
|
super().__init__(gwy)
|
|
62
62
|
|
|
63
|
-
# FIXME:
|
|
63
|
+
# FIXME: gwy.msg_db entities must know their parent device ID and their own idx
|
|
64
64
|
self._z_id = dev_addr.id # the responsible device is itself
|
|
65
65
|
self._z_idx = None # depends upon its location in the schema
|
|
66
66
|
|
|
@@ -76,8 +76,9 @@ class DeviceBase(Entity):
|
|
|
76
76
|
self._scheme: Vendor = None
|
|
77
77
|
|
|
78
78
|
def __str__(self) -> str:
|
|
79
|
-
if self._STATE_ATTR:
|
|
80
|
-
|
|
79
|
+
if self._STATE_ATTR and hasattr(self, self._STATE_ATTR):
|
|
80
|
+
state: float | None = getattr(self, self._STATE_ATTR)
|
|
81
|
+
return f"{self.id} ({self._SLUG}): {state}"
|
|
81
82
|
return f"{self.id} ({self._SLUG})"
|
|
82
83
|
|
|
83
84
|
def __lt__(self, other: object) -> bool:
|
|
@@ -165,7 +166,11 @@ class DeviceBase(Entity):
|
|
|
165
166
|
@property
|
|
166
167
|
def has_battery(self) -> None | bool: # 1060
|
|
167
168
|
"""Return True if the device is battery powered (excludes battery-backup)."""
|
|
168
|
-
|
|
169
|
+
if self._gwy.msg_db:
|
|
170
|
+
code_list = self._msg_dev_qry()
|
|
171
|
+
return isinstance(self, BatteryState) or (
|
|
172
|
+
code_list is not None and Code._1060 in code_list
|
|
173
|
+
) # TODO(eb): clean up next line Q1 2026
|
|
169
174
|
return isinstance(self, BatteryState) or Code._1060 in self._msgz
|
|
170
175
|
|
|
171
176
|
@property
|
|
@@ -204,7 +209,7 @@ class DeviceBase(Entity):
|
|
|
204
209
|
|
|
205
210
|
@property
|
|
206
211
|
def traits(self) -> dict[str, Any]:
|
|
207
|
-
"""
|
|
212
|
+
"""Get the traits of the device."""
|
|
208
213
|
|
|
209
214
|
result = super().traits
|
|
210
215
|
|
ramses_rf/device/heat.py
CHANGED
|
@@ -144,7 +144,7 @@ class Actuator(DeviceHeat): # 3EF0, 3EF1 (for 10:/13:)
|
|
|
144
144
|
if self._gwy.config.disable_discovery:
|
|
145
145
|
return
|
|
146
146
|
|
|
147
|
-
# TODO: why are we doing this here? Should simply use
|
|
147
|
+
# TODO: why are we doing this here? Should simply use discovery poller!
|
|
148
148
|
if msg.code == Code._3EF0 and msg.verb == I_ and not self.is_faked:
|
|
149
149
|
# lf._send_cmd(Command.get_relay_demand(self.id), qos=QOS_LOW)
|
|
150
150
|
self._send_cmd(
|
|
@@ -429,7 +429,7 @@ class UfhController(Parent, DeviceHeat): # UFC (02):
|
|
|
429
429
|
def _handle_msg(self, msg: Message) -> None:
|
|
430
430
|
super()._handle_msg(msg)
|
|
431
431
|
|
|
432
|
-
# Several assumptions
|
|
432
|
+
# Several assumptions are made, regarding 000C pkts:
|
|
433
433
|
# - UFC bound only to CTL (not, e.g. SEN)
|
|
434
434
|
# - all circuits bound to the same controller
|
|
435
435
|
|
|
@@ -450,7 +450,7 @@ class UfhController(Parent, DeviceHeat): # UFC (02):
|
|
|
450
450
|
# )
|
|
451
451
|
# self._send_cmd(cmd)
|
|
452
452
|
|
|
453
|
-
elif msg.code == Code._0008: # relay_demand, TODO: use
|
|
453
|
+
elif msg.code == Code._0008: # relay_demand, TODO: use msgIndex DB?
|
|
454
454
|
if msg.payload.get(SZ_DOMAIN_ID) == FC:
|
|
455
455
|
self._relay_demand = msg
|
|
456
456
|
else: # FA
|
|
@@ -491,7 +491,6 @@ class UfhController(Parent, DeviceHeat): # UFC (02):
|
|
|
491
491
|
|
|
492
492
|
# elif msg.code not in (Code._10E0, Code._22D0):
|
|
493
493
|
# print("xxx")
|
|
494
|
-
|
|
495
494
|
# "0008|FA/FC", "22C9|array", "22D0|none", "3150|ZZ/array(/FC?)"
|
|
496
495
|
|
|
497
496
|
# TODO: should be a private method
|
|
@@ -635,7 +634,7 @@ def _to_msg_id(data_id: OtDataId) -> MsgId:
|
|
|
635
634
|
return f"{data_id:02X}"
|
|
636
635
|
|
|
637
636
|
|
|
638
|
-
# NOTE: config.use_native_ot should
|
|
637
|
+
# NOTE: config.use_native_ot should enforce sends, but not reads from _msgz DB
|
|
639
638
|
class OtbGateway(Actuator, HeatDemand): # OTB (10): 3220 (22D9, others)
|
|
640
639
|
"""The OTB class, specifically an OpenTherm Bridge (R8810A Bridge)."""
|
|
641
640
|
|
|
@@ -668,7 +667,16 @@ class OtbGateway(Actuator, HeatDemand): # OTB (10): 3220 (22D9, others)
|
|
|
668
667
|
|
|
669
668
|
self._child_id = FC # NOTE: domain_id
|
|
670
669
|
|
|
671
|
-
|
|
670
|
+
# TODO(eb): cleanup
|
|
671
|
+
# should fix src/ramses_rf/database.py _add_record try/except when activating next line
|
|
672
|
+
if self._gwy.msg_db:
|
|
673
|
+
self._add_record(
|
|
674
|
+
address=self.addr, code=Code._3220, verb="RP"
|
|
675
|
+
) # << essential?
|
|
676
|
+
# adds a "sim" RP opentherm_msg to the SQLite MessageIndex with code _3220
|
|
677
|
+
# causes exc when fetching ALL, when no "real" msg was added to _msgs_. We skip those.
|
|
678
|
+
else:
|
|
679
|
+
self._msgz[Code._3220] = {RP: {}} # No ctx! (not None)
|
|
672
680
|
|
|
673
681
|
# lf._use_ot = self._gwy.config.use_native_ot
|
|
674
682
|
self._msgs_ot: dict[MsgId, Message] = {}
|
|
@@ -758,7 +766,7 @@ class OtbGateway(Actuator, HeatDemand): # OTB (10): 3220 (22D9, others)
|
|
|
758
766
|
if msg.payload.get(SZ_VALUE) is None:
|
|
759
767
|
return
|
|
760
768
|
|
|
761
|
-
# msg_id is int in msg payload/opentherm.py, but MsgId (str)
|
|
769
|
+
# msg_id is int in msg payload/opentherm.py, but MsgId (str) in this module
|
|
762
770
|
msg_id = _to_msg_id(msg.payload[SZ_MSG_ID])
|
|
763
771
|
self._msgs_ot[msg_id] = msg
|
|
764
772
|
|
ramses_rf/device/hvac.py
CHANGED
|
@@ -426,7 +426,7 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A], 2411
|
|
|
426
426
|
Also handles 2411 parameter messages for configuration.
|
|
427
427
|
Since 2411 is not supported by all vendors, discovery is used to determine if it is supported.
|
|
428
428
|
Since more than 1 different parameters can be sent on 2411 messages,
|
|
429
|
-
we
|
|
429
|
+
we process these in the dedicated _handle_2411_message method.
|
|
430
430
|
"""
|
|
431
431
|
|
|
432
432
|
# Itho Daalderop (NL)
|
|
@@ -606,7 +606,7 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A], 2411
|
|
|
606
606
|
It handles parameter value normalization and validation.
|
|
607
607
|
|
|
608
608
|
:param msg: The incoming 2411 message
|
|
609
|
-
:type msg: Message
|
|
609
|
+
:type msg: Message to process
|
|
610
610
|
"""
|
|
611
611
|
if not hasattr(msg, "payload") or not isinstance(msg.payload, dict):
|
|
612
612
|
_LOGGER.debug("Invalid 2411 message format: %s", msg)
|
|
@@ -653,7 +653,7 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A], 2411
|
|
|
653
653
|
handling for 2411 parameter messages. It updates the device state and
|
|
654
654
|
triggers any necessary callbacks.
|
|
655
655
|
|
|
656
|
-
After handling the messages, it calls the initialized callback if set to notify that
|
|
656
|
+
After handling the messages, it calls the initialized callback - if set - to notify that
|
|
657
657
|
the device was fully initialized.
|
|
658
658
|
|
|
659
659
|
:param msg: The incoming message to process
|
|
@@ -911,14 +911,38 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A], 2411
|
|
|
911
911
|
@property
|
|
912
912
|
def fan_info(self) -> str | None:
|
|
913
913
|
"""
|
|
914
|
-
Extract fan info description from _31D9 or _31DA
|
|
914
|
+
Extract fan info description from MessageIndex _31D9 or _31DA payload,
|
|
915
|
+
e.g. "speed 2, medium".
|
|
915
916
|
By its name, the result is picked up by a sensor in HA Climate UI.
|
|
916
917
|
Some manufacturers (Orcon, Vasco) include the fan mode (auto, manual), others don't (Itho).
|
|
917
918
|
|
|
918
|
-
:return:
|
|
919
|
-
"""
|
|
919
|
+
:return: string describing fan mode, speed
|
|
920
|
+
"""
|
|
921
|
+
if self._gwy.msg_db:
|
|
922
|
+
# Use SQLite query on MessageIndex. res_rate/res_mode not exposed yet
|
|
923
|
+
sql = f"""
|
|
924
|
+
SELECT code from messages WHERE verb in (' I', 'RP')
|
|
925
|
+
AND (src = ? OR dst = ?)
|
|
926
|
+
AND (plk LIKE '%{SZ_FAN_MODE}%')
|
|
927
|
+
"""
|
|
928
|
+
res_mode: list = self._msg_qry(sql)
|
|
929
|
+
# SQLite query on MessageIndex
|
|
930
|
+
_LOGGER.debug(f"{res_mode} # FAN_MODE FETCHED from MessageIndex")
|
|
931
|
+
|
|
932
|
+
sql = f"""
|
|
933
|
+
SELECT code from messages WHERE verb in (' I', 'RP')
|
|
934
|
+
AND (src = ? OR dst = ?)
|
|
935
|
+
AND (plk LIKE '%{SZ_FAN_RATE}%')
|
|
936
|
+
"""
|
|
937
|
+
res_rate: list = self._msg_qry(sql)
|
|
938
|
+
# SQLite query on MessageIndex
|
|
939
|
+
_LOGGER.debug(
|
|
940
|
+
f"{res_rate} # FAN_RATE FETCHED from MessageIndex"
|
|
941
|
+
) # DEBUG always empty?
|
|
942
|
+
|
|
920
943
|
if Code._31D9 in self._msgs:
|
|
921
|
-
#
|
|
944
|
+
# was a dict by Code
|
|
945
|
+
# Itho, Vasco D60 and ClimaRad MiniBox fan send mode/speed in _31D9
|
|
922
946
|
v: str
|
|
923
947
|
for k, v in self._msgs[Code._31D9].payload.items():
|
|
924
948
|
if k == SZ_FAN_MODE and len(v) > 2: # prevent non-lookups to pass
|
ramses_rf/dispatcher.py
CHANGED
|
@@ -268,10 +268,10 @@ def process_msg(gwy: Gateway, msg: Message) -> None:
|
|
|
268
268
|
|
|
269
269
|
else:
|
|
270
270
|
logger_xxxx(msg)
|
|
271
|
-
if gwy.msg_db:
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
271
|
+
# if gwy.msg_db:
|
|
272
|
+
# gwy.msg_db.add(
|
|
273
|
+
# msg
|
|
274
|
+
# ) # why add it? passes all tests without
|
|
275
275
|
|
|
276
276
|
|
|
277
277
|
# TODO: this needs cleaning up (e.g. handle intervening packet)
|