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_rf/entity_base.py
CHANGED
|
@@ -15,7 +15,7 @@ from types import ModuleType
|
|
|
15
15
|
from typing import TYPE_CHECKING, Any, Final
|
|
16
16
|
|
|
17
17
|
from ramses_rf.helpers import schedule_task
|
|
18
|
-
from ramses_tx import Priority, QosParams
|
|
18
|
+
from ramses_tx import Address, Priority, QosParams
|
|
19
19
|
from ramses_tx.address import ALL_DEVICE_ID
|
|
20
20
|
from ramses_tx.const import MsgId
|
|
21
21
|
from ramses_tx.opentherm import OPENTHERM_MESSAGES
|
|
@@ -67,7 +67,8 @@ if TYPE_CHECKING:
|
|
|
67
67
|
|
|
68
68
|
|
|
69
69
|
_QOS_TX_LIMIT = 12 # TODO: needs work
|
|
70
|
-
|
|
70
|
+
_ID_SLICE = 9 # base address only, legacy _msgs 9
|
|
71
|
+
_SQL_SLICE = 12 # msg_db dst field query 12
|
|
71
72
|
_SZ_LAST_PKT: Final = "last_msg"
|
|
72
73
|
_SZ_NEXT_DUE: Final = "next_due"
|
|
73
74
|
_SZ_TIMEOUT: Final = "timeout"
|
|
@@ -95,7 +96,7 @@ class _Entity:
|
|
|
95
96
|
"""The ultimate base class for Devices/Zones/Systems.
|
|
96
97
|
|
|
97
98
|
This class is mainly concerned with:
|
|
98
|
-
- if the entity can Rx packets (e.g. can the HGI send it an RQ)
|
|
99
|
+
- if the entity can Rx packets (e.g. can the HGI send it an RQ?)
|
|
99
100
|
"""
|
|
100
101
|
|
|
101
102
|
_SLUG: str = None # type: ignore[assignment]
|
|
@@ -124,15 +125,10 @@ class _Entity:
|
|
|
124
125
|
"(consider adjusting device_id filters)"
|
|
125
126
|
) # TODO: take whitelist into account
|
|
126
127
|
|
|
127
|
-
def _handle_msg(self, msg: Message) -> None:
|
|
128
|
-
"""Store a msg in
|
|
129
|
-
|
|
130
|
-
raise NotImplementedError
|
|
131
|
-
|
|
132
|
-
# super()._handle_msg(msg) # store the message in the database
|
|
128
|
+
def _handle_msg(self, msg: Message) -> None:
|
|
129
|
+
"""Store a msg in the DBs."""
|
|
133
130
|
|
|
134
|
-
#
|
|
135
|
-
# self.deprecate_device(msg._pkt, reset=True)
|
|
131
|
+
raise NotImplementedError # to be handled by implementing classes
|
|
136
132
|
|
|
137
133
|
# FIXME: this is a mess - to deprecate for async version?
|
|
138
134
|
def _send_cmd(self, cmd: Command, **kwargs: Any) -> asyncio.Task | None:
|
|
@@ -149,7 +145,6 @@ class _Entity:
|
|
|
149
145
|
raise RuntimeError("Deprecated kwargs: %s", kwargs)
|
|
150
146
|
|
|
151
147
|
# cmd._source_entity = self # TODO: is needed?
|
|
152
|
-
# _msgs.pop(cmd.code, None) # NOTE: Cause of DHW bug
|
|
153
148
|
return self._gwy.send_cmd(cmd, wait_for_reply=False, **kwargs)
|
|
154
149
|
|
|
155
150
|
# FIXME: this is a mess
|
|
@@ -183,63 +178,124 @@ class _MessageDB(_Entity):
|
|
|
183
178
|
ctl: Controller
|
|
184
179
|
tcs: Evohome
|
|
185
180
|
|
|
186
|
-
# These attr used must be in this class
|
|
181
|
+
# These attr used must be in this class
|
|
187
182
|
_z_id: DeviceIdT
|
|
188
|
-
_z_idx: DevIndexT | None # e.g. 03, HW
|
|
183
|
+
_z_idx: DevIndexT | None # e.g. 03, HW. Is None for CTL, TCS.
|
|
184
|
+
# idx is one of:
|
|
185
|
+
# - a simple index (e.g. zone_idx, domain_id, aka child_id)
|
|
186
|
+
# - a compound ctx (e.g. 0005/000C/0418)
|
|
187
|
+
# - True (an array of elements, each with its own idx),
|
|
188
|
+
# - False (no idx, is usu. 00),
|
|
189
|
+
# - None (not determinable, rare)
|
|
189
190
|
|
|
190
191
|
def __init__(self, gwy: Gateway) -> None:
|
|
191
192
|
super().__init__(gwy)
|
|
192
193
|
|
|
193
|
-
self._msgs_: dict[
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
194
|
+
self._msgs_: dict[
|
|
195
|
+
Code, Message
|
|
196
|
+
] = {} # TODO(eb): deprecated, used in test, remove Q1 2026
|
|
197
|
+
if not self._gwy.msg_db: # TODO(eb): deprecated since 0.52.1, remove Q1 2026
|
|
198
|
+
self._msgz_: dict[
|
|
199
|
+
Code, dict[VerbT, dict[bool | str | None, Message]]
|
|
200
|
+
] = {} # code/verb/ctx,
|
|
197
201
|
|
|
198
|
-
|
|
199
|
-
|
|
202
|
+
# As of 0.52.1 we use SQLite MessageIndex, see ramses_rf/database.py
|
|
203
|
+
# _msgz_ (nested) was only used in this module. Note:
|
|
204
|
+
# _msgz (now rebuilt from _msgs) also used in: client, base, device.heat
|
|
205
|
+
|
|
206
|
+
def _handle_msg(self, msg: Message) -> None:
|
|
207
|
+
"""Store a msg in the DBs.
|
|
208
|
+
Uses SQLite MessageIndex since 0.52.1
|
|
209
|
+
"""
|
|
200
210
|
|
|
201
211
|
if not (
|
|
202
|
-
msg.src.id == self.id[:
|
|
203
|
-
or (
|
|
204
|
-
|
|
212
|
+
msg.src.id == self.id[:_ID_SLICE] # do store if dev is msg.src
|
|
213
|
+
or (
|
|
214
|
+
msg.dst.id == self.id[:_ID_SLICE] and msg.verb != RQ
|
|
215
|
+
) # skip RQs to self
|
|
216
|
+
or (
|
|
217
|
+
msg.dst.id == ALL_DEVICE_ID and msg.code == Code._1FC9
|
|
218
|
+
) # skip rf_bind rq
|
|
205
219
|
):
|
|
206
|
-
return #
|
|
220
|
+
return # don't store the rest
|
|
207
221
|
|
|
208
|
-
|
|
209
|
-
|
|
222
|
+
if self._gwy.msg_db: # central SQLite MessageIndex
|
|
223
|
+
self._gwy.msg_db.add(msg)
|
|
224
|
+
debug_code: Code = Code._3150
|
|
225
|
+
if msg.code == debug_code and msg.src.id.startswith("01:"):
|
|
226
|
+
_LOGGER.debug(
|
|
227
|
+
"Added msg from %s with code %s to _gwy.msg_db. hdr=%s",
|
|
228
|
+
msg.src,
|
|
229
|
+
msg.code,
|
|
230
|
+
msg._pkt._hdr,
|
|
231
|
+
)
|
|
232
|
+
# print(self._gwy.get(src=str(msg.src[:9]), code=debug_code)) # < success!
|
|
233
|
+
# Result in test log: lookup fails
|
|
234
|
+
# msg.src = 01:073976 (CTL)
|
|
235
|
+
# Added msg from 01:073976 (CTL) with code 0005 to _gwy.msg_db
|
|
236
|
+
# query is for: 01:073976 < no suffix, extended lookup to [:12] chars
|
|
237
|
+
|
|
238
|
+
# ignore any replaced message that might be returned
|
|
239
|
+
else: # TODO(eb): remove Q1 2026
|
|
240
|
+
if msg.code not in self._msgz_: # deprecated since 0.52.1
|
|
241
|
+
# Store msg verb + ctx by code in nested self._msgz_ Dict
|
|
242
|
+
self._msgz_[msg.code] = {msg.verb: {msg._pkt._ctx: msg}}
|
|
243
|
+
elif msg.verb not in self._msgz_[msg.code]:
|
|
244
|
+
# Same, 1 level deeper
|
|
245
|
+
self._msgz_[msg.code][msg.verb] = {msg._pkt._ctx: msg}
|
|
246
|
+
else:
|
|
247
|
+
# Same, replacing previous message
|
|
248
|
+
self._msgz_[msg.code][msg.verb][msg._pkt._ctx] = msg
|
|
249
|
+
|
|
250
|
+
# Also store msg by code in flat self._msgs_ dict (stores the latest I/RP msgs by code)
|
|
251
|
+
# TODO(eb): deprecated since 0.52.1, remove next block _msgs_ Q1 2026
|
|
252
|
+
if msg.verb in (I_, RP): # drop RQ's
|
|
253
|
+
# if msg.code == Code._3150 and msg.src.id.startswith(
|
|
254
|
+
# "02:"
|
|
255
|
+
# ): # print for UFC only, 1 failing test
|
|
256
|
+
# print(
|
|
257
|
+
# f"Added msg with code {msg.code} to {self.id}._msgs_. hdr={msg._pkt._hdr}"
|
|
258
|
+
# )
|
|
210
259
|
self._msgs_[msg.code] = msg
|
|
211
260
|
|
|
212
|
-
if msg.code not in self._msgz_:
|
|
213
|
-
# Store msg verb + ctx by code in nested self._msgz_ Dict (deprecated)
|
|
214
|
-
self._msgz_[msg.code] = {msg.verb: {msg._pkt._ctx: msg}}
|
|
215
|
-
elif msg.verb not in self._msgz_[msg.code]:
|
|
216
|
-
# Same, 1 level deeper
|
|
217
|
-
self._msgz_[msg.code][msg.verb] = {msg._pkt._ctx: msg}
|
|
218
|
-
else:
|
|
219
|
-
# Same, replacing previous message
|
|
220
|
-
self._msgz_[msg.code][msg.verb][msg._pkt._ctx] = msg
|
|
221
|
-
|
|
222
261
|
@property
|
|
223
|
-
def
|
|
224
|
-
"""Return a flattened
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
262
|
+
def _msg_list(self) -> list[Message]:
|
|
263
|
+
"""Return a flattened list of all messages logged on device."""
|
|
264
|
+
# (only) used in gateway.py#get_state() and in tests/tests/test_eavesdrop_schema.py
|
|
265
|
+
if self._gwy.msg_db:
|
|
266
|
+
msg_list_qry: list[Message] = []
|
|
267
|
+
code_list = self._msg_dev_qry()
|
|
268
|
+
if code_list:
|
|
269
|
+
for c in code_list:
|
|
270
|
+
if c in self._msgs:
|
|
271
|
+
# safeguard against lookup failures ("sim" packets?)
|
|
272
|
+
msg_list_qry.append(self._msgs[c])
|
|
273
|
+
else:
|
|
274
|
+
_LOGGER.debug("Could not fetch self._msgs[%s]", c)
|
|
275
|
+
return msg_list_qry
|
|
276
|
+
# else create from legacy nested dict
|
|
233
277
|
return [m for c in self._msgz.values() for v in c.values() for m in v.values()]
|
|
234
278
|
|
|
279
|
+
def _add_record(
|
|
280
|
+
self, address: Address, code: Code | None = None, verb: str = " I"
|
|
281
|
+
) -> None:
|
|
282
|
+
"""Add a (dummy) record to the central SQLite MessageIndex."""
|
|
283
|
+
# used by heat.py init
|
|
284
|
+
if self._gwy.msg_db:
|
|
285
|
+
self._gwy.msg_db.add_record(str(address), code=str(code), verb=verb)
|
|
286
|
+
# else:
|
|
287
|
+
# _LOGGER.warning("Missing MessageIndex")
|
|
288
|
+
# raise NotImplementedError
|
|
289
|
+
|
|
235
290
|
def _delete_msg(self, msg: Message) -> None: # FIXME: this is a mess
|
|
236
|
-
"""Remove the msg from all state databases."""
|
|
291
|
+
"""Remove the msg from all state databases. Used for expired msgs."""
|
|
237
292
|
|
|
238
293
|
from .device import Device
|
|
239
294
|
|
|
240
295
|
obj: _MessageDB
|
|
241
296
|
|
|
242
|
-
|
|
297
|
+
# delete from the central SQLite MessageIndex
|
|
298
|
+
if self._gwy.msg_db:
|
|
243
299
|
self._gwy.msg_db.rem(msg)
|
|
244
300
|
|
|
245
301
|
entities: list[_MessageDB] = []
|
|
@@ -252,42 +308,76 @@ class _MessageDB(_Entity):
|
|
|
252
308
|
entities.extend(msg.src.tcs.zones)
|
|
253
309
|
|
|
254
310
|
# remove the msg from all the state DBs
|
|
311
|
+
# TODO(eb): remove Q1 2026
|
|
255
312
|
for obj in entities:
|
|
256
313
|
if msg in obj._msgs_.values():
|
|
257
314
|
del obj._msgs_[msg.code]
|
|
258
|
-
|
|
259
|
-
|
|
315
|
+
if not self._gwy.msg_db: # _msgz_ is deprecated, only used during migration
|
|
316
|
+
with contextlib.suppress(KeyError):
|
|
317
|
+
del obj._msgz_[msg.code][msg.verb][msg._pkt._ctx]
|
|
318
|
+
|
|
319
|
+
# EntityBase msg_db query methods > copy to docs/source/ramses_rf.rst
|
|
320
|
+
# (ix = database.py.MessageIndex method)
|
|
321
|
+
#
|
|
322
|
+
# +----+----------------------+--------------------+------------+----------+----------+
|
|
323
|
+
# | e. |method name | args | returns | uses | used by |
|
|
324
|
+
# +====+======================+====================+============+==========+==========+
|
|
325
|
+
# | e1 | _get_msg_by_hdr | hdr | Message | i3 | discover |
|
|
326
|
+
# +----+----------------------+--------------------+------------+----------+----------+
|
|
327
|
+
# | e2 | _msg_value | code(s), Msg, args | dict[k,v] | e3,e4 | |
|
|
328
|
+
# +----+----------------------+--------------------+------------+----------+----------+
|
|
329
|
+
# | e3 | _msg_value_code | code, verb, key | dict[k,v] | e4,e5,e6 | e6 |
|
|
330
|
+
# +----+----------------------+--------------------+------------+----------+----------+
|
|
331
|
+
# | e4 | _msg_value_msg | Msg, (code) | dict[k,v] | | e2,e3 |
|
|
332
|
+
# +----+----------------------+--------------------+------------+----------+----------+
|
|
333
|
+
# | e5 | _msg_qry_by_code_key | code, key, (verb=) | | | e6, |
|
|
334
|
+
# +----+----------------------+--------------------+------------+----------+----------+
|
|
335
|
+
# | e6 | _msg_value_qry_by_code_key | code, key | str/float | e3,e5 | |
|
|
336
|
+
# +----+----------------------+--------------------+------------+----------+----------+
|
|
337
|
+
# | e7 | _msg_qry | sql | | | e8 |
|
|
338
|
+
# +----+----------------------+--------------------+------------+----------+----------+
|
|
339
|
+
# | e8 | _msg_count | sql | | e7 | |
|
|
340
|
+
# +----+----------------------+--------------------+------------+----------+----------+
|
|
341
|
+
# | e9 | supported_cmds | | list(Codes)| i7 | |
|
|
342
|
+
# +----+----------------------+--------------------+------------+----------+----------+
|
|
343
|
+
# | e10| _msgs() | | | i5 | |
|
|
344
|
+
# +----+----------------------+--------------------+------------+----------+----------+
|
|
260
345
|
|
|
261
346
|
def _get_msg_by_hdr(self, hdr: HeaderT) -> Message | None:
|
|
262
|
-
"""Return a msg, if any, that matches a header."""
|
|
347
|
+
"""Return a msg, if any, that matches a given header."""
|
|
348
|
+
|
|
349
|
+
if self._gwy.msg_db:
|
|
350
|
+
# use central SQLite MessageIndex
|
|
351
|
+
msgs = self._gwy.msg_db.get(hdr=hdr)
|
|
352
|
+
# only 1 result expected since hdr is a unique key in _gwy.msg_db
|
|
353
|
+
if msgs:
|
|
354
|
+
if msgs[0]._pkt._hdr != hdr:
|
|
355
|
+
raise LookupError
|
|
356
|
+
return msgs[0]
|
|
357
|
+
else:
|
|
358
|
+
msg: Message
|
|
359
|
+
code: Code
|
|
360
|
+
verb: VerbT
|
|
263
361
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
# return msgs[0] if msgs else None
|
|
362
|
+
# _ is device_id
|
|
363
|
+
code, verb, _, *args = hdr.split("|") # type: ignore[assignment]
|
|
267
364
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
elif False in self._msgz[code][verb]:
|
|
279
|
-
msg = self._msgz[code][verb][False]
|
|
280
|
-
elif None in self._msgz[code][verb]:
|
|
281
|
-
msg = self._msgz[code][verb][None]
|
|
282
|
-
else:
|
|
365
|
+
try:
|
|
366
|
+
if args and (ctx := args[0]): # ctx may == True
|
|
367
|
+
msg = self._msgz[code][verb][ctx]
|
|
368
|
+
elif False in self._msgz[code][verb]:
|
|
369
|
+
msg = self._msgz[code][verb][False]
|
|
370
|
+
elif None in self._msgz[code][verb]:
|
|
371
|
+
msg = self._msgz[code][verb][None]
|
|
372
|
+
else:
|
|
373
|
+
return None
|
|
374
|
+
except KeyError:
|
|
283
375
|
return None
|
|
284
|
-
except KeyError:
|
|
285
|
-
return None
|
|
286
376
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
return
|
|
377
|
+
if msg._pkt._hdr != hdr:
|
|
378
|
+
raise LookupError
|
|
379
|
+
return msg
|
|
380
|
+
return None
|
|
291
381
|
|
|
292
382
|
def _msg_flag(self, code: Code, key: str, idx: int) -> bool | None:
|
|
293
383
|
if flags := self._msg_value(code, key=key):
|
|
@@ -297,12 +387,20 @@ class _MessageDB(_Entity):
|
|
|
297
387
|
def _msg_value(
|
|
298
388
|
self, code: Code | Iterable[Code], *args: Any, **kwargs: Any
|
|
299
389
|
) -> dict | list | None:
|
|
390
|
+
"""
|
|
391
|
+
Get the value for a Code from the database or from a Message object provided.
|
|
392
|
+
|
|
393
|
+
:param code: filter messages by Code or a tuple of codes (optional)
|
|
394
|
+
:param args: Message (optional)
|
|
395
|
+
:param kwargs: zone to filter on (optional)
|
|
396
|
+
:return: a dict containing key: value pairs, or a list of those
|
|
397
|
+
"""
|
|
300
398
|
if isinstance(code, str | tuple): # a code or a tuple of codes
|
|
301
399
|
return self._msg_value_code(code, *args, **kwargs)
|
|
302
|
-
|
|
400
|
+
|
|
303
401
|
assert isinstance(code, Message), (
|
|
304
402
|
f"Invalid format: _msg_value({code})"
|
|
305
|
-
) # catch invalidly formatted code, only Message
|
|
403
|
+
) # catch invalidly formatted code, only handle Message from here
|
|
306
404
|
return self._msg_value_msg(code, *args, **kwargs)
|
|
307
405
|
|
|
308
406
|
def _msg_value_code(
|
|
@@ -312,22 +410,41 @@ class _MessageDB(_Entity):
|
|
|
312
410
|
key: str | None = None,
|
|
313
411
|
**kwargs: Any,
|
|
314
412
|
) -> dict | list | None:
|
|
413
|
+
"""
|
|
414
|
+
Query the message dict or the SQLite index for the most recent
|
|
415
|
+
key: value pairs(s) for a given code.
|
|
416
|
+
|
|
417
|
+
:param code: filter messages by Code or a tuple of Codes, optional
|
|
418
|
+
:param verb: filter on I, RQ, RP, optional, only with a single Code
|
|
419
|
+
:param key: value keyword to retrieve, not together with verb RQ
|
|
420
|
+
:param kwargs: not used for now
|
|
421
|
+
:return: a dict containing key: value pairs, or a list of those
|
|
422
|
+
"""
|
|
315
423
|
assert not isinstance(code, tuple) or verb is None, (
|
|
316
424
|
f"Unsupported: using a tuple ({code}) with a verb ({verb})"
|
|
317
425
|
)
|
|
318
426
|
|
|
319
427
|
if verb:
|
|
428
|
+
if verb == VerbT("RQ"):
|
|
429
|
+
# must be a single code
|
|
430
|
+
assert not isinstance(code, tuple) or verb is None, (
|
|
431
|
+
f"Unsupported: using a keyword ({key}) with verb RQ. Ignoring key"
|
|
432
|
+
)
|
|
433
|
+
key = None
|
|
320
434
|
try:
|
|
321
|
-
|
|
435
|
+
if self._gwy.msg_db: # central SQLite MessageIndex, use verb= kwarg
|
|
436
|
+
code = Code(self._msg_qry_by_code_key(code, key, verb=verb))
|
|
437
|
+
msg = self._msgs.get(code)
|
|
438
|
+
else: # deprecated lookup in nested _msgz
|
|
439
|
+
msgs = self._msgz[code][verb]
|
|
440
|
+
msg = max(msgs.values()) if msgs else None
|
|
322
441
|
except KeyError:
|
|
323
442
|
msg = None
|
|
324
|
-
|
|
325
|
-
msg = max(msgs.values()) if msgs else None
|
|
443
|
+
|
|
326
444
|
elif isinstance(code, tuple):
|
|
327
445
|
msgs = [m for m in self._msgs.values() if m.code in code]
|
|
328
|
-
msg = (
|
|
329
|
-
|
|
330
|
-
) # return highest value found in code:value pairs
|
|
446
|
+
msg = max(msgs) if msgs else None
|
|
447
|
+
# return highest = latest? value found in code:value pairs
|
|
331
448
|
else:
|
|
332
449
|
msg = self._msgs.get(code)
|
|
333
450
|
|
|
@@ -336,10 +453,20 @@ class _MessageDB(_Entity):
|
|
|
336
453
|
def _msg_value_msg(
|
|
337
454
|
self,
|
|
338
455
|
msg: Message | None,
|
|
339
|
-
key: str
|
|
456
|
+
key: str = "*",
|
|
340
457
|
zone_idx: str | None = None,
|
|
341
458
|
domain_id: str | None = None,
|
|
342
459
|
) -> dict | list | None:
|
|
460
|
+
"""
|
|
461
|
+
Get from a Message all or a specific key with its value(s),
|
|
462
|
+
optionally filtering for a zone or a domain
|
|
463
|
+
|
|
464
|
+
:param msg: a Message to inspect
|
|
465
|
+
:param key: the key to filter on
|
|
466
|
+
:param zone_idx: the zone to filter on
|
|
467
|
+
:param domain_id: the domain to filter on
|
|
468
|
+
:return: a dict containing key: value pairs, or a list of those
|
|
469
|
+
"""
|
|
343
470
|
if msg is None:
|
|
344
471
|
return None
|
|
345
472
|
elif msg._expired:
|
|
@@ -349,7 +476,7 @@ class _MessageDB(_Entity):
|
|
|
349
476
|
return [x[1] for x in msg.payload]
|
|
350
477
|
|
|
351
478
|
idx: str | None = None
|
|
352
|
-
val: str | None = None
|
|
479
|
+
val: str | None = None # holds the expected matching id value
|
|
353
480
|
|
|
354
481
|
if domain_id:
|
|
355
482
|
idx, val = SZ_DOMAIN_ID, domain_id
|
|
@@ -357,37 +484,187 @@ class _MessageDB(_Entity):
|
|
|
357
484
|
idx, val = SZ_ZONE_IDX, zone_idx
|
|
358
485
|
|
|
359
486
|
if isinstance(msg.payload, dict):
|
|
360
|
-
msg_dict = msg.payload
|
|
361
|
-
|
|
362
|
-
elif idx:
|
|
487
|
+
msg_dict = msg.payload # could be a mismatch on idx, accept
|
|
488
|
+
elif idx: # a list of dicts, e.g. SZ_DOMAIN_ID=FC
|
|
363
489
|
msg_dict = {
|
|
364
490
|
k: v for d in msg.payload for k, v in d.items() if d[idx] == val
|
|
365
491
|
}
|
|
366
|
-
else:
|
|
492
|
+
else: # a list without idx
|
|
367
493
|
# TODO: this isn't ideal: e.g. a controller is being treated like a 'stat
|
|
368
494
|
# .I 101 --:------ --:------ 12:126457 2309 006 0107D0-0207D0 # is a CTL
|
|
369
|
-
msg_dict = msg.payload[0]
|
|
495
|
+
msg_dict = msg.payload[0] # we pick the first
|
|
496
|
+
|
|
497
|
+
assert (
|
|
498
|
+
(not domain_id and not zone_idx)
|
|
499
|
+
or (msg_dict.get(idx) == val)
|
|
500
|
+
or (idx == SZ_DOMAIN_ID)
|
|
501
|
+
), (
|
|
502
|
+
f"full dict:{msg_dict}, payload:{msg.payload} < Coding error: key='{idx}', val='{val}'"
|
|
503
|
+
) # should not be there (TODO(eb): BUG but occurs when using SQLite MessageIndex)
|
|
504
|
+
|
|
505
|
+
if (
|
|
506
|
+
key == "*" or not key
|
|
507
|
+
): # from a SQLite wildcard query, return first=only? k,v
|
|
508
|
+
return {
|
|
509
|
+
k: v
|
|
510
|
+
for k, v in msg_dict.items()
|
|
511
|
+
if k not in ("dhw_idx", SZ_DOMAIN_ID, SZ_ZONE_IDX) and k[:1] != "_"
|
|
512
|
+
}
|
|
513
|
+
return msg_dict.get(key)
|
|
370
514
|
|
|
371
|
-
|
|
372
|
-
f"{msg_dict} < Coding error: key={idx}, val={val}"
|
|
373
|
-
)
|
|
515
|
+
# SQLite methods, since 0.52.0
|
|
374
516
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
517
|
+
def _msg_dev_qry(self) -> list[Code] | None:
|
|
518
|
+
"""
|
|
519
|
+
Retrieve from the MessageIndex a list of Code keys involving this device.
|
|
520
|
+
|
|
521
|
+
:return: list of Codes or empty list when query returned empty
|
|
522
|
+
"""
|
|
523
|
+
if self._gwy.msg_db:
|
|
524
|
+
# SQLite query on MessageIndex
|
|
525
|
+
sql = """
|
|
526
|
+
SELECT code from messages WHERE verb in (' I', 'RP')
|
|
527
|
+
AND (src = ? OR dst = ?)
|
|
528
|
+
"""
|
|
529
|
+
res: list[Code] = []
|
|
530
|
+
|
|
531
|
+
for rec in self._gwy.msg_db.qry_field(
|
|
532
|
+
sql, (self.id[:_SQL_SLICE], self.id[:_SQL_SLICE])
|
|
533
|
+
):
|
|
534
|
+
_LOGGER.debug("Fetched from index: %s", rec[0])
|
|
535
|
+
# Example: "Fetched from index: code 1FD4"
|
|
536
|
+
res.append(Code(str(rec[0])))
|
|
537
|
+
return res
|
|
538
|
+
else:
|
|
539
|
+
_LOGGER.warning("Missing MessageIndex")
|
|
540
|
+
raise NotImplementedError
|
|
541
|
+
|
|
542
|
+
def _msg_qry_by_code_key(
|
|
543
|
+
self,
|
|
544
|
+
code: Code | tuple[Code] | None = None,
|
|
545
|
+
key: str | None = None,
|
|
546
|
+
**kwargs: Any,
|
|
547
|
+
) -> Code | None:
|
|
548
|
+
"""
|
|
549
|
+
Retrieve from the MessageIndex the most current Code for a code(s) &
|
|
550
|
+
keyword combination involving this device.
|
|
551
|
+
|
|
552
|
+
:param code: (optional) a message Code to use, e.g. 31DA or a tuple of Codes
|
|
553
|
+
:param key: (optional) message keyword to fetch, e.g. SZ_HUMIDITY
|
|
554
|
+
:param kwargs: optional verb='vb' single verb
|
|
555
|
+
:return: Code of most recent query result message or None when query returned empty
|
|
556
|
+
"""
|
|
557
|
+
if self._gwy.msg_db:
|
|
558
|
+
code_qry: str = ""
|
|
559
|
+
if code is None:
|
|
560
|
+
code_qry = "*"
|
|
561
|
+
elif isinstance(code, tuple):
|
|
562
|
+
for cd in code:
|
|
563
|
+
code_qry += f"'{str(cd)}' OR code = '"
|
|
564
|
+
code_qry = code_qry[:-13] # trim last OR
|
|
565
|
+
else:
|
|
566
|
+
code_qry = str(code)
|
|
567
|
+
key = "*" if key is None else f"%{key}%"
|
|
568
|
+
if kwargs["verb"] and kwargs["verb"] in (" I", "RP"):
|
|
569
|
+
vb = f"('{str(kwargs['verb'])}',)"
|
|
570
|
+
else:
|
|
571
|
+
vb = "(' I', 'RP',)"
|
|
572
|
+
# SQLite query on MessageIndex
|
|
573
|
+
sql = """
|
|
574
|
+
SELECT dtm, code from messages WHERE verb in ?
|
|
575
|
+
AND (src = ? OR dst = ?)
|
|
576
|
+
AND (code = ?)
|
|
577
|
+
AND (plk LIKE ?)
|
|
578
|
+
"""
|
|
579
|
+
latest: dt = dt(0, 0, 0)
|
|
580
|
+
res = None
|
|
581
|
+
|
|
582
|
+
for rec in self._gwy.msg_db.qry_field(
|
|
583
|
+
sql, (vb, self.id[:_SQL_SLICE], self.id[:_SQL_SLICE], code_qry, key)
|
|
584
|
+
):
|
|
585
|
+
_LOGGER.debug("Fetched from index: %s", rec)
|
|
586
|
+
assert isinstance(rec[0], dt) # mypy hint
|
|
587
|
+
if rec[0] > latest: # dtm, only use most recent
|
|
588
|
+
res = Code(rec[1])
|
|
589
|
+
latest = rec[0]
|
|
590
|
+
return res
|
|
591
|
+
else:
|
|
592
|
+
_LOGGER.warning("Missing MessageIndex")
|
|
593
|
+
raise NotImplementedError
|
|
594
|
+
|
|
595
|
+
def _msg_value_qry_by_code_key(
|
|
596
|
+
self,
|
|
597
|
+
code: Code | None = None,
|
|
598
|
+
key: str | None = None,
|
|
599
|
+
**kwargs: Any,
|
|
600
|
+
) -> str | float | None:
|
|
601
|
+
"""
|
|
602
|
+
Retrieve from the _msgs dict the most current value of a specific code & keyword combination
|
|
603
|
+
or the first key's value when no key is specified.
|
|
604
|
+
|
|
605
|
+
:param code: (optional) a single message Code to use, e.g. 31DA
|
|
606
|
+
:param key: (optional) message keyword to fetch the value for, e.g. SZ_HUMIDITY or * (wildcard)
|
|
607
|
+
:param kwargs: not used as of 0.52.1
|
|
608
|
+
:return: a single string or float value or None when qry returned empty
|
|
609
|
+
"""
|
|
610
|
+
val_msg: dict | list | None = None
|
|
611
|
+
val: object = None
|
|
612
|
+
cd: Code | None = self._msg_qry_by_code_key(code, key)
|
|
613
|
+
if cd is None or cd not in self._msgs:
|
|
614
|
+
_LOGGER.warning("Code %s not in device %s's messages", cd, self.id)
|
|
615
|
+
else:
|
|
616
|
+
val_msg = self._msg_value_msg(
|
|
617
|
+
self._msgs[cd],
|
|
618
|
+
key=key, # key can be wildcard *
|
|
619
|
+
)
|
|
620
|
+
if val_msg:
|
|
621
|
+
val = val_msg[0]
|
|
622
|
+
_LOGGER.debug("Extracted val %s for code %s, key %s", val, code, key)
|
|
623
|
+
|
|
624
|
+
if isinstance(val, float):
|
|
625
|
+
return float(val)
|
|
626
|
+
else:
|
|
627
|
+
return str(val)
|
|
628
|
+
|
|
629
|
+
def _msg_qry(self, sql: str) -> list[dict]:
|
|
630
|
+
"""
|
|
631
|
+
SQLite custom query for an entity's stored payloads using the full MessageIndex.
|
|
632
|
+
See ramses_rf/database.py
|
|
633
|
+
|
|
634
|
+
:param sql: custom SQLite query on MessageIndex. Can include multiple CODEs in SELECT.
|
|
635
|
+
:return: list of payload dicts from the selected messages, or an empty list
|
|
636
|
+
"""
|
|
637
|
+
|
|
638
|
+
res: list[dict] = []
|
|
639
|
+
if sql and self._gwy.msg_db:
|
|
640
|
+
# example query:
|
|
641
|
+
# """SELECT code from messages WHERE verb in (' I', 'RP') AND (src = ? OR dst = ?)
|
|
642
|
+
# AND (code = '31DA' OR ...) AND (plk LIKE '%{SZ_FAN_INFO}%' OR ...)""" = 2 params
|
|
643
|
+
for rec in self._gwy.msg_db.qry_field(
|
|
644
|
+
sql, (self.id[:_SQL_SLICE], self.id[:_SQL_SLICE])
|
|
645
|
+
):
|
|
646
|
+
_pl = self._msgs[Code(rec[0])].payload
|
|
647
|
+
# add payload dict to res(ults)
|
|
648
|
+
res.append(_pl) # only if newer, handled by MessageIndex
|
|
649
|
+
return res
|
|
650
|
+
|
|
651
|
+
def _msg_count(self, sql: str) -> int:
|
|
652
|
+
"""
|
|
653
|
+
Get the number of messages in a query result.
|
|
654
|
+
|
|
655
|
+
:param sql: custom SQLite query on MessageIndex.
|
|
656
|
+
:return: amount of messages in entity's database, 0 for no results
|
|
657
|
+
"""
|
|
658
|
+
return len(self._msg_qry(sql))
|
|
382
659
|
|
|
383
660
|
@property
|
|
384
|
-
def traits(self) -> dict:
|
|
385
|
-
"""
|
|
661
|
+
def traits(self) -> dict[str, Any]:
|
|
662
|
+
"""Get the codes seen by the entity."""
|
|
386
663
|
|
|
387
664
|
codes = {
|
|
388
|
-
|
|
389
|
-
for
|
|
390
|
-
if self._msgs[
|
|
665
|
+
code: (CODES_SCHEMA[code][SZ_NAME] if code in CODES_SCHEMA else None)
|
|
666
|
+
for code in sorted(self._msgs)
|
|
667
|
+
if self._msgs[code].src == (self if hasattr(self, "addr") else self.ctl)
|
|
391
668
|
}
|
|
392
669
|
|
|
393
670
|
return {"_sent": list(codes.keys())}
|
|
@@ -395,34 +672,64 @@ class _MessageDB(_Entity):
|
|
|
395
672
|
@property
|
|
396
673
|
def _msgs(self) -> dict[Code, Message]:
|
|
397
674
|
"""
|
|
398
|
-
Get a flat
|
|
675
|
+
Get a flat dict af all I/RP messages logged with this device as src or dst.
|
|
399
676
|
|
|
400
|
-
:return:
|
|
677
|
+
:return: flat dict of messages by Code
|
|
401
678
|
"""
|
|
402
|
-
if not self._gwy.msg_db:
|
|
679
|
+
if not self._gwy.msg_db:
|
|
403
680
|
return self._msgs_
|
|
681
|
+
# _LOGGER.warning("Missing MessageIndex")
|
|
682
|
+
# raise NotImplementedError
|
|
683
|
+
|
|
684
|
+
if self.id[:3] == "18:": # HGI, confirm this is correct, tests suggest so
|
|
685
|
+
return {}
|
|
404
686
|
|
|
405
687
|
sql = """
|
|
406
688
|
SELECT dtm from messages WHERE verb in (' I', 'RP') AND (src = ? OR dst = ?)
|
|
407
689
|
"""
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
690
|
+
|
|
691
|
+
# handy routine to debug dict creation, see test_systems.py
|
|
692
|
+
# print(f"Create _msgs for {self.id}:")
|
|
693
|
+
# results = self._gwy.msg_db._cu.execute("SELECT dtm, src, code from messages WHERE verb in (' I', 'RP') and code is '3150'")
|
|
694
|
+
# for r in results:
|
|
695
|
+
# print(r)
|
|
696
|
+
|
|
697
|
+
_msg_dict = { # ? use ctx (context) instead of just the address?
|
|
698
|
+
m.code: m
|
|
699
|
+
for m in self._gwy.msg_db.qry(
|
|
700
|
+
sql, (self.id[:_SQL_SLICE], self.id[:_SQL_SLICE])
|
|
701
|
+
) # e.g. 01:123456_HW
|
|
702
|
+
}
|
|
703
|
+
# if CTL, remove 3150, 3220 heat_demand, both are only stored on children
|
|
704
|
+
# HACK
|
|
705
|
+
if self.id[:3] == "01:" and self._SLUG == "CTL":
|
|
706
|
+
# with next ON: 2 errors , both 1x UFC, 1x CTR
|
|
707
|
+
# with next OFF: 4 errors, all CTR
|
|
708
|
+
# if Code._3150 in _msg_dict: # Note: CTL can send a 3150 (see heat_ufc_00)
|
|
709
|
+
# _msg_dict.pop(Code._3150) # keep, prefer to have 2 extra instead of missing 1
|
|
710
|
+
if Code._3220 in _msg_dict:
|
|
711
|
+
_msg_dict.pop(Code._3220)
|
|
712
|
+
# _LOGGER.debug(f"Removed 3150/3220 from %s._msgs dict", self.id)
|
|
713
|
+
return _msg_dict
|
|
411
714
|
|
|
412
715
|
@property
|
|
413
716
|
def _msgz(self) -> dict[Code, dict[VerbT, dict[bool | str | None, Message]]]:
|
|
414
717
|
"""
|
|
415
|
-
Get a nested
|
|
718
|
+
Get a nested dict of all I/RP messages logged with this device as either src or dst.
|
|
719
|
+
Based on SQL query on MessageIndex with device as src or dst.
|
|
416
720
|
|
|
417
|
-
:return:
|
|
721
|
+
:return: dict of messages involving this device, nested by Code, Verb, Context
|
|
418
722
|
"""
|
|
419
|
-
if not self._gwy.msg_db:
|
|
420
|
-
return self._msgz_
|
|
723
|
+
if not self._gwy.msg_db:
|
|
724
|
+
return self._msgz_ # TODO(eb): remove and uncomment next Q1 2026
|
|
725
|
+
# _LOGGER.warning("Missing MessageIndex")
|
|
726
|
+
# raise NotImplementedError
|
|
421
727
|
|
|
728
|
+
# build _msgz from MessageIndex/_msgs:
|
|
422
729
|
msgs_1: dict[Code, dict[VerbT, dict[bool | str | None, Message]]] = {}
|
|
423
730
|
msg: Message
|
|
424
731
|
|
|
425
|
-
for msg in self._msgs.values():
|
|
732
|
+
for msg in self._msgs.values(): # contains only verbs I, RP
|
|
426
733
|
if msg.code not in msgs_1:
|
|
427
734
|
msgs_1[msg.code] = {msg.verb: {msg._pkt._ctx: msg}}
|
|
428
735
|
elif msg.verb not in msgs_1[msg.code]:
|
|
@@ -461,7 +768,17 @@ class _Discovery(_MessageDB):
|
|
|
461
768
|
@property
|
|
462
769
|
def supported_cmds(self) -> dict[Code, Any]:
|
|
463
770
|
"""Return the current list of pollable command codes."""
|
|
464
|
-
|
|
771
|
+
if self._gwy.msg_db:
|
|
772
|
+
return {
|
|
773
|
+
code: CODES_SCHEMA[code][SZ_NAME]
|
|
774
|
+
for code in sorted(
|
|
775
|
+
self._gwy.msg_db.get_rp_codes(
|
|
776
|
+
(self.id[:_SQL_SLICE], self.id[:_SQL_SLICE])
|
|
777
|
+
)
|
|
778
|
+
)
|
|
779
|
+
if self._is_not_deprecated_cmd(code)
|
|
780
|
+
}
|
|
781
|
+
return { # TODO(eb): deprecated since 0.52.1, remove Q1 2026
|
|
465
782
|
code: (CODES_SCHEMA[code][SZ_NAME] if code in CODES_SCHEMA else None)
|
|
466
783
|
for code in sorted(self._msgz)
|
|
467
784
|
if self._msgz[code].get(RP) and self._is_not_deprecated_cmd(code)
|
|
@@ -474,12 +791,35 @@ class _Discovery(_MessageDB):
|
|
|
474
791
|
def _to_data_id(msg_id: MsgId | str) -> OtDataId:
|
|
475
792
|
return int(msg_id, 16) # type: ignore[return-value]
|
|
476
793
|
|
|
477
|
-
def _to_msg_id(data_id: OtDataId | int) -> MsgId: # not used
|
|
478
|
-
|
|
794
|
+
# def _to_msg_id(data_id: OtDataId | int) -> MsgId: # not used
|
|
795
|
+
# return f"{data_id:02X}" # type: ignore[return-value]
|
|
796
|
+
|
|
797
|
+
res: list[str] = []
|
|
798
|
+
# look for the "sim" OT 3220 record initially added in OtbGateway.init
|
|
799
|
+
if self._gwy.msg_db:
|
|
800
|
+
# SQLite query for ctx field on MessageIndex
|
|
801
|
+
sql = """
|
|
802
|
+
SELECT ctx from messages WHERE
|
|
803
|
+
verb = 'RP'
|
|
804
|
+
AND code = '3220'
|
|
805
|
+
AND (src = ? OR dst = ?)
|
|
806
|
+
"""
|
|
807
|
+
for rec in self._gwy.msg_db.qry_field(
|
|
808
|
+
sql, (self.id[:_SQL_SLICE], self.id[:_SQL_SLICE])
|
|
809
|
+
):
|
|
810
|
+
_LOGGER.debug("Fetched OT ctx from index: %s", rec[0])
|
|
811
|
+
res.append(rec[0])
|
|
812
|
+
else: # TODO(eb): remove next Q1 2026
|
|
813
|
+
res_dict: dict[bool | str | None, Message] | list[Any] = self._msgz[
|
|
814
|
+
Code._3220
|
|
815
|
+
].get(RP, [])
|
|
816
|
+
assert isinstance(res_dict, dict)
|
|
817
|
+
res = list(res_dict.keys())
|
|
818
|
+
# raise NotImplementedError
|
|
479
819
|
|
|
480
820
|
return {
|
|
481
821
|
f"0x{msg_id}": OPENTHERM_MESSAGES[_to_data_id(msg_id)].get("en") # type: ignore[misc]
|
|
482
|
-
for msg_id in sorted(
|
|
822
|
+
for msg_id in sorted(res)
|
|
483
823
|
if (
|
|
484
824
|
self._is_not_deprecated_cmd(Code._3220, ctx=msg_id)
|
|
485
825
|
and _to_data_id(msg_id) in OPENTHERM_MESSAGES
|
|
@@ -573,7 +913,9 @@ class _Discovery(_MessageDB):
|
|
|
573
913
|
|
|
574
914
|
async def discover(self) -> None:
|
|
575
915
|
def find_latest_msg(hdr: HeaderT, task: dict) -> Message | None:
|
|
576
|
-
"""
|
|
916
|
+
"""
|
|
917
|
+
:return: the latest message for a header from any source (not just RPs).
|
|
918
|
+
"""
|
|
577
919
|
msgs: list[Message] = [
|
|
578
920
|
m
|
|
579
921
|
for m in [self._get_msg_by_hdr(hdr[:5] + v + hdr[7:]) for v in (I_, RP)]
|
|
@@ -582,7 +924,25 @@ class _Discovery(_MessageDB):
|
|
|
582
924
|
|
|
583
925
|
try:
|
|
584
926
|
if task[_SZ_COMMAND].code in (Code._000A, Code._30C9):
|
|
585
|
-
|
|
927
|
+
if self._gwy.msg_db: # use bespoke MessageIndex qry
|
|
928
|
+
sql = """
|
|
929
|
+
SELECT dtm from messages WHERE
|
|
930
|
+
code = ?
|
|
931
|
+
verb = ' I'
|
|
932
|
+
AND ctx = 'True'
|
|
933
|
+
AND (src = ? OR dst = ?)
|
|
934
|
+
"""
|
|
935
|
+
msgs += self._gwy.msg_db.qry(
|
|
936
|
+
sql,
|
|
937
|
+
(
|
|
938
|
+
task[_SZ_COMMAND].code,
|
|
939
|
+
self.tcs.id[:_ID_SLICE], # OK? not _SQL_SLICE?
|
|
940
|
+
self.tcs.id[:_ID_SLICE], # OK? not _SQL_SLICE?
|
|
941
|
+
),
|
|
942
|
+
)[0] # expect 1 Message in returned tuple
|
|
943
|
+
else: # TODO(eb) remove next Q1 2026
|
|
944
|
+
msgs += [self.tcs._msgz[task[_SZ_COMMAND].code][I_][True]]
|
|
945
|
+
# raise NotImplementedError
|
|
586
946
|
except KeyError:
|
|
587
947
|
pass
|
|
588
948
|
|
|
@@ -895,7 +1255,7 @@ class Child(Entity): # A Zone, Device or a UfhCircuit
|
|
|
895
1255
|
if SZ_ZONE_IDX not in msg.payload:
|
|
896
1256
|
return
|
|
897
1257
|
|
|
898
|
-
if isinstance(self, Device): # FIXME: a mess...
|
|
1258
|
+
if isinstance(self, Device): # FIXME: a mess... see issue ramses_cc #249
|
|
899
1259
|
# the following is a mess - may just be better off deprecating it
|
|
900
1260
|
if self.type in DEV_TYPE_MAP.HEAT_ZONE_ACTUATORS:
|
|
901
1261
|
self.set_parent(msg.dst, child_id=msg.payload[SZ_ZONE_IDX])
|