ramses-rf 0.52.2__py3-none-any.whl → 0.52.4__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/binding_fsm.py CHANGED
@@ -178,6 +178,8 @@ class BindContextBase:
178
178
  self, state: type[BindStateBase], result: asyncio.Future[Message] | None = None
179
179
  ) -> None:
180
180
  """Transition the State of the Context, and process the result, if any."""
181
+ # Ensure prev_state is always available, not only during debugging
182
+ prev_state = self._state
181
183
 
182
184
  # if False and result:
183
185
  # try:
@@ -185,10 +187,6 @@ class BindContextBase:
185
187
  # except exc.BindingError as err:
186
188
  # self._fut.set_result(err)
187
189
 
188
- if _DBG_MAINTAIN_STATE_CHAIN: # HACK for debugging
189
- # if prev_state in (None, )
190
- prev_state = self._state
191
-
192
190
  self._state = state(self)
193
191
  if not self.is_binding:
194
192
  self._is_respondent = None
@@ -197,6 +195,14 @@ class BindContextBase:
197
195
  elif state is SuppSendOfferWaitForAccept:
198
196
  self._is_respondent = False
199
197
 
198
+ # Log binding completion transitions
199
+ if isinstance(
200
+ self._state, (RespHasBoundAsRespondent, SuppHasBoundAsSupplicant)
201
+ ):
202
+ _LOGGER.info(
203
+ f"{self._dev.id}: Binding process completed: {type(prev_state).__name__} -> {state.__name__} (role: {self.role})"
204
+ )
205
+
200
206
  if _DBG_MAINTAIN_STATE_CHAIN: # HACK for debugging
201
207
  setattr(self._state, "_prev_state", prev_state) # noqa: B010
202
208
 
@@ -663,6 +669,10 @@ class RespHasBoundAsRespondent(BindStateBase):
663
669
 
664
670
  _attr_role = BindRole.IS_DORMANT
665
671
 
672
+ def __init__(self, context: BindContextBase) -> None:
673
+ super().__init__(context)
674
+ _LOGGER.info(f"{context._dev.id}: Binding completed as respondent")
675
+
666
676
 
667
677
  class RespIsWaitingForAddenda(_DevIsWaitingForMsg, BindStateBase):
668
678
  """Respondent has received a Confirm & is waiting for an Addenda."""
@@ -715,6 +725,10 @@ class SuppHasBoundAsSupplicant(BindStateBase):
715
725
 
716
726
  _attr_role = BindRole.IS_DORMANT
717
727
 
728
+ def __init__(self, context: BindContextBase) -> None:
729
+ super().__init__(context)
730
+ _LOGGER.info(f"{context._dev.id}: Binding completed as supplicant")
731
+
718
732
 
