ramses-rf 0.22.40__py3-none-any.whl → 0.51.2__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.
Files changed (71) hide show
  1. ramses_cli/__init__.py +18 -0
  2. ramses_cli/client.py +597 -0
  3. ramses_cli/debug.py +20 -0
  4. ramses_cli/discovery.py +405 -0
  5. ramses_cli/utils/cat_slow.py +17 -0
  6. ramses_cli/utils/convert.py +60 -0
  7. ramses_rf/__init__.py +31 -10
  8. ramses_rf/binding_fsm.py +787 -0
  9. ramses_rf/const.py +124 -105
  10. ramses_rf/database.py +297 -0
  11. ramses_rf/device/__init__.py +69 -39
  12. ramses_rf/device/base.py +187 -376
  13. ramses_rf/device/heat.py +540 -552
  14. ramses_rf/device/hvac.py +279 -171
  15. ramses_rf/dispatcher.py +153 -177
  16. ramses_rf/entity_base.py +478 -361
  17. ramses_rf/exceptions.py +82 -0
  18. ramses_rf/gateway.py +377 -513
  19. ramses_rf/helpers.py +57 -19
  20. ramses_rf/py.typed +0 -0
  21. ramses_rf/schemas.py +148 -194
  22. ramses_rf/system/__init__.py +16 -23
  23. ramses_rf/system/faultlog.py +363 -0
  24. ramses_rf/system/heat.py +295 -302
  25. ramses_rf/system/schedule.py +312 -198
  26. ramses_rf/system/zones.py +318 -238
  27. ramses_rf/version.py +2 -8
  28. ramses_rf-0.51.2.dist-info/METADATA +72 -0
  29. ramses_rf-0.51.2.dist-info/RECORD +55 -0
  30. {ramses_rf-0.22.40.dist-info → ramses_rf-0.51.2.dist-info}/WHEEL +1 -2
  31. ramses_rf-0.51.2.dist-info/entry_points.txt +2 -0
  32. {ramses_rf-0.22.40.dist-info → ramses_rf-0.51.2.dist-info/licenses}/LICENSE +1 -1
  33. ramses_tx/__init__.py +160 -0
  34. {ramses_rf/protocol → ramses_tx}/address.py +65 -59
  35. ramses_tx/command.py +1454 -0
  36. ramses_tx/const.py +903 -0
  37. ramses_tx/exceptions.py +92 -0
  38. {ramses_rf/protocol → ramses_tx}/fingerprints.py +56 -15
  39. {ramses_rf/protocol → ramses_tx}/frame.py +132 -131
  40. ramses_tx/gateway.py +338 -0
  41. ramses_tx/helpers.py +883 -0
  42. {ramses_rf/protocol → ramses_tx}/logger.py +67 -53
  43. {ramses_rf/protocol → ramses_tx}/message.py +155 -191
  44. ramses_tx/opentherm.py +1260 -0
  45. ramses_tx/packet.py +210 -0
  46. {ramses_rf/protocol → ramses_tx}/parsers.py +1266 -1003
  47. ramses_tx/protocol.py +801 -0
  48. ramses_tx/protocol_fsm.py +672 -0
  49. ramses_tx/py.typed +0 -0
  50. {ramses_rf/protocol → ramses_tx}/ramses.py +262 -185
  51. {ramses_rf/protocol → ramses_tx}/schemas.py +150 -133
  52. ramses_tx/transport.py +1471 -0
  53. ramses_tx/typed_dicts.py +492 -0
  54. ramses_tx/typing.py +181 -0
  55. ramses_tx/version.py +4 -0
  56. ramses_rf/discovery.py +0 -398
  57. ramses_rf/protocol/__init__.py +0 -59
  58. ramses_rf/protocol/backports.py +0 -42
  59. ramses_rf/protocol/command.py +0 -1576
  60. ramses_rf/protocol/const.py +0 -697
  61. ramses_rf/protocol/exceptions.py +0 -111
  62. ramses_rf/protocol/helpers.py +0 -390
  63. ramses_rf/protocol/opentherm.py +0 -1170
  64. ramses_rf/protocol/packet.py +0 -235
  65. ramses_rf/protocol/protocol.py +0 -613
  66. ramses_rf/protocol/transport.py +0 -1011
  67. ramses_rf/protocol/version.py +0 -10
  68. ramses_rf/system/hvac.py +0 -82
  69. ramses_rf-0.22.40.dist-info/METADATA +0 -64
  70. ramses_rf-0.22.40.dist-info/RECORD +0 -42
  71. ramses_rf-0.22.40.dist-info/top_level.txt +0 -1
