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_tx/parsers.py CHANGED
@@ -190,6 +190,16 @@ _LOGGER = _PKT_LOGGER = logging.getLogger(__name__)
190
190
 
191
191
  # rf_unknown
192
192
  def parser_0001(payload: str, msg: Message) -> Mapping[str, bool | str | None]:
193
+ """Parse the 0001 (rf_unknown) packet.
194
+
195
+ :param payload: The raw hex payload
196
+ :type payload: str
197
+ :param msg: The message object containing context
198
+ :type msg: Message
199
+ :return: A mapping of parsed slot and parameter data
200
+ :rtype: Mapping[str, bool | str | None]
201
+ :raises AssertionError: If the payload format does not match expected constants.
202
+ """
193
203
  # When in test mode, a 12: will send a W ?every 6 seconds:
194
204
  # 12:39:56.099 061 W --- 12:010740 --:------ 12:010740 0001 005 0000000501
195
205
  # 12:40:02.098 061 W --- 12:010740 --:------ 12:010740 0001 005 0000000501
@@ -265,6 +275,15 @@ def parser_0001(payload: str, msg: Message) -> Mapping[str, bool | str | None]:
265
275
 
266
276
  # outdoor_sensor (outdoor_weather / outdoor_temperature)
267
277
  def parser_0002(payload: str, msg: Message) -> dict[str, Any]:
278
+ """Parse the 0002 (outdoor_sensor) packet.
279
+
280
+ :param payload: The raw hex payload
281
+ :type payload: str
282
+ :param msg: The message object
283
+ :type msg: Message
284
+ :return: A dictionary containing the outdoor temperature
285
+ :rtype: dict[str, Any]
286
+ """
268
287
  if payload[6:] == "02": # or: msg.src.type == DEV_TYPE_MAP.OUT:
