ramses-rf 0.52.5__py3-none-any.whl → 0.53.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ramses_tx/const.py CHANGED
@@ -1,5 +1,9 @@
1
1
  #!/usr/bin/env python3
2
- """RAMSES RF - a RAMSES-II protocol decoder & analyser."""
2
+ """RAMSES RF - a RAMSES-II protocol decoder & analyser.
3
+
4
+ This module contains constants, enums, and helper classes used throughout the
5
+ library to decode and encode RAMSES-II protocol packets.
6
+ """
3
7
 
4
8
  from __future__ import annotations
5
9
 
@@ -15,16 +19,23 @@ DEV_MODE = __dev_mode__
15
19
  DEFAULT_DISABLE_QOS: Final[bool | None] = None
16
20
  DEFAULT_WAIT_FOR_REPLY: Final[bool | None] = None
17
21
 
18
- DEFAULT_ECHO_TIMEOUT: Final[float] = 0.50 # waiting for echo pkt after cmd sent
19
- DEFAULT_RPLY_TIMEOUT: Final[float] = 0.50 # waiting for reply pkt after echo pkt rcvd
22
+ #: Waiting for echo pkt after cmd sent (seconds)
23
+ DEFAULT_ECHO_TIMEOUT: Final[float] = 0.50
24
+
25
+ #: Waiting for reply pkt after echo pkt rcvd (seconds)
26
+ DEFAULT_RPLY_TIMEOUT: Final[float] = 0.50
20
27
  DEFAULT_BUFFER_SIZE: Final[int] = 32
21
28
 
22
- DEFAULT_SEND_TIMEOUT: Final[float] = 20.0 # total waiting for successful send: FIXME
23
- MAX_SEND_TIMEOUT: Final[float] = 20.0 # for a command to be sent, incl. queuing time
29
+ #: Total waiting for successful send (seconds)
30
+ DEFAULT_SEND_TIMEOUT: Final[float] = 20.0
31
+ #: For a command to be sent, incl. queuing time (seconds)
32
+ MAX_SEND_TIMEOUT: Final[float] = 20.0
24
33
 
25
- MAX_RETRY_LIMIT: Final[int] = 3 # for a command to be re-sent (not incl. 1st send)
34
+ #: For a command to be re-sent (not incl. 1st send)
35
+ MAX_RETRY_LIMIT: Final[int] = 3
26
36
 
27
- MIN_INTER_WRITE_GAP: Final[float] = 0.05 # seconds
37
+ #: Minimum gap between writes (seconds)
38
+ MIN_INTER_WRITE_GAP: Final[float] = 0.05
28
39
  DEFAULT_GAP_DURATION: Final[float] = MIN_INTER_WRITE_GAP
29
40
  DEFAULT_MAX_RETRIES: Final[int] = 3
30
41
  DEFAULT_NUM_REPEATS: Final[int] = 0
@@ -139,6 +150,8 @@ SZ_OTC_ACTIVE: Final = "otc_active"
139
150
 
140
151
  @verify(EnumCheck.UNIQUE)
141
152
  class Priority(IntEnum):
153
+ """Priority levels for protocol messages."""
154
+
142
155
  LOWEST = 4
143
156
  LOW = 2
144
157
  DEFAULT = 0
@@ -147,31 +160,60 @@ class Priority(IntEnum):
147
160
 
148
161
 
149
162
  def slug(string: str) -> str:
150
- """Convert a string to snake_case."""
163
+ """Convert a string to snake_case.
164
+
165
+ :param string: The input string to convert.
166
+ :return: The string converted to snake_case (lowercase, with non-alphanumerics replaced by underscores).
167
+ """
151
168
  return re.sub(r"[\W_]+", "_", string.lower())
152
169
 
153
170
 
154
171
  # TODO: FIXME: This is a mess - needs converting to StrEnum
155
172
  class AttrDict(dict): # type: ignore[type-arg]
173
+ """A read-only dictionary that supports dot-access and two-way lookup.
174
+
175
+ This class is typically used to map hex codes (keys) to human-readable slugs (values),
176
+ while also allowing reverse lookup via dot notation (e.g., ``map.SLUG``).
177
+
178
+ .. warning::
179
+ This class is immutable. Attempting to modify it will raise a :exc:`TypeError`.
180
+ """
181
+
156
182
  _SZ_AKA_SLUG: Final = "_root_slug"
