ramses-rf 0.52.3__py3-none-any.whl → 0.52.5__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 CHANGED
@@ -588,6 +588,7 @@ def main() -> None:
588
588
  print(" - event_loop_policy set for win32") # do before asyncio.run()
589
589
  asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
590
590
 
591
+ profile = None
591
592
  try:
592
593
  if _PROFILE_LIBRARY:
593
594
  profile = cProfile.Profile()
ramses_cli/debug.py CHANGED
@@ -9,7 +9,7 @@ DEBUG_PORT = 5678
9
9
 
10
10
 
11
11
  def start_debugging(wait_for_client: bool) -> None:
12
- import debugpy # type: ignore[import-untyped]
12
+ import debugpy
13
13
 
14
14
  debugpy.listen(address=(DEBUG_ADDR, DEBUG_PORT))
15
15
  print(f" - Debugging is enabled, listening on: {DEBUG_ADDR}:{DEBUG_PORT}")
@@ -17,7 +17,7 @@ parser.add_argument("-i", "--input-file", type=argparse.FileType("r"), default="
17
17
  args = parser.parse_args()
18
18
 
19
19
 
20
- def convert_json_to_yaml(data: dict) -> str:
20
+ def convert_json_to_yaml(data: dict) -> None:
21
21
  """Convert from json (client.py -C config.json) to yaml (HA configuration.yaml)."""
22
22
  (config, schema, include, exclude) = load_config("/dev/ttyMOCK", None, **data)
23
23
 
@@ -37,7 +37,7 @@ def convert_json_to_yaml(data: dict) -> str:
37
37
  print(yaml.dump({"ramses_cc": result}, sort_keys=False))
38
38
 
39
39
 
40
- def convert_yaml_to_json(data: dict) -> str:
40
+ def convert_yaml_to_json(data: dict) -> None:
41
41
  """Convert from yaml (HA configuration.yaml) to json (client.py -C config.json)."""
42
42
 
43
43
  result = data["ramses_cc"]
ramses_rf/database.py CHANGED
@@ -30,7 +30,7 @@ from collections import OrderedDict
30
30
  from datetime import datetime as dt, timedelta as td
31
31
  from typing import TYPE_CHECKING, Any, NewType
32
32
 
33
- from ramses_tx import CODES_SCHEMA, Code, Message
33
+ from ramses_tx import CODES_SCHEMA, RQ, Code, Message, Packet
34
34
 
35
35
  if TYPE_CHECKING:
36
36
  DtmStrT = NewType("DtmStrT", str)
@@ -130,11 +130,15 @@ class MessageIndex:
130
130
  def stop(self) -> None:
131
131
  """Stop the housekeeper loop."""
132
132
 
133
- if self._housekeeping_task and (not self._housekeeping_task.done()):
133
+ if (
134
+ self.maintain
135
+ and self._housekeeping_task
136
+ and (not self._housekeeping_task.done())
137
+ ):
134
138
  self._housekeeping_task.cancel() # stop the housekeeper
135
139
 
136
140
  self._cx.commit() # just in case
137
- # self._cx.close() # may still need to do queries after engine has stopped?
141
+ self._cx.close() # may still need to do queries after engine has stopped?
138
142
 
139
143
  @property
140
144
  def msgs(self) -> MsgDdT:
@@ -151,7 +155,7 @@ class MessageIndex:
151
155
  - verb " I", "RQ" etc.
152
156
  - src message origin address
153
157
  - dst message destination address
154
- - code packet code aka command class e.g. _0005, _31DA
158
+ - code packet code aka command class e.g. 0005, 31DA
155
159
  - ctx message context, created from payload as index + extra markers (Heat)
156
160
  - hdr packet header e.g. 000C|RP|01:223036|0208 (see: src/ramses_tx/frame.py)
157
161
  - plk the keys stored in the parsed payload, separated by the | char
@@ -183,7 +187,7 @@ class MessageIndex:
183
187
 
184
188
  async def _housekeeping_loop(self) -> None:
185
189
  """Periodically remove stale messages from the index,
186
- unless `self.maintain` is False."""
190
+ unless `self.maintain` is False - as in (most) tests."""
187
191
 
188
192
  async def housekeeping(dt_now: dt, _cutoff: td = td(days=1)) -> None:
189
193
  """
@@ -191,9 +195,10 @@ class MessageIndex:
191
195
  :param dt_now: current timestamp
192
196
  :param _cutoff: the oldest timestamp to retain, default is 24 hours ago
193
197
  """
194
- dtm = dt_now - _cutoff # .isoformat(timespec="microseconds") < needed?
198
+ msgs = None
199
+ dtm = dt_now - _cutoff
195
200
 
196
- self._cu.execute("SELECT dtm FROM messages WHERE dtm => ?", (dtm,))
201
+ self._cu.execute("SELECT dtm FROM messages WHERE dtm >= ?", (dtm,))
197
202
  rows = self._cu.fetchall() # fetch dtm of current messages to retain
198
203
 
199
204
  try: # make this operation atomic, i.e. update self._msgs only on success
@@ -208,6 +213,10 @@ class MessageIndex:
208
213
  self._msgs = msgs
209
214
  finally:
210
215
  self._lock.release()
216
+ if msgs:
217
+ _LOGGER.debug(
218
+ "MessageIndex size was: %d, now: %d", len(rows), len(msgs)
219
+ )
211
220
 
212
221
  while True:
213
222
  self._last_housekeeping = dt.now()
@@ -242,13 +251,17 @@ class MessageIndex:
242
251
  else:
243
252
  # _msgs dict requires a timestamp reformat
244
253
  dtm: DtmStrT = msg.dtm.isoformat(timespec="microseconds") # type: ignore[assignment]
254
+ # add msg to self._msgs dict
245
255
  self._msgs[dtm] = msg
246
256
 
247
257
  finally:
248
258
  pass # self._lock.release()
249
259
 
250
260
  if (
251
- dup and msg.src is not msg.dst and not msg.dst.id.startswith("18:") # HGI
261
+ dup
262
+ and (msg.src is not msg.dst)
263
+ and not msg.dst.id.startswith("18:") # HGI
264
+ and msg.verb != RQ # these may come very quickly
252
265
  ): # when src==dst, expect to add duplicate, don't warn
253
266
  _LOGGER.debug(
254
267
  "Overwrote dtm (%s) for %s: %s (contrived log?)",
@@ -256,8 +269,6 @@ class MessageIndex:
256
269
  msg._pkt._hdr,
257
270
  dup[0]._pkt,
258
271
  )
259
- if old is not None:
260
- _LOGGER.debug("Old msg replaced: %s", old)
261
272
 
262
273
  return old
263
274
 
@@ -270,7 +281,8 @@ class MessageIndex:
270
281
  :param verb: two letter verb str to use
271
282
  """
272
283
  # Used by OtbGateway init, via entity_base.py
273
- dtm: DtmStrT = dt.strftime(dt.now(), "%Y-%m-%dT%H:%M:%S") # type: ignore[assignment]
284
+ _now: dt = dt.now()
285
+ dtm: DtmStrT = _now.isoformat(timespec="microseconds") # type: ignore[assignment]
274
286
  hdr = f"{code}|{verb}|{src}|00" # dummy record has no contents
275
287
 
276
288
  dup = self._delete_from(hdr=hdr)
@@ -283,7 +295,7 @@ class MessageIndex:
283
295
  self._cu.execute(
284
296
  sql,
285
297
  (
286
- dtm,
298
+ _now,
287
299
  verb,
288
300
  src,
289
301
  src,
@@ -295,6 +307,14 @@ class MessageIndex:
295
307
  )
296
308
  except sqlite3.Error:
297
309
  self._cx.rollback()
310
+ else:
311
+ # also add dummy 3220 msg to self._msgs dict to allow maintenance loop
312
+ msg: Message = Message._from_pkt(
313
+ Packet(
314
+ _now, f"... {verb} --- {src} --:------ {src} {code} 005 0000000000"
315
+ )
316
+ )
317
+ self._msgs[dtm] = msg
298
318
 
299
319
  if dup: # expected when more than one heat system in schema
300
320
  _LOGGER.debug("Replaced record with same hdr: %s", hdr)
@@ -355,7 +375,7 @@ class MessageIndex:
355
375
  if not bool(msg) ^ bool(kwargs):
356
376
  raise ValueError("Either a Message or kwargs should be provided, not both")
357
377
  if msg:
358
- kwargs["dtm"] = msg.dtm # .isoformat(timespec="microseconds")
378
+ kwargs["dtm"] = msg.dtm
359
379
 
360
380
  msgs = None
361
381
  try: # make this operation atomic, i.e. update self._msgs only on success
@@ -406,7 +426,7 @@ class MessageIndex:
406
426
  raise ValueError("Either a Message or kwargs should be provided, not both")
407
427
 
408
428
  if msg:
409
- kwargs["dtm"] = msg.dtm # .isoformat(timespec="microseconds")
429
+ kwargs["dtm"] = msg.dtm
410
430
 
411
431
  return self._select_from(**kwargs)
412
432
 
@@ -428,10 +448,15 @@ class MessageIndex:
428
448
  :returns: a tuple of qualifying messages
429
449
  """
430
450
 
431
- return tuple(
432
- self._msgs[row[0].isoformat(timespec="microseconds")]
433
- for row in self.qry_dtms(**kwargs)
434
- )
451
+ # CHANGE: Use a list comprehension with a check to avoid KeyError
452
+ res: list[Message] = []
453
+ for row in self.qry_dtms(**kwargs):
454
+ ts: DtmStrT = row[0].isoformat(timespec="microseconds")
455
+ if ts in self._msgs:
456
+ res.append(self._msgs[ts])
457
+ else:
458
+ _LOGGER.debug("MessageIndex timestamp %s not in device messages", ts)
459
+ return tuple(res)
435
460
 
436
461
  def qry_dtms(self, **kwargs: bool | dt | str) -> list[Any]:
437
462
  """
@@ -483,6 +508,7 @@ class MessageIndex:
483
508
  # _msgs stamp format: 2022-09-08T13:40:52.447364
484
509
  if ts in self._msgs:
485
510
  lst.append(self._msgs[ts])
511
+ # _LOGGER.debug("MessageIndex ts %s added to qry.lst", ts) # too frequent
486
512
  else: # happens in tests with artificial msg from heat
487
513
  _LOGGER.info("MessageIndex timestamp %s not in device messages", ts)
488
514
  return tuple(lst)
@@ -546,8 +572,9 @@ class MessageIndex:
546
572
  if ts in self._msgs:
547
573
  # if include_expired or not self._msgs[ts].HAS_EXPIRED: # not working
548
574
  lst.append(self._msgs[ts])
549
- else: # happens in tests with dummy msg from heat init
550
- _LOGGER.info("MessageIndex ts %s not in device messages", ts)
575
+ _LOGGER.debug("MessageIndex ts %s added to all.lst", ts)
576
+ else: # happens in tests and real evohome setups with dummy msg from heat init
577
+ _LOGGER.debug("MessageIndex ts %s not in device messages", ts)
551
578
  return tuple(lst)
552
579
 
553
580
  def clr(self) -> None:
ramses_rf/device/base.py CHANGED
@@ -87,9 +87,10 @@ class DeviceBase(Entity):
87
87
  return self.id < other.id # type: ignore[no-any-return]
88
88
 
89
89
  def _update_traits(self, **traits: Any) -> None:
90
- """Update a device with new schema attrs.
90
+ """Update a device with new schema attributes.
91
91
 
92
- Raise an exception if the new schema is not a superset of the existing schema.
92
+ :param traits: The traits to apply (e.g., alias, class, faked)
93
+ :raises TypeError: If the device is not fakeable but 'faked' is set.
93
94
  """
94
95
 
95
96
  traits = shrink(SCH_TRAITS(traits))
@@ -342,7 +343,17 @@ class Fakeable(DeviceBase):
342
343
  idx: IndexT = "00",
343
344
  require_ratify: bool = False,
344
345
  ) -> tuple[Packet, Packet, Packet, Packet | None]:
345
- """Listen for a binding and return the Offer, or raise an exception."""
346
+ """Listen for a binding and return the Offer packets.
347
+
348
+ :param accept_codes: The codes allowed for this binding
349
+ :type accept_codes: Iterable[Code]
350
+ :param idx: The index to bind to, defaults to "00"
351
+ :type idx: IndexT
352
+ :param require_ratify: Whether a ratification step is required, defaults to False
353
+ :type require_ratify: bool
354
+ :return: A tuple of the four binding transaction packets
355
+ :rtype: tuple[Packet, Packet, Packet, Packet | None]
356
+ """
346
357
 
347
358
  if not self._bind_context:
348
359
  raise TypeError(f"{self}: Faking not enabled")
ramses_rf/device/heat.py CHANGED
@@ -450,7 +450,7 @@ class UfhController(Parent, DeviceHeat): # UFC (02):
450
450
  # )
451
451
  # self._send_cmd(cmd)
452
452
 
453
- elif msg.code == Code._0008: # relay_demand, TODO: use msgIndex DB?
453
+ elif msg.code == Code._0008: # relay_demand
454
454
  if msg.payload.get(SZ_DOMAIN_ID) == FC:
455
455
  self._relay_demand = msg
456
456
  else: # FA
@@ -669,7 +669,7 @@ class OtbGateway(Actuator, HeatDemand): # OTB (10): 3220 (22D9, others)
669
669
 
670
670
  # TODO(eb): cleanup
671
671
  if self._gwy.msg_db:
672
- self._add_record(address=self.addr, code=Code._3220, verb="RP")
672
+ self._add_record(id=self.id, code=Code._3220, verb="RP")
673
673
  # adds a "sim" RP opentherm_msg to the SQLite MessageIndex with code _3220
674
674
  # causes exc when fetching ALL, when no "real" msg was added to _msgs_. We skip those.
675
675
  else:
ramses_rf/device/hvac.py CHANGED
@@ -921,27 +921,30 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A], 2411
921
921
 
922
922
  :return: string describing fan mode, speed
923
923
  """
924
- if self._gwy.msg_db:
925
- # Use SQLite query on MessageIndex. res_rate/res_mode not exposed yet
926
- sql = f"""
927
- SELECT code from messages WHERE verb in (' I', 'RP')
928
- AND (src = ? OR dst = ?)
929
- AND (plk LIKE '%{SZ_FAN_MODE}%')
930
- """
931
- res_mode: list = self._msg_qry(sql)
932
- # SQLite query on MessageIndex
933
- _LOGGER.debug(f"{res_mode} # FAN_MODE FETCHED from MessageIndex")
934
-
935
- sql = f"""
936
- SELECT code from messages WHERE verb in (' I', 'RP')
937
- AND (src = ? OR dst = ?)
938
- AND (plk LIKE '%{SZ_FAN_RATE}%')
939
- """
940
- res_rate: list = self._msg_qry(sql)
941
- # SQLite query on MessageIndex
942
- _LOGGER.debug(
943
- f"{res_rate} # FAN_RATE FETCHED from MessageIndex"
944
- ) # DEBUG always empty?
924
+ # if self._gwy.msg_db:
925
+ # Use SQLite query on MessageIndex. res_rate/res_mode not exposed yet
926
+ # working fine in 0.52.4, no need to specify code, only payload key
927
+ # sql = f"""
928
+ # SELECT code from messages WHERE verb in (' I', 'RP')
929
+ # AND (src = ? OR dst = ?)
930
+ # AND (plk LIKE '%{SZ_FAN_MODE}%')
931
+ # """
932
+ # res_mode: list = self._msg_qry(sql)
933
+ # # SQLite query on MessageIndex
934
+ # _LOGGER.debug(
935
+ # f"# Fetched FAN_MODE for {self.id} from MessageIndex: {res_mode}"
936
+ # )
937
+
938
+ # sql = f"""
939
+ # SELECT code from messages WHERE verb in (' I', 'RP')
940
+ # AND (src = ? OR dst = ?)
941
+ # AND (plk LIKE '%{SZ_FAN_RATE}%')
942
+ # """
943
+ # res_rate: list = self._msg_qry(sql)
944
+ # # SQLite query on MessageIndex
945
+ # _LOGGER.debug(
946
+ # f"# Fetched FAN_RATE for {self.id} from MessageIndex: {res_rate}"
947
+ # )
945
948
 
946
949
  if Code._31D9 in self._msgs:
947
950
  # was a dict by Code
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 Address, Priority, QosParams
18
+ from ramses_tx import 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
@@ -224,7 +224,10 @@ class _MessageDB(_Entity):
224
224
 
225
225
  # As of 0.52.1 we use SQLite MessageIndex, see ramses_rf/database.py
226
226
  # _msgz_ (nested) was only used in this module. Note:
227
- # _msgz (now rebuilt from _msgs) also used in: client, base, device.heat
227
+ # _msgz (now rebuilt from _msgs) is also used in:
228
+ # - client.py: for code in device._msgz.values()
229
+ # - base.py: Code._1060 in self._msgz
230
+ # [x] device.heat (no longer used)
228
231
 
229
232
  def _handle_msg(self, msg: Message) -> None:
230
233
  """Store a msg in the DBs.
@@ -244,14 +247,14 @@ class _MessageDB(_Entity):
244
247
 
245
248
  if self._gwy.msg_db: # central SQLite MessageIndex
246
249
  _LOGGER.debug(
247
- "For %s (z_id %s) add msg %s, src %s, dst %s to msg_db.",
250
+ "For %s (_z_id %s) add to msg_db: %s, src %s, dst %s",
248
251
  self.id,
249
252
  self._z_id,
250
253
  msg,
251
254
  msg.src,
252
255
  msg.dst,
253
256
  )
254
- debug_code: Code = Code._3150
257
+ debug_code: Code = Code._3150 # for debugging only log these, pick your own
255
258
  if msg.code == debug_code and msg.src.id.startswith("01:"):
256
259
  _LOGGER.debug(
257
260
  "Added msg from %s with code %s to _gwy.msg_db. hdr=%s",
@@ -293,6 +296,7 @@ class _MessageDB(_Entity):
293
296
  def _msg_list(self) -> list[Message]:
294
297
  """Return a flattened list of all messages logged on this device."""
295
298
  # (only) used in gateway.py#get_state() and in tests/tests/test_eavesdrop_schema.py
299
+ # TODO remove _msg_list Q1 2026
296
300
  if self._gwy.msg_db:
297
301
  msg_list_qry: list[Message] = []
298
302
  code_list = self._msg_dev_qry()
@@ -303,24 +307,29 @@ class _MessageDB(_Entity):
303
307
  msg_list_qry.append(self._msgs[c])
304
308
  else:
305
309
  # evohome has these errors
306
- # _msg_list could not fetch self._msgs[7FFF] for 18:072981 (z_id 18:072981)
310
+ # _msg_list could not fetch self._msgs[7FFF] for 18:072981 (_z_id 18:072981)
307
311
  _LOGGER.debug(
308
- "_msg_list could not fetch self._msgs[%s] for %s (z_id %s)",
312
+ "_msg_list could not fetch self._msgs[%s] for %s (_z_id %s)",
309
313
  c,
310
314
  self.id,
311
315
  self._z_id,
312
316
  )
313
317
  return msg_list_qry
314
318
  # else create from legacy nested dict
315
- return [m for c in self._msgz.values() for v in c.values() for m in v.values()]
319
+ return [
320
+ msg
321
+ for code in self._msgz.values()
322
+ for ctx in code.values()
323
+ for msg in ctx.values()
324
+ ]
316
325
 
317
326
  def _add_record(
318
- self, address: Address, code: Code | None = None, verb: str = " I"
327
+ self, id: DeviceIdT, code: Code | None = None, verb: str = " I"
319
328
  ) -> None:
320
329
  """Add a (dummy) record to the central SQLite MessageIndex."""
321
330
  # used by heat.py init
322
331
  if self._gwy.msg_db:
323
- self._gwy.msg_db.add_record(str(address), code=str(code), verb=verb)
332
+ self._gwy.msg_db.add_record(id, code=str(code), verb=verb)
324
333
  # else:
325
334
  # _LOGGER.warning("Missing MessageIndex")
326
335
  # raise NotImplementedError
@@ -424,7 +433,7 @@ class _MessageDB(_Entity):
424
433
  **kwargs: Any,
425
434
  ) -> dict | list | None:
426
435
  """
427
- Query the message dict or the SQLite index for the most recent
436
+ Query the _msgz message dict or the SQLite MessageIndex for the most recent
428
437
  key: value pairs(s) for a given code.
429
438
 
430
439
  :param code: filter messages by Code or a tuple of Codes, optional
@@ -517,7 +526,7 @@ class _MessageDB(_Entity):
517
526
  or (idx == SZ_DOMAIN_ID)
518
527
  ), (
519
528
  f"full dict:{msg_dict}, payload:{msg.payload} < Coding error: key='{idx}', val='{val}'"
520
- ) # should not be there (TODO(eb): BUG but occurs when using SQLite MessageIndex)
529
+ ) # should not be there
521
530
 