@@ -1,22 +1,22 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- #
4
2
  """RAMSES RF - a RAMSES-II protocol decoder & analyser.
5
3
 
6
4
  Provide the base class for commands (constructed/sent packets) and packets.
7
5
  """
6
+
8
7
  from __future__ import annotations
9
8
 
10
9
  import logging
10
+ from typing import TYPE_CHECKING
11
11
 
12
- from .address import NON_DEV_ADDR, NUL_DEV_ADDR, Address, pkt_addrs
13
- from .const import COMMAND_REGEX, DEV_ROLE_MAP, DEV_TYPE_MAP, __dev_mode__
14
- from .exceptions import InvalidPacketError, InvalidPayloadError
12
+ from . import exceptions as exc
13
+ from .address import ALL_DEV_ADDR, NON_DEV_ADDR, Address, pkt_addrs
14
+ from .const import COMMAND_REGEX, DEV_ROLE_MAP, DEV_TYPE_MAP
15
15
  from .ramses import (
16
- CODE_IDX_COMPLEX,
16
+ CODE_IDX_ARE_COMPLEX,
17
+ CODE_IDX_ARE_NONE,
18
+ CODE_IDX_ARE_SIMPLE,
17
19
  CODE_IDX_DOMAIN,
18
- CODE_IDX_NONE,
19
- CODE_IDX_SIMPLE,
20
20
  CODES_ONLY_FROM_CTL,
21
21
  CODES_SCHEMA,
22
22
  CODES_WITH_ARRAYS,
@@ -25,34 +25,31 @@ from .ramses import (
25
25
 
26
26
  # TODO: add _has_idx (as func return only one type, or raise)
27
27
 
28
-
29
- # skipcq: PY-W2000
30
28
  from .const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
31
29
  I_,
32
30
  RP,
33
31
  RQ,
34
32
  W_,
33
+ Code,
34
+ )
35
+ from .const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
35
36
  F8,
36
37
  F9,
37
38
  FA,
38
39
  FC,
39
40
  FF,
40
- Code,
41
41
  )
42
42
 
43
+ if TYPE_CHECKING:
44
+ from .const import VerbT
43
45
 
44
- DEV_MODE = __dev_mode__ and False
45
46
 
46
47
  _LOGGER = logging.getLogger(__name__)
47
- if DEV_MODE:
48
- _LOGGER.setLevel(logging.DEBUG)
49
48
 
50
49
 
51
- _CodeT = str
52
- _DeviceIdT = str
53
- _HeaderT = str
54
- _PayloadT = str
55
- _VerbT = str
50
+ HeaderT = str
51
+ PayloadT = str
52
+ _PktIdxT = str
56
53
 
57
54
 
58
55
  class Frame:
@@ -61,17 +58,9 @@ class Frame:
61
58
  `RQ --- 01:078710 10:067219 --:------ 3220 005 0000050000`
62
59
  """
63
60
 
64
- _frame: str
65
-
66
- verb: _VerbT
67
- seqn: str # TODO: or, better as int?
68
- src: Address
69
- dst: Address
61
+ src: Address # Address | Device
62
+ dst: Address # Address | Device
70
63
  _addrs: tuple[Address, Address, Address]
71
- code: _CodeT
72
- len_: str
73
- _len: int # int(len(payload) / 2)
74
- payload: _PayloadT
75
64
 
76
65
  def __init__(self, frame: str) -> None:
77
66
  """Create a frame from a string.
@@ -79,30 +68,28 @@ class Frame:
79
68
  Will raise InvalidPacketError if it is invalid.