157
183
  _SZ_DEFAULT: Final = "_default"
158
184
  _SZ_SLUGS: Final = "SLUGS"
159
185
 
160
- @classmethod
161
- def __readonly(cls, *args: Any, **kwargs: Any) -> NoReturn:
162
- raise TypeError(f"'{cls.__class__.__name__}' object is read only")
186
+ def _readonly(self, *args: Any, **kwargs: Any) -> NoReturn:
187
+ """Raise TypeError for read-only operations."""
188
+ raise TypeError(f"'{self.__class__.__name__}' object is read only")
189
+
190
+ def __setitem__(self, key: Any, value: Any) -> NoReturn:
191
+ self._readonly()
163
192
 
164
- __delitem__ = __readonly
165
- __setitem__ = __readonly
166
- clear = __readonly
167
- pop = __readonly
168
- popitem = __readonly
169
- setdefault = __readonly
170
- update = __readonly
193
+ def __delitem__(self, key: Any) -> NoReturn:
194
+ self._readonly()
171
195
 
172
- del __readonly
196
+ def clear(self) -> NoReturn:
197
+ self._readonly()
198
+
199
+ def pop(self, *args: Any, **kwargs: Any) -> NoReturn:
200
+ self._readonly()
201
+
202
+ def popitem(self) -> NoReturn:
203
+ self._readonly()
204
+
205
+ def setdefault(self, *args: Any, **kwargs: Any) -> NoReturn:
206
+ self._readonly()
207
+
208
+ def update(self, *args: Any, **kwargs: Any) -> NoReturn:
209
+ self._readonly()
173
210
 
174
211
  def __init__(self, main_table: dict[str, dict], attr_table: dict[str, Any]) -> None: # type: ignore[type-arg]
212
+ """Initialize the AttrDict.
213
+
214
+ :param main_table: A dictionary mapping keys (usually hex codes) to property dictionaries.
215
+ :param attr_table: A dictionary of additional attributes to expose on the object.
216
+ """
175
217
  self._main_table = main_table
176
218
  self._attr_table = attr_table
177
219
  self._attr_table[self._SZ_SLUGS] = tuple(sorted(main_table.keys()))
@@ -234,10 +276,17 @@ class AttrDict(dict): # type: ignore[type-arg]
234
276
  return self._forward[name[1:]]
235
277
  elif name.isupper() and name.lower() in self._reverse: # map.DHW_SENSOR -> "0D"
236
278
  return self[name.lower()]
237
- return self.__getattribute__(name)
279
+ raise AttributeError(
280
+ f"'{type(self).__name__}' object has no attribute '{name}'"
281
+ )
238
282
 
239
283
  def _hex(self, key: str) -> str:
240
- """Return the key/ID (2-byte hex string) of the two-way dict (e.g. '04')."""
284
+ """Return the key/ID (2-byte hex string) of the two-way dict (e.g. '04').
285
+
286
+ :param key: The lookup key (can be slug or code).
287
+ :raises KeyError: If the key is not found.
288
+ :return: The 2-byte hex string identifier.
289
+ """
241
290
  if key in self._main_table:
242
291
  return list(self._main_table[key].keys())[0] # type: ignore[no-any-return]
243
292
  if key in self._reverse:
@@ -245,7 +294,12 @@ class AttrDict(dict): # type: ignore[type-arg]
245
294
  raise KeyError(key)
246
295
 
247
296
  def _str(self, key: str) -> str:
248
- """Return the value (string) of the two-way dict (e.g. 'radiator_valve')."""
297
+ """Return the value (string) of the two-way dict (e.g. 'radiator_valve').
298
+
299
+ :param key: The lookup key.
300
+ :raises KeyError: If the key is not found.
301
+ :return: The human-readable slug string.
302
+ """
249
303
  if key in self._main_table:
250
304
  return list(self._main_table[key].values())[0] # type: ignore[no-any-return]
251
305
  if key in self:
@@ -256,14 +310,23 @@ class AttrDict(dict): # type: ignore[type-arg]
256
310
  # return {k: k for k in super().values()}.values()