719
733
  class SuppIsReadyToSendAddenda(
720
734
  _DevIsReadyToSendCmd, BindStateBase
ramses_rf/database.py CHANGED
@@ -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:
@@ -193,7 +197,7 @@ class MessageIndex:
193
197
  """
194
198
  dtm = dt_now - _cutoff # .isoformat(timespec="microseconds") < needed?
195
199
 
196
- self._cu.execute("SELECT dtm FROM messages WHERE dtm => ?", (dtm,))
200
+ self._cu.execute("SELECT dtm FROM messages WHERE dtm >= ?", (dtm,))
197
201
  rows = self._cu.fetchall() # fetch dtm of current messages to retain
198
202
 
199
203
  try: # make this operation atomic, i.e. update self._msgs only on success
@@ -334,7 +338,7 @@ class MessageIndex:
334
338
  payload_keys(msg.payload),
335
339
  ),
336
340
  )
337
- _LOGGER.debug(f"Added {msg} to gwy.msg_db")
341
+ # _LOGGER.debug(f"Added {msg} to gwy.msg_db")
338
342
 
339
343
  return _old_msgs[0] if _old_msgs else None
340
344
 
@@ -435,7 +439,7 @@ class MessageIndex:
435
439
 
436
440
  def qry_dtms(self, **kwargs: bool | dt | str) -> list[Any]:
437
441
  """
438
- Select from the ImageIndex a list of dtms that match the provided arguments.
442
+ Select from the MessageIndex a list of dtms that match the provided arguments.
439
443
 
440
444
  :param kwargs: data table field names and criteria
441
445
  :return: list of unformatted dtms that match, useful for msg lookup, or an empty list if 0 matches
ramses_rf/device/heat.py CHANGED
@@ -120,7 +120,7 @@ _LOGGER = logging.getLogger(__name__)
120
120
 
121
121
  class Actuator(DeviceHeat): # 3EF0, 3EF1 (for 10:/13:)
122
122
  # .I --- 13:109598 --:------ 13:109598 3EF0 003 00C8FF # event-driven, 00/C8
123
- # RP --- 13:109598 18:002563 --:------ 0008 002 00C8 # 00/C8, as abobe
123
+ # RP --- 13:109598 18:002563 --:------ 0008 002 00C8 # 00/C8, as above
124
124
  # RP --- 13:109598 18:002563 --:------ 3EF1 007 0000BF-00BFC8FF # 00/C8, as above
125
125
 
126
126
  # RP --- 10:048122 18:140805 --:------ 3EF1 007 007FFF-003C2A10 # 10:s only RP, always 7FFF
@@ -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
@@ -668,11 +668,8 @@ class OtbGateway(Actuator, HeatDemand): # OTB (10): 3220 (22D9, others)
668
668
  self._child_id = FC # NOTE: domain_id
669
669
 
670
670
  # TODO(eb): cleanup
671
- # should fix src/ramses_rf/database.py _add_record try/except when activating next line
672
671
  if self._gwy.msg_db:
673
- self._add_record(
674
- address=self.addr, code=Code._3220, verb="RP"
675
- ) # << essential?
672
+ self._add_record(address=self.addr, code=Code._3220, verb="RP")
676
673
  # adds a "sim" RP opentherm_msg to the SQLite MessageIndex with code _3220
677
674
  # causes exc when fetching ALL, when no "real" msg was added to _msgs_. We skip those.
678
675
  else:
@@ -1411,7 +1408,7 @@ class UfhCircuit(Child, Entity): # FIXME
1411
1408
  def __init__(self, ufc: UfhController, ufh_idx: str) -> None:
1412
1409
  super().__init__(ufc._gwy)
1413
1410
 
1414
- # FIXME: ZZZ entities must know their parent device ID and their own idx
1411
+ # FIXME: gwy.msg_db entities must know their parent device ID and their own idx
1415
1412
  self._z_id = ufc.id
1416
1413
  self._z_idx = ufh_idx
1417
1414
 
ramses_rf/entity_base.py CHANGED
@@ -67,8 +67,7 @@ if TYPE_CHECKING:
67
67
 
68
68
 
69
69
  _QOS_TX_LIMIT = 12 # TODO: needs work
70
- _ID_SLICE = 9 # base address only, legacy _msgs 9
71
- _SQL_SLICE = 12 # msg_db dst field query 12
70
+ _ID_SLICE = 9
72
71
  _SZ_LAST_PKT: Final = "last_msg"
73
72
  _SZ_NEXT_DUE: Final = "next_due"
74
73
  _SZ_TIMEOUT: Final = "timeout"
@@ -225,7 +224,10 @@ class _MessageDB(_Entity):
225
224
 
226
225
  # As of 0.52.1 we use SQLite MessageIndex, see ramses_rf/database.py
227
226
  # _msgz_ (nested) was only used in this module. Note:
228
- # _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)
229
231
 
230
232
  def _handle_msg(self, msg: Message) -> None:
231
233
  """Store a msg in the DBs.
@@ -245,13 +247,14 @@ class _MessageDB(_Entity):
245
247
 
246
248
  if self._gwy.msg_db: # central SQLite MessageIndex
247
249
  _LOGGER.debug(
248
- "For %s (z_id %s) add msg %s, src %s, dst %s to msg_db.",
250
+ "For %s (_z_id %s) add msg %s, src %s, dst %s to msg_db.",
249
251
  self.id,
250
252
  self._z_id,
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",
@@ -291,8 +294,9 @@ class _MessageDB(_Entity):
291
294
 
292
295
  @property
293
296
  def _msg_list(self) -> list[Message]:
294
- """Return a flattened list of all messages logged on device."""
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()
@@ -302,15 +306,22 @@ class _MessageDB(_Entity):
302
306
  # safeguard against lookup failures ("sim" packets?)
303
307
  msg_list_qry.append(self._msgs[c])
304
308
  else:
309
+ # evohome has these errors
310
+ # _msg_list could not fetch self._msgs[7FFF] for 18:072981 (_z_id 18:072981)
305
311
  _LOGGER.debug(
306
- "_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)",
307
313
  c,
308
314
  self.id,
309
315
  self._z_id,
310
316
  )
311
317
  return msg_list_qry
312
318
  # else create from legacy nested dict
313
- 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
+ ]
314
325
 
315
326
  def _add_record(
316
327
  self, address: Address, code: Code | None = None, verb: str = " I"
@@ -422,13 +433,13 @@ class _MessageDB(_Entity):
422
433
  **kwargs: Any,
423
434
  ) -> dict | list | None:
424
435
  """
425
- 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
426
437
  key: value pairs(s) for a given code.
427
438
 
428
439
  :param code: filter messages by Code or a tuple of Codes, optional
429
440
  :param verb: filter on I, RQ, RP, optional, only with a single Code
430
441
  :param key: value keyword to retrieve, not together with verb RQ
431
- :param kwargs: not used for now
442
+ :param kwargs: extra filter, e.g. zone_idx='01'
432
443
  :return: a dict containing key: value pairs, or a list of those
433
444
  """
434
445
  assert not isinstance(code, tuple) or verb is None, (
@@ -444,7 +455,9 @@ class _MessageDB(_Entity):
444
455
  key = None
445
456
  try:
446
457
  if self._gwy.msg_db: # central SQLite MessageIndex, use verb= kwarg
447
- code = Code(self._msg_qry_by_code_key(code, key, verb=verb))
458
+ code = Code(
459
+ self._msg_qry_by_code_key(code, key, **kwargs, verb=verb)
460
+ )
448
461
  msg = self._msgs.get(code)
449
462
  else: # deprecated lookup in nested _msgz
450
463
  msgs = self._msgz[code][verb]
@@ -456,7 +469,9 @@ class _MessageDB(_Entity):
456
469
  msgs = [m for m in self._msgs.values() if m.code in code]
457
470
  msg = max(msgs) if msgs else None
458
471
  # return highest = latest? value found in code:value pairs
459
- else:
472
+ else: # single Code
473
+ # for Zones, this doesn't work, returns first result = often wrong
474
+ # TODO fix in _msg_qry_by_code_key()
460
475
  msg = self._msgs.get(code)
461
476
 
462
477
  return self._msg_value_msg(msg, key=key, **kwargs)
@@ -511,7 +526,7 @@ class _MessageDB(_Entity):
511
526
  or (idx == SZ_DOMAIN_ID)
512
527
  ), (
513
528
  f"full dict:{msg_dict}, payload:{msg.payload} < Coding error: key='{idx}', val='{val}'"
514
- ) # should not be there (TODO(eb): BUG but occurs when using SQLite MessageIndex)
529
+ ) # should not be there
515
530
 
516
531
  if (
517
532
  key == "*" or not key
@@ -529,21 +544,53 @@ class _MessageDB(_Entity):
529
544
  """
530
545
  Retrieve from the MessageIndex a list of Code keys involving this device.
531
546
 
532
- :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
533
548
  """
549
+
534
550
  if self._gwy.msg_db:
535
551
  # SQLite query on MessageIndex
536
- sql = """
537
- SELECT code from messages WHERE verb in (' I', 'RP')
538
- AND (src = ? OR dst = ?)
539
- """
540
552
  res: list[Code] = []
541
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
566
+ sql = """
567
+ SELECT code from messages WHERE
568
+ verb in (' I', 'RP')
569
+ AND (src = ? OR dst = ?)
570
+ AND (ctx IN ('FC', 'FA', 'F9', 'FA') OR plk LIKE ?)
571
+ """
572
+ _ctx_qry = "%dhw_idx%"
573
+
574
+ else:
575
+ # fetch a zone's message codes
576
+ sql = """
577
+ SELECT code from messages WHERE
578
+ verb in (' I', 'RP')
579
+ AND (src = ? OR dst = ?)
580
+ AND ctx LIKE ?
581
+ """
582
+ _ctx_qry = f"%{self.id[_ID_SLICE + 1 :]}%"
583
+
542
584
  for rec in self._gwy.msg_db.qry_field(
543
- sql, (self.id[:_SQL_SLICE], self.id[:_SQL_SLICE])
585
+ sql, (self.id[:_ID_SLICE], self.id[:_ID_SLICE], _ctx_qry)
544
586
  ):
545
- _LOGGER.debug("Fetched from index: %s", rec[0])
546
- # Example: "Fetched from index: code 1FD4"
587
+ _LOGGER.debug(
588
+ "Fetched from index: %s for %s (_z_id %s)",
589
+ rec[0],
590
+ self.id,
591
+ self._z_id,
592
+ )
593
+ # Example: "Fetched from index: code 1FD4 for 01:123456 (_z_id 01)"
547
594
  res.append(Code(str(rec[0])))
548
595
  return res
549
596
  else:
@@ -566,32 +613,48 @@ class _MessageDB(_Entity):
566
613
  :return: Code of most recent query result message or None when query returned empty
567
614
  """
568
615
  if self._gwy.msg_db:
569
- code_qry: str = ""
616
+ code_qry: str = "= "
570
617
  if code is None:
571
- code_qry = "*"
618
+ code_qry = "LIKE '%'" # wildcard
572
619
  elif isinstance(code, tuple):
573
620
  for cd in code:
574
621
  code_qry += f"'{str(cd)}' OR code = '"
575
622
  code_qry = code_qry[:-13] # trim last OR
576
623
  else:
577
- code_qry = str(code)
578
- key = "*" if key is None else f"%{key}%"
624
+ code_qry += str(code)
579
625
  if kwargs["verb"] and kwargs["verb"] in (" I", "RP"):
580
626
  vb = f"('{str(kwargs['verb'])}',)"
581
627
  else:
582
628
  vb = "(' I', 'RP',)"
629
+ ctx_qry = "%"
630
+ if kwargs["zone_idx"]:
631
+ ctx_qry = f"%{kwargs['zone_idx']}%"
632
+ elif kwargs["dhw_idx"]: # DHW
633
+ ctx_qry = f"%{kwargs['dhw_idx']}%"
634
+ key_qry = "%" if key is None else f"%{key}%"
635
+
583
636
  # SQLite query on MessageIndex
584
637
  sql = """
585
- SELECT dtm, code from messages WHERE verb in ?
638
+ SELECT dtm, code from messages WHERE
639
+ verb in ?
586
640
  AND (src = ? OR dst = ?)
587
- AND (code = ?)
641
+ AND (code ?)
642
+ AND (ctx LIKE ?)
588
643
  AND (plk LIKE ?)
589
644
  """
590
645
  latest: dt = dt(0, 0, 0)
591
646
  res = None
592
647
 
593
648
  for rec in self._gwy.msg_db.qry_field(
594
- sql, (vb, self.id[:_SQL_SLICE], self.id[:_SQL_SLICE], code_qry, key)
649
+ sql,
650
+ (
651
+ vb,
652
+ self.id[:_ID_SLICE],
653
+ self.id[:_ID_SLICE],
654
+ code_qry,
655
+ ctx_qry,
656
+ key_qry,
657
+ ),
595
658
  ):
596
659
  _LOGGER.debug(
597
660
  "_msg_qry_by_code_key fetched rec: %s, code: %s", rec, code_qry
@@ -654,7 +717,7 @@ class _MessageDB(_Entity):
654
717
  # """SELECT code from messages WHERE verb in (' I', 'RP') AND (src = ? OR dst = ?)
655
718
  # AND (code = '31DA' OR ...) AND (plk LIKE '%{SZ_FAN_INFO}%' OR ...)""" = 2 params
656
719
  for rec in self._gwy.msg_db.qry_field(
657
- sql, (self.id[:_SQL_SLICE], self.id[:_SQL_SLICE])
720
+ sql, (self.id[:_ID_SLICE], self.id[:_ID_SLICE])
658
721
  ):
659
722
  _pl = self._msgs[Code(rec[0])].payload
660
723
  # add payload dict to res(ults)
@@ -694,35 +757,61 @@ class _MessageDB(_Entity):
694
757
  # _LOGGER.warning("Missing MessageIndex")
695
758
  # raise NotImplementedError
696
759
 
697
- if self.id[:3] == "18:": # HGI, confirm this is correct, tests suggest so
698
- return {}
699
-
700
- sql = """
701
- SELECT dtm from messages WHERE verb in (' I', 'RP') AND (src = ? OR dst = ?)
702
- """
760
+ # if self.id[:3] == "18:": # HGI, confirm this is correct, tests suggest so
761
+ # return {}
703
762
 
704
- # handy routine to debug dict creation, see test_systems.py
763
+ # a routine to debug dict creation, see test_systems.py:
705
764
  # print(f"Create _msgs for {self.id}:")
706
765
  # results = self._gwy.msg_db._cu.execute("SELECT dtm, src, code from messages WHERE verb in (' I', 'RP') and code is '3150'")
707
766
  # for r in results:
708
767
  # print(r)
709
768
 
710
- _msg_dict = { # ? use ctx (context) instead of just the address?
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
781
+ sql = """
782
+ SELECT dtm from messages WHERE
783
+ verb in (' I', 'RP')
784
+ AND (src = ? OR dst = ?)
785
+ AND (ctx IN ('FC', 'FA', 'F9', 'FA') OR plk LIKE ?)
786
+ """
787
+ _ctx_qry = "%dhw_idx%"
788
+ # TODO add Children messages? self.ctl.dhw
789
+ else:
790
+ # fetch a zone's message dtms
791
+ sql = """
792
+ SELECT dtm from messages WHERE
793
+ verb in (' I', 'RP')
794
+ AND (src = ? OR dst = ?)
795
+ AND ctx LIKE ?
796
+ """
797
+ _ctx_qry = f"%{self.id[_ID_SLICE + 1 :]}%"
798
+
799
+ _msg_dict = { # since 0.52.3 use ctx (context) instead of just the address
711
800
  m.code: m
712
801
  for m in self._gwy.msg_db.qry(
713
- sql, (self.id[:_SQL_SLICE], self.id[:_SQL_SLICE])
714
- ) # e.g. 01:123456_HW
802
+ sql, (self.id[:_ID_SLICE], self.id[:_ID_SLICE], _ctx_qry)
803
+ ) # e.g. 01:123456_HW, 01:123456_02 (Zone)
715
804
  }
716
805
  # if CTL, remove 3150, 3220 heat_demand, both are only stored on children
717
806
  # HACK
718
- if self.id[:3] == "01:" and self._SLUG == "CTL":
719
- # with next ON: 2 errors , both 1x UFC, 1x CTR
720
- # with next OFF: 4 errors, all CTR
721
- # if Code._3150 in _msg_dict: # Note: CTL can send a 3150 (see heat_ufc_00)
722
- # _msg_dict.pop(Code._3150) # keep, prefer to have 2 extra instead of missing 1
723
- if Code._3220 in _msg_dict:
724
- _msg_dict.pop(Code._3220)
725
- # _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)
726
815
  return _msg_dict
727
816
 
728
817
  @property
@@ -786,7 +875,7 @@ class _Discovery(_MessageDB):
786
875
  code: CODES_SCHEMA[code][SZ_NAME]
787
876
  for code in sorted(
788
877
  self._gwy.msg_db.get_rp_codes(
789
- (self.id[:_SQL_SLICE], self.id[:_SQL_SLICE])
878
+ (self.id[:_ID_SLICE], self.id[:_ID_SLICE])
790
879
  )
791
880
  )
792
881
  if self._is_not_deprecated_cmd(code)
@@ -808,7 +897,6 @@ class _Discovery(_MessageDB):
808
897
  # return f"{data_id:02X}" # type: ignore[return-value]
809
898
 
810
899
  res: list[str] = []
811
- # look for the "sim" OT 3220 record initially added in OtbGateway.init
812
900
  if self._gwy.msg_db:
813
901
  # SQLite query for ctx field on MessageIndex
814
902
  sql = """
@@ -818,7 +906,7 @@ class _Discovery(_MessageDB):
818
906
  AND (src = ? OR dst = ?)
819
907
  """
820
908
  for rec in self._gwy.msg_db.qry_field(
821
- sql, (self.id[:_SQL_SLICE], self.id[:_SQL_SLICE])
909
+ sql, (self.id[:_ID_SLICE], self.id[:_ID_SLICE])
822
910
  ):
823
911
  _LOGGER.debug("Fetched OT ctx from index: %s", rec[0])
824
912
  res.append(rec[0])
@@ -941,18 +1029,24 @@ class _Discovery(_MessageDB):
941
1029
  sql = """
942
1030
  SELECT dtm from messages WHERE
943
1031
  code = ?
944
- verb = ' I'
1032
+ AND verb = ' I'
945
1033
  AND ctx = 'True'
946
1034
  AND (src = ? OR dst = ?)
947
1035
  """
948
- msgs += self._gwy.msg_db.qry(
1036
+ res = self._gwy.msg_db.qry(
949
1037
  sql,
950
1038
  (
951
1039
  task[_SZ_COMMAND].code,
952
- self.tcs.id[:_ID_SLICE], # OK? not _SQL_SLICE?
953
- self.tcs.id[:_ID_SLICE], # OK? not _SQL_SLICE?
1040
+ self.tcs.id[:_ID_SLICE],
1041
+ self.tcs.id[:_ID_SLICE],
954
1042
  ),
955
- )[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}, tesk code {task[_SZ_COMMAND].code}"
1049
+ )
956
1050
  else: # TODO(eb) remove next Q1 2026
957
1051
  msgs += [self.tcs._msgz[task[_SZ_COMMAND].code][I_][True]]
958
1052
  # raise NotImplementedError
@@ -1091,6 +1185,7 @@ class Parent(Entity): # A System, Zone, DhwZone or a UfhController
1091
1185
  (incl. the DHW Zone), and also any UFH controllers.
1092
1186
 
1093
1187
  For a heating Zone, children are limited to a sensor, and a number of actuators.
1188
+
1094
1189
  For the DHW Zone, the children are limited to a sensor, a DHW valve, and/or a
1095
1190
  heating valve.
1096
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
 
ramses_rf/system/heat.py CHANGED
@@ -532,7 +532,7 @@ class MultiZone(SystemBase): # 0005 (+/- 000C?)
532
532
  schema = shrink(SCH_TCS_ZONES_ZON(schema))
533
533
 
534
534
  zon: Zone = self.zone_by_idx.get(zone_idx) # type: ignore[assignment]
535
- if zon is None:
535
+ if zon is None: # not found in tcs, create it
536
536
  zon = zone_factory(self, zone_idx, msg=msg, **schema) # type: ignore[unreachable]
537
537
  self.zone_by_idx[zon.idx] = zon
538
538
  self.zones.append(zon)
ramses_rf/system/zones.py CHANGED
@@ -233,7 +233,7 @@ class DhwZone(ZoneSchedule): # CS92A
233
233
  # def eavesdrop_dhw_sensor(this: Message, *, prev: Message | None = None) -> None:
234
234
  # """Eavesdrop packets, or pairs of packets, to maintain the system state.
235
235
 
236
- # There are only 2 ways to to find a controller's DHW sensor:
236
+ # There are only 2 ways to find a controller's DHW sensor:
237
237
  # 1. The 10A0 RQ/RP *from/to a 07:* (1x/4h) - reliable
238
238
  # 2. Use sensor temp matching - non-deterministic
239
239
 
ramses_rf/version.py CHANGED
@@ -1,4 +1,4 @@
1
1
  """RAMSES RF - a RAMSES-II protocol decoder & analyser (application layer)."""
2
2
 
3
- __version__ = "0.52.2"
3
+ __version__ = "0.52.4"
4
4
  VERSION = __version__
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ramses_rf
3
- Version: 0.52.2
3
+ Version: 0.52.4
4
4
  Summary: A stateful RAMSES-II protocol decoder & analyser.
5
5
  Project-URL: Homepage, https://github.com/ramses-rf/ramses_rf
6
6
  Project-URL: Bug Tracker, https://github.com/ramses-rf/ramses_rf/issues
@@ -22,6 +22,7 @@ Description-Content-Type: text/markdown
22
22
  ![Linting](https://github.com/ramses-rf/ramses_rf/actions/workflows/check-lint.yml/badge.svg)
23
23
  ![Typing](https://github.com/ramses-rf/ramses_rf/actions/workflows/check-type.yml/badge.svg)
24
24
  ![Testing](https://github.com/ramses-rf/ramses_rf/actions/workflows/check-test.yml/badge.svg)
25
+ [![Coverage](https://github.com/ramses-rf/ramses_rf/actions/workflows/check-cov.yml/badge.svg?event=push)](https://github.com/ramses-rf/ramses_rf/actions/workflows/check-cov.yml)
25
26
 
26
27
  ## Overview
27
28
 
@@ -5,51 +5,51 @@ ramses_cli/discovery.py,sha256=MWoahBnAAVzfK2S7EDLsY2WYqN_ZK9L-lktrj8_4cb0,12978
5
5
  ramses_cli/utils/cat_slow.py,sha256=AhUpM5gnegCitNKU-JGHn-DrRzSi-49ZR1Qw6lxe_t8,607
6
6
  ramses_cli/utils/convert.py,sha256=D_YiCyX5na9pgC-_NhBlW9N1dgRKUK-uLtLBfofjzZM,1804
7
7
  ramses_rf/__init__.py,sha256=vp2TyFGqc1fGQHsevhmaw0QEmSSCnZx7fqizKiEwHtw,1245
8
- ramses_rf/binding_fsm.py,sha256=uZAOl3i19KCXqqlaLJWkEqMMP7NJBhVPW3xTikQD1fY,25996
8
+ ramses_rf/binding_fsm.py,sha256=fuqvcc9YW-wr8SPH8zadpPqrHAvzl_eeWF-IBtlLppY,26632
9
9
  ramses_rf/const.py,sha256=L3z31CZ-xqno6oZp_h-67CB_5tDDqTwSWXsqRtsjMcs,5460
10
- ramses_rf/database.py,sha256=pfCOt6wvABAaW275mNLoRnObFSQ9nT5d7PtDLMTvgr4,20340
10
+ ramses_rf/database.py,sha256=2sHiBhI5121HdX-IMquY3h5qQzDljeGWpQ9x6U4duEo,20408
11
11
  ramses_rf/dispatcher.py,sha256=YjEU-QrBLo9IfoEhJo2ikg_FxOaMYoWvzelr9Vi-JZ8,11398
12
- ramses_rf/entity_base.py,sha256=OPdQEeJ5zk49FKCFmRui4urFQVR2tLBS1iWCTWeZkNo,55712
12
+ ramses_rf/entity_base.py,sha256=LifFLcxI867j4DmsaNSHsVl2fUEPkXpJdz4b7ZFpupo,58929
13
13
  ramses_rf/exceptions.py,sha256=mt_T7irqHSDKir6KLaf6oDglUIdrw0S40JbOrWJk5jc,3657
14
- ramses_rf/gateway.py,sha256=VYZqMppU_kDYhT3EUmqpHf0LuLXPMH7ASx9jylAopWE,21218
14
+ ramses_rf/gateway.py,sha256=c88_HCAEBYPaNETMC0IUK1HU9QuEOnCGnK-5wze08_s,21289
15
15
  ramses_rf/helpers.py,sha256=TNk_QkpIOB3alOp1sqnA9LOzi4fuDCeapNlW3zTzNas,4250
16
16
  ramses_rf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
17
  ramses_rf/schemas.py,sha256=jvpx0hZiMhaogyxbxnrbxS7m2GekKK4DFfVTNs7h-WQ,13476
18
- ramses_rf/version.py,sha256=3xbuWkwVUOezgu-nlN98fGrnAgqtfivOcMVKjCTSBds,125
18
+ ramses_rf/version.py,sha256=9IsbhdNecTxEJDHwnSjdsa0r2hpABWxeNcD6LfKKNs0,125
19
19
  ramses_rf/device/__init__.py,sha256=sUbH5dhbYFXSoM_TPFRutpRutBRpup7_cQ9smPtDTy8,4858
20
20
  ramses_rf/device/base.py,sha256=Yx0LZwMEb49naY8FolZ8HEBFb6XCPQBTVN_TWyO2nKg,17777
21
- ramses_rf/device/heat.py,sha256=hvTX32Evp23vqjXu-YHqTuneem4_QBRSxCkYR7dg4VI,54705
21
+ ramses_rf/device/heat.py,sha256=I9_NlB_cOxCfCb3Zx88hY6wd7yIrknzo6msrWxXeeEs,54545
22
22
  ramses_rf/device/hvac.py,sha256=gdpVACUvtq6ERMI0mwRhqIJtKYEmybzJA2NeyN1ELrs,48431
23
23
  ramses_rf/system/__init__.py,sha256=uZLKio3gLlBzePa2aDQ1nxkcp1YXOGrn6iHTG8LiNIw,711
24
24
  ramses_rf/system/faultlog.py,sha256=GdGmVGT3137KsTlV_nhccgIFEmYu6DFsLTn4S-8JSok,12799
25
- ramses_rf/system/heat.py,sha256=3jaFEChU-HlWCRMY1y7u09s7AH4hT0pC63hnqwdmZOc,39223
25
+ ramses_rf/system/heat.py,sha256=qQmzgmyHy2x87gHAstn0ee7ZVVOq-GJIfDxCrC-6gFU,39254
26
26
  ramses_rf/system/schedule.py,sha256=Ts6tdZPTQLV5NkgwA73tPa5QUsnZNIIuYoKC-8VsXDk,18808
27
- ramses_rf/system/zones.py,sha256=9AH7ooN5QfiqvWuor2P1Dn8aILjQb2RWL9rWqDH1IjA,36075
27
+ ramses_rf/system/zones.py,sha256=YN_HAbeaa2YioUOjafEpp-0IHmIwmKNSK_77pPcjtns,36072
28
28
  ramses_tx/__init__.py,sha256=sqnjM7pUGJDmec6igTtKViSB8FLX49B5gwhAmcY9ERY,3596
29
29
  ramses_tx/address.py,sha256=F5ZE-EbPNNom1fW9XXUILvD7DYSMBxNJvsHVliT5gjw,8452
30
30
  ramses_tx/command.py,sha256=tqVECwd_QokEcRv2MSk7TUU4JSBzCZcJh1eQ0jIGgoY,125122
31
- ramses_tx/const.py,sha256=pnxq5upXvLUizv9Ye_I1llD9rAa3wddHgsSkc91AIUc,30300
31
+ ramses_tx/const.py,sha256=AMwHitDq115rB24f3fzclNGC4ArMW16DbqiFWQc0U5o,30306
32
32
  ramses_tx/exceptions.py,sha256=FJSU9YkvpKjs3yeTqUJX1o3TPFSe_B01gRGIh9b3PNc,2632
33
33
  ramses_tx/fingerprints.py,sha256=nfftA1E62HQnb-eLt2EqjEi_la0DAoT0wt-PtTMie0s,11974
34
34
  ramses_tx/frame.py,sha256=GzNsXr15YLeidJYGtk_xPqsZQh4ehDDlUCtT6rTDhT8,22046
35
- ramses_tx/gateway.py,sha256=8o9NyEAyIui3B6HA0R8o64u4U8NZFlNl0TQ6rhwMoCk,11369
35
+ ramses_tx/gateway.py,sha256=Fl6EqAUU2DnLOiA2_87sS7VPBEyxA1a_ICCmg55WMMA,11494
36
36
  ramses_tx/helpers.py,sha256=J4OCRckp3JshGQTvvqEskFjB1hPS7uA_opVsuIqmZds,32915
37
- ramses_tx/logger.py,sha256=qYbUoNPnPaFWKVsYvLG6uTVuPTdZ8HsMzBbGx0DpBqc,10177
37
+ ramses_tx/logger.py,sha256=1iKRHKUaqHqGd76CkE_6mCVR0sYODtxshRRwfY61fTk,10426
38
38
  ramses_tx/message.py,sha256=-moQ8v3HVlNSl-x3U0DDfDcj8WQ7vLqclMNxsohbmnw,13449
39
39
  ramses_tx/opentherm.py,sha256=58PXz9l5x8Ou6Fm3y-R_UnGHCYahoi2RKIDdYStUMzk,42378
40
40
  ramses_tx/packet.py,sha256=_qHiPFWpQpKueZOgf1jJ93Y09iZjo3LZWStLglVkXg4,7370
41
- ramses_tx/parsers.py,sha256=Z0ochrNyO2l8SAP0oJxN-tx2nJluEaNP_uJCIm_lsA8,111279
42
- ramses_tx/protocol.py,sha256=9R3aCzuiWEyXmugmB5tyR_RhBlgfnpUXj7AsMP9BzzU,28867
41
+ ramses_tx/parsers.py,sha256=xkffUleahvY6uPb34pcBDYnJ7QNQUzRuaED7wOVeEsE,113466
42
+ ramses_tx/protocol.py,sha256=zjOz0TCzMmbUiLouLFXKRL7u9a6p9YsCkLYDVC5vG-Y,28867
43
43
  ramses_tx/protocol_fsm.py,sha256=ZKtehCr_4TaDdfdlfidFLJaOVTYtaEq5h4tLqNIhb9s,26827
44
44
  ramses_tx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
45
- ramses_tx/ramses.py,sha256=NG81GBNZlap-Gi9ac-r6OFE-KaHvXsgPDWy-I2Irr-4,53698
46
- ramses_tx/schemas.py,sha256=U8SkwbW41zac0MQe0NgP6qTUqymmbeCG-UHPEw_GAv0,13267
47
- ramses_tx/transport.py,sha256=kyvnVf2RpuA_8YRO6C4S60YHctDag0ywfoMzb0wWDGY,58552
45
+ ramses_tx/ramses.py,sha256=vp748Tf_a-56OMM8CWDA2ZktRfTuj0QVyPRcnsOstSM,53983
46
+ ramses_tx/schemas.py,sha256=bqKW_V0bR6VbBD8ZQiBExNtVdXs0fryVKe3GEhupgIo,13424
47
+ ramses_tx/transport.py,sha256=8U6Hskle_0tdI4WViJT0e9Bl6-OeK1uKBvTQZvHtqyY,58923
48
48
  ramses_tx/typed_dicts.py,sha256=w-0V5t2Q3GiNUOrRAWiW9GtSwbta_7luME6GfIb1zhI,10869
49
49
  ramses_tx/typing.py,sha256=eF2SlPWhNhEFQj6WX2AhTXiyRQVXYnFutiepllYl2rI,5042
50
- ramses_tx/version.py,sha256=NEkkAJQW7Hx8GmvkR9BZpLSOi30OMi9Awth13sWXuTQ,123
51
- ramses_rf-0.52.2.dist-info/METADATA,sha256=Fm3vwa0FvFkMEHz44B8FDq0PjboTn_g9sGHyK7HiydA,4000
52
- ramses_rf-0.52.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
53
- ramses_rf-0.52.2.dist-info/entry_points.txt,sha256=NnyK29baOCNg8DinPYiZ368h7MTH7bgTW26z2A1NeIE,50
54
- ramses_rf-0.52.2.dist-info/licenses/LICENSE,sha256=-Kc35W7l1UkdiQ4314_yVWv7vDDrg7IrJfMLUiq6Nfs,1074
55
- ramses_rf-0.52.2.dist-info/RECORD,,
50
+ ramses_tx/version.py,sha256=UqvJQixbILsFY_XbawFKzLgN4gL1DKFAtz6JsZAXk4I,123
51
+ ramses_rf-0.52.4.dist-info/METADATA,sha256=XTTcXOovtegIupIA9b5wQPCfRjUeuqtHRMOKKd_5bPo,4179
52
+ ramses_rf-0.52.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
53
+ ramses_rf-0.52.4.dist-info/entry_points.txt,sha256=NnyK29baOCNg8DinPYiZ368h7MTH7bgTW26z2A1NeIE,50
54
+ ramses_rf-0.52.4.dist-info/licenses/LICENSE,sha256=-Kc35W7l1UkdiQ4314_yVWv7vDDrg7IrJfMLUiq6Nfs,1074
55
+ ramses_rf-0.52.4.dist-info/RECORD,,
ramses_tx/const.py CHANGED
@@ -440,7 +440,7 @@ DEV_TYPE_MAP = attr_dict_factory(
440
440
  ), # CH/DHW devices instead of HVAC/other
441
441
  "HEAT_ZONE_SENSORS": ("00", "01", "03", "04", "12", "22", "34"),
442
442
  "HEAT_ZONE_ACTUATORS": ("00", "02", "04", "13"),
443
- "THM_DEVICES": ("03", "12", "22", "34"),
443
+ "THM_DEVICES": ("03", "12", "21", "22", "34"),
444
444
  "TRV_DEVICES": ("00", "04"),
445
445
  "CONTROLLERS": ("01", "02", "12", "22", "23", "34"), # potentially controllers
446
446
  "PROMOTABLE_SLUGS": (DevType.DEV, DevType.HEA, DevType.HVC),
ramses_tx/gateway.py CHANGED
@@ -34,6 +34,7 @@ from .schemas import (
34
34
  SZ_DISABLE_QOS,
35
35
  SZ_DISABLE_SENDING,
36
36
  SZ_ENFORCE_KNOWN_LIST,
37
+ SZ_LOG_ALL_MQTT,
37
38
  SZ_PACKET_LOG,
38
39
  SZ_PORT_CONFIG,
39
40
  SZ_PORT_NAME,
@@ -114,6 +115,7 @@ class Engine:
114
115
  self._exclude,
115
116
  )
116
117
  self._sqlite_index = kwargs.pop(SZ_SQLITE_INDEX, False) # default True?
118
+ self._log_all_mqtt = kwargs.pop(SZ_LOG_ALL_MQTT, False)
117
119
  self._kwargs: dict[str, Any] = kwargs # HACK
118
120
 
119
121
  self._engine_lock = Lock() # FIXME: threading lock, or asyncio lock?
@@ -197,6 +199,7 @@ class Engine:
197
199
  self._protocol,
198
200
  disable_sending=self._disable_sending,
199
201
  loop=self._loop,
202
+ log_all=self._log_all_mqtt,
200
203
  **pkt_source,
201
204
  **self._kwargs, # HACK: odd/misc params, e.g. comms_params
202
205
  )
ramses_tx/logger.py CHANGED
@@ -162,6 +162,14 @@ class StdOutFilter(logging.Filter): # record.levelno < logging.WARNING
162
162
  return record.levelno < logging.WARNING
163
163
 
164
164
 
165
+ class BlockMqttFilter(logging.Filter):
166
+ """Block mqtt traffic"""
167
+
168
+ def filter(self, record: logging.LogRecord) -> bool:
169
+ """Return True if the record is to be processed."""
170
+ return not record.getMessage().startswith("mq Rx: ")
171
+
172
+
165
173
  class TimedRotatingFileHandler(_TimedRotatingFileHandler):
166
174
  def __init__(self, *args: Any, **kwargs: Any) -> None:
167
175
  super().__init__(*args, **kwargs)
ramses_tx/parsers.py CHANGED
@@ -1912,6 +1912,7 @@ def parser_2411(payload: str, msg: Message) -> dict[str, Any]:
1912
1912
  "01": (2, centile), # 52 (0.0-25.0) (%)
1913
1913
  "0F": (2, hex_to_percent), # xx (0.0-1.0) (%)
1914
1914
  "10": (4, counter), # 31 (0-1800) (days)
1915
+ # "20": (4, counter), # unknown data type, uncomment when we have more info
1915
1916
  "92": (4, hex_to_temp), # 75 (0-30) (C)
1916
1917
  } # TODO: _2411_TYPES.get(payload[8:10], (8, no_op))
1917
1918
 
@@ -1938,11 +1939,35 @@ def parser_2411(payload: str, msg: Message) -> dict[str, Any]:
1938
1939
  return result
1939
1940
 
1940
1941
  try:
1941
- assert payload[8:10] in _2411_DATA_TYPES, (
1942
- f"param {param_id} has unknown data_type: {payload[8:10]}"
1943
- ) # _INFORM_DEV_MSG
1944
- length, parser = _2411_DATA_TYPES.get(payload[8:10], (8, lambda x: x))
1942
+ # Handle unknown data types gracefully instead of asserting
1943
+ if payload[8:10] not in _2411_DATA_TYPES:
1944
+ warningmsg = (
1945
+ f"{msg!r} < {_INFORM_DEV_MSG} (param {param_id} has unknown data_type: {payload[8:10]}). "
1946
+ f"This parameter uses an unrecognized data type. "
1947
+ f"Please report this packet and any context about what changed on your system."
1948
+ )
1949
+ # Return partial result with raw hex values for unknown data types
1950
+ if msg.len == 9:
1951
+ result |= {
1952
+ "value": f"0x{payload[10:18]}", # Raw hex value
1953
+ "_value_06": payload[6:10],
1954
+ "_unknown_data_type": payload[8:10],
1955
+ }
1956
+ else:
1957
+ result |= {
1958
+ "value": f"0x{payload[10:18]}", # Raw hex value
1959
+ "_value_06": payload[6:10],
1960
+ "min_value": f"0x{payload[18:26]}", # Raw hex value
1961
+ "max_value": f"0x{payload[26:34]}", # Raw hex value
1962
+ "precision": f"0x{payload[34:42]}", # Raw hex value
1963
+ "_value_42": payload[42:],
1964
+ # Flexible footer - capture everything after precision
1965
+ }
1966
+ _LOGGER.warning(f"{warningmsg}. Found values: {result}")
1967
+ return result
1945
1968
 
1969
+ # Handle known data types normally
1970
+ length, parser = _2411_DATA_TYPES[payload[8:10]]
1946
1971
  result |= {
1947
1972
  "value": parser(payload[10:18][-length:]), # type: ignore[operator]
1948
1973
  "_value_06": payload[6:10],
@@ -1962,10 +1987,11 @@ def parser_2411(payload: str, msg: Message) -> dict[str, Any]:
1962
1987
  # eg. older Orcon models may have a footer of 2 bytes
1963
1988
  }
1964
1989
  )
1965
- except AssertionError as err:
1966
- _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({err})")
1967
- # Return partial result for unknown parameters
1968
- result["value"] = ""
1990
+ except Exception as err:
1991
+ _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} (Error parsing 2411: {err})")
1992
+ # Return partial result for any parsing errors
1993
+ result["value"] = f"0x{payload[10:18]}" # Raw hex value
1994
+ result["_parse_error"] = f"Parser error: {err}"
1969
1995
  return result
1970
1996
 
1971
1997
 
@@ -2981,7 +3007,11 @@ def parser_unknown(payload: str, msg: Message) -> dict[str, Any]:
2981
3007
  "_value": hex_to_temp(payload[2:]),
2982
3008
  }
2983
3009
 
2984
- raise NotImplementedError
3010
+ return {
3011
+ "_payload": payload,
3012
+ "_unknown_code": msg.code,
3013
+ "_parse_error": "No parser available for this packet type",
3014
+ }
2985
3015
 
2986
3016
 
2987
3017
  _PAYLOAD_PARSERS = {
@@ -2998,8 +3028,23 @@ def parse_payload(msg: Message) -> dict | list[dict]:
2998
3028
  :return: a dict of key: value pairs or a list of such dicts, e.g. {'temperature': 21.5}
2999
3029
  """
3000
3030
  result: dict | list[dict]
3001
- result = _PAYLOAD_PARSERS.get(msg.code, parser_unknown)(msg._pkt.payload, msg)
3002
- if isinstance(result, dict) and msg.seqn.isnumeric(): # e.g. 22F1/3
3003
- result["seqx_num"] = msg.seqn
3031
+ try:
3032
+ result = _PAYLOAD_PARSERS.get(msg.code, parser_unknown)(msg._pkt.payload, msg)
3033
+ if isinstance(result, dict) and msg.seqn.isnumeric(): # e.g. 22F1/3
3034
+ result["seqx_num"] = msg.seqn
3035
+ except AssertionError as err:
3036
+ _LOGGER.warning(
3037
+ f"{msg!r} < {_INFORM_DEV_MSG} ({err}). "
3038
+ f"This packet could not be parsed completely. "
3039
+ f"Please report this message and any context about what changed on your system when this occurred."
3040
+ )
3041
+ # Return partial result with error info
3042
+ result = {
3043
+ "_payload": msg._pkt.payload,
3044
+ "_parse_error": f"AssertionError: {err}",
3045
+ "_unknown_code": msg.code,
3046
+ }
3047
+ if isinstance(result, dict) and msg.seqn.isnumeric():
3048
+ result["seqx_num"] = msg.seqn
3004
3049
 
3005
3050
  return result
ramses_tx/protocol.py CHANGED
@@ -583,7 +583,7 @@ class PortProtocol(_DeviceIdFilterMixin, _BaseProtocol):
583
583
  self._context.pause_writing()
584
584
 
585
585
  def resume_writing(self) -> None:
586
- """Inform the FSM that the Protocol has been paused."""
586
+ """Inform the FSM that the Protocol has been resumed."""
587
587
 
588
588
  super().resume_writing()
589
589
  if self._context:
@@ -597,7 +597,7 @@ class PortProtocol(_DeviceIdFilterMixin, _BaseProtocol):
597
597
  self._context.pkt_received(pkt)
598
598
 
599
599
  async def _send_impersonation_alert(self, cmd: Command) -> None:
600
- """Send an puzzle packet warning that impersonation is occurring."""
600
+ """Send a puzzle packet warning that impersonation is occurring."""
601
601
 
602
602
  if _DBG_DISABLE_IMPERSONATION_ALERTS:
603
603
  return
ramses_tx/ramses.py CHANGED
@@ -377,10 +377,10 @@ CODES_SCHEMA: dict[Code, dict[str, Any]] = { # rf_unknown
377
377
  I_: r"^(0[0-9A-F][0-9A-F]{8}0[12]){1,4}(0[12]03)?$", # (0[12]03)? only if len(array) == 1
378
378
  W_: r"^(0[0-9A-F][0-9A-F]{8}0[12])$", # never an array
379
379
  },
380
- Code._22D0: { # unknown_22d0, HVAC system switch?
380
+ Code._22D0: { # unknown_22d0, Spider thermostat, HVAC system switch?
381
381
  SZ_NAME: "message_22d0",
382
- I_: r"^(00|03)",
383
- W_: r"^03",
382
+ I_: r"^(00|03)[0-9]{6}$",
383
+ W_: r"^03[0-9]{4}1E14030020$",
384
384
  },
385
385
  Code._22D9: { # boiler_setpoint
386
386
  SZ_NAME: "boiler_setpoint",
@@ -843,6 +843,7 @@ _DEV_KLASSES_HEAT: dict[str, dict[Code, dict[VerbT, Any]]] = {
843
843
  Code._000C: {I_: {}},
844
844
  Code._000E: {I_: {}},
845
845
  Code._0016: {RQ: {}},
846
+ Code._01FF: {I_: {}, RQ: {}},
846
847
  Code._042F: {I_: {}},
847
848
  Code._1030: {I_: {}},
848
849
  Code._1060: {I_: {}},
@@ -853,9 +854,11 @@ _DEV_KLASSES_HEAT: dict[str, dict[Code, dict[VerbT, Any]]] = {
853
854
  Code._1F09: {I_: {}},
854
855
  Code._1FC9: {I_: {}},
855
856
  Code._22C9: {W_: {}}, # DT4R
857
+ Code._22D0: {W_: {}}, # Spider master THM
856
858
  Code._2309: {I_: {}, RQ: {}, W_: {}},
857
859
  Code._2349: {RQ: {}, W_: {}},
858
860
  Code._30C9: {I_: {}},
861
+ Code._3110: {I_: {}}, # Spider THM
859
862
  Code._3120: {I_: {}},
860
863
  Code._313F: {
861
864
  I_: {}
@@ -880,6 +883,7 @@ _DEV_KLASSES_HEAT: dict[str, dict[Code, dict[VerbT, Any]]] = {
880
883
  Code._3110: {I_: {}}, # Spider Autotemp
881
884
  Code._3150: {I_: {}},
882
885
  Code._4E01: {I_: {}}, # Spider Autotemp Zone controller
886
+ Code._4E04: {I_: {}}, # idem
883
887
  },
884
888
  DevType.TRV: { # e.g. HR92/HR91: Radiator Controller
885
889
  Code._0001: {W_: {r"^0[0-9A-F]"}},
@@ -1178,9 +1182,9 @@ _CODE_ONLY_FROM_CTL: tuple[Code, ...] = tuple(
1178
1182
  CODES_ONLY_FROM_CTL: tuple[Code, ...] = (
1179
1183
  Code._1030,
1180
1184
  Code._1F09,
1181
- Code._22D0,
1185
+ # Code._22D0, # also _W from the Spider master THM! issue #340
1182
1186
  Code._313F,
1183
- ) # I packets, TODO: 31Dx too?
1187
+ ) # I packets, TODO: 31Dx too? not 31D9/31DA!
1184
1188
 
1185
1189
  #
1186
1190
  ########################################################################################
ramses_tx/schemas.py CHANGED
@@ -413,6 +413,7 @@ SZ_EVOFW_FLAG: Final = "evofw_flag"
413
413
  SZ_SQLITE_INDEX: Final = (
414
414
  "sqlite_index" # temporary 0.52.x SQLite dev config option in ramses_cc
415
415
  )
416
+ SZ_LOG_ALL_MQTT: Final = "log_all_mqtt"
416
417
  SZ_USE_REGEX: Final = "use_regex"
417
418
 
418
419
  SCH_ENGINE_DICT = {
@@ -427,6 +428,9 @@ SCH_ENGINE_DICT = {
427
428
  vol.Optional(
428
429
  SZ_SQLITE_INDEX, default=False
429
430
  ): bool, # temporary 0.52.x dev config option
431
+ vol.Optional(
432
+ SZ_LOG_ALL_MQTT, default=False
433
+ ): bool, # log all incoming MQTT traffic config option
430
434
  vol.Optional(SZ_USE_REGEX): dict, # vol.All(ConvertNullToDict(), dict),
431
435
  vol.Optional(SZ_COMMS_PARAMS): SCH_COMMS_PARAMS,
432
436
  }
ramses_tx/transport.py CHANGED
@@ -1024,6 +1024,7 @@ class PortTransport(_RegHackMixin, _FullTransport, _PortTransportAbstractor): #
1024
1024
 
1025
1025
  class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1026
1026
  """Send/receive packets to/from ramses_esp via MQTT.
1027
+ For full RX logging, turn on debug logging.
1027
1028
 
1028
1029
  See: https://github.com/IndaloTech/ramses_esp
1029
1030
  """
@@ -1066,6 +1067,9 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1066
1067
  self._max_tokens: float = self._MAX_TOKENS * 2 # allow for the initial burst
1067
1068
  self._num_tokens: float = self._MAX_TOKENS * 2
1068
1069
 
1070
+ # set log MQTT flag
1071
+ self._log_all = kwargs.pop("log_all", False)
1072
+
1069
1073
  # instantiate a paho mqtt client
1070
1074
  self.client = mqtt.Client(
1071
1075
  protocol=mqtt.MQTTv5, callback_api_version=CallbackAPIVersion.VERSION2
@@ -1283,8 +1287,9 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1283
1287
 
1284
1288
  if _DBG_FORCE_FRAME_LOGGING:
1285
1289
  _LOGGER.warning("Rx: %s", msg.payload)
1286
- elif _LOGGER.getEffectiveLevel() == logging.INFO: # log for INFO not DEBUG
1287
- _LOGGER.info("Rx: %s", msg.payload)
1290
+ elif self._log_all and _LOGGER.getEffectiveLevel() == logging.INFO:
1291
+ # log for INFO not DEBUG
1292
+ _LOGGER.info("mq Rx: %s", msg.payload) # TODO remove mq marker?
1288
1293
 
1289
1294
  if msg.topic[-3:] != "/rx": # then, e.g. 'RAMSES/GATEWAY/18:017804'
1290
1295
  if msg.payload == b"offline":
@@ -1507,6 +1512,7 @@ async def transport_factory(
1507
1512
  disable_sending: bool | None = False,
1508
1513
  extra: dict[str, Any] | None = None,
1509
1514
  loop: asyncio.AbstractEventLoop | None = None,
1515
+ log_all: bool = False,
1510
1516
  **kwargs: Any, # HACK: odd/misc params
1511
1517
  ) -> RamsesTransportT:
1512
1518
  """Create and return a Ramses-specific async packet Transport."""
@@ -1558,19 +1564,24 @@ async def transport_factory(
1558
1564
  "Packet source must be exactly one of: packet_dict, packet_log, port_name"
1559
1565
  )
1560
1566
 
1567
+ # File
1561
1568
  if (pkt_source := packet_log or packet_dict) is not None:
1562
1569
  return FileTransport(pkt_source, protocol, extra=extra, loop=loop, **kwargs)
1563
1570
 
1564
1571
  assert port_name is not None # mypy check
1565
1572
  assert port_config is not None # mypy check
1566
1573
 
1574
+ # MQTT
1567
1575
  if port_name[:4] == "mqtt": # TODO: handle disable_sending
1568
- transport = MqttTransport(port_name, protocol, extra=extra, loop=loop, **kwargs)
1576
+ transport = MqttTransport(
1577
+ port_name, protocol, extra=extra, loop=loop, log_all=log_all, **kwargs
1578
+ )
1569
1579
 
1570
1580
  # TODO: remove this? better to invoke timeout after factory returns?
1571
1581
  await protocol.wait_for_connection_made(timeout=_DEFAULT_TIMEOUT_MQTT)
1572
1582
  return transport
1573
1583
 
1584
+ # Serial
1574
1585
  ser_instance = get_serial_instance(port_name, port_config)
1575
1586
 
1576
1587
  if os.name == "nt" or ser_instance.portstr[:7] in ("rfc2217", "socket:"):
@@ -1587,4 +1598,5 @@ async def transport_factory(
1587
1598
 
1588
1599
  # TODO: remove this? better to invoke timeout after factory returns?
1589
1600
  await protocol.wait_for_connection_made(timeout=_DEFAULT_TIMEOUT_PORT)
1601
+ # pytest-cov times out in virtual_rf.py when set below 30.0 on GitHub Actions
1590
1602
  return transport
ramses_tx/version.py CHANGED
@@ -1,4 +1,4 @@
1
1
  """RAMSES RF - a RAMSES-II protocol decoder & analyser (transport layer)."""
2
2
 
3
- __version__ = "0.52.2"
3
+ __version__ = "0.52.4"
4
4
  VERSION = __version__