80
69
  """
81
70
 
82
- self._frame = frame
83
- if not isinstance(self._frame, str):
84
- raise InvalidPacketError(f"Bad frame: not a string: {type(self._frame)}")
71
+ self._frame: str = frame
85
72
  if not COMMAND_REGEX.match(self._frame):
86
- raise InvalidPacketError("Bad frame: invalid structure")
73
+ raise exc.PacketInvalid(f"Bad frame: invalid structure: >>>{frame}<<<")
87
74
 
88
75
  fields = frame.lstrip().split(" ")
89
76
 
90
- self.verb = frame[:2] # cant use fields[0] as leading space ' I'
91
- self.seqn = fields[1] # frame[3:6]
92
- self.code = fields[5] # frame[37:41]
93
- self.len_ = fields[6] # frame[42:45]
94
- self.payload = fields[7] # frame[46:].split(" ")[0]
95
- self._len = int(len(self.payload) / 2)
77
+ self.verb: VerbT = frame[:2] # type: ignore[assignment]
78
+ self.seqn: str = fields[1] # . frame[3:6]
79
+ self.code: Code = fields[5] # type: ignore[assignment]
80
+ self.len_: str = fields[6] # . frame[42:45] FIXME: len_, _len & len(payload)/2
81
+ self.payload: PayloadT = fields[7] # frame[46:].split(" ")[0]
82
+ self._len: int = int(len(self.payload) / 2)
96
83
 
97
84
  try:
98
85
  self.src, self.dst, *self._addrs = pkt_addrs( # type: ignore[assignment]
99
86
  " ".join(fields[i] for i in range(2, 5)) # frame[7:36]
100
87
  )
101
- except InvalidPacketError as exc: # will be: InvalidAddrSetError
102
- raise InvalidPacketError(f"Bad frame: invalid address set {exc}")
88
+ except exc.PacketInvalid as err: # will be: InvalidAddrSetError
89
+ raise exc.PacketInvalid("Bad frame: invalid address set") from err
103
90
 
104
91
  if len(self.payload) != int(self.len_) * 2:
105
- raise InvalidPacketError(
92
+ raise exc.PacketInvalid(
106
93
  f"Bad frame: invalid payload: "
107
94
  f"len({self.payload}) is not int('{self.len_}' * 2))"
108
95
  )
@@ -115,54 +102,46 @@ class Frame:
115
102
  self._has_ctl_: bool = None # type: ignore[assignment] # TODO: remove
116
103
  self._has_payload_: bool = None # type: ignore[assignment]
117
104
 
118
- self._repr = None
119
-
120
- @classmethod # for internal use only
121
- def _from_attrs(
122
- cls, verb: _VerbT, *addrs, code: _CodeT, payload: _PayloadT, seqn=None
123
- ):
124
- """Create a frame from its attributes (args, kwargs)."""
105
+ self._repr: str = None # type: ignore[assignment]
125
106
 
126
- seqn = seqn or "---"
127
- len_ = f"{int(len(payload) / 2):03d}"
128
-
129
- try:
130
- return cls(" ".join((verb, seqn, *addrs, code, len_, payload)))
131
- except TypeError as exc:
132
- raise InvalidPacketError(f"Bad frame: invalid attr: {exc}")
133
-
134
- def _validate(self, *, strict_checking: bool = None) -> None:
107
+ # FIXME: this is messy
108
+ def _validate(self, *, strict_checking: bool = False) -> None:
135
109
  """Validate the frame: it may be a cmd or a (response) pkt.
136
110
 
137
111
  Raise an exception InvalidPacketError (InvalidAddrSetError) if it is not valid.