522
531
  if (
523
532
  key == "*" or not key
@@ -535,21 +544,35 @@ class _MessageDB(_Entity):
535
544
  """
536
545
  Retrieve from the MessageIndex a list of Code keys involving this device.
537
546
 
538
- :return: list of Codes or empty list when query returned empty
547
+ :return: list of Codes or an empty list when the query returned empty
539
548
  """
540
549
 
541
550
  if self._gwy.msg_db:
542
551
  # SQLite query on MessageIndex
543
552
  res: list[Code] = []
544
- if self.id[_ID_SLICE:] == "_HW":
553
+
554
+ if len(self.id) == 9:
555
+ # fetch a ctl's message codes (add all its children?)
556
+ sql = """
557
+ SELECT code from messages WHERE
558
+ verb in (' I', 'RP')
559
+ AND (src = ? OR dst = ?)
560
+ AND ctx LIKE ?
561
+ """
562
+ _ctx_qry = "%"
563
+
564
+ elif self.id[_ID_SLICE:] == "_HW":
565
+ # fetch a DHW entity's message codes
545
566
  sql = """
546
567
  SELECT code from messages WHERE
547
568
  verb in (' I', 'RP')
548
569
  AND (src = ? OR dst = ?)
549
570
  AND (ctx IN ('FC', 'FA', 'F9', 'FA') OR plk LIKE ?)
550
571
  """
551
- _ctx_qry = "%dhw_idx%" # syntax error ?
572
+ _ctx_qry = "%dhw_idx%"
573
+
552
574
  else:
575
+ # fetch a zone's message codes
553
576
  sql = """
554
577
  SELECT code from messages WHERE
555
578
  verb in (' I', 'RP')
@@ -562,12 +585,12 @@ class _MessageDB(_Entity):
562
585
  sql, (self.id[:_ID_SLICE], self.id[:_ID_SLICE], _ctx_qry)
563
586
  ):
564
587
  _LOGGER.debug(
565
- "Fetched from index: %s for %s (z_id %s)",
588
+ "Fetched from index: %s for %s (_z_id %s)",
566
589
  rec[0],
567
590
  self.id,
568
591
  self._z_id,
569
592
  )
570
- # Example: "Fetched from index: code 1FD4 for 01:123456 (z_id 01)"
593
+ # Example: "Fetched from index: code 1FD4 for 01:123456 (_z_id 01)"
571
594
  res.append(Code(str(rec[0])))
572
595
  return res
573
596
  else:
@@ -743,7 +766,18 @@ class _MessageDB(_Entity):
743
766
  # for r in results:
744
767
  # print(r)
745
768
 
746
- if self.id[_ID_SLICE:] == "_HW":
769
+ if len(self.id) == 9:
770
+ # fetch a ctl's message dtms (add all its children?)
771
+ sql = """
772
+ SELECT dtm from messages WHERE
773
+ verb in (' I', 'RP')
774
+ AND (src = ? OR dst = ?)
775
+ AND ctx LIKE ?
776
+ """
777
+ _ctx_qry = "%"
778
+
779
+ elif self.id[_ID_SLICE:] == "_HW":
780
+ # fetch a DHW entity's message dtms
747
781
  sql = """
748
782
  SELECT dtm from messages WHERE
749
783
  verb in (' I', 'RP')
@@ -753,6 +787,7 @@ class _MessageDB(_Entity):
753
787
  _ctx_qry = "%dhw_idx%"
754
788
  # TODO add Children messages? self.ctl.dhw
755
789
  else:
790
+ # fetch a zone's message dtms
756
791
  sql = """
757
792
  SELECT dtm from messages WHERE
758
793
  verb in (' I', 'RP')
@@ -769,14 +804,14 @@ class _MessageDB(_Entity):
769
804
  }
770
805
  # if CTL, remove 3150, 3220 heat_demand, both are only stored on children
771
806
  # HACK
772
- if self.id[:3] == "01:" and self._SLUG == "CTL":
773
- # with next ON: 2 errors , both 1x UFC, 1x CTR
774
- # with next OFF: 4 errors, all CTR
775
- # if Code._3150 in _msg_dict: # Note: CTL can send a 3150 (see heat_ufc_00)
776
- # _msg_dict.pop(Code._3150) # keep, prefer to have 2 extra instead of missing 1
777
- if Code._3220 in _msg_dict:
778
- _msg_dict.pop(Code._3220)
779
- # _LOGGER.debug(f"Removed 3150/3220 from %s._msgs dict", self.id)
807
+ # if self.id[:3] == "01:" and self._SLUG == "CTL":
808
+ # with next ON: 2 errors , both 1x UFC, 1x CTR
809
+ # with next OFF: 4 errors, all CTR
810
+ # if Code._3150 in _msg_dict: # Note: CTL can send a 3150 (see heat_ufc_00)
811
+ # _msg_dict.pop(Code._3150) # keep, prefer to have 2 extra instead of missing 1
812
+ # if Code._3220 in _msg_dict:
813
+ # _msg_dict.pop(Code._3220)
814
+ # _LOGGER.debug(f"Removed 3150/3220 from %s._msgs dict", self.id)
780
815
  return _msg_dict
781
816
 
782
817
  @property
@@ -862,7 +897,6 @@ class _Discovery(_MessageDB):
862
897
  # return f"{data_id:02X}" # type: ignore[return-value]
863
898
 
864
899
  res: list[str] = []
865
- # look for the "sim" OT 3220 record initially added in OtbGateway.init
866
900
  if self._gwy.msg_db:
867
901
  # SQLite query for ctx field on MessageIndex
868
902
  sql = """
@@ -995,18 +1029,24 @@ class _Discovery(_MessageDB):
995
1029
  sql = """
996
1030
  SELECT dtm from messages WHERE
997
1031
  code = ?
998
- verb = ' I'
1032
+ AND verb in (' I', 'RP')
999
1033
  AND ctx = 'True'
1000
1034
  AND (src = ? OR dst = ?)
1001
1035
  """
1002
- msgs += self._gwy.msg_db.qry(
1036
+ res = self._gwy.msg_db.qry(
1003
1037
  sql,
1004
1038
  (
1005
1039
  task[_SZ_COMMAND].code,
1006
1040
  self.tcs.id[:_ID_SLICE],
1007
1041
  self.tcs.id[:_ID_SLICE],
1008
1042
  ),
1009
- )[0] # expect 1 Message in returned tuple
1043
+ )
1044
+ if len(res) > 0:
1045
+ msgs += res[0] # expect 1 Message in returned tuple
1046
+ else:
1047
+ _LOGGER.debug(
1048
+ f"No msg found for hdr {hdr}, task code {task[_SZ_COMMAND].code}"
1049
+ )
1010
1050
  else: # TODO(eb) remove next Q1 2026
1011
1051
  msgs += [self.tcs._msgz[task[_SZ_COMMAND].code][I_][True]]
1012
1052
  # raise NotImplementedError
@@ -1145,6 +1185,7 @@ class Parent(Entity): # A System, Zone, DhwZone or a UfhController
1145
1185
  (incl. the DHW Zone), and also any UFH controllers.
1146
1186
 
1147
1187
  For a heating Zone, children are limited to a sensor, and a number of actuators.
1188
+
1148
1189
  For the DHW Zone, the children are limited to a sensor, a DHW valve, and/or a
1149
1190
  heating valve.
1150
1191
 
ramses_rf/gateway.py CHANGED
@@ -258,14 +258,6 @@ class Gateway(Engine):
258
258
  # return True
259
259
  return include_expired or not msg._expired
260
260
 
261
- msgs = [m for device in self.devices for m in device._msg_list]
262
-
263
- for system in self.systems:
264
- msgs.extend(list(system._msgs.values()))
265
- msgs.extend([m for z in system.zones for m in z._msgs.values()])
266
- # msgs.extend([m for z in system.dhw for m in z._msgs.values()]) # TODO: DHW
267
- # Related to/Fixes ramses_cc Issue 249 non-existing via-device _HW ?
268
-
269
261
  if self.msg_db:
270
262
  pkts = {
271
263
  f"{repr(msg._pkt)[:26]}": f"{repr(msg._pkt)[27:]}"
@@ -273,12 +265,20 @@ class Gateway(Engine):
273
265
  if wanted_msg(msg, include_expired=include_expired)
274
266
  }
275
267
  else: # deprecated, to be removed in Q1 2026
276
- # _LOGGER.warning("Missing MessageIndex")
268
+ msgs = [m for device in self.devices for m in device._msg_list]
269
+ # add systems._msgs and zones._msgs
270
+ for system in self.systems:
271
+ msgs.extend(list(system._msgs.values()))
272
+ msgs.extend([m for z in system.zones for m in z._msgs.values()])
273
+ # msgs.extend([m for z in system.dhw for m in z._msgs.values()]) # TODO: DHW
274
+ # Related to/Fixes ramses_cc Issue 249 non-existing via-device _HW ?
275
+
277
276
  pkts = { # BUG: assumes pkts have unique dtms: may be untrue for contrived logs
278
277
  f"{repr(msg._pkt)[:26]}": f"{repr(msg._pkt)[27:]}"
279
278
  for msg in msgs
280
279
  if wanted_msg(msg, include_expired=include_expired)
281
280
  }
281
+ # _LOGGER.warning("Missing MessageIndex")
282
282
 
283
283
  self._resume()
284
284
 
@@ -360,13 +360,25 @@ class Gateway(Engine):
360
360
  child_id: str | None = None,
361
361
  is_sensor: bool | None = None,
362
362
  ) -> Device: # TODO: **schema/traits) -> Device: # may: LookupError