257
311
 
258
312
  def slug(self, key: str) -> str:
259
- """WIP: Return master slug for a hex key/ID (e.g. 00 -> 'TRV', not 'TR0')."""
313
+ """Return master slug for a hex key/ID.
314
+
315
+ Example: 00 -> 'TRV' (master), not 'TR0'.
316
+
317
+ :param key: The hex key to look up.
318
+ :return: The master slug.
319
+ """
260
320
  slug_ = self._slug_lookup[key]
261
321
  # if slug_ in self._attr_table["_TRANSFORMS"]:
262
322
  # return self._attr_table["_TRANSFORMS"][slug_]
263
323
  return slug_ # type: ignore[no-any-return]
264
324
 
265
325
  def slugs(self) -> tuple[str]:
266
- """Return the slugs from the main table."""
326
+ """Return the slugs from the main table.
327
+
328
+ :return: A tuple of all available slugs.
329
+ """
267
330
  return self._attr_table[self._SZ_SLUGS] # type: ignore[no-any-return]
268
331
 
269
332
 
@@ -271,6 +334,12 @@ def attr_dict_factory(
271
334
  main_table: dict[str, dict], # type: ignore[type-arg]
272
335
  attr_table: dict | None = None, # type: ignore[type-arg]
273
336
  ) -> AttrDict: # is: SlottedAttrDict
337
+ """Create a new AttrDict instance with a slotted subclass.
338
+
339
+ :param main_table: The primary mapping of codes to slugs.
340
+ :param attr_table: Optional additional attributes to attach to the instance.
341
+ :return: An instance of a dynamic AttrDict subclass.
342
+ """
274
343
  if attr_table is None:
275
344
  attr_table = {}
276
345
 