138
112
  """
139
113
 
140
- if (seqn := self._frame[3:6]) == "...":
141
- raise InvalidPacketError(f"Bad frame: deprecated seqn: {seqn}")
142
-
143
- if not strict_checking:
144
- return
145
-
146
114
  if len(self._frame[46:].split(" ")[0]) != int(self._frame[42:45]) * 2:
147
- raise InvalidPacketError("Bad frame: payload length mismatch")
115
+ raise exc.PacketInvalid("Bad frame: Payload length mismatch")
148
116
 
149
117
  try:
150
- self.src, self.dst, *self._addrs = pkt_addrs(self._frame[7:36]) # type: ignore[assignment]
151
- except InvalidPacketError as exc: # will be: InvalidAddrSetError
152
- raise InvalidPacketError(f"Bad frame: invalid address set: {exc}")
118
+ # self.src, self.dst, *self._addrs = pkt_addrs(self._frame[7:36])
119
+ src, dst, *addrs = pkt_addrs(self._frame[7:36])
120
+ except exc.PacketInvalid as err: # will be: InvalidAddrSetError
121
+ raise exc.PacketInvalid("Bad frame: Invalid address set") from err
153
122
 
154
- if True: # below is done at a higher layer
123
+ if not strict_checking:
155
124
  return
156
125
 
157
- if (code := self._frame[37.41]) not in CODES_SCHEMA:
158
- raise InvalidPacketError(f"Bad frame: unknown code: {code}")
126
+ try: # Strict checking: helps users avoid to constructing bad commands
127
+ if addrs[0] == NON_DEV_ADDR:
128
+ assert self.verb == I_, "wrong verb or dst addr should be present"
129
+ elif addrs[2] == NON_DEV_ADDR:
130
+ assert self.verb == I_ or src is not dst, (
131
+ "wrong verb or dst addr should not be src"
132
+ )
133
+ elif addrs[0] is addrs[2]:
134
+ assert self.verb == I_, "wrong verb or dst addr should not be src"
135
+ else:
136
+ assert self.verb in (I_, W_), "wrong verb or dst addr should be src"
137
+ except AssertionError as err:
138
+ raise exc.PacketInvalid(f"Bad frame: Invalid address set: {err}") from err
159
139
 
160
140
  def __repr__(self) -> str:
161
141
  """Return a unambiguous string representation of this object."""
162
- # repr(self) == repr(cls(repr(self)))
163
142
 
164
143
  if self._repr is None:
165
- self._repr = " ".join(
144
+ self._repr = " ".join( # type: ignore[unreachable]
166
145
  (
167
146
  self.verb,
168
147
  self.seqn,
@@ -180,8 +159,13 @@ class Frame:
180
159
 
181
160
  try:
182
161
  return f"{self!r} # {self._hdr}" # code|ver|device_id|context
183
- except AttributeError as exc:
184
- return f"{self!r} < {exc}"
162
+ except AttributeError as err:
163
+ return f"{self!r} < {err}"
164
+
165
+ def __eq__(self, other: object) -> bool:
166
+ if not hasattr(other, "_frame"):
167
+ return NotImplemented
168
+ return self._frame[4:] == other._frame[4:] # type: ignore[no-any-return]
185
169
 
186
170
  @property
187
171
  def _has_array(self) -> None | bool: # TODO: a mess - has false negatives
@@ -193,7 +177,7 @@ class Frame:
193
177
  2309/30C9/000A packets).
194
178
  """
195
179
 
196
- if self._has_array_ is not None: # HACK: overriden by detect_array(msg, prev)
180
+ if self._has_array_ is not None: # HACK: overridden by detect_array(msg, prev)
197
181
  return self._has_array_
198
182
 
199
183
  # False -ves (array length is 1) are an acceptable compromise to extensive checking
@@ -202,7 +186,7 @@ class Frame:
202
186
  # .I --- 01:145038 --:------ 01:145038 1FC9 018 07000806368E-FC3B0006368E-071FC906368E
203
187
  # .I --- 01:145038 --:------ 01:145038 1FC9 018 FA000806368E-FC3B0006368E-FA1FC906368E
204
188
  # .I --- 34:092243 --:------ 34:092243 1FC9 030 0030C9896853-002309896853-001060896853-0010E0896853-001FC9896853
205
- if self.code == Code._1FC9:
189
+ if self.code == Code._1FC9: # type: ignore[unreachable]
206
190
  self._has_array_ = self.verb != RQ # safe to treat all as array, even len=1
207
191
  return self._has_array_ # don't do any checks for 1FC9 (they will fail)
208
192
 
@@ -230,9 +214,9 @@ class Frame:
230
214
  if self._has_array_:
231
215
  len_ = CODES_WITH_ARRAYS[self.code][0]
232
216
 
