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_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: # 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
128
+ def _handle_msg(self, msg: Message) -> None:
129
+ """Store a msg in the DBs."""
133
130
 
134
- # if self._gwy.hgi and msg.src.id != self._gwy.hgi.id:
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, and the Entity base class
181
+ # These attr used must be in this class
187
182
  _z_id: DeviceIdT
188
- _z_idx: DevIndexT | None # e.g. 03, HW, is None for CTL, TCS
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[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?
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
- def _handle_msg(self, msg: Message) -> None: # TODO: beware, this is a mess
199
- """Store a msg in the DBs."""
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[: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)
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 # ZZZ: don't store these
220
+ return # don't store the rest
207
221
 
208
- # Store msg by code in flat self._msgs_ Dict (deprecated since 0.50.3)
209
- if msg.verb in (I_, RP):
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 _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
- """
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
- if self._gwy.msg_db: # central SQLite MessageIndex
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
- with contextlib.suppress(KeyError):
259
- del obj._msgz_[msg.code][msg.verb][msg._pkt._ctx]
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
- # 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
362
+ # _ is device_id
363
+ code, verb, _, *args = hdr.split("|") # type: ignore[assignment]
267
364
 
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:
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
- if msg._pkt._hdr != hdr:
288
- raise LookupError
289
-
290
- return msg
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
- # raise RuntimeError
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
- msgs = self._msgz[code][verb]
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
- else:
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
- max(msgs) if msgs else None
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 | 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(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
- 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
- )
515
+ # SQLite methods, since 0.52.0
374
516
 
375
- if key:
376
- return msg_dict.get(key)
377
- return {
378
- k: v
379
- for k, v in msg_dict.items()
380
- if k not in ("dhw_idx", SZ_DOMAIN_ID, SZ_ZONE_IDX) and k[:1] != "_"
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
- """Return the codes seen by the entity."""
661
+ def traits(self) -> dict[str, Any]:
662
+ """Get the codes seen by the entity."""
386
663
 
387
664
  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)
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 Dict af all I/RP messages logged with this device as src or dst.
675
+ Get a flat dict af all I/RP messages logged with this device as src or dst.
399
676
 
400
- :return: nested Dict of messages by Code
677
+ :return: flat dict of messages by Code
401
678
  """
402
- if not self._gwy.msg_db: # no central SQLite MessageIndex
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
- 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
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 Dict of all I/RP messages logged with this device as src or dst.
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: Dict of messages, nested by Code, Verb, Context
721
+ :return: dict of messages involving this device, nested by Code, Verb, Context
418
722
  """
419
- if not self._gwy.msg_db: # no central SQLite MessageIndex
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
- return {
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
- return f"{data_id:02X}" # type: ignore[return-value]
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(self._msgz[Code._3220].get(RP, [])) # type: ignore[type-var]
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
- """Return the latest message for a header from any source (not just RPs)."""
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
- msgs += [self.tcs._msgz[task[_SZ_COMMAND].code][I_][True]]
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])