@@ -295,6 +364,8 @@ def attr_dict_factory(
295
364
  # slugs for device/zone entity klasses, used by 0005/000C
296
365
  @verify(EnumCheck.UNIQUE)
297
366
  class DevRole(StrEnum):
367
+ """Slugs for device/zone entity classes, used by commands 0005/000C."""
368
+
298
369
  #
299
370
  # Generic device/zone classes
300
371
  ACT = "ACT" # Generic heating zone actuator group
@@ -345,6 +416,8 @@ DEV_ROLE_MAP = attr_dict_factory(
345
416
  # slugs for device entity types, used in device_ids
346
417
  @verify(EnumCheck.UNIQUE)
347
418
  class DevType(StrEnum):
419
+ """Slugs for device entity types, used in device_ids."""
420
+
348
421
  #
349
422
  # Promotable/Generic devices
350
423
  DEV = "DEV" # xx: Promotable device
@@ -457,6 +530,8 @@ DEV_TYPE_MAP = attr_dict_factory(
457
530
 
458
531
  # slugs for zone entity klasses, used by 0005/000C
459
532
  class ZoneRole(StrEnum):
533
+ """Slugs for zone entity classes, used by commands 0005/000C."""
534
+
460
535
  #
461
536
  # Generic device/zone classes
462
537
  ACT = "ACT" # Generic heating zone actuator group
@@ -645,6 +720,8 @@ MESSAGE_REGEX = re.compile(f"^{r} {v} {r} {d} {d} {d} {c} {l} {p}$")
645
720
 
646
721
  # Used by 0418/system_fault parser
647
722
  class FaultDeviceClass(StrEnum):
723
+ """Device classes for system faults."""
724
+
648
725
  CONTROLLER = "controller"
649
726
  SENSOR = "sensor"
650
727
  SETPOINT = "setpoint"
@@ -666,6 +743,8 @@ FAULT_DEVICE_CLASS: Final[dict[str, FaultDeviceClass]] = {
666
743
 
667
744
 
668
745
  class FaultState(StrEnum):
746
+ """States for system faults."""
747
+
669
748
  FAULT = "fault"
670
749
  RESTORE = "restore"
671
750
  UNKNOWN_C0 = "unknown_c0"
@@ -680,6 +759,8 @@ FAULT_STATE: Final[dict[str, FaultState]] = { # a bitmap?
680
759
 
681
760
 
682
761
  class FaultType(StrEnum):
762
+ """Types of system faults."""
763
+
683
764
  SYSTEM_FAULT = "system_fault"
684
765
  MAINS_LOW = "mains_low"
685
766
  BATTERY_LOW = "battery_low"
@@ -703,6 +784,8 @@ FAULT_TYPE: Final[dict[str, FaultType]] = {
703
784
 
704
785
 
705
786
  class SystemType(StrEnum):
787
+ """System types (e.g. Evohome, Hometronics)."""
788
+
706
789
  CHRONOTHERM = "chronotherm"
707
790
  EVOHOME = "evohome"
708
791
  HOMETRONICS = "hometronics"
@@ -742,6 +825,8 @@ FAN_RATE: Final = "fan_rate" # percentage, 0.0 - 1.0 # deprecated, use SZ_FAN_
742
825
  # Below, verbs & codes - can use Verb/Code/Index for mypy type checking
743
826
  @verify(EnumCheck.UNIQUE)
744
827
  class VerbT(StrEnum):
828
+ """Protocol verbs (message types)."""
829
+
745
830
  I_ = " I"
746
831
  RQ = "RQ"
747
832
  RP = "RP"
@@ -756,6 +841,8 @@ W_: Final = VerbT.W_
756
841
 
757
842
  @verify(EnumCheck.UNIQUE)
758
843
  class MsgId(StrEnum):
844
+ """Message identifiers."""
845
+
759
846
  _00 = "00"
760
847
  _03 = "03"
761
848
  _06 = "06"
@@ -791,6 +878,8 @@ class MsgId(StrEnum):
791
878
  # StrEnum is intended to include all known codes, see: test suite, code schema in ramses.py
792
879
  @verify(EnumCheck.UNIQUE)
793
880
  class Code(StrEnum):
881
+ """Protocol command codes."""
882
+
794
883
  _0001 = "0001"
795
884
  _0002 = "0002"
796
885
  _0004 = "0004"
ramses_tx/gateway.py CHANGED
@@ -114,7 +114,9 @@ class Engine:
114
114
  self._include,
115
115
  self._exclude,
116
116
  )
117
- self._sqlite_index = kwargs.pop(SZ_SQLITE_INDEX, False) # default True?
117
+ self._sqlite_index = kwargs.pop(
118
+ SZ_SQLITE_INDEX, False
119
+ ) # TODO Q1 2026: default True
118
120
  self._log_all_mqtt = kwargs.pop(SZ_LOG_ALL_MQTT, False)
119
121
  self._kwargs: dict[str, Any] = kwargs # HACK
120
122
 
ramses_tx/helpers.py CHANGED
@@ -149,7 +149,7 @@ def timestamp() -> float:
149
149
 
150
150
 
151
151
  def dt_now() -> dt:
152
- """Return the current datetime as a local/naive datetime object.
152
+ """Get the current datetime as a local/naive datetime object.
153
153
 
154
154
  This is slower, but potentially more accurate, than dt.now(), and is used mainly for
155
155
  packet timestamps.
ramses_tx/message.py CHANGED
@@ -70,22 +70,24 @@ class MessageBase:
70
70
  self.verb: VerbT = pkt.verb
71
71
  self.seqn: str = (
72
72
  pkt.seqn
73
- ) # the msg is part of a set for 1 Code, received in order
73
+ ) # the msg is part of a set for a Code, received in order
74
74
  self.code: Code = pkt.code
75
75
  self.len: int = pkt._len
76
76
 
77
- self._payload = self._validate(
78
- self._pkt.payload
79
- ) # ? may raise InvalidPacketError
77
+ self._payload = self._validate(self._pkt.payload) # ? may raise PacketInvalid
80
78
 
81
79
  self._str: str = None # type: ignore[assignment]
82
80
 
83
81
  def __repr__(self) -> str:
84
- """Return an unambiguous string representation of this object."""
82
+ """
83
+ :return: an unambiguous string representation of this object.
84
+ """
85
85
  return str(self._pkt) # repr or str?
86
86
 
87
87
  def __str__(self) -> str:
88
- """Return a brief readable string representation of this object."""
88
+ """
89
+ :return: a brief readable string representation of this object.
90
+ """
89
91
 
90
92
  def ctx(pkt: Packet) -> str:
91
93
  ctx = {True: "[..]", False: "", None: "??"}.get(pkt._ctx, pkt._ctx) # type: ignore[arg-type]
@@ -128,17 +130,22 @@ class MessageBase:
128
130
  return self.dtm < other.dtm
129
131
 
130
132
  def _name(self, addr: Address) -> str:
131
- """Return a friendly name for an Address, or a Device."""
133
+ """
134
+ :return: a friendly name for an Address, or a Device.
135
+ """
132
136
  return f" {addr.id}" # can't do 'CTL:123456' instead of ' 01:123456'
133
137
 
134
138
  @property
135
139
  def payload(self): # type: ignore[no-untyped-def] # FIXME -> dict | list:
136
- """Return the payload."""
140
+ """
141
+ :return: the payload.
142
+ """
137
143
  return self._payload
138
144
 
139
145
  @property
140
146
  def _has_payload(self) -> bool:
141
- """Return False if there is no payload (may falsely Return True).
147
+ """
148
+ :return: False if there is no payload (may falsely return True).
142
149
 
143
150
  The message (i.e. the raw payload) may still have an idx.
144
151
  """
@@ -234,7 +241,7 @@ class MessageBase:
234
241
  return {}
235
242
 
236
243
  # TODO: also 000C (but is a complex idx)
237
- # TODO: also 3150 (when not domain, and will be array if so)
244
+ # TODO: also 3150 (when not domain; will be array in that case)
238
245
  if self.code in (Code._000A, Code._2309) and self.src.type == DEV_TYPE_MAP.UFC:
239
246
  assert isinstance(self._pkt._idx, str) # mypy hint
240
247
  return {IDX_NAMES[Code._22C9]: self._pkt._idx}
@@ -250,7 +257,7 @@ class MessageBase:
250
257
  """Validate a message packet payload, and parse it if valid.
251
258
 
252
259
  :return: a dict containing key: value pairs, or a list of those created from the payload
253
- :raises an InvalidPacketError exception if it is not valid.
260
+ :raises PacketInvalid exception if it is not valid.
254
261
  """
255
262
 
256
263
  try: # parse the payload
@@ -292,7 +299,7 @@ class MessageBase:
292
299
 
293
300
 
294
301
  class Message(MessageBase):
295
- """Extend the Message class, so is useful to a stateful Gateway.
302
+ """Extend the Message class, so it is useful to a stateful Gateway.
296
303
 
297
304
  Adds _expired attr to the Message class.
298
305
  """
@@ -303,7 +310,7 @@ class Message(MessageBase):
303
310
  # .HAS_DIED = 1.0 # fraction_expired >= 1.0 (is expected lifespan)
304
311
  IS_EXPIRING = 0.8 # fraction_expired >= 0.8 (and < HAS_EXPIRED)
305
312
 
306
- _gwy: Gateway
313
+ _gwy: Gateway | None = None
307
314
  _fraction_expired: float | None = None
308
315
 
309
316
  @classmethod
@@ -318,13 +325,19 @@ class Message(MessageBase):
318
325
 
319
326
  @property
320
327
  def _expired(self) -> bool:
321
- """Return True if the message is dated (or False otherwise)."""
328
+ """
329
+ :return: True if the message is dated, False otherwise
330
+ """
322
331
  # fraction_expired = (dt_now - self.dtm - _TD_SECONDS_003) / self._pkt._lifespan
323
332
  # TODO: keep none >7d, even 10E0, etc.
324
333
 
325
334
  def fraction_expired(lifespan: td) -> float:
326
- """Return the packet's age as fraction of its 'normal' life span."""
327
- return (self._gwy._dt_now() - self.dtm - _TD_SECS_003) / lifespan
335
+ """
336
+ :return: the packet's age as fraction of its 'normal' life span.
337
+ """
338
+ if self._gwy: # self._gwy is set in ramses_tx.gateway.Engine._msg_handler
339
+ return (self._gwy._dt_now() - self.dtm - _TD_SECS_003) / lifespan
340
+ return (dt.now() - self.dtm - _TD_SECS_003) / lifespan
328
341
 
329
342
  # 1. Look for easy win...
330
343
  if self._fraction_expired is not None:
ramses_tx/parsers.py CHANGED
@@ -3291,13 +3291,13 @@ def parser_3220(payload: str, msg: Message) -> dict[str, Any]:
3291
3291
  "FFFF",
3292
3292
  ), f"OpenTherm: Invalid msg-type|data-value: {ot_type}|{payload[6:10]}"
3293
3293
 
3294
- # HACK: These OT data id can pop in/out of 47AB, which is an invalid value
3294
+ # HACK: These OT data id's can pop in/out of 47AB, which is an invalid value
3295
3295
  if payload[6:] == "47AB" and ot_id in (0x12, 0x13, 0x19, 0x1A, 0x1B, 0x1C):
3296
3296
  ot_value[SZ_VALUE] = None
3297
3297
  # HACK: This OT data id can be 1980, which is an invalid value
3298
3298
  if payload[6:] == "1980" and ot_id: # CH pressure is 25.5 bar!
3299
3299
  ot_value[SZ_VALUE] = None
3300
- # HACK: Done above, not in OT.decode_frame() as they isn't in the OT specification
3300
+ # HACK: Done above, not in OT.decode_frame() as values aren't in the OT specification
3301
3301
 
3302
3302
  if ot_type not in _LIST:
3303
3303
  assert ot_type in (
ramses_tx/protocol.py CHANGED
@@ -251,21 +251,30 @@ class _BaseProtocol(asyncio.Protocol):
251
251
 
252
252
  # FIX: Patch command with actual HGI ID if it uses the default placeholder
253
253
  # NOTE: HGI80s (TI 3410) require the default ID (18:000730), or they will silent-fail
254
+
254
255
  if (
255
- self._active_hgi
256
+ self.hgi_id
256
257
  and self._is_evofw3 # Only patch if using evofw3 (not HGI80)
257
258
  and cmd._addrs[0].id == HGI_DEV_ADDR.id
258
- and self._active_hgi != HGI_DEV_ADDR.id
259
+ and self.hgi_id != HGI_DEV_ADDR.id
259
260
  ):
260
261
  # The command uses the default 18:000730, but we know the real ID.
261
262
  # Reconstruct the command string with the correct address.
262
263
 
264
+ _LOGGER.debug(
265
+ f"Patching command with active HGI ID: swapped {HGI_DEV_ADDR.id} -> {self.hgi_id} for {cmd._hdr}"
266
+ )
267
+
268
+ # Get current addresses as strings
269
+ # The command uses the default 18:000730, but we know the real ID.
270
+ # Reconstruct the command string with the correct address.
271
+
263
272
  # Get current addresses as strings
264
273
  new_addrs = [a.id for a in cmd._addrs]
265
274
 
266
275
  # ONLY patch the Source Address (Index 0).
267
276
  # Leave Dest (Index 1/2) alone to avoid breaking tests that expect 18:000730 there.
268
- new_addrs[0] = self._active_hgi
277
+ new_addrs[0] = self.hgi_id
269
278
 
270
279
  new_frame = f"{cmd.verb} {cmd.seqn} {new_addrs[0]} {new_addrs[1]} {new_addrs[2]} {cmd.code} {int(cmd.len_):03d} {cmd.payload}"
271
280
  cmd = Command(new_frame)
@@ -349,6 +358,7 @@ class _BaseProtocol(asyncio.Protocol):
349
358
  """
350
359
 
351
360
  if self._msg_handler: # type: ignore[truthy-function]
361
+ _LOGGER.debug(f"Dispatching valid message to handler: {msg}")
352
362
  self._loop.call_soon_threadsafe(self._msg_handler, msg)
353
363
  for callback in self._msg_handlers:
354
364
  # TODO: if handler's filter returns True:
@@ -389,9 +399,10 @@ class _DeviceIdFilterMixin(_BaseProtocol):
389
399
  def hgi_id(self) -> DeviceIdT:
390
400
  if not self._transport:
391
401
  return self._known_hgi or HGI_DEV_ADDR.id
392
- return self._transport.get_extra_info( # type: ignore[no-any-return]
393
- SZ_ACTIVE_HGI, self._known_hgi or HGI_DEV_ADDR.id
394
- )
402
+ # CRITICAL FIX: get_extra_info returns None if key exists but val is None
403
+ # We must ensure we fallback to the known HGI or default if it returns None
404
+ hgi = self._transport.get_extra_info(SZ_ACTIVE_HGI)
405
+ return hgi or self._known_hgi or HGI_DEV_ADDR.id
395
406
 
396
407
  @staticmethod
397
408
  def _extract_known_hgi_id(
@@ -434,7 +445,10 @@ class _DeviceIdFilterMixin(_BaseProtocol):
434
445
 
435
446
  known_hgi = (explicit_hgis if explicit_hgis else implicit_hgis)[0]
436
447
 
437
- if include_list[known_hgi].get(SZ_CLASS) != DevType.HGI:
448
+ if include_list[known_hgi].get(SZ_CLASS) not in (
449
+ DevType.HGI,
450
+ DEV_TYPE_MAP[DevType.HGI],
451
+ ):
438
452
  logger(
439
453
  f"The {SZ_KNOWN_LIST} SHOULD include exactly one gateway (HGI): "
440
454
  f"{known_hgi} should specify 'class: HGI', as 18: is also used for HVAC"
@@ -866,7 +880,7 @@ async def create_stack(
866
880
  )
867
881
 
868
882
  transport: RamsesTransportT = await (transport_factory_ or transport_factory)( # type: ignore[operator]
869
- protocol, disable_sending=disable_sending, **kwargs
883
+ protocol, disable_sending=bool(disable_sending), **kwargs
870
884
  )
871
885
 
872
886
  if not kwargs.get(SZ_PORT_NAME):
ramses_tx/protocol_fsm.py CHANGED
@@ -146,9 +146,13 @@ class ProtocolContext:
146
146
  self._multiplier = min(3, old_val + 1)
147
147
 
148
148
  if isinstance(self._state, WantEcho):
149
- _LOGGER.warning("TOUT.. = %s: echo_timeout=%s", self, delay)
149
+ _LOGGER.warning(
150
+ f"Timeout expired waiting for echo: {self} (delay={delay})"
151
+ )
150
152
  else: # isinstance(self._state, WantRply):
151
- _LOGGER.warning("TOUT.. = %s: rply_timeout=%s", self, delay)
153
+ _LOGGER.warning(
154
+ f"Timeout expired waiting for reply: {self} (delay={delay})"
155
+ )
152
156
 
153
157
  assert isinstance(self.is_sending, bool), (
154
158
  f"{self}: Coding error"
@@ -197,8 +201,16 @@ class ProtocolContext:
197
201
  # _fut.cancel() (incl. via a send_cmd(qos.timeout) -> wait_for(timeout))
198
202
 
199
203
  # Changing the order of the following is fraught with danger
204
+ # Formatitng: [TRACE_FSM] [State: Old->New] [Reason] | ...
205
+
206
+ current_state_name = self._state.__class__.__name__
207
+ new_state_name = state_class.__name__
208
+ transition = f"{current_state_name}->{new_state_name}"
209
+
200
210
  if self._fut is None: # logging only - IsInIdle, Inactive
201
- _LOGGER.debug("BEFORE = %s", self)
211
+ _LOGGER.debug(
212
+ f"FSM state changed {transition}: no active future (ctx={self})"
213
+ )
202
214
  assert self._cmd is None, f"{self}: Coding error" # mypy hint
203
215
  assert isinstance(self._state, IsInIdle | Inactive | None), (
204
216
  f"{self}: Coding error"
@@ -207,14 +219,18 @@ class ProtocolContext:
207
219
  elif self._fut.cancelled() and not isinstance(self._state, IsInIdle):
208
220
  # cancelled by wait_for(timeout), cancel("buffer overflow"), or other?
209
221
  # was for previous send_cmd if currently IsInIdle (+/- Inactive?)
210
- _LOGGER.debug("BEFORE = %s: expired=%s (global)", self, expired)
222
+ _LOGGER.debug(
223
+ f"FSM state changed {transition}: future cancelled (expired={expired}, ctx={self})"
224
+ )
211
225
  assert self._cmd is not None, f"{self}: Coding error" # mypy hint
212
226
  assert isinstance(self._state, WantEcho | WantRply), (
213
227
  f"{self}: Coding error"
214
228
  ) # mypy hint
215
229
 
216
230
  elif exception:
217
- _LOGGER.debug("BEFORE = %s: exception=%s", self, exception)
231
+ _LOGGER.debug(
232
+ f"FSM state changed {transition}: exception occurred (error={exception}, ctx={self})"
233
+ )
218
234
  assert not self._fut.done(), (
219
235
  f"{self}: Coding error ({self._fut})"
220
236
  ) # mypy hint
@@ -224,7 +240,9 @@ class ProtocolContext:
224
240
  self._fut.set_exception(exception) # apologise to the sender
225
241
 
226
242
  elif result:
227
- _LOGGER.debug("BEFORE = %s: result=%s", self, result._hdr)
243
+ _LOGGER.debug(
244
+ f"FSM state changed {transition}: result received (result={result._hdr}, ctx={self})"
245
+ )
228
246
  assert not self._fut.done(), (
229
247
  f"{self}: Coding error ({self._fut})"
230
248
  ) # mypy hint
@@ -234,7 +252,7 @@ class ProtocolContext:
234
252
  self._fut.set_result(result)
235
253
 
236
254
  elif expired: # by expire_state_on_timeout(echo_timeout/reply_timeout)
237
- _LOGGER.debug("BEFORE = %s: expired=%s", self, expired)
255
+ _LOGGER.debug(f"FSM state changed {transition}: timer expired (ctx={self})")
238
256
  assert not self._fut.done(), (
239
257
  f"{self}: Coding error ({self._fut})"
240
258
  ) # mypy hint
@@ -246,7 +264,7 @@ class ProtocolContext:
246
264
  )
247
265
 
248
266
  else: # logging only - WantEcho, WantRply
249
- _LOGGER.debug("BEFORE = %s", self)
267
+ _LOGGER.debug(f"FSM state changed {transition}: successful (ctx={self})")
250
268
  assert self._fut is None or self._fut.cancelled() or not self._fut.done(), (
251
269
  f"{self}: Coding error ({self._fut})"
252
270
  ) # mypy hint
@@ -281,11 +299,11 @@ class ProtocolContext:
281
299
  self._loop.call_soon_threadsafe(effect_state, timed_out) # calls expire_state
282
300
 
283
301
  if not isinstance(self._state, WantRply):
284
- _LOGGER.debug("AFTER. = %s", self)
302
+ # _LOGGER.debug("AFTER. = %s", self)
285
303
  return
286
304
 
287
305
  assert self._qos is not None, f"{self}: Coding error" # mypy hint
288
- _LOGGER.debug("AFTER. = %s: wait_for_reply=%s", self, self._qos.wait_for_reply)
306
+ # _LOGGER.debug("AFTER. = %s: wait_for_reply=%s", self, self._qos.wait_for_reply)
289
307
 
290
308
  def connection_made(self, transport: RamsesTransportT) -> None:
291
309
  # may want to set some instance variables, according to type of transport
ramses_tx/schemas.py CHANGED
@@ -294,7 +294,7 @@ def sch_global_traits_dict_factory(
294
294
  )
295
295
  SCH_TRAITS_HEAT = SCH_TRAITS_HEAT.extend(
296
296
  heat_traits,
297
- extra=vol.PREVENT_EXTRA if heat_traits else vol.REMOVE_EXTRA,
297
+ extra=vol.PREVENT_EXTRA, # Always prevent extra keys
298
298
  )
299
299
 
300
300
  # NOTE: voluptuous doesn't like StrEnums, hence str(s)
@@ -314,7 +314,7 @@ def sch_global_traits_dict_factory(
314
314
  )
315
315
  SCH_TRAITS_HVAC = SCH_TRAITS_HVAC.extend(
316
316
  hvac_traits,
317
- extra=vol.PREVENT_EXTRA if hvac_traits else vol.REMOVE_EXTRA,
317
+ extra=vol.PREVENT_EXTRA, # Always prevent extra keys
318
318
  )
319
319
 
320
320
  SCH_TRAITS = vol.Any(