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