233
- assert (
234
- self._len % len_ == 0
235
- ), f"{self} < array has length ({self._len}) that is not multiple of {len_}"
217
+ assert self._len % len_ == 0, (
218
+ f"{self} < array has length ({self._len}) that is not multiple of {len_}"
219
+ )
236
220
  assert (
237
221
  self.src.type in (DEV_TYPE_MAP.DTS, DEV_TYPE_MAP.DT2)
238
222
  or self.src == self.dst # DEX
@@ -259,12 +243,17 @@ class Frame:
259
243
  def _has_ctl(self) -> None | bool:
260
244
  """Return True if the packet is to/from a controller."""
261
245
 
246
+ # NB: the difference between these (_has_ctl, src, dst, -:-) and above (_has_ctl, src, -:-, src)
247
+ # 2000-01-01T03:00:00.000000 ... I --- 37:123456 --:------ 37:123456 31DA 030 00C8400518646427102AF82EE031FFFFFFC800C8C83FFF64640AFF0AFF00
248
+ # 2022-11-20T08:32:06.904058 063 RP --- 32:134446 37:171685 --:------ 31DA 030 00EF007FFF2F1B0226069A07EEFFE4F8000038988F0000EFEF1F2420FC00
249
+ # Maybe only use this for CH/DHW, and not HVAC?
250
+
262
251
  if self._has_ctl_ is not None:
263
252
  return self._has_ctl_
264
253
 
265
254
  # TODO: handle RQ/RP to/from HGI/RFG, handle HVAC
266
255
 
267
- if {self.src.type, self.dst.type} & {
256
+ if {self.src.type, self.dst.type} & { # type: ignore[unreachable]
268
257
  DEV_TYPE_MAP.CTL,
269
258
  DEV_TYPE_MAP.UFC,
270
259
  DEV_TYPE_MAP.PRG,
@@ -317,12 +306,13 @@ class Frame:
317
306
 
318
307
  # .I --- 34:021943 63:262142 --:------ 10E0 038 000001C8380A01... # unknown
319
308
  # .I --- 32:168090 30:082155 --:------ 31E0 004 0000C800 # unknown
309
+
320
310
  if self._has_ctl_ is None:
321
- if DEV_MODE and DEV_TYPE_MAP.HGI not in (
322
- self.src.type,
323
- self.dst.type,
324
- ): # DEX
325
- _LOGGER.warning(f"{self} # has_ctl - undetermined (99)")
311
+ # if DEV_MODE and DEV_TYPE_MAP.HGI not in (
312
+ # self.src.type,
313
+ # self.dst.type,
314
+ # ): # DEX
315
+ # _LOGGER.warning(f"{self} # has_ctl - undetermined (99)")
326
316
  self._has_ctl_ = False
327
317
 
328
318
  return self._has_ctl_
@@ -343,7 +333,7 @@ class Frame:
343
333
  if self._has_payload_ is not None:
344
334
  return self._has_payload_
345
335
 
346
- self._has_payload_ = not any(
336
+ self._has_payload_ = not any( # type: ignore[unreachable]
347
337
  (
348
338
  self._len == 1,
349
339
  self.verb == RQ and self.code in RQ_NO_PAYLOAD,
@@ -381,44 +371,51 @@ class Frame:
381
371
  Used to store packets in the entity's message DB. It is a superset of _idx.
382
372
  """
383
373
 
384
- if self._ctx_ is None:
385
- if self.code in (
386
- Code._0005,
387
- Code._000C,
388
- ): # zone_idx, zone_type (device_role)
389
- self._ctx_ = self.payload[:4]
390
- elif self.code == Code._0404: # zone_idx, frag_idx
391
- self._ctx_ = self._idx + self.payload[10:12]
392
- else:
393
- self._ctx_ = self._idx
374
+ if self._ctx_ is not None:
375
+ return self._ctx_
376
+
377
+ if self.code in ( # type: ignore[unreachable]
378
+ Code._0005,
379
+ Code._000C,
380
+ ): # zone_idx, zone_type (device_role)
381
+ self._ctx_ = self.payload[:4]
382
+ elif self.code == Code._0404: # zone_idx, frag_idx
383
+ self._ctx_ = self._idx + self.payload[10:12]
384
+ else:
385
+ self._ctx_ = self._idx
394
386
  return self._ctx_
395
387
 
396
388
  @property
397
- def _hdr(self) -> _HeaderT: # incl. self._ctx
389
+ def _hdr(self) -> HeaderT: # incl. self._ctx
398
390
  """Return the QoS header (fingerprint) of this packet (i.e. device_id/code/hdr).
399
391
 
400
392
  Used for QoS (timeouts, retries), callbacks, etc.
401
393
  """
402
394
 
403
- if self._hdr_ is None:
404
- self._hdr_ = "|".join((self.code, self.verb)) # HACK: RecursionError
405
- self._hdr_ = pkt_header(self)
395
+ if self._hdr_ is not None:
396
+ return self._hdr_
397
+
398
+ # FIXME: HACK: sometimes RecursionError
399
+ self._hdr_ = "|".join((self.code, self.verb)) # type: ignore[unreachable]
400
+ self._hdr_ = pkt_header(self)
406
401
  return self._hdr_
407
402
 
408
403
  @property
409
- def _idx(self) -> bool | str:
404
+ def _idx(self) -> bool | str: # FIXME: a mess
410
405
  """Return the payload's index, if any (e.g. zone_idx, domain_id or log_idx).
411
406
 
412
407
  Used to route a packet to the correct entity's (i.e. zone/domain) msg handler.
413
408
  """
414
409
 
415
- if self._idx_ is None:
416
- self._idx_ = _pkt_idx(self) or False
410
+ if self._idx_ is not None:
411
+ return self._idx_
412
+
413
+ self._idx_ = _pkt_idx(self) or False # type: ignore[unreachable]
417
414
  return self._idx_
418
415
 
419
416
 
420
417
  # TODO: a mess - has false negatives
421
- def _pkt_idx(pkt) -> None | bool | str: # _has_array, _has_ctl
418
+ def _pkt_idx(pkt: Frame) -> None | bool | str: # _has_array, _has_ctl
422
419
  """Return the payload's 2-byte context (e.g. zone_idx, domain_id or log_idx).
423
420
 
424
421
  May return a 2-byte string (usu. pkt.payload[:2]), or:
@@ -440,14 +437,14 @@ def _pkt_idx(pkt) -> None | bool | str: # _has_array, _has_ctl
440
437
 
441
438
  if pkt.code == Code._000C: # zone_idx/domain_id (complex, payload[0:4])
442
439
  if pkt.payload[2:4] == DEV_ROLE_MAP.APP: # "000F"
443
- return FC
440
+ return str(FC) # mypy
444
441
  if pkt.payload[0:4] == f"01{DEV_ROLE_MAP.HTG}": # "010E"
445
- return F9
442
+ return str(F9) # mypy
446
443
  if pkt.payload[2:4] in (
447
444
  DEV_ROLE_MAP.DHW,
448
445
  DEV_ROLE_MAP.HTG,
449
446
  ): # "000D", "000E"
450
- return FA
447
+ return str(FA) # mypy
451
448
  return pkt.payload[:2]
452
449
 
453
450
  if pkt.code == Code._0404: # assumes only 1 DHW zone (can be 2, but never seen)
@@ -462,15 +459,16 @@ def _pkt_idx(pkt) -> None | bool | str: # _has_array, _has_ctl
462
459
  if pkt.code == Code._3220: # msg_id/data_id (payload[4:6])
463
460
  return pkt.payload[4:6]
464
461
 
465
- if pkt.code in CODE_IDX_COMPLEX:
462
+ if pkt.code in CODE_IDX_ARE_COMPLEX: # these should be handled above
466
463
  raise NotImplementedError(f"{pkt} # CODE_IDX_COMPLEX") # a coding error
467
464
 
468
465
  # mutex 1/4, CODE_IDX_NONE: always returns False
469
- if pkt.code in CODE_IDX_NONE: # returns False
470
- if CODES_SCHEMA[pkt.code].get(pkt.verb, "")[:3] == "^00" and (
471
- pkt.payload[:2] != "00"
466
+ if pkt.code in CODE_IDX_ARE_NONE: # returns False
467
+ if (
468
+ CODES_SCHEMA[pkt.code].get(pkt.verb, "")[:3] == "^00"
469
+ and pkt.payload[:2] != "00"
472
470
  ):
473
- raise InvalidPayloadError(
471
+ raise exc.PacketPayloadInvalid(
474
472
  f"Packet idx is {pkt.payload[:2]}, but expecting no idx (00) (0xAA)"
475
473
  )
476
474
  return False
@@ -482,13 +480,13 @@ def _pkt_idx(pkt) -> None | bool | str: # _has_array, _has_ctl
482
480
  # TODO: is this needed?: exceptions to CODE_IDX_SIMPLE
483
481
  if pkt.payload[:2] in (F8, F9, FA, FC): # TODO: F6, F7?, FB, FD
484
482
  if pkt.code not in CODE_IDX_DOMAIN:
485
- raise InvalidPayloadError(
483
+ raise exc.PacketPayloadInvalid(
486
484
  f"Packet idx is {pkt.payload[:2]}, but not expecting a domain id"
487
485
  )
488
486
  return pkt.payload[:2]
489
487
 
490
488
  if (
491
- pkt._has_ctl
489
+ pkt._has_ctl # TODO: exclude HVAC?
492
490
  ): # risk of false -ves, TODO: pkt.src.type == DEV_TYPE_MAP.HGI too? # DEX
493
491
  # 02: 22C9: would be picked up as an array, if len==1 counted
494
492
  # 03: # .I 028 03:094242 --:------ 03:094242 30C9 003 010B22 # ctl
@@ -496,15 +494,15 @@ def _pkt_idx(pkt) -> None | bool | str: # _has_array, _has_ctl
496
494
  # 23: 0009|10A0
497
495
  return pkt.payload[:2] # tcs._max_zones checked elsewhere
498
496
 
499
- # if pkt.code in (Code._31D9,):
500
- # return pkt.payload[:2]
497
+ if pkt.code in (Code._31D9, Code._31DA):
498
+ return pkt.payload[:2]
501
499
 
502
500
  if pkt.payload[:2] != "00":
503
- raise InvalidPayloadError(
501
+ raise exc.PacketPayloadInvalid(
504
502
  f"Packet idx is {pkt.payload[:2]}, but expecting no idx (00) (0xAB)"
505
503
  ) # TODO: add a test for this
506
504
 
507
- if pkt.code in CODE_IDX_SIMPLE:
505
+ if pkt.code in CODE_IDX_ARE_SIMPLE:
508
506
  return None # False # TODO: return None (less precise) or risk false -ves?
509
507
 
510
508
  # mutex 4/4, CODE_IDX_UNKNOWN: an unknown code
@@ -512,9 +510,7 @@ def _pkt_idx(pkt) -> None | bool | str: # _has_array, _has_ctl
512
510
  return None
513
511
 
514
512
 
515
- def pkt_header(
516
- pkt, rx_header: bool = None
517
- ) -> None | _HeaderT: # NOTE: used in command.py
513
+ def pkt_header(pkt: Frame, /, rx_header: bool = False) -> None | HeaderT:
518
514
  """Return the header of a packet (all packets have a header).
519
515
 
520
516
  Used for QoS, and others.
@@ -532,10 +528,10 @@ def pkt_header(
532
528
 
533
529
  if pkt.code == Code._1FC9:
534
530
  # .I --- 34:021943 --:------ 34:021943 1FC9 024 00-2309-8855B7 00-1FC9-8855B7
535
- # .W --- 01:145038 34:021943 --:------ 1FC9 006 00-2309-06368E # wont know src until it arrives
531
+ # .W --- 01:145038 34:021943 --:------ 1FC9 006 00-2309-06368E # won't know src until it arrives
536
532
  # .I --- 34:021943 01:145038 --:------ 1FC9 006 00-2309-8855B7
537
533
  if not rx_header:
538
- device_id = NUL_DEV_ADDR.id if pkt.src == pkt.dst else pkt.dst.id
534
+ device_id = ALL_DEV_ADDR.id if pkt.src == pkt.dst else pkt.dst.id
539
535
  return "|".join((pkt.code, pkt.verb, device_id))
540
536
  if pkt.src == pkt.dst: # and pkt.verb == I_:
541
537
  return "|".join((pkt.code, W_, pkt.src.id))
@@ -545,15 +541,20 @@ def pkt_header(
545
541
  # return "|".join((pkt.code, RP, pkt.dst.id))
546
542
  return None
547
543
 
548
- addr = pkt.dst if pkt.src.type == DEV_TYPE_MAP.HGI else pkt.src # DEX
549
- if not rx_header:
550
- header = "|".join((pkt.code, pkt.verb, addr.id))
544
+ # RQ and W use the dst.id rather than the src.id, as:
545
+ # - cmd.src could be 18:000730, and echo .src will have changed to (say) 18:123456
546
+ # - cmd.dst is the effector
551
547
 
552
- elif pkt.verb in (I_, RP) or pkt.src == pkt.dst: # announcements, etc.: no response
553
- return None
548
+ if rx_header:
549
+ if pkt.verb in (I_, RP) or pkt.src == pkt.dst: # say: xxxx| W|00:000000|xx
550
+ return None # no response expected
551
+ header = "|".join((pkt.code, RP if pkt.verb == RQ else I_, pkt.dst.id))
552
+
553
+ elif pkt.verb in (I_, RP) or pkt.src == pkt.dst:
554
+ header = "|".join((pkt.code, pkt.verb, pkt.src.id))
554
555
 
555
- else: # RQ/RP, or W/I
556
- header = "|".join((pkt.code, RP if pkt.verb == RQ else I_, addr.id))
556
+ else:
557
+ header = "|".join((pkt.code, pkt.verb, pkt.dst.id))
557
558
 
558
559
  try:
559
560
  return f"{header}|{pkt._ctx}" if isinstance(pkt._ctx, str) else header