363
- """Return a device, create it if required.
364
-
365
- First, use the traits to create/update it, then pass it any msg to handle.
366
- All devices have traits, but only controllers (CTL, UFC) have a schema.
367
-
368
- Devices are uniquely identified by a device id.
369
- If a device is created, attach it to the gateway.
363
+ """Return a device, creating it if it does not already exist.
364
+
365
+ This method uses provided traits to create or update a device and optionally
366
+ passes a message for it to handle. All devices have traits, but only
367
+ controllers (CTL, UFC) have a schema.
368
+
369
+ :param device_id: The unique identifier for the device (e.g., '01:123456')
370
+ :type device_id: DeviceIdT
371
+ :param msg: An optional initial message for the device to process, defaults to None
372
+ :type msg: Message | None
373
+ :param parent: The parent entity of this device, if any, defaults to None
374
+ :type parent: Parent | None
375
+ :param child_id: The specific ID of the child component if applicable, defaults to None
376
+ :type child_id: str | None
377
+ :param is_sensor: Indicates if this device should be treated as a sensor, defaults to None
378
+ :type is_sensor: bool | None
379
+ :return: The existing or newly created device instance
380
+ :rtype: Device
381
+ :raises LookupError: If the device ID is blocked or not in the allowed known_list.
370
382
  """
371
383
 
372
384
  def check_filter_lists(dev_id: DeviceIdT) -> None: # may: LookupError
@@ -620,9 +632,8 @@ class Gateway(Engine):
620
632
  If wait_for_reply is True (*and* the Command has a rx_header), return the
621
633
  reply Packet. Otherwise, simply return the echo Packet.
622
634
 
623
- If the expected Packet can't be returned, raise:
624
- ProtocolSendFailed: tried to Tx Command, but didn't get echo/reply
625
- ProtocolError: didn't attempt to Tx Command for some reason
635
+ :raises ProtocolSendFailed: If the command was sent but no reply/echo was received.
636
+ :raises ProtocolError: If the system failed to attempt the transmission.
626
637
  """
627
638
 
628
639
  return await super().async_send_cmd(
ramses_rf/schemas.py CHANGED
@@ -285,7 +285,7 @@ SCH_GLOBAL_CONFIG = (
285
285
  #
286
286
  # 6/7: External Schemas, to be used by clients of this library
287
287
  def NormaliseRestoreCache() -> Callable[[bool | dict[str, bool]], dict[str, bool]]:
288
- """Convert a short-hand restore_cache bool to a dict.
288
+ """Convert a shorthand restore_cache bool to a dict.
289
289
 
290
290
  restore_cache: bool -> restore_cache:
291
291
  restore_schema: bool