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_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: # TODO: beware, this is a mess
128
- """Store a msg in _msgs[code] (only latest I/RP) and _msgz[code][verb][ctx]."""
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
- # if self._gwy.hgi and msg.src.id != self._gwy.hgi.id:
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, and the Entity base class
180
+ # These attr used must be in this class
187
181
  _z_id: DeviceIdT
188
- _z_idx: DevIndexT | None # e.g. 03, HW, is None for CTL, TCS
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[Code, Message] = {} # code, should be code/ctx?
194
- self._msgz_: dict[
195
- Code, dict[VerbT, dict[bool | str | None, Message]]
196
- ] = {} # code/verb/ctx, should be code/ctx/verb?
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
- def _handle_msg(self, msg: Message) -> None: # TODO: beware, this is a mess
199
- """Store a msg in the DBs."""
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[:9]
203
- or (msg.dst.id == self.id[:9] and msg.verb != RQ)
204
- or (msg.dst.id == ALL_DEVICE_ID and msg.code == Code._1FC9)
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 # ZZZ: don't store these
219
+ return # don't store the rest
207
220
 
208
- # Store msg by code in flat self._msgs_ Dict (deprecated since 0.50.3)
209
- if msg.verb in (I_, RP):
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 _msg_db(self) -> list[Message]: # flattened version of _msgz[code][verb][index]
224
- """Return a flattened version of _msgz[code][verb][index].
225
-
226
- The idx is one of:
227
- - a simple index (e.g. zone_idx, domain_id, aka child_id)
228
- - a compound ctx (e.g. 0005/000C/0418)
229
- - True (an array of elements, each with its own idx),
230
- - False (no idx, is usu. 00),
231
- - None (not determinable, rare)
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
- if self._gwy.msg_db: # central SQLite MessageIndex
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
- with contextlib.suppress(KeyError):
259
- del obj._msgz_[msg.code][msg.verb][msg._pkt._ctx]
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
- # if self._gwy.msg_db: # central SQLite MessageIndex
265
- # msgs = self._gwy.msg_db.get(hdr=hdr)
266
- # return msgs[0] if msgs else None
361
+ # _ is device_id
362
+ code, verb, _, *args = hdr.split("|") # type: ignore[assignment]
267
363
 
268
- msg: Message
269
- code: Code
270
- verb: VerbT
271
-
272
- # _ is device_id
273
- code, verb, _, *args = hdr.split("|") # type: ignore[assignment]
274
-
275
- try:
276
- if args and (ctx := args[0]): # ctx may == True
277
- msg = self._msgz[code][verb][ctx]
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
- if msg._pkt._hdr != hdr:
288
- raise LookupError
289
-
290
- return msg
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
- # raise RuntimeError
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
- msgs = self._msgz[code][verb]
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
- else:
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 | None = None,
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 (not domain_id and not zone_idx) or (msg_dict.get(idx) == val), (
372
- f"{msg_dict} < Coding error: key={idx}, val={val}"
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
- """Return the codes seen by the entity."""
662
+ def traits(self) -> dict[str, Any]:
663
+ """Get the codes seen by the entity."""
386
664
 
387
665
  codes = {
388
- k: (CODES_SCHEMA[k][SZ_NAME] if k in CODES_SCHEMA else None)
389
- for k in sorted(self._msgs)
390
- if self._msgs[k].src == (self if hasattr(self, "addr") else self.ctl)
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 Dict af all I/RP messages logged with this device as src or dst.
676
+ Get a flat dict af all I/RP messages logged with this device as src or dst.
399
677
 
400
- :return: nested Dict of messages by Code
678
+ :return: flat dict of messages by Code
401
679
  """
402
- if not self._gwy.msg_db: # no central SQLite MessageIndex
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
- return { # ? use ctx (context) instead of just the address?
409
- m.code: m for m in self._gwy.msg_db.qry(sql, (self.id[:9], self.id[:9]))
410
- } # e.g. 01:123456_HW
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 Dict of all I/RP messages logged with this device as src or dst.
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: Dict of messages, nested by Code, Verb, Context
722
+ :return: dict of messages involving this device, nested by Code, Verb, Context
418
723
  """
419
- if not self._gwy.msg_db: # no central SQLite MessageIndex
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
- return {
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
- return f"{data_id:02X}" # type: ignore[return-value]
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(self._msgz[Code._3220].get(RP, [])) # type: ignore[type-var]
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
- """Return the latest message for a header from any source (not just RPs)."""
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
- msgs += [self.tcs._msgz[task[_SZ_COMMAND].code][I_][True]]
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])