269
288
  return {
270
289
  SZ_TEMPERATURE: hex_to_temp(payload[2:6]),
@@ -276,6 +295,15 @@ def parser_0002(payload: str, msg: Message) -> dict[str, Any]:
276
295
 
277
296
  # zone_name
278
297
  def parser_0004(payload: str, msg: Message) -> PayDictT._0004:
298
+ """Parse the 0004 (zone_name) packet.
299
+
300
+ :param payload: The raw hex payload
301
+ :type payload: str
302
+ :param msg: The message object
303
+ :type msg: Message
304
+ :return: A dictionary containing the zone name
305
+ :rtype: PayDictT._0004
306
+ """
279
307
  # RQ payload is zz00; limited to 12 chars in evohome UI? if "7F"*20: not a zone
280
308
 
281
309
  return {} if payload[4:] == "7F" * 20 else {SZ_NAME: hex_to_str(payload[4:])}
@@ -283,6 +311,16 @@ def parser_0004(payload: str, msg: Message) -> PayDictT._0004:
283
311
 
284
312
  # system_zones (add/del a zone?) # TODO: needs a cleanup
285
313
  def parser_0005(payload: str, msg: Message) -> dict | list[dict]: # TODO: only dict
314
+ """Parse the 0005 (system_zones) packet to identify zone types and masks.
315
+
316
+ :param payload: The raw hex payload
317
+ :type payload: str
318
+ :param msg: The message object
319
+ :type msg: Message
320
+ :return: A list or dictionary of zone classes and masks
321
+ :rtype: dict | list[dict]
322
+ :raises AssertionError: If the message source is not a recognized device type.
323
+ """
286
324
  # .I --- 01:145038 --:------ 01:145038 0005 004 00000100
287
325
  # RP --- 02:017205 18:073736 --:------ 0005 004 0009001F
288
326
  # .I --- 34:064023 --:------ 34:064023 0005 012 000A0000-000F0000-00100000
@@ -317,10 +355,15 @@ def parser_0005(payload: str, msg: Message) -> dict | list[dict]: # TODO: only
317
355
 
318
356
  # schedule_sync (any changes?)
319
357
  def parser_0006(payload: str, msg: Message) -> PayDictT._0006:
320
- """Return the total number of changes to the schedules, including the DHW schedule.
321
-
322
- An RQ is sent every ~60s by a RFG100, an increase will prompt it to send a run of
323
- RQ|0404s (it seems to assume only the zones may have changed?).
358
+ """Return the total number of changes to the system schedules.
359
+
360
+ :param payload: The raw hex payload
361
+ :type payload: str
362
+ :param msg: The message object
363
+ :type msg: Message
364
+ :return: A dictionary containing the schedule change counter
365
+ :rtype: PayDictT._0006
366
+ :raises AssertionError: If the payload header is invalid.
324
367
  """
325
368
  # 16:10:34.288 053 RQ --- 30:071715 01:145038 --:------ 0006 001 00
326
369
  # 16:10:34.291 053 RP --- 01:145038 30:071715 --:------ 0006 004 00050008
@@ -337,6 +380,16 @@ def parser_0006(payload: str, msg: Message) -> PayDictT._0006:
337
380
 
338
381
  # relay_demand (domain/zone/device)
339
382
  def parser_0008(payload: str, msg: Message) -> PayDictT._0008:
383
+ """Parse the 0008 (relay_demand) packet.
384
+
385
+ :param payload: The raw hex payload
386
+ :type payload: str
387
+ :param msg: The message object
388
+ :type msg: Message
389
+ :return: A dictionary containing the relay demand percentage
390
+ :rtype: PayDictT._0008
391
+ :raises AssertionError: If the message length is invalid for specific device types.
392
+ """
340
393
  # https://www.domoticaforum.eu/viewtopic.php?f=7&t=5806&start=105#p73681
341
394
  # e.g. Electric Heat Zone
342
395
 
@@ -360,7 +413,8 @@ def parser_0008(payload: str, msg: Message) -> PayDictT._0008:
360
413
 
361
414
  # relay_failsafe
362
415
  def parser_0009(payload: str, msg: Message) -> dict | list[dict]: # TODO: only dict
363
- """The relay failsafe mode.
416
+ """Parse the 0009 (relay_failsafe) packet.
417
+ The relay failsafe mode.
364
418
 
365
419
  The failsafe mode defines the relay behaviour if the RF communication is lost (e.g.
366
420
  when a room thermostat stops communicating due to discharged batteries):
@@ -369,6 +423,14 @@ def parser_0009(payload: str, msg: Message) -> dict | list[dict]: # TODO: only
369
423
  - True (enabled) - if RF comms are lost, relay will cycle at 20% ON, 80% OFF
370
424
 
371
425
  This setting may need to be enabled to ensure frost protect mode.
426
+
427
+ :param payload: The raw hex payload
428
+ :type payload: str
429
+ :param msg: The message object
430
+ :type msg: Message
431
+ :return: A dictionary defining if failsafe mode is enabled
432
+ :rtype: dict | list[dict]
433
+ :raises AssertionError: If the domain ID in the payload is invalid.
372
434
  """
373
435
  # can get: 003 or 006, e.g.: FC01FF-F901FF or FC00FF-F900FF
374
436
  # .I --- 23:100224 --:------ 23:100224 0009 003 0100FF # 2-zone ST9520C
@@ -395,6 +457,17 @@ def parser_0009(payload: str, msg: Message) -> dict | list[dict]: # TODO: only
395
457
  def parser_000a(
396
458
  payload: str, msg: Message
397
459
  ) -> PayDictT._000A | list[PayDictT._000A] | PayDictT.EMPTY:
460
+ """Parse the 000a (zone_params) packet.
461
+
462
+ :param payload: The raw hex payload
463
+ :type payload: str
464
+ :param msg: The message object
465
+ :type msg: Message
466
+ :return: A dictionary of zone parameters including min/max temps
467
+ :rtype: PayDictT._000A | list[PayDictT._000A] | PayDictT.EMPTY
468
+ :raises AssertionError: If the message length is unexpected.
469
+ """
470
+
398
471
  def _parser(seqx: str) -> PayDictT._000A: # null_rp: "007FFF7FFF"
399
472
  bitmap = int(seqx[2:4], 16)
400
473
  return {
@@ -424,6 +497,17 @@ def parser_000a(
424
497
 
425
498
  # zone_devices
426
499
  def parser_000c(payload: str, msg: Message) -> dict[str, Any]:
500
+ """Parse the 000c (zone_devices) packet.
501
+
502
+ :param payload: The raw hex payload
503
+ :type payload: str
504
+ :param msg: The message object
505
+ :type msg: Message
506
+ :return: A dictionary mapping device IDs to zone indices
507
+ :rtype: dict[str, Any]
508
+ :raises PacketPayloadInvalid: If the element length in the payload is malformed.
509
+ :raises AssertionError: If indices or device IDs are invalid.
510
+ """
427
511
  # .I --- 34:092243 --:------ 34:092243 000C 018 00-0A-7F-FFFFFF 00-0F-7F-FFFFFF 00-10-7F-FFFFFF # noqa: E501
428
512
  # RP --- 01:145038 18:013393 --:------ 000C 006 00-00-00-10DAFD
429
513
  # RP --- 01:145038 18:013393 --:------ 000C 012 01-00-00-10DAF5 01-00-00-10DAFB
@@ -515,6 +599,16 @@ def parser_000c(payload: str, msg: Message) -> dict[str, Any]:
515
599
 
516
600
  # unknown_000e, from STA
517
601
  def parser_000e(payload: str, msg: Message) -> dict[str, Any]:
602
+ """Parse the 000e packet.
603
+
604
+ :param payload: The raw hex payload
605
+ :type payload: str
606
+ :param msg: The message object
607
+ :type msg: Message
608
+ :return: A dictionary containing the raw payload
609
+ :rtype: dict[str, Any]
610
+ :raises AssertionError: If the payload value is not recognized.
611
+ """
518
612
  assert payload in ("000014", "000028"), _INFORM_DEV_MSG
519
613
 
520
614
  return {
@@ -524,6 +618,15 @@ def parser_000e(payload: str, msg: Message) -> dict[str, Any]:
524
618
 
525
619
  # rf_check
526
620
  def parser_0016(payload: str, msg: Message) -> dict[str, Any]:
621
+ """Parse the 0016 (rf_check) packet.
622
+
623
+ :param payload: The raw hex payload
624
+ :type payload: str
625
+ :param msg: The message object containing context
626
+ :type msg: Message
627
+ :return: A dictionary containing rf_strength and rf_value
628
+ :rtype: dict[str, Any]
629
+ """
527
630
  # TODO: does 0016 include parent_idx?, but RQ|07:|0000?
528
631
  # RQ --- 22:060293 01:078710 --:------ 0016 002 0200
529
632
  # RP --- 01:078710 22:060293 --:------ 0016 002 021E
@@ -544,6 +647,15 @@ def parser_0016(payload: str, msg: Message) -> dict[str, Any]:
544
647
 
545
648
  # language (of device/system)
546
649
  def parser_0100(payload: str, msg: Message) -> PayDictT._0100 | PayDictT.EMPTY:
650
+ """Parse the 0100 (language) packet.
651
+
652
+ :param payload: The raw hex payload
653
+ :type payload: str
654
+ :param msg: The message object containing context
655
+ :type msg: Message
656
+ :return: A dictionary containing the language string
657
+ :rtype: PayDictT._0100 | PayDictT.EMPTY
658
+ """
547
659
  if msg.verb == RQ and msg.len == 1: # some RQs have a payload
548
660
  return {}
549
661
 
@@ -555,6 +667,16 @@ def parser_0100(payload: str, msg: Message) -> PayDictT._0100 | PayDictT.EMPTY:
555
667
 
556
668
  # unknown_0150, from OTB
557
669
  def parser_0150(payload: str, msg: Message) -> dict[str, Any]:
670
+ """Parse the 0150 packet.
671
+
672
+ :param payload: The raw hex payload
673
+ :type payload: str
674
+ :param msg: The message object
675
+ :type msg: Message
676
+ :return: A dictionary containing the raw payload
677
+ :rtype: dict[str, Any]
678
+ :raises AssertionError: If the payload is not the expected '000000'.
679
+ """
558
680
  assert payload == "000000", _INFORM_DEV_MSG
559
681
 
560
682
  return {
@@ -564,6 +686,16 @@ def parser_0150(payload: str, msg: Message) -> dict[str, Any]:
564
686
 
565
687
  # unknown_01d0, from a HR91 (when its buttons are pushed)
566
688
  def parser_01d0(payload: str, msg: Message) -> dict[str, Any]:
689
+ """Parse the 01d0 packet (HR91 button push).
690
+
691
+ :param payload: The raw hex payload
692
+ :type payload: str
693
+ :param msg: The message object
694
+ :type msg: Message
695
+ :return: A dictionary containing the unknown state value
696
+ :rtype: dict[str, Any]
697
+ :raises AssertionError: If the payload value is not recognized.
698
+ """
567
699
  # 23:57:28.869 045 W --- 04:000722 01:158182 --:------ 01D0 002 0003
568
700
  # 23:57:28.931 045 I --- 01:158182 04:000722 --:------ 01D0 002 0003
569
701
  # 23:57:31.581 048 W --- 04:000722 01:158182 --:------ 01E9 002 0003
@@ -579,6 +711,16 @@ def parser_01d0(payload: str, msg: Message) -> dict[str, Any]:
579
711
 
580
712
  # unknown_01e9, from a HR91 (when its buttons are pushed)
581
713
  def parser_01e9(payload: str, msg: Message) -> dict[str, Any]:
714
+ """Parse the 01e9 packet (HR91 button push).
715
+
716
+ :param payload: The raw hex payload
717
+ :type payload: str
718
+ :param msg: The message object
719
+ :type msg: Message
720
+ :return: A dictionary containing the unknown state value
721
+ :rtype: dict[str, Any]
722
+ :raises AssertionError: If the payload value is not recognized.
723
+ """
582
724
  # 23:57:31.581348 048 W --- 04:000722 01:158182 --:------ 01E9 002 0003
583
725
  # 23:57:31.643188 045 I --- 01:158182 04:000722 --:------ 01E9 002 0000
584
726
 
@@ -590,6 +732,16 @@ def parser_01e9(payload: str, msg: Message) -> dict[str, Any]:
590
732
 
591
733
  # unknown_01ff, to/from a Itho Spider/Thermostat
592
734
  def parser_01ff(payload: str, msg: Message) -> dict[str, Any]:
735
+ """Parse the 01ff (Itho Spider) packet.
736
+
737
+ :param payload: The raw hex payload
738
+ :type payload: str
739
+ :param msg: The message object containing context
740
+ :type msg: Message
741
+ :return: A dictionary of temperature, setpoint bounds, and flags
742
+ :rtype: dict[str, Any]
743
+ :raises AssertionError: If internal payload constraints are violated.
744
+ """
593
745
  # see: https://github.com/zxdavb/ramses_rf/issues/73 & 101
594
746
 
595
747
  # lots of '80's, and I see temps are `int(payload[6:8], 16) / 2`, so I wonder if 0x80 is N/A?
@@ -671,6 +823,17 @@ def parser_01ff(payload: str, msg: Message) -> dict[str, Any]:
671
823
 
672
824
  # zone_schedule (fragment)
673
825
  def parser_0404(payload: str, msg: Message) -> PayDictT._0404:
826
+ """Parse the 0404 (zone_schedule) fragment.
827
+
828
+ :param payload: The raw hex payload
829
+ :type payload: str
830
+ :param msg: The message object
831
+ :type msg: Message
832
+ :return: A dictionary containing schedule fragment data and total fragments
833
+ :rtype: PayDictT._0404
834
+ :raises PacketPayloadInvalid: If the fragment length does not match the header.
835
+ :raises AssertionError: If internal context bytes are invalid.
836
+ """
674
837
  # Retrieval of Zone schedule (NB: 200008)
675
838
  # RQ --- 30:185469 01:037519 --:------ 0404 007 00-200008-00-0100
676
839
  # RP --- 01:037519 30:185469 --:------ 0404 048 00-200008-29-0103-6E2...
@@ -735,6 +898,15 @@ def parser_0404(payload: str, msg: Message) -> PayDictT._0404:
735
898
 
736
899
  # system_fault (fault_log_entry) - needs refactoring
737
900
  def parser_0418(payload: str, msg: Message) -> PayDictT._0418 | PayDictT._0418_NULL:
901
+ """Parse the 0418 (system_fault) packet.
902
+
903
+ :param payload: The raw hex payload
904
+ :type payload: str
905
+ :param msg: The message object
906
+ :type msg: Message
907
+ :return: A dictionary containing a fault log entry or null entry
908
+ :rtype: PayDictT._0418 | PayDictT._0418_NULL
909
+ """
738
910
  null_result: PayDictT._0418_NULL
739
911
  full_result: PayDictT._0418
740
912
 
@@ -813,6 +985,15 @@ def parser_0418(payload: str, msg: Message) -> PayDictT._0418 | PayDictT._0418_N
813
985
 
814
986
  # unknown_042f, from STA, VMS
815
987
  def parser_042f(payload: str, msg: Message) -> dict[str, Any]:
988
+ """Parse the 042f packet.
989
+
990
+ :param payload: The raw hex payload
991
+ :type payload: str
992
+ :param msg: The message object
993
+ :type msg: Message
994
+ :return: A dictionary of extracted hex counters
995
+ :rtype: dict[str, Any]
996
+ """
816
997
  return {
817
998
  "counter_1": f"0x{payload[2:6]}",
818
999
  "counter_3": f"0x{payload[6:10]}",
@@ -823,6 +1004,15 @@ def parser_042f(payload: str, msg: Message) -> dict[str, Any]:
823
1004
 
824
1005
  # TODO: unknown_0b04, from THM (only when its a CTL?)
825
1006
  def parser_0b04(payload: str, msg: Message) -> dict[str, Any]:
1007
+ """Parse the 0b04 packet.
1008
+
1009
+ :param payload: The raw hex payload
1010
+ :type payload: str
1011
+ :param msg: The message object
1012
+ :type msg: Message
1013
+ :return: A dictionary containing the unknown data value
1014
+ :rtype: dict[str, Any]
1015
+ """
826
1016
  # .I --- --:------ --:------ 12:207082 0B04 002 00C8 # batch of 3, every 24h
827
1017
 
828
1018
  return {
@@ -832,6 +1022,16 @@ def parser_0b04(payload: str, msg: Message) -> dict[str, Any]:
832
1022
 
833
1023
  # mixvalve_config (zone), FAN
834
1024
  def parser_1030(payload: str, msg: Message) -> PayDictT._1030:
1025
+ """Parse the 1030 (mixvalve_config) packet.
1026
+
1027
+ :param payload: The raw hex payload
1028
+ :type payload: str
1029
+ :param msg: The message object containing context
1030
+ :type msg: Message
1031
+ :return: A dictionary of mixing valve parameters
1032
+ :rtype: PayDictT._1030
1033
+ :raises AssertionError: If the message length is unexpected or parameters are malformed.
1034
+ """
835
1035
  # .I --- 01:145038 --:------ 01:145038 1030 016 0A-C80137-C9010F-CA0196-CB0100-CC0101
836
1036
  # .I --- --:------ --:------ 12:144017 1030 016 01-C80137-C9010F-CA0196-CB010F-CC0101
837
1037
  # RP --- 32:155617 18:005904 --:------ 1030 007 00-200100-21011F
@@ -860,9 +1060,17 @@ def parser_1030(payload: str, msg: Message) -> PayDictT._1030:
860
1060
 
861
1061
  # device_battery (battery_state)
862
1062
  def parser_1060(payload: str, msg: Message) -> PayDictT._1060:
863
- """Return the battery state.
1063
+ """Parse the 1060 (device_battery) packet.
1064
+ Return the battery state.
864
1065
 
865
1066
  Some devices (04:) will also report battery level.
1067
+ :param payload: The raw hex payload
1068
+ :type payload: str
1069
+ :param msg: The message object containing context
1070
+ :type msg: Message
1071
+ :return: A dictionary containing battery low status and level percentage
1072
+ :rtype: PayDictT._1060
1073
+ :raises AssertionError: If the message length is invalid.
866
1074
  """
867
1075
 
868
1076
  assert msg.len == 3, msg.len
@@ -876,11 +1084,30 @@ def parser_1060(payload: str, msg: Message) -> PayDictT._1060:
876
1084
 
877
1085
  # max_ch_setpoint (supply high limit)
878
1086
  def parser_1081(payload: str, msg: Message) -> PayDictT._1081:
1087
+ """Parse the 1081 (max_ch_setpoint) packet.
1088
+
1089
+ :param payload: The raw hex payload
1090
+ :type payload: str
1091
+ :param msg: The message object
1092
+ :type msg: Message
1093
+ :return: A dictionary containing the temperature setpoint
1094
+ :rtype: PayDictT._1081
1095
+ """
879
1096
  return {SZ_SETPOINT: hex_to_temp(payload[2:])}
880
1097
 
881
1098
 
882
1099
  # unknown_1090 (non-Evohome, e.g. ST9520C)
883
1100
  def parser_1090(payload: str, msg: Message) -> PayDictT._1090:
1101
+ """Parse the 1090 packet.
1102
+
1103
+ :param payload: The raw hex payload
1104
+ :type payload: str
1105
+ :param msg: The message object
1106
+ :type msg: Message
1107
+ :return: A dictionary containing two temperature values
1108
+ :rtype: PayDictT._1090
1109
+ :raises AssertionError: If the message length or payload index is invalid.
1110
+ """
884
1111
  # 14:08:05.176 095 RP --- 23:100224 22:219457 --:------ 1090 005 007FFF01F4
885
1112
  # 18:08:05.809 095 RP --- 23:100224 22:219457 --:------ 1090 005 007FFF01F4
886
1113
 
@@ -896,6 +1123,16 @@ def parser_1090(payload: str, msg: Message) -> PayDictT._1090:
896
1123
 
897
1124
  # unknown_1098, from OTB
898
1125
  def parser_1098(payload: str, msg: Message) -> dict[str, Any]:
1126
+ """Parse the 1098 packet.
1127
+
1128
+ :param payload: The raw hex payload
1129
+ :type payload: str
1130
+ :param msg: The message object
1131
+ :type msg: Message
1132
+ :return: A dictionary containing the raw payload and its interpreted value
1133
+ :rtype: dict[str, Any]
1134
+ :raises AssertionError: If the payload does not match expected constants.
1135
+ """
899
1136
  assert payload == "00C8", _INFORM_DEV_MSG
900
1137
 
901
1138
  return {
@@ -908,6 +1145,16 @@ def parser_1098(payload: str, msg: Message) -> dict[str, Any]:
908
1145
 
909
1146
  # dhw (cylinder) params # FIXME: a bit messy
910
1147
  def parser_10a0(payload: str, msg: Message) -> PayDictT._10A0 | PayDictT.EMPTY:
1148
+ """Parse the 10a0 (dhw_params) packet.
1149
+
1150
+ :param payload: The raw hex payload
1151
+ :type payload: str
1152
+ :param msg: The message object containing context
1153
+ :type msg: Message
1154
+ :return: A dictionary of DHW parameters or an empty dictionary
1155
+ :rtype: PayDictT._10A0 | PayDictT.EMPTY
1156
+ :raises AssertionError: If the message length or valve index is invalid.
1157
+ """
911
1158
  # RQ --- 07:045960 01:145038 --:------ 10A0 006 00-1087-00-03E4 # RQ/RP, every 24h
912
1159
  # RP --- 01:145038 07:045960 --:------ 10A0 006 00-109A-00-03E8
913
1160
  # RP --- 10:048122 18:006402 --:------ 10A0 003 00-1B58
@@ -950,6 +1197,16 @@ def parser_10a0(payload: str, msg: Message) -> PayDictT._10A0 | PayDictT.EMPTY:
950
1197
 
951
1198
  # unknown_10b0, from OTB
952
1199
  def parser_10b0(payload: str, msg: Message) -> dict[str, Any]:
1200
+ """Parse the 10b0 packet.
1201
+
1202
+ :param payload: The raw hex payload
1203
+ :type payload: str
1204
+ :param msg: The message object
1205
+ :type msg: Message
1206
+ :return: A dictionary containing the raw payload and interpreted value
1207
+ :rtype: dict[str, Any]
1208
+ :raises AssertionError: If the payload is invalid.
1209
+ """
953
1210
  assert payload == "0000", _INFORM_DEV_MSG
954
1211
 
955
1212
  return {
@@ -962,6 +1219,15 @@ def parser_10b0(payload: str, msg: Message) -> dict[str, Any]:
962
1219
 
963
1220
  # filter_change, HVAC
964
1221
  def parser_10d0(payload: str, msg: Message) -> dict[str, Any]:
1222
+ """Parse the 10d0 (filter_change) packet.
1223
+
1224
+ :param payload: The raw hex payload
1225
+ :type payload: str
1226
+ :param msg: The message object containing context
1227
+ :type msg: Message
1228
+ :return: A dictionary of remaining days, lifetime, and percentage
1229
+ :rtype: dict[str, Any]
1230
+ """
965
1231
  # 2022-07-03T22:52:34.571579 045 W --- 37:171871 32:155617 --:------ 10D0 002 00FF
966
1232
  # 2022-07-03T22:52:34.596526 066 I --- 32:155617 37:171871 --:------ 10D0 006 0047B44F0000
967
1233
  # then...
@@ -992,6 +1258,16 @@ def parser_10d0(payload: str, msg: Message) -> dict[str, Any]:
992
1258
 
993
1259
  # device_info
994
1260
  def parser_10e0(payload: str, msg: Message) -> dict[str, Any]:
1261
+ """Parse the 10e0 (device_info) packet.
1262
+
1263
+ :param payload: The raw hex payload
1264
+ :type payload: str
1265
+ :param msg: The message object
1266
+ :type msg: Message
1267
+ :return: A dictionary of device specifications and manufacturing data
1268
+ :rtype: dict[str, Any]
1269
+ :raises AssertionError: If the message length is invalid for the reported signature.
1270
+ """
995
1271
  if payload == "00": # some HVAC devices will RP|10E0|00
996
1272
  return {}
997
1273
 
@@ -1031,11 +1307,30 @@ def parser_10e0(payload: str, msg: Message) -> dict[str, Any]:
1031
1307
 
1032
1308
  # device_id
1033
1309
  def parser_10e1(payload: str, msg: Message) -> PayDictT._10E1:
1310
+ """Parse the 10e1 (device_id) packet.
1311
+
1312
+ :param payload: The raw hex payload
1313
+ :type payload: str
1314
+ :param msg: The message object
1315
+ :type msg: Message
1316
+ :return: A dictionary containing the device ID
1317
+ :rtype: PayDictT._10E1
1318
+ """
1034
1319
  return {SZ_DEVICE_ID: hex_id_to_dev_id(payload[2:])}
1035
1320
 
1036
1321
 
1037
1322
  # unknown_10e2 - HVAC
1038
1323
  def parser_10e2(payload: str, msg: Message) -> dict[str, Any]:
1324
+ """Parse the 10e2 (HVAC counter) packet.
1325
+
1326
+ :param payload: The raw hex payload
1327
+ :type payload: str
1328
+ :param msg: The message object containing context
1329
+ :type msg: Message
1330
+ :return: A dictionary containing the extracted counter
1331
+ :rtype: dict[str, Any]
1332
+ :raises AssertionError: If the payload length is not 6 or prefix is not '00'.
1333
+ """
1039
1334
  # .I --- --:------ --:------ 20:231151 10E2 003 00AD74 # every 2 minutes
1040
1335
 
1041
1336
  assert payload[:2] == "00", _INFORM_DEV_MSG
@@ -1050,6 +1345,17 @@ def parser_10e2(payload: str, msg: Message) -> dict[str, Any]:
1050
1345
  def parser_1100(
1051
1346
  payload: str, msg: Message
1052
1347
  ) -> PayDictT._1100 | PayDictT._1100_IDX | PayDictT._JASPER | PayDictT.EMPTY:
1348
+ """Parse the 1100 (tpi_params) packet.
1349
+
1350
+ :param payload: The raw hex payload
1351
+ :type payload: str
1352
+ :param msg: The message object containing context
1353
+ :type msg: Message
1354
+ :return: A dictionary of TPI parameters or domain index
1355
+ :rtype: PayDictT._1100 | PayDictT._1100_IDX | PayDictT._JASPER | PayDictT.EMPTY
1356
+ :raises AssertionError: If TPI values are outside of recognized ranges.
1357
+ """
1358
+
1053
1359
  def complex_idx(seqx: str) -> PayDictT._1100_IDX | PayDictT.EMPTY:
1054
1360
  return {SZ_DOMAIN_ID: seqx} if seqx[:1] == "F" else {} # type: ignore[typeddict-item] # only FC
1055
1361
 
@@ -1101,6 +1407,16 @@ def parser_1100(
1101
1407
 
1102
1408
  # unknown_11f0, from heatpump relay
1103
1409
  def parser_11f0(payload: str, msg: Message) -> dict[str, Any]:
1410
+ """Parse the 11f0 (heatpump relay) packet.
1411
+
1412
+ :param payload: The raw hex payload
1413
+ :type payload: str
1414
+ :param msg: The message object
1415
+ :type msg: Message
1416
+ :return: A dictionary containing the raw payload
1417
+ :rtype: dict[str, Any]
1418
+ :raises AssertionError: If the payload does not match the expected constant string.
1419
+ """
1104
1420
  assert payload == "000009000000000000", _INFORM_DEV_MSG
1105
1421
 
1106
1422
  return {
@@ -1110,22 +1426,58 @@ def parser_11f0(payload: str, msg: Message) -> dict[str, Any]:
1110
1426
 
1111
1427
  # dhw cylinder temperature
1112
1428
  def parser_1260(payload: str, msg: Message) -> PayDictT._1260:
1429
+ """Parse the 1260 (dhw_temp) packet.
1430
+
1431
+ :param payload: The raw hex payload
1432
+ :type payload: str
1433
+ :param msg: The message object
1434
+ :type msg: Message
1435
+ :return: A dictionary containing the DHW temperature
1436
+ :rtype: PayDictT._1260
1437
+ """
1113
1438
  return {SZ_TEMPERATURE: hex_to_temp(payload[2:])}
1114
1439
 
1115
1440
 
1116
1441
  # HVAC: outdoor humidity
1117
1442
  def parser_1280(payload: str, msg: Message) -> PayDictT._1280:
1443
+ """Parse the 1280 (outdoor_humidity) packet.
1444
+
1445
+ :param payload: The raw hex payload
1446
+ :type payload: str
1447
+ :param msg: The message object
1448
+ :type msg: Message
1449
+ :return: A dictionary containing the outdoor humidity percentage
1450
+ :rtype: PayDictT._1280
1451
+ """
1118
1452
  return parse_outdoor_humidity(payload[2:])
1119
1453
 
1120
1454
 
1121
1455
  # outdoor temperature
1122
1456
  def parser_1290(payload: str, msg: Message) -> PayDictT._1290:
1457
+ """Parse the 1290 (outdoor_temp) packet.
1458
+
1459
+ :param payload: The raw hex payload
1460
+ :type payload: str
1461
+ :param msg: The message object
1462
+ :type msg: Message
1463
+ :return: A dictionary containing the outdoor temperature
1464
+ :rtype: PayDictT._1290
1465
+ """
1123
1466
  # evohome responds to an RQ, also from OTB
1124
1467
  return parse_outdoor_temp(payload[2:])
1125
1468
 
1126
1469
 
1127
1470
  # HVAC: co2_level, see: 31DA[6:10]
1128
1471
  def parser_1298(payload: str, msg: Message) -> PayDictT._1298:
1472
+ """Parse the 1298 (co2_level) packet.
1473
+
1474
+ :param payload: The raw hex payload
1475
+ :type payload: str
1476
+ :param msg: The message object
1477
+ :type msg: Message
1478
+ :return: A dictionary containing the CO2 level in PPM
1479
+ :rtype: PayDictT._1298
1480
+ """
1129
1481
  return parse_co2_level(payload[2:6])
1130
1482
 
1131
1483
 
@@ -1133,6 +1485,15 @@ def parser_1298(payload: str, msg: Message) -> PayDictT._1298:
1133
1485
  def parser_12a0(
1134
1486
  payload: str, msg: Message
1135
1487
  ) -> PayDictT.INDOOR_HUMIDITY | list[PayDictT._12A0]:
1488
+ """Parse the 12a0 (indoor_humidity) packet.
1489
+
1490
+ :param payload: The raw hex payload
1491
+ :type payload: str
1492
+ :param msg: The message object containing context
1493
+ :type msg: Message
1494
+ :return: A single humidity dict or a list of sensor element dicts
1495
+ :rtype: PayDictT.INDOOR_HUMIDITY | list[PayDictT._12A0]
1496
+ """
1136
1497
  if len(payload) <= 14:
1137
1498
  return parse_indoor_humidity(payload[2:12])
1138
1499
 
@@ -1147,6 +1508,16 @@ def parser_12a0(
1147
1508
 
1148
1509
  # window_state (of a device/zone)
1149
1510
  def parser_12b0(payload: str, msg: Message) -> PayDictT._12B0:
1511
+ """Parse the 12b0 (window_state) packet.
1512
+
1513
+ :param payload: The raw hex payload
1514
+ :type payload: str
1515
+ :param msg: The message object
1516
+ :type msg: Message
1517
+ :return: A dictionary containing the window open status
1518
+ :rtype: PayDictT._12B0
1519
+ :raises AssertionError: If the payload state bytes are unrecognized.
1520
+ """
1150
1521
  assert payload[2:] in ("0000", "C800", "FFFF"), payload[2:] # "FFFF" means N/A
1151
1522
 
1152
1523
  return {
@@ -1156,6 +1527,15 @@ def parser_12b0(payload: str, msg: Message) -> PayDictT._12B0:
1156
1527
 
1157
1528
  # displayed temperature (on a TR87RF bound to a RFG100)
1158
1529
  def parser_12c0(payload: str, msg: Message) -> PayDictT._12C0:
1530
+ """Parse the 12c0 (displayed_temp) packet.
1531
+
1532
+ :param payload: The raw hex payload
1533
+ :type payload: str
1534
+ :param msg: The message object
1535
+ :type msg: Message
1536
+ :return: A dictionary containing the temperature and its measurement units
1537
+ :rtype: PayDictT._12C0
1538
+ """
1159
1539
  if payload[2:4] == "80":
1160
1540
  temp: float | None = None
1161
1541
  elif payload[4:6] == "00": # units are 1.0 F
@@ -1174,22 +1554,59 @@ def parser_12c0(payload: str, msg: Message) -> PayDictT._12C0:
1174
1554
 
1175
1555
  # HVAC: air_quality (and air_quality_basis), see: 31DA[2:6]
1176
1556
  def parser_12c8(payload: str, msg: Message) -> PayDictT._12C8:
1557
+ """Parse the 12c8 (air_quality) packet.
1558
+
1559
+ :param payload: The raw hex payload
1560
+ :type payload: str
1561
+ :param msg: The message object
1562
+ :type msg: Message
1563
+ :return: A dictionary containing the air quality percentage and basis
1564
+ :rtype: PayDictT._12C8
1565
+ """
1177
1566
  return parse_air_quality(payload[2:6])
1178
1567
 
1179
1568
 
1180
1569
  # dhw_flow_rate
1181
1570
  def parser_12f0(payload: str, msg: Message) -> PayDictT._12F0:
1571
+ """Parse the 12f0 (dhw_flow_rate) packet.
1572
+
1573
+ :param payload: The raw hex payload
1574
+ :type payload: str
1575
+ :param msg: The message object
1576
+ :type msg: Message
1577
+ :return: A dictionary containing the DHW flow rate
1578
+ :rtype: PayDictT._12F0
1579
+ """
1182
1580
  return {SZ_DHW_FLOW_RATE: hex_to_temp(payload[2:])}
1183
1581
 
1184
1582
 
1185
1583
  # ch_pressure
1186
1584
  def parser_1300(payload: str, msg: Message) -> PayDictT._1300:
1585
+ """Parse the 1300 (ch_pressure) packet.
1586
+
1587
+ :param payload: The raw hex payload
1588
+ :type payload: str
1589
+ :param msg: The message object
1590
+ :type msg: Message
1591
+ :return: A dictionary containing the system pressure in bar
1592
+ :rtype: PayDictT._1300
1593
+ """
1187
1594
  # 0x9F6 (2550 dec = 2.55 bar) appears to be a sentinel value
1188
1595
  return {SZ_PRESSURE: None if payload[2:] == "09F6" else hex_to_temp(payload[2:])}
1189
1596
 
1190
1597
 
1191
1598
  # programme_scheme, HVAC
1192
1599
  def parser_1470(payload: str, msg: Message) -> dict[str, Any]:
1600
+ """Parse the 1470 (programme_scheme) packet.
1601
+
1602
+ :param payload: The raw hex payload
1603
+ :type payload: str
1604
+ :param msg: The message object containing context
1605
+ :type msg: Message
1606
+ :return: A dictionary of the schedule scheme and daily setpoint count
1607
+ :rtype: dict[str, Any]
1608
+ :raises AssertionError: If the payload format or constants are unrecognized.
1609
+ """
1193
1610
  # Seen on Orcon: see 1470, 1F70, 22B0
1194
1611
 
1195
1612
  SCHEDULE_SCHEME = {
@@ -1221,6 +1638,16 @@ def parser_1470(payload: str, msg: Message) -> dict[str, Any]:
1221
1638
 
1222
1639
  # system_sync
1223
1640
  def parser_1f09(payload: str, msg: Message) -> PayDictT._1F09:
1641
+ """Parse the 1f09 (system_sync) packet.
1642
+
1643
+ :param payload: The raw hex payload
1644
+ :type payload: str
1645
+ :param msg: The message object containing context
1646
+ :type msg: Message
1647
+ :return: A dictionary with remaining seconds and the calculated next sync time
1648
+ :rtype: PayDictT._1F09
1649
+ :raises AssertionError: If the packet length is not 3.
1650
+ """
1224
1651
  # 22:51:19.287 067 I --- --:------ --:------ 12:193204 1F09 003 010A69
1225
1652
  # 22:51:19.318 068 I --- --:------ --:------ 12:193204 2309 003 010866
1226
1653
  # 22:51:19.321 067 I --- --:------ --:------ 12:193204 30C9 003 0108C3
@@ -1244,6 +1671,16 @@ def parser_1f09(payload: str, msg: Message) -> PayDictT._1F09:
1244
1671
 
1245
1672
  # dhw_mode
1246
1673
  def parser_1f41(payload: str, msg: Message) -> PayDictT._1F41:
1674
+ """Parse the 1f41 (dhw_mode) packet.
1675
+
1676
+ :param payload: The raw hex payload
1677
+ :type payload: str
1678
+ :param msg: The message object
1679
+ :type msg: Message
1680
+ :return: A dictionary containing DHW mode, activity, and duration/until data
1681
+ :rtype: PayDictT._1F41
1682
+ :raises AssertionError: If payload constants or message lengths are invalid.
1683
+ """
1247
1684
  # 053 RP --- 01:145038 18:013393 --:------ 1F41 006 00FF00FFFFFF # no stored DHW
1248
1685
 
1249
1686
  assert payload[4:6] in ZON_MODE_MAP, f"{payload[4:6]} (0xjj)"
@@ -1270,6 +1707,16 @@ def parser_1f41(payload: str, msg: Message) -> PayDictT._1F41:
1270
1707
 
1271
1708
  # programme_config, HVAC
1272
1709
  def parser_1f70(payload: str, msg: Message) -> dict[str, Any]:
1710
+ """Parse the 1f70 (programme_config) packet.
1711
+
1712
+ :param payload: The raw hex payload
1713
+ :type payload: str
1714
+ :param msg: The message object containing context
1715
+ :type msg: Message
1716
+ :return: A dictionary containing schedule indices and start times
1717
+ :rtype: dict[str, Any]
1718
+ :raises AssertionError: If internal payload constraints are violated.
1719
+ """
1273
1720
  # Seen on Orcon: see 1470, 1F70, 22B0
1274
1721
 
1275
1722
  try:
@@ -1306,6 +1753,18 @@ def parser_1f70(payload: str, msg: Message) -> dict[str, Any]:
1306
1753
 
1307
1754
  # rf_bind
1308
1755
  def parser_1fc9(payload: str, msg: Message) -> PayDictT._1FC9:
1756
+ """Parse the 1fc9 (rf_bind) packet.
1757
+
1758
+ :param payload: The raw hex payload
1759
+ :type payload: str
1760
+ :param msg: The message object containing context
1761
+ :type msg: Message
1762
+ :return: A dictionary identifying the binding phase (Offer/Accept/Confirm) and bindings
1763
+ :rtype: PayDictT._1FC9
1764
+ :raises PacketPayloadInvalid: If the binding format is unknown.
1765
+ :raises AssertionError: If the payload length or constants are invalid.
1766
+ """
1767
+
1309
1768
  def _parser(seqx: str) -> list[str]:
1310
1769
  if seqx[:2] not in ("90",):
1311
1770
  assert (
@@ -1358,6 +1817,15 @@ def parser_1fc9(payload: str, msg: Message) -> PayDictT._1FC9:
1358
1817
 
1359
1818
  # unknown_1fca, HVAC?
1360
1819
  def parser_1fca(payload: str, msg: Message) -> Mapping[str, str]:
1820
+ """Parse the 1fca packet.
1821
+
1822
+ :param payload: The raw hex payload
1823
+ :type payload: str
1824
+ :param msg: The message object
1825
+ :type msg: Message
1826
+ :return: A mapping of unknown identifiers and associated device IDs
1827
+ :rtype: Mapping[str, str]
1828
+ """
1361
1829
  # .W --- 30:248208 34:021943 --:------ 1FCA 009 00-01FF-7BC990-FFFFFF # sent x2
1362
1830
 
1363
1831
  return {
@@ -1370,6 +1838,16 @@ def parser_1fca(payload: str, msg: Message) -> Mapping[str, str]:
1370
1838
 
1371
1839
  # unknown_1fd0, from OTB
1372
1840
  def parser_1fd0(payload: str, msg: Message) -> dict[str, Any]:
1841
+ """Parse the 1fd0 (OpenTherm) packet.
1842
+
1843
+ :param payload: The raw hex payload
1844
+ :type payload: str
1845
+ :param msg: The message object
1846
+ :type msg: Message
1847
+ :return: A dictionary containing the raw payload
1848
+ :rtype: dict[str, Any]
1849
+ :raises AssertionError: If the payload does not match the expected null string.
1850
+ """
1373
1851
  assert payload == "0000000000000000", _INFORM_DEV_MSG
1374
1852
 
1375
1853
  return {
@@ -1379,11 +1857,30 @@ def parser_1fd0(payload: str, msg: Message) -> dict[str, Any]:
1379
1857
 
1380
1858
  # opentherm_sync, otb_sync
1381
1859
  def parser_1fd4(payload: str, msg: Message) -> PayDictT._1FD4:
1860
+ """Parse the 1fd4 (opentherm_sync) packet.
1861
+
1862
+ :param payload: The raw hex payload
1863
+ :type payload: str
1864
+ :param msg: The message object
1865
+ :type msg: Message
1866
+ :return: A dictionary containing the sync ticker value
1867
+ :rtype: PayDictT._1FD4
1868
+ """
1382
1869
  return {"ticker": int(payload[2:], 16)}
1383
1870
 
1384
1871
 
1385
1872
  # WIP: HVAC auto requests (confirmed for Orcon, others?)
1386
1873
  def parser_2210(payload: str, msg: Message) -> dict[str, Any]:
1874
+ """Parse the 2210 (HVAC auto request) packet.
1875
+
1876
+ :param payload: The raw hex payload
1877
+ :type payload: str
1878
+ :param msg: The message object
1879
+ :type msg: Message
1880
+ :return: A dictionary of fan speed, request reason, and unknown flags
1881
+ :rtype: dict[str, Any]
1882
+ :raises AssertionError: If payload constants or internal consistency checks fail.
1883
+ """
1387
1884
  try:
1388
1885
  assert msg.verb in (RP, I_) or payload == "00"
1389
1886
  assert payload[10:12] == payload[38:40], (
@@ -1429,6 +1926,15 @@ def parser_2210(payload: str, msg: Message) -> dict[str, Any]:
1429
1926
 
1430
1927
  # now_next_setpoint - Programmer/Hometronics
1431
1928
  def parser_2249(payload: str, msg: Message) -> dict | list[dict]: # TODO: only dict
1929
+ """Parse the 2249 (now_next_setpoint) packet.
1930
+
1931
+ :param payload: The raw hex payload
1932
+ :type payload: str
1933
+ :param msg: The message object
1934
+ :type msg: Message
1935
+ :return: A dictionary or list of current/next setpoints and time remaining
1936
+ :rtype: dict | list[dict]
1937
+ """
1432
1938
  # see: https://github.com/jrosser/honeymon/blob/master/decoder.cpp#L357-L370
1433
1939
  # .I --- 23:100224 --:------ 23:100224 2249 007 00-7EFF-7EFF-FFFF
1434
1940
 
@@ -1457,6 +1963,15 @@ def parser_2249(payload: str, msg: Message) -> dict | list[dict]: # TODO: only
1457
1963
 
1458
1964
  # program_enabled, HVAC
1459
1965
  def parser_22b0(payload: str, msg: Message) -> dict[str, Any]:
1966
+ """Parse the 22b0 (program_enabled) packet.
1967
+
1968
+ :param payload: The raw hex payload
1969
+ :type payload: str
1970
+ :param msg: The message object
1971
+ :type msg: Message
1972
+ :return: A dictionary containing the program enabled status
1973
+ :rtype: dict[str, Any]
1974
+ """
1460
1975
  # Seen on Orcon: see 1470, 1F70, 22B0
1461
1976
 
1462
1977
  # .W --- 37:171871 32:155617 --:------ 22B0 002 0005 # enable, calendar on
@@ -1472,6 +1987,16 @@ def parser_22b0(payload: str, msg: Message) -> dict[str, Any]:
1472
1987
 
1473
1988
  # setpoint_bounds, TODO: max length = 24?
1474
1989
  def parser_22c9(payload: str, msg: Message) -> dict | list[dict]: # TODO: only dict
1990
+ """Parse the 22c9 (setpoint_bounds) packet.
1991
+
1992
+ :param payload: The raw hex payload
1993
+ :type payload: str
1994
+ :param msg: The message object
1995
+ :type msg: Message
1996
+ :return: A dictionary or list containing mode and temperature bounds
1997
+ :rtype: dict | list[dict]
1998
+ :raises AssertionError: If the payload length or suffix is unrecognized.
1999
+ """
1475
2000
  # .I --- 02:001107 --:------ 02:001107 22C9 024 00-0834-0A28-01-0108340A2801-0208340A2801-0308340A2801 # noqa: E501
1476
2001
  # .I --- 02:001107 --:------ 02:001107 22C9 006 04-0834-0A28-01
1477
2002
 
@@ -1505,6 +2030,17 @@ def parser_22c9(payload: str, msg: Message) -> dict | list[dict]: # TODO: only
1505
2030
 
1506
2031
  # unknown_22d0, UFH system mode (heat/cool)
1507
2032
  def parser_22d0(payload: str, msg: Message) -> dict[str, Any]:
2033
+ """Parse the 22d0 (UFH system mode) packet.
2034
+
2035
+ :param payload: The raw hex payload
2036
+ :type payload: str
2037
+ :param msg: The message object
2038
+ :type msg: Message
2039
+ :return: A dictionary of UFH index, flags, and active modes
2040
+ :rtype: dict[str, Any]
2041
+ :raises AssertionError: If payload constants or flags are invalid.
2042
+ """
2043
+
1508
2044
  def _parser(seqx: str) -> dict:
1509
2045
  # assert seqx[2:4] in ("00", "03", "10", "13", "14"), _INFORM_DEV_MSG
1510
2046
  assert seqx[4:6] == "00", _INFORM_DEV_MSG
@@ -1527,11 +2063,32 @@ def parser_22d0(payload: str, msg: Message) -> dict[str, Any]:
1527
2063
 
1528
2064
  # desired boiler setpoint
1529
2065
  def parser_22d9(payload: str, msg: Message) -> PayDictT._22D9:
2066
+ """Parse the 22d9 (desired boiler setpoint) packet.
2067
+
2068
+ :param payload: The raw hex payload
2069
+ :type payload: str
2070
+ :param msg: The message object
2071
+ :type msg: Message
2072
+ :return: A dictionary containing the target temperature setpoint
2073
+ :rtype: PayDictT._22D9
2074
+ """
1530
2075
  return {SZ_SETPOINT: hex_to_temp(payload[2:6])}
1531
2076
 
1532
2077
 
1533
2078
  # WIP: unknown, HVAC
1534
2079
  def parser_22e0(payload: str, msg: Message) -> Mapping[str, float | None]:
2080
+ """Parse the 22e0 packet.
2081
+
2082
+ :param payload: The raw hex payload
2083
+ :type payload: str
2084
+ :param msg: The message object
2085
+ :type msg: Message
2086
+ :return: A mapping of percentage values extracted from the payload
2087
+ :rtype: Mapping[str, float | None]
2088
+ :raises AssertionError: If a value exceeds the expected 200 threshold.
2089
+ :raises ValueError: If the payload cannot be parsed as percentages.
2090
+ """
2091
+
1535
2092
  # RP --- 32:155617 18:005904 --:------ 22E0 004 00-34-A0-1E
1536
2093
  # RP --- 32:153258 18:005904 --:------ 22E0 004 00-64-A0-1E
1537
2094
  def _parser(seqx: str) -> float:
@@ -1553,6 +2110,15 @@ def parser_22e0(payload: str, msg: Message) -> Mapping[str, float | None]:
1553
2110
 
1554
2111
  # WIP: unknown, HVAC
1555
2112
  def parser_22e5(payload: str, msg: Message) -> Mapping[str, float | None]:
2113
+ """Parse the 22e5 packet.
2114
+
2115
+ :param payload: The raw hex payload
2116
+ :type payload: str
2117
+ :param msg: The message object
2118
+ :type msg: Message
2119
+ :return: A mapping of percentage values extracted from the payload
2120
+ :rtype: Mapping[str, float | None]
2121
+ """
1556
2122
  # RP --- 32:153258 18:005904 --:------ 22E5 004 00-96-C8-14
1557
2123
  # RP --- 32:155617 18:005904 --:------ 22E5 004 00-72-C8-14
1558
2124
 
@@ -1561,6 +2127,15 @@ def parser_22e5(payload: str, msg: Message) -> Mapping[str, float | None]:
1561
2127
 
1562
2128
  # WIP: unknown, HVAC
1563
2129
  def parser_22e9(payload: str, msg: Message) -> Mapping[str, float | str | None]:
2130
+ """Parse the 22e9 packet.
2131
+
2132
+ :param payload: The raw hex payload
2133
+ :type payload: str
2134
+ :param msg: The message object
2135
+ :type msg: Message
2136
+ :return: A mapping of unknown identifiers or percentage values
2137
+ :rtype: Mapping[str, float | str | None]
2138
+ """
1564
2139
  if payload[2:4] == "01":
1565
2140
  return {
1566
2141
  "unknown_4": payload[4:6],
@@ -1571,6 +2146,16 @@ def parser_22e9(payload: str, msg: Message) -> Mapping[str, float | str | None]:
1571
2146
 
1572
2147
  # fan_speed (switch_mode), HVAC
1573
2148
  def parser_22f1(payload: str, msg: Message) -> dict[str, Any]:
2149
+ """Parse the 22f1 (fan_speed) packet.
2150
+
2151
+ :param payload: The raw hex payload
2152
+ :type payload: str
2153
+ :param msg: The message object containing context
2154
+ :type msg: Message
2155
+ :return: A dictionary containing the fan mode, scheme, and internal indices
2156
+ :rtype: dict[str, Any]
2157
+ :raises AssertionError: If the fan mode or mode set is unrecognized.
2158
+ """
1574
2159
  try:
1575
2160
  assert payload[0:2] in ("00", "63")
1576
2161
  assert not payload[4:] or int(payload[2:4], 16) <= int(payload[4:], 16), (
@@ -1632,6 +2217,15 @@ def parser_22f1(payload: str, msg: Message) -> dict[str, Any]:
1632
2217
 
1633
2218
  # WIP: unknown, HVAC (flow rate?)
1634
2219
  def parser_22f2(payload: str, msg: Message) -> list: # TODO: only dict
2220
+ """Parse the 22f2 (HVAC flow rate) packet.
2221
+
2222
+ :param payload: The raw hex payload
2223
+ :type payload: str
2224
+ :param msg: The message object containing context
2225
+ :type msg: Message
2226
+ :return: A list of dictionaries containing HVAC indices and measurements
2227
+ :rtype: list
2228
+ """
1635
2229
  # ClimeRad minibox uses 22F2 for speed feedback
1636
2230
 
1637
2231
  def _parser(seqx: str) -> dict:
@@ -1647,6 +2241,16 @@ def parser_22f2(payload: str, msg: Message) -> list: # TODO: only dict
1647
2241
 
1648
2242
  # fan_boost, HVAC
1649
2243
  def parser_22f3(payload: str, msg: Message) -> dict[str, Any]:
2244
+ """Parse the 22f3 (fan_boost) packet.
2245
+
2246
+ :param payload: The raw hex payload
2247
+ :type payload: str
2248
+ :param msg: The message object containing context
2249
+ :type msg: Message
2250
+ :return: A dictionary of boost settings, duration, and fan modes
2251
+ :rtype: dict[str, Any]
2252
+ :raises AssertionError: If internal payload structure is malformed.
2253
+ """
1650
2254
  # NOTE: for boost timer for high
1651
2255
  try:
1652
2256
  assert msg.len <= 7 or payload[14:] == "0000", f"byte 7: {payload[14:]}"
@@ -1700,6 +2304,16 @@ def parser_22f3(payload: str, msg: Message) -> dict[str, Any]:
1700
2304
 
1701
2305
  # WIP: unknown, HVAC
1702
2306
  def parser_22f4(payload: str, msg: Message) -> dict[str, Any]:
2307
+ """Parse the 22f4 packet.
2308
+
2309
+ :param payload: The raw hex payload
2310
+ :type payload: str
2311
+ :param msg: The message object containing context
2312
+ :type msg: Message
2313
+ :return: A dictionary containing interpreted fan mode and rate
2314
+ :rtype: dict[str, Any]
2315
+ :raises AssertionError: If the extracted mode or rate is invalid.
2316
+ """
1703
2317
  if msg.len == 13 and payload[14:] == "000000000000":
1704
2318
  # ClimaRad Ventura fan & remote
1705
2319
  _pl = payload[:4] + payload[12:14] if payload[10:12] == "00" else payload[8:14]
@@ -1734,6 +2348,15 @@ def parser_22f4(payload: str, msg: Message) -> dict[str, Any]:
1734
2348
 
1735
2349
  # bypass_mode, HVAC
1736
2350
  def parser_22f7(payload: str, msg: Message) -> dict[str, Any]:
2351
+ """Parse the 22f7 (bypass_mode) packet.
2352
+
2353
+ :param payload: The raw hex payload
2354
+ :type payload: str
2355
+ :param msg: The message object containing context
2356
+ :type msg: Message
2357
+ :return: A dictionary of bypass mode, state, and position
2358
+ :rtype: dict[str, Any]
2359
+ """
1737
2360
  result = {
1738
2361
  SZ_BYPASS_MODE: {"00": "off", "C8": "on", "FF": "auto"}.get(payload[2:4]),
1739
2362
  }
@@ -1746,6 +2369,15 @@ def parser_22f7(payload: str, msg: Message) -> dict[str, Any]:
1746
2369
 
1747
2370
  # WIP: unknown_mode, HVAC
1748
2371
  def parser_22f8(payload: str, msg: Message) -> dict[str, Any]:
2372
+ """Parse the 22f8 packet.
2373
+
2374
+ :param payload: The raw hex payload
2375
+ :type payload: str
2376
+ :param msg: The message object containing context
2377
+ :type msg: Message
2378
+ :return: A dictionary of raw internal values
2379
+ :rtype: dict[str, Any]
2380
+ """
1749
2381
  # from: https://github.com/arjenhiemstra/ithowifi/blob/master/software/NRG_itho_wifi/src/IthoPacket.h
1750
2382
 
1751
2383
  # message command bytes specific for AUTO RFT (536-0150)
@@ -1766,6 +2398,15 @@ def parser_22f8(payload: str, msg: Message) -> dict[str, Any]:
1766
2398
  def parser_2309(
1767
2399
  payload: str, msg: Message
1768
2400
  ) -> PayDictT._2309 | list[PayDictT._2309] | PayDictT.EMPTY:
2401
+ """Parse the 2309 (setpoint) packet.
2402
+
2403
+ :param payload: The raw hex payload
2404
+ :type payload: str
2405
+ :param msg: The message object containing context
2406
+ :type msg: Message
2407
+ :return: A setpoint dictionary, list of setpoints, or an empty dictionary
2408
+ :rtype: PayDictT._2309 | list[PayDictT._2309] | PayDictT.EMPTY
2409
+ """
1769
2410
  if msg._has_array:
1770
2411
  return [
1771
2412
  {
@@ -1784,6 +2425,16 @@ def parser_2309(
1784
2425
 
1785
2426
  # zone_mode # TODO: messy
1786
2427
  def parser_2349(payload: str, msg: Message) -> PayDictT._2349 | PayDictT.EMPTY:
2428
+ """Parse the 2349 (zone_mode) packet.
2429
+
2430
+ :param payload: The raw hex payload
2431
+ :type payload: str
2432
+ :param msg: The message object containing context
2433
+ :type msg: Message
2434
+ :return: A dictionary containing zone mode, setpoint, and override details
2435
+ :rtype: PayDictT._2349 | PayDictT.EMPTY
2436
+ :raises AssertionError: If the message length or mode is invalid.
2437
+ """
1787
2438
  # RQ --- 34:225071 30:258557 --:------ 2349 001 00
1788
2439
  # RP --- 30:258557 34:225071 --:------ 2349 013 007FFF00FFFFFFFFFFFFFFFFFF
1789
2440
  # RP --- 30:253184 34:010943 --:------ 2349 013 00064000FFFFFF00110E0507E5
@@ -1823,6 +2474,15 @@ def parser_2349(payload: str, msg: Message) -> PayDictT._2349 | PayDictT.EMPTY:
1823
2474
 
1824
2475
  # unknown_2389, from 03:
1825
2476
  def parser_2389(payload: str, msg: Message) -> dict[str, Any]:
2477
+ """Parse the 2389 packet.
2478
+
2479
+ :param payload: The raw hex payload
2480
+ :type payload: str
2481
+ :param msg: The message object containing context
2482
+ :type msg: Message
2483
+ :return: A dictionary containing an unknown temperature measurement
2484
+ :rtype: dict[str, Any]
2485
+ """
1826
2486
  return {
1827
2487
  "_unknown": hex_to_temp(payload[2:6]),
1828
2488
  }
@@ -1830,6 +2490,15 @@ def parser_2389(payload: str, msg: Message) -> dict[str, Any]:
1830
2490
 
1831
2491
  # unknown_2400, from OTB, FAN
1832
2492
  def parser_2400(payload: str, msg: Message) -> dict[str, Any]:
2493
+ """Parse the 2400 packet.
2494
+
2495
+ :param payload: The raw hex payload
2496
+ :type payload: str
2497
+ :param msg: The message object containing context
2498
+ :type msg: Message
2499
+ :return: A dictionary containing the raw payload
2500
+ :rtype: dict[str, Any]
2501
+ """
1833
2502
  # RP --- 32:155617 18:005904 --:------ 2400 045 00001111-1010929292921110101020110010000080100010100000009191111191910011119191111111111100 # Orcon FAN
1834
2503
  # RP --- 10:048122 18:006402 --:------ 2400 004 0000000F
1835
2504
  # assert payload == "0000000F", _INFORM_DEV_MSG
@@ -1841,6 +2510,16 @@ def parser_2400(payload: str, msg: Message) -> dict[str, Any]:
1841
2510
 
1842
2511
  # unknown_2401, from OTB
1843
2512
  def parser_2401(payload: str, msg: Message) -> dict[str, Any]:
2513
+ """Parse the 2401 packet.
2514
+
2515
+ :param payload: The raw hex payload
2516
+ :type payload: str
2517
+ :param msg: The message object containing context
2518
+ :type msg: Message
2519
+ :return: A dictionary of decoded flags and valve demand
2520
+ :rtype: dict[str, Any]
2521
+ :raises AssertionError: If payload constants or bit flags are unrecognized.
2522
+ """
1844
2523
  try:
1845
2524
  assert payload[2:4] == "00", f"byte 1: {payload[2:4]}"
1846
2525
  assert int(payload[4:6], 16) & 0b11110000 == 0, (
@@ -1859,6 +2538,16 @@ def parser_2401(payload: str, msg: Message) -> dict[str, Any]:
1859
2538
 
1860
2539
  # unknown_2410, from OTB, FAN
1861
2540
  def parser_2410(payload: str, msg: Message) -> dict[str, Any]:
2541
+ """Parse the 2410 packet.
2542
+
2543
+ :param payload: The raw hex payload
2544
+ :type payload: str
2545
+ :param msg: The message object containing context
2546
+ :type msg: Message
2547
+ :return: A dictionary of current, min, and max values and metadata
2548
+ :rtype: dict[str, Any]
2549
+ :raises AssertionError: If the payload format does not match expected constants.
2550
+ """
1862
2551
  # RP --- 10:048122 18:006402 --:------ 2410 020 00-00000000-00000000-00000001-00000001-00000C # OTB
1863
2552
  # RP --- 32:155617 18:005904 --:------ 2410 020 00-00003EE8-00000000-FFFFFFFF-00000000-1002A6 # Orcon Fan
1864
2553
 
@@ -1895,6 +2584,15 @@ def parser_2410(payload: str, msg: Message) -> dict[str, Any]:
1895
2584
 
1896
2585
  # fan_params, HVAC
1897
2586
  def parser_2411(payload: str, msg: Message) -> dict[str, Any]:
2587
+ """Parse the 2411 (fan_params) packet.
2588
+
2589
+ :param payload: The raw hex payload
2590
+ :type payload: str
2591
+ :param msg: The message object containing context
2592
+ :type msg: Message
2593
+ :return: A dictionary containing the parameter ID, description, and decoded value
2594
+ :rtype: dict[str, Any]
2595
+ """
1898
2596
  # There is a relationship between 0001 and 2411
1899
2597
  # RQ --- 37:171871 32:155617 --:------ 0001 005 0020000A04
1900
2598
  # RP --- 32:155617 37:171871 --:------ 0001 008 0020000A004E0B00 # 0A -> 2411|4E
@@ -1912,6 +2610,7 @@ def parser_2411(payload: str, msg: Message) -> dict[str, Any]:
1912
2610
  "01": (2, centile), # 52 (0.0-25.0) (%)
1913
2611
  "0F": (2, hex_to_percent), # xx (0.0-1.0) (%)
1914
2612
  "10": (4, counter), # 31 (0-1800) (days)
2613
+ # "20": (4, counter), # unknown data type, uncomment when we have more info
1915
2614
  "92": (4, hex_to_temp), # 75 (0-30) (C)
1916
2615
  } # TODO: _2411_TYPES.get(payload[8:10], (8, no_op))
1917
2616
 
@@ -1938,11 +2637,35 @@ def parser_2411(payload: str, msg: Message) -> dict[str, Any]:
1938
2637
  return result
1939
2638
 
1940
2639
  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))
2640
+ # Handle unknown data types gracefully instead of asserting
2641
+ if payload[8:10] not in _2411_DATA_TYPES:
2642
+ warningmsg = (
2643
+ f"{msg!r} < {_INFORM_DEV_MSG} (param {param_id} has unknown data_type: {payload[8:10]}). "
2644
+ f"This parameter uses an unrecognized data type. "
2645
+ f"Please report this packet and any context about what changed on your system."
2646
+ )
2647
+ # Return partial result with raw hex values for unknown data types
2648
+ if msg.len == 9:
2649
+ result |= {
2650
+ "value": f"0x{payload[10:18]}", # Raw hex value
2651
+ "_value_06": payload[6:10],
2652
+ "_unknown_data_type": payload[8:10],
2653
+ }
2654
+ else:
2655
+ result |= {
2656
+ "value": f"0x{payload[10:18]}", # Raw hex value
2657
+ "_value_06": payload[6:10],
2658
+ "min_value": f"0x{payload[18:26]}", # Raw hex value
2659
+ "max_value": f"0x{payload[26:34]}", # Raw hex value
2660
+ "precision": f"0x{payload[34:42]}", # Raw hex value
2661
+ "_value_42": payload[42:],
2662
+ # Flexible footer - capture everything after precision
2663
+ }
2664
+ _LOGGER.warning(f"{warningmsg}. Found values: {result}")
2665
+ return result
1945
2666
 
2667
+ # Handle known data types normally
2668
+ length, parser = _2411_DATA_TYPES[payload[8:10]]
1946
2669
  result |= {
1947
2670
  "value": parser(payload[10:18][-length:]), # type: ignore[operator]
1948
2671
  "_value_06": payload[6:10],
@@ -1962,15 +2685,26 @@ def parser_2411(payload: str, msg: Message) -> dict[str, Any]:
1962
2685
  # eg. older Orcon models may have a footer of 2 bytes
1963
2686
  }
1964
2687
  )
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"] = ""
2688
+ except Exception as err:
2689
+ _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} (Error parsing 2411: {err})")
2690
+ # Return partial result for any parsing errors
2691
+ result["value"] = f"0x{payload[10:18]}" # Raw hex value
2692
+ result["_parse_error"] = f"Parser error: {err}"
1969
2693
  return result
1970
2694
 
1971
2695
 
1972
2696
  # unknown_2420, from OTB
1973
2697
  def parser_2420(payload: str, msg: Message) -> dict[str, Any]:
2698
+ """Parse the 2420 (OpenTherm) packet.
2699
+
2700
+ :param payload: The raw hex payload
2701
+ :type payload: str
2702
+ :param msg: The message object containing context
2703
+ :type msg: Message
2704
+ :return: A dictionary containing the raw payload
2705
+ :rtype: dict[str, Any]
2706
+ :raises AssertionError: If the payload does not match the expected constant string.
2707
+ """
1974
2708
  assert payload == "00000010" + "00" * 34, _INFORM_DEV_MSG
1975
2709
 
1976
2710
  return {
@@ -1980,6 +2714,16 @@ def parser_2420(payload: str, msg: Message) -> dict[str, Any]:
1980
2714
 
1981
2715
  # _state (of cooling?), from BDR91T, hometronics CTL
1982
2716
  def parser_2d49(payload: str, msg: Message) -> PayDictT._2D49:
2717
+ """Parse the 2d49 packet.
2718
+
2719
+ :param payload: The raw hex payload
2720
+ :type payload: str
2721
+ :param msg: The message object containing context
2722
+ :type msg: Message
2723
+ :return: A dictionary containing the boolean state
2724
+ :rtype: PayDictT._2D49
2725
+ :raises AssertionError: If the payload state bytes are unrecognized.
2726
+ """
1983
2727
  assert payload[2:] in ("0000", "00FF", "C800", "C8FF"), _INFORM_DEV_MSG
1984
2728
 
1985
2729
  return {
@@ -1989,6 +2733,16 @@ def parser_2d49(payload: str, msg: Message) -> PayDictT._2D49:
1989
2733
 
1990
2734
  # system_mode
1991
2735
  def parser_2e04(payload: str, msg: Message) -> PayDictT._2E04:
2736
+ """Parse the 2e04 (system_mode) packet.
2737
+
2738
+ :param payload: The raw hex payload
2739
+ :type payload: str
2740
+ :param msg: The message object containing context
2741
+ :type msg: Message
2742
+ :return: A dictionary containing the system mode and optional duration
2743
+ :rtype: PayDictT._2E04
2744
+ :raises AssertionError: If the system mode or packet length is invalid.
2745
+ """
1992
2746
  # if msg.verb == W_:
1993
2747
 
1994
2748
  # .I --— 01:020766 --:------ 01:020766 2E04 016 FFFFFFFFFFFFFF0007FFFFFFFFFFFF04 # Manual # noqa: E501
@@ -2023,6 +2777,16 @@ def parser_2e04(payload: str, msg: Message) -> PayDictT._2E04:
2023
2777
 
2024
2778
  # presence_detect, HVAC sensor, or Timed boost for Vasco D60
2025
2779
  def parser_2e10(payload: str, msg: Message) -> dict[str, Any]:
2780
+ """Parse the 2e10 packet.
2781
+
2782
+ :param payload: The raw hex payload
2783
+ :type payload: str
2784
+ :param msg: The message object containing context
2785
+ :type msg: Message
2786
+ :return: A dictionary defining if presence is detected
2787
+ :rtype: dict[str, Any]
2788
+ :raises AssertionError: If the payload is not in a recognized format.
2789
+ """
2026
2790
  assert payload in ("0001", "000000", "000100"), _INFORM_DEV_MSG
2027
2791
  presence: int = int(payload[2:4])
2028
2792
  return {
@@ -2033,6 +2797,15 @@ def parser_2e10(payload: str, msg: Message) -> dict[str, Any]:
2033
2797
 
2034
2798
  # current temperature (of device, zone/s)
2035
2799
  def parser_30c9(payload: str, msg: Message) -> dict | list[dict]: # TODO: only dict
2800
+ """Parse the 30c9 (temperature) packet.
2801
+
2802
+ :param payload: The raw hex payload
2803
+ :type payload: str
2804
+ :param msg: The message object containing context
2805
+ :type msg: Message
2806
+ :return: A dictionary or list of temperatures by zone index
2807
+ :rtype: dict | list[dict]
2808
+ """
2036
2809
  if msg._has_array:
2037
2810
  return [
2038
2811
  {
@@ -2047,6 +2820,16 @@ def parser_30c9(payload: str, msg: Message) -> dict | list[dict]: # TODO: only
2047
2820
 
2048
2821
  # ufc_demand, HVAC (Itho autotemp / spider)
2049
2822
  def parser_3110(payload: str, msg: Message) -> PayDictT._3110:
2823
+ """Parse the 3110 (ufc_demand) packet.
2824
+
2825
+ :param payload: The raw hex payload
2826
+ :type payload: str
2827
+ :param msg: The message object containing context
2828
+ :type msg: Message
2829
+ :return: A dictionary containing the operating mode and demand percentage
2830
+ :rtype: PayDictT._3110
2831
+ :raises AssertionError: If payload constants or demand values are invalid.
2832
+ """
2050
2833
  # .I --- 02:250708 --:------ 02:250708 3110 004 0000C820 # cooling, 100%
2051
2834
  # .I --- 21:042656 --:------ 21:042656 3110 004 00000010 # heating, 0%
2052
2835
 
@@ -2079,6 +2862,16 @@ def parser_3110(payload: str, msg: Message) -> PayDictT._3110:
2079
2862
 
2080
2863
  # unknown_3120, from STA, FAN
2081
2864
  def parser_3120(payload: str, msg: Message) -> dict[str, Any]:
2865
+ """Parse the 3120 packet.
2866
+
2867
+ :param payload: The raw hex payload
2868
+ :type payload: str
2869
+ :param msg: The message object containing context
2870
+ :type msg: Message
2871
+ :return: A dictionary of raw internal segments
2872
+ :rtype: dict[str, Any]
2873
+ :raises AssertionError: If individual byte segments fail validation.
2874
+ """
2082
2875
  # .I --- 34:136285 --:------ 34:136285 3120 007 0070B0000000FF # every ~3:45:00!
2083
2876
  # RP --- 20:008749 18:142609 --:------ 3120 007 0070B000009CFF
2084
2877
  # .I --- 37:258565 --:------ 37:258565 3120 007 0080B0010003FF
@@ -2103,6 +2896,16 @@ def parser_3120(payload: str, msg: Message) -> dict[str, Any]:
2103
2896
 
2104
2897
  # WIP: unknown, HVAC
2105
2898
  def parser_313e(payload: str, msg: Message) -> dict[str, Any]:
2899
+ """Parse the 313e packet.
2900
+
2901
+ :param payload: The raw hex payload
2902
+ :type payload: str
2903
+ :param msg: The message object containing context
2904
+ :type msg: Message
2905
+ :return: A dictionary containing calculated Zulu time and raw internal values
2906
+ :rtype: dict[str, Any]
2907
+ :raises AssertionError: If the payload prefix or expected constant suffix is invalid.
2908
+ """
2106
2909
  assert payload[:2] == "00"
2107
2910
  assert payload[12:] == "003C800000"
2108
2911
 
@@ -2120,6 +2923,16 @@ def parser_313e(payload: str, msg: Message) -> dict[str, Any]:
2120
2923
 
2121
2924
  # datetime
2122
2925
  def parser_313f(payload: str, msg: Message) -> PayDictT._313F: # TODO: look for TZ
2926
+ """Parse the 313f (datetime) packet.
2927
+
2928
+ :param payload: The raw hex payload
2929
+ :type payload: str
2930
+ :param msg: The message object containing context
2931
+ :type msg: Message
2932
+ :return: A dictionary containing the datetime and DST flag
2933
+ :rtype: PayDictT._313F
2934
+ :raises AssertionError: If the payload context is unexpected for the source device type.
2935
+ """
2123
2936
  # 2020-03-28T03:59:21.315178 045 RP --- 01:158182 04:136513 --:------ 313F 009 00FC3500A41C0307E4
2124
2937
  # 2020-03-29T04:58:30.486343 045 RP --- 01:158182 04:136485 --:------ 313F 009 00FC8400C51D0307E4
2125
2938
  # 2022-09-20T20:50:32.800676 065 RP --- 01:182924 18:068640 --:------ 313F 009 00F9203234140907E6
@@ -2152,6 +2965,15 @@ def parser_313f(payload: str, msg: Message) -> PayDictT._313F: # TODO: look for
2152
2965
 
2153
2966
  # heat_demand (of device, FC domain) - valve status (%open)
2154
2967
  def parser_3150(payload: str, msg: Message) -> dict | list[dict]: # TODO: only dict
2968
+ """Parse the 3150 (heat_demand) packet.
2969
+
2970
+ :param payload: The raw hex payload
2971
+ :type payload: str
2972
+ :param msg: The message object containing context
2973
+ :type msg: Message
2974
+ :return: A dictionary or list of dictionaries containing zone indices and valve demand
2975
+ :rtype: dict | list[dict]
2976
+ """
2155
2977
  # event-driven, and periodically; FC domain is maximum of all zones
2156
2978
  # TODO: all have a valid domain will UFC/CTL respond to an RQ, for FC, for a zone?
2157
2979
 
@@ -2176,6 +2998,16 @@ def parser_3150(payload: str, msg: Message) -> dict | list[dict]: # TODO: only
2176
2998
 
2177
2999
  # fan state (ventilation status), HVAC
2178
3000
  def parser_31d9(payload: str, msg: Message) -> dict[str, Any]:
3001
+ """Parse the 31d9 (fan state) packet.
3002
+
3003
+ :param payload: The raw hex payload
3004
+ :type payload: str
3005
+ :param msg: The message object containing context
3006
+ :type msg: Message
3007
+ :return: A dictionary containing fan mode, speed, and status flags
3008
+ :rtype: dict[str, Any]
3009
+ :raises AssertionError: If payload constants or byte segments fail validation.
3010
+ """
2179
3011
  # NOTE: Itho and ClimaRad use 0x00-C8 for %, whilst Nuaire uses 0x00-64
2180
3012
  try:
2181
3013
  assert payload[4:6] == "FF" or int(payload[4:6], 16) <= 200, (
@@ -2244,6 +3076,15 @@ def parser_31d9(payload: str, msg: Message) -> dict[str, Any]:
2244
3076
 
2245
3077
  # ventilation state (extended), HVAC
2246
3078
  def parser_31da(payload: str, msg: Message) -> PayDictT._31DA:
3079
+ """Parse the 31da (extended ventilation state) packet.
3080
+
3081
+ :param payload: The raw hex payload
3082
+ :type payload: str
3083
+ :param msg: The message object containing context
3084
+ :type msg: Message
3085
+ :return: A dictionary of all decoded ventilation parameters
3086
+ :rtype: PayDictT._31DA
3087
+ """
2247
3088
  # see: https://github.com/python/typing/issues/1445
2248
3089
  result = {
2249
3090
  **parse_exhaust_fan_speed(payload[38:40]), # maybe 31D9[4:6] for some?
@@ -2290,14 +3131,20 @@ def parser_31da(payload: str, msg: Message) -> PayDictT._31DA:
2290
3131
 
2291
3132
  # vent_demand, HVAC
2292
3133
  def parser_31e0(payload: str, msg: Message) -> dict | list[dict]: # TODO: only dict
2293
- """Notes are.
2294
-
3134
+ """Parse the 31e0 (vent_demand) packet.
2295
3135
  "van" means "of".
2296
3136
  - 0 = min. van min. potm would be:
2297
3137
  - 0 = minimum of minimum potentiometer
2298
3138
 
2299
3139
  See: https://www.industrialcontrolsonline.com/honeywell-t991a
2300
3140
  - modulates air temperatures in ducts
3141
+ :param payload: The raw hex payload
3142
+ :type payload: str
3143
+ :param msg: The message object containing context
3144
+ :type msg: Message
3145
+ :return: A dictionary or list of dictionaries containing flags and demand percentage
3146
+ :rtype: dict | list[dict]
3147
+ :raises AssertionError: If the payload suffix is not a recognized constant.
2301
3148
  """
2302
3149
 
2303
3150
  # coding note:
@@ -2360,16 +3207,45 @@ def parser_31e0(payload: str, msg: Message) -> dict | list[dict]: # TODO: only
2360
3207
 
2361
3208
  # supplied boiler water (flow) temp
2362
3209
  def parser_3200(payload: str, msg: Message) -> PayDictT._3200:
3210
+ """Parse the 3200 (supplied_temp) packet.
3211
+
3212
+ :param payload: The raw hex payload
3213
+ :type payload: str
3214
+ :param msg: The message object containing context
3215
+ :type msg: Message
3216
+ :return: A dictionary containing the water flow temperature
3217
+ :rtype: PayDictT._3200
3218
+ """
2363
3219
  return {SZ_TEMPERATURE: hex_to_temp(payload[2:])}
2364
3220
 
2365
3221
 
2366
3222
  # return (boiler) water temp
2367
3223
  def parser_3210(payload: str, msg: Message) -> PayDictT._3210:
3224
+ """Parse the 3210 (return_temp) packet.
3225
+
3226
+ :param payload: The raw hex payload
3227
+ :type payload: str
3228
+ :param msg: The message object containing context
3229
+ :type msg: Message
3230
+ :return: A dictionary containing the return water temperature
3231
+ :rtype: PayDictT._3210
3232
+ """
2368
3233
  return {SZ_TEMPERATURE: hex_to_temp(payload[2:])}
2369
3234
 
2370
3235
 
2371
3236
  # opentherm_msg, from OTB (and OT_RND)
2372
3237
  def parser_3220(payload: str, msg: Message) -> dict[str, Any]:
3238
+ """Parse an OpenTherm message packet.
3239
+
3240
+ :param payload: The raw hex payload
3241
+ :type payload: str
3242
+ :param msg: The message object containing context
3243
+ :type msg: Message
3244
+ :return: A dictionary of decoded OpenTherm data and descriptions
3245
+ :rtype: dict[str, Any]
3246
+ :raises AssertionError: If internal OpenTherm consistency checks fail.
3247
+ :raises PacketPayloadInvalid: If the OpenTherm frame is malformed or uses unknown IDs.
3248
+ """
2373
3249
  try:
2374
3250
  ot_type, ot_id, ot_value, ot_schema = decode_frame(payload[2:10])
2375
3251
  except AssertionError as err:
@@ -2453,6 +3329,16 @@ def parser_3220(payload: str, msg: Message) -> dict[str, Any]:
2453
3329
 
2454
3330
  # unknown_3221, from OTB, FAN
2455
3331
  def parser_3221(payload: str, msg: Message) -> dict[str, Any]:
3332
+ """Parse the 3221 packet.
3333
+
3334
+ :param payload: The raw hex payload
3335
+ :type payload: str
3336
+ :param msg: The message object containing context
3337
+ :type msg: Message
3338
+ :return: A dictionary containing the extracted numeric value
3339
+ :rtype: dict[str, Any]
3340
+ :raises AssertionError: If the extracted value exceeds the valid 0xC8 threshold.
3341
+ """
2456
3342
  # RP --- 10:052644 18:198151 --:------ 3221 002 000F
2457
3343
  # RP --- 10:048122 18:006402 --:------ 3221 002 0000
2458
3344
  # RP --- 32:155617 18:005904 --:------ 3221 002 000A
@@ -2467,6 +3353,16 @@ def parser_3221(payload: str, msg: Message) -> dict[str, Any]:
2467
3353
 
2468
3354
  # WIP: unknown, HVAC
2469
3355
  def parser_3222(payload: str, msg: Message) -> dict[str, Any]:
3356
+ """Parse the 3222 packet.
3357
+
3358
+ :param payload: The raw hex payload
3359
+ :type payload: str
3360
+ :param msg: The message object containing context
3361
+ :type msg: Message
3362
+ :return: A dictionary containing offset, length, and raw data
3363
+ :rtype: dict[str, Any]
3364
+ :raises AssertionError: If the payload prefix is not '00'.
3365
+ """
2470
3366
  assert payload[:2] == "00"
2471
3367
 
2472
3368
  # e.g. RP|3222|00FE00 (payload = 3 bytes)
@@ -2487,6 +3383,16 @@ def parser_3222(payload: str, msg: Message) -> dict[str, Any]:
2487
3383
 
2488
3384
  # unknown_3223, from OTB
2489
3385
  def parser_3223(payload: str, msg: Message) -> dict[str, Any]:
3386
+ """Parse the 3223 (OpenTherm) packet.
3387
+
3388
+ :param payload: The raw hex payload
3389
+ :type payload: str
3390
+ :param msg: The message object containing context
3391
+ :type msg: Message
3392
+ :return: A dictionary containing the extracted value
3393
+ :rtype: dict[str, Any]
3394
+ :raises AssertionError: If the value exceeds the valid 0xC8 threshold.
3395
+ """
2490
3396
  assert int(payload[2:], 16) <= 0xC8, _INFORM_DEV_MSG
2491
3397
 
2492
3398
  return {
@@ -2497,9 +3403,10 @@ def parser_3223(payload: str, msg: Message) -> dict[str, Any]:
2497
3403
 
2498
3404
  # actuator_sync (aka sync_tpi: TPI cycle sync)
2499
3405
  def parser_3b00(payload: str, msg: Message) -> PayDictT._3B00:
2500
- # system timing master: the device that sends I/FCC8 pkt controls the heater relay
2501
3406
  """Decode a 3B00 packet (actuator_sync).
2502
3407
 
3408
+ This signal marks the start or end of a TPI cycle to synchronize relay behavior.
3409
+
2503
3410
  The heat relay regularly broadcasts a 3B00 at the end(?) of every TPI cycle, the
2504
3411
  frequency of which is determined by the (TPI) cycle rate in 1100.
2505
3412
 
@@ -2507,7 +3414,16 @@ def parser_3b00(payload: str, msg: Message) -> PayDictT._3B00:
2507
3414
 
2508
3415
  The OTB does not send these packets, but the CTL sends a regular broadcast anyway
2509
3416
  for the benefit of any zone actuators (e.g. zone valve zones).
3417
+
3418
+ :param payload: The raw hex payload
3419
+ :type payload: str
3420
+ :param msg: The message object containing context
3421
+ :type msg: Message
3422
+ :return: A dictionary containing the sync state and domain ID
3423
+ :rtype: PayDictT._3B00
3424
+ :raises AssertionError: If the payload length or constants are invalid for the device type.
2510
3425
  """
3426
+ # system timing master: the device that sends I/FCC8 pkt controls the heater relay
2511
3427
 
2512
3428
  # 053 I --- 13:209679 --:------ 13:209679 3B00 002 00C8
2513
3429
  # 045 I --- 01:158182 --:------ 01:158182 3B00 002 FCC8
@@ -2544,6 +3460,16 @@ def parser_3b00(payload: str, msg: Message) -> PayDictT._3B00:
2544
3460
 
2545
3461
  # actuator_state
2546
3462
  def parser_3ef0(payload: str, msg: Message) -> PayDictT._3EF0 | PayDictT._JASPER:
3463
+ """Parse the 3ef0 (actuator_state) packet.
3464
+
3465
+ :param payload: The raw hex payload
3466
+ :type payload: str
3467
+ :param msg: The message object containing context
3468
+ :type msg: Message
3469
+ :return: A dictionary of modulation levels, flags, and setpoints
3470
+ :rtype: PayDictT._3EF0 | PayDictT._JASPER
3471
+ :raises AssertionError: If payload constants, flags, or message lengths are unrecognized.
3472
+ """
2547
3473
  result: dict[str, Any]
2548
3474
 
2549
3475
  if msg.src.type == DEV_TYPE_MAP.JIM: # Honeywell Jasper
@@ -2646,6 +3572,16 @@ def parser_3ef0(payload: str, msg: Message) -> PayDictT._3EF0 | PayDictT._JASPER
2646
3572
 
2647
3573
  # actuator_cycle
2648
3574
  def parser_3ef1(payload: str, msg: Message) -> PayDictT._3EF1 | PayDictT._JASPER:
3575
+ """Parse the 3ef1 (actuator_cycle) packet.
3576
+
3577
+ :param payload: The raw hex payload
3578
+ :type payload: str
3579
+ :param msg: The message object containing context
3580
+ :type msg: Message
3581
+ :return: A dictionary of modulation levels and cycle/actuator countdowns
3582
+ :rtype: PayDictT._3EF1 | PayDictT._JASPER
3583
+ :raises AssertionError: If the countdown values exceed recognized thresholds.
3584
+ """
2649
3585
  if msg.src.type == DEV_TYPE_MAP.JIM: # Honeywell Jasper, DEX
2650
3586
  assert msg.len == 18, f"expecting len 18, got: {msg.len}"
2651
3587
  return {
@@ -2698,6 +3634,16 @@ def parser_3ef1(payload: str, msg: Message) -> PayDictT._3EF1 | PayDictT._JASPER
2698
3634
 
2699
3635
  # timestamp, HVAC
2700
3636
  def parser_4401(payload: str, msg: Message) -> dict[str, Any]:
3637
+ """Parse the 4401 (HVAC timestamp) packet.
3638
+
3639
+ :param payload: The raw hex payload
3640
+ :type payload: str
3641
+ :param msg: The message object containing context
3642
+ :type msg: Message
3643
+ :return: A dictionary of source/destination timestamps and update flags
3644
+ :rtype: dict[str, Any]
3645
+ :raises AssertionError: If the payload format or constants are invalid.
3646
+ """
2701
3647
  if msg.verb == RP:
2702
3648
  return {}
2703
3649
 
@@ -2760,6 +3706,16 @@ def parser_4401(payload: str, msg: Message) -> dict[str, Any]:
2760
3706
 
2761
3707
  # temperatures (see: 4e02) - Itho spider/autotemp
2762
3708
  def parser_4e01(payload: str, msg: Message) -> dict[str, Any]:
3709
+ """Parse the 4e01 (Itho temperatures) packet.
3710
+
3711
+ :param payload: The raw hex payload
3712
+ :type payload: str
3713
+ :param msg: The message object containing context
3714
+ :type msg: Message
3715
+ :return: A dictionary containing an array of temperature measurements
3716
+ :rtype: dict[str, Any]
3717
+ :raises AssertionError: If the number of temperature groups does not match the packet length.
3718
+ """
2763
3719
  # .I --- 02:248945 02:250708 --:------ 4E01 018 00-7FFF7FFF7FFF09077FFF7FFF7FFF7FFF-00 # 23.11, 8-group
2764
3720
  # .I --- 02:250984 02:250704 --:------ 4E01 018 00-7FFF7FFF7FFF7FFF08387FFF7FFF7FFF-00 # 21.04
2765
3721
 
@@ -2782,6 +3738,16 @@ def parser_4e01(payload: str, msg: Message) -> dict[str, Any]:
2782
3738
  def parser_4e02(
2783
3739
  payload: str, msg: Message
2784
3740
  ) -> dict[str, Any]: # sent a triplets, 1 min apart
3741
+ """Parse the 4e02 (Itho setpoint bounds) packet.
3742
+
3743
+ :param payload: The raw hex payload
3744
+ :type payload: str
3745
+ :param msg: The message object containing context
3746
+ :type msg: Message
3747
+ :return: A dictionary containing the mode and associated setpoint bounds
3748
+ :rtype: dict[str, Any]
3749
+ :raises AssertionError: If the payload constants or mode indicators are invalid.
3750
+ """
2785
3751
  # .I --- 02:248945 02:250708 --:------ 4E02 034 00-7FFF7FFF7FFF07D07FFF7FFF7FFF7FFF-02-7FFF7FFF7FFF08347FFF7FFF7FFF7FFF # 20.00-21.00
2786
3752
  # .I --- 02:250984 02:250704 --:------ 4E02 034 00-7FFF7FFF7FFF076C7FFF7FFF7FFF7FFF-02-7FFF7FFF7FFF07D07FFF7FFF7FFF7FFF #
2787
3753
 
@@ -2815,6 +3781,16 @@ def parser_4e02(
2815
3781
 
2816
3782
  # hvac_4e04
2817
3783
  def parser_4e04(payload: str, msg: Message) -> dict[str, Any]:
3784
+ """Parse the 4e04 (HVAC mode) packet.
3785
+
3786
+ :param payload: The raw hex payload
3787
+ :type payload: str
3788
+ :param msg: The message object containing context
3789
+ :type msg: Message
3790
+ :return: A dictionary containing the system mode
3791
+ :rtype: dict[str, Any]
3792
+ :raises AssertionError: If the mode byte or data value is unrecognized.
3793
+ """
2818
3794
  MODE = {
2819
3795
  "00": "off",
2820
3796
  "01": "heat",
@@ -2838,6 +3814,15 @@ def parser_4e04(payload: str, msg: Message) -> dict[str, Any]:
2838
3814
 
2839
3815
  # WIP: AT outdoor low - Itho spider/autotemp
2840
3816
  def parser_4e0d(payload: str, msg: Message) -> dict[str, Any]:
3817
+ """Parse the 4e0d packet.
3818
+
3819
+ :param payload: The raw hex payload
3820
+ :type payload: str
3821
+ :param msg: The message object containing context
3822
+ :type msg: Message
3823
+ :return: A dictionary containing the raw payload
3824
+ :rtype: dict[str, Any]
3825
+ """
2841
3826
  # .I --- 02:250704 02:250984 --:------ 4E0D 002 0100 # Itho Autotemp: only(?) master -> slave
2842
3827
  # .I --- 02:250704 02:250984 --:------ 4E0D 002 0101 # why does it have a context?
2843
3828
 
@@ -2848,16 +3833,34 @@ def parser_4e0d(payload: str, msg: Message) -> dict[str, Any]:
2848
3833
 
2849
3834
  # AT fault circulation - Itho spider/autotemp
2850
3835
  def parser_4e14(payload: str, msg: Message) -> dict[str, Any]:
2851
- """
3836
+ """Parse the 4e14 (circulation fault) packet.
2852
3837
  result = "AT fault circulation";
2853
3838
  result = (((payload[2:] & 0x01) != 0x01) ? " Fault state : no fault " : " Fault state : fault ")
2854
3839
  result = (((payload[2:] & 0x02) != 0x02) ? (text4 + "Circulation state : no fault ") : (text4 + " Circulation state : fault "))
3840
+
3841
+ :param payload: The raw hex payload
3842
+ :type payload: str
3843
+ :param msg: The message object containing context
3844
+ :type msg: Message
3845
+ :return: A dictionary indicating fault and circulation states
3846
+ :rtype: dict[str, Any]
2855
3847
  """
2856
3848
  return {}
2857
3849
 
2858
3850
 
2859
3851
  # wpu_state (hvac state) - Itho spider/autotemp
2860
3852
  def parser_4e15(payload: str, msg: Message) -> dict[str, Any]:
3853
+ """Parse the 4e15 (WPU state) packet.
3854
+
3855
+ :param payload: The raw hex payload
3856
+ :type payload: str
3857
+ :param msg: The message object containing context
3858
+ :type msg: Message
3859
+ :return: A dictionary of boolean flags for cooling, heating, and DHW activity
3860
+ :rtype: dict[str, Any]
3861
+ :raises TypeError: If the payload indicates simultaneous heating and cooling.
3862
+ :raises AssertionError: If unknown bit flags are present.
3863
+ """
2861
3864
  # .I --- 21:034158 02:250676 --:------ 4E15 002 0000 # WPU "off" (maybe heating, but compressor off)
2862
3865
  # .I --- 21:064743 02:250708 --:------ 4E15 002 0001 # WPU cooling active
2863
3866
  # .I --- 21:057565 02:250677 --:------ 4E15 002 0002 # WPU heating, compressor active
@@ -2891,6 +3894,16 @@ def parser_4e15(payload: str, msg: Message) -> dict[str, Any]:
2891
3894
 
2892
3895
  # TODO: hvac_4e16 - Itho spider/autotemp
2893
3896
  def parser_4e16(payload: str, msg: Message) -> dict[str, Any]:
3897
+ """Parse the 4e16 packet.
3898
+
3899
+ :param payload: The raw hex payload
3900
+ :type payload: str
3901
+ :param msg: The message object containing context
3902
+ :type msg: Message
3903
+ :return: A dictionary containing the raw payload
3904
+ :rtype: dict[str, Any]
3905
+ :raises AssertionError: If the payload is not the expected null sequence.
3906
+ """
2894
3907
  # .I --- 02:250984 02:250704 --:------ 4E16 007 00000000000000 # Itho Autotemp: slave -> master
2895
3908
 
2896
3909
  assert payload == "00000000000000", _INFORM_DEV_MSG
@@ -2902,16 +3915,25 @@ def parser_4e16(payload: str, msg: Message) -> dict[str, Any]:
2902
3915
 
2903
3916
  # TODO: Fan characteristics - Itho
2904
3917
  def parser_4e20(payload: str, msg: Message) -> dict[str, Any]:
2905
- """
3918
+ """Parse the 4e20 (fan characteristics) packet.
3919
+
2906
3920
  result = "Fan characteristics: "
2907
3921
  result += [C[ABC][210] hex_to_sint32[i:i+4] for i in range(2, 34, 4)]
3922
+
3923
+ :param payload: The raw hex payload
3924
+ :type payload: str
3925
+ :param msg: The message object containing context
3926
+ :type msg: Message
3927
+ :return: A dictionary of decoded fan constants
3928
+ :rtype: dict[str, Any]
2908
3929
  """
2909
3930
  return {}
2910
3931
 
2911
3932
 
2912
3933
  # TODO: Potentiometer control - Itho
2913
3934
  def parser_4e21(payload: str, msg: Message) -> dict[str, Any]:
2914
- """
3935
+ """Parse the 4e21 (potentiometer control) packet.
3936
+
2915
3937
  result = "Potentiometer control: "
2916
3938
  result += "Rel min: " + hex_to_sint16(data[2:4]) # 16 bit, 2's complement
2917
3939
  result += "Min of rel min: " + hex_to_sint16(data[4:6])
@@ -2919,12 +3941,27 @@ def parser_4e21(payload: str, msg: Message) -> dict[str, Any]:
2919
3941
  result += "Rel max: " + hex_to_sint16(data[8:10])
2920
3942
  result += "Max rel: " + hex_to_sint16(data[10:12])
2921
3943
  result += "Abs max: " + hex_to_sint16(data[12:14]))
3944
+
3945
+ :param payload: The raw hex payload
3946
+ :type payload: str
3947
+ :param msg: The message object containing context
3948
+ :type msg: Message
3949
+ :return: A dictionary of absolute and relative power limits
3950
+ :rtype: dict[str, Any]
2922
3951
  """
2923
3952
  return {}
2924
3953
 
2925
3954
 
2926
3955
  # # faked puzzle pkt shouldn't be decorated
2927
3956
  def parser_7fff(payload: str, _: Message) -> dict[str, Any]:
3957
+ """Parse the 7fff (puzzle) packet.
3958
+
3959
+ :param payload: The raw hex payload
3960
+ :type payload: str
3961
+ :param _: The message object (unused)
3962
+ :return: A dictionary containing the message type, timestamp, and metadata
3963
+ :rtype: dict[str, Any]
3964
+ """
2928
3965
  if payload[:2] != "00":
2929
3966
  _LOGGER.debug("Invalid/deprecated Puzzle packet")
2930
3967
  return {
@@ -2966,6 +4003,15 @@ def parser_7fff(payload: str, _: Message) -> dict[str, Any]:
2966
4003
 
2967
4004
 
2968
4005
  def parser_unknown(payload: str, msg: Message) -> dict[str, Any]:
4006
+ """Apply a generic parser for unrecognized packet codes.
4007
+
4008
+ :param payload: The raw hex payload
4009
+ :type payload: str
4010
+ :param msg: The message object containing context
4011
+ :type msg: Message
4012
+ :return: A dictionary containing the raw payload and code information
4013
+ :rtype: dict[str, Any]
4014
+ """
2969
4015
  # TODO: it may be useful to generically search payloads for hex_ids, commands, etc.
2970
4016
 
2971
4017
  # These are generic parsers
@@ -2981,7 +4027,11 @@ def parser_unknown(payload: str, msg: Message) -> dict[str, Any]:
2981
4027
  "_value": hex_to_temp(payload[2:]),
2982
4028
  }
2983
4029
 
2984
- raise NotImplementedError
4030
+ return {
4031
+ "_payload": payload,
4032
+ "_unknown_code": msg.code,
4033
+ "_parse_error": "No parser available for this packet type",
4034
+ }
2985
4035
 
2986
4036
 
2987
4037
  _PAYLOAD_PARSERS = {
@@ -2992,14 +4042,32 @@ _PAYLOAD_PARSERS = {
2992
4042
 
2993
4043
 
2994
4044
  def parse_payload(msg: Message) -> dict | list[dict]:
2995
- """
2996
- Apply the appropriate parser defined in this module to the message.
2997
- :param msg: a Message object containing packet data and extra attributes
2998
- :return: a dict of key: value pairs or a list of such dicts, e.g. {'temperature': 21.5}
4045
+ """Apply the appropriate parser defined in this module to the message.
4046
+
4047
+ :param msg: A Message object containing packet data and extra attributes
4048
+ :type msg: Message
4049
+ :return: A dict of key:value pairs or a list of such dicts
4050
+ :rtype: dict | list[dict]
4051
+ :raises AssertionError: If the packet fails an internal consistency check.
2999
4052
  """
3000
4053
  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
4054
+ try:
4055
+ result = _PAYLOAD_PARSERS.get(msg.code, parser_unknown)(msg._pkt.payload, msg)
4056
+ if isinstance(result, dict) and msg.seqn.isnumeric(): # e.g. 22F1/3
4057
+ result["seqx_num"] = msg.seqn
4058
+ except AssertionError as err:
4059
+ _LOGGER.warning(
4060
+ f"{msg!r} < {_INFORM_DEV_MSG} ({err}). "
4061
+ f"This packet could not be parsed completely. "
4062
+ f"Please report this message and any context about what changed on your system when this occurred."
4063
+ )
4064
+ # Return partial result with error info
4065
+ result = {
4066
+ "_payload": msg._pkt.payload,
4067
+ "_parse_error": f"AssertionError: {err}",
4068
+ "_unknown_code": msg.code,
4069
+ }
4070
+ if isinstance(result, dict) and msg.seqn.isnumeric():
4071
+ result["seqx_num"] = msg.seqn
3004
4072
 
3005
4073
  return result