ramses-rf 0.52.5__py3-none-any.whl → 0.53.0__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()))
@@ -237,7 +279,12 @@ class AttrDict(dict): # type: ignore[type-arg]
237
279
  return self.__getattribute__(name)
238
280
 
239
281
  def _hex(self, key: str) -> str:
240
- """Return the key/ID (2-byte hex string) of the two-way dict (e.g. '04')."""
282
+ """Return the key/ID (2-byte hex string) of the two-way dict (e.g. '04').
283
+
284
+ :param key: The lookup key (can be slug or code).
285
+ :raises KeyError: If the key is not found.
286
+ :return: The 2-byte hex string identifier.
287
+ """
241
288
  if key in self._main_table:
242
289
  return list(self._main_table[key].keys())[0] # type: ignore[no-any-return]
243
290
  if key in self._reverse:
@@ -245,7 +292,12 @@ class AttrDict(dict): # type: ignore[type-arg]
245
292
  raise KeyError(key)
246
293
 
247
294
  def _str(self, key: str) -> str:
248
- """Return the value (string) of the two-way dict (e.g. 'radiator_valve')."""
295
+ """Return the value (string) of the two-way dict (e.g. 'radiator_valve').
296
+
297
+ :param key: The lookup key.
298
+ :raises KeyError: If the key is not found.
299
+ :return: The human-readable slug string.
300
+ """
249
301
  if key in self._main_table:
250
302
  return list(self._main_table[key].values())[0] # type: ignore[no-any-return]
251
303
  if key in self:
@@ -256,14 +308,23 @@ class AttrDict(dict): # type: ignore[type-arg]
256
308
  # return {k: k for k in super().values()}.values()
257
309
 
258
310
  def slug(self, key: str) -> str:
259
- """WIP: Return master slug for a hex key/ID (e.g. 00 -> 'TRV', not 'TR0')."""
311
+ """Return master slug for a hex key/ID.
312
+
313
+ Example: 00 -> 'TRV' (master), not 'TR0'.
314
+
315
+ :param key: The hex key to look up.
316
+ :return: The master slug.
317
+ """
260
318
  slug_ = self._slug_lookup[key]
261
319
  # if slug_ in self._attr_table["_TRANSFORMS"]:
262
320
  # return self._attr_table["_TRANSFORMS"][slug_]
263
321
  return slug_ # type: ignore[no-any-return]
264
322
 
265
323
  def slugs(self) -> tuple[str]:
266
- """Return the slugs from the main table."""
324
+ """Return the slugs from the main table.
325
+
326
+ :return: A tuple of all available slugs.
327
+ """
267
328
  return self._attr_table[self._SZ_SLUGS] # type: ignore[no-any-return]
268
329
 
269
330
 
@@ -271,6 +332,12 @@ def attr_dict_factory(
271
332
  main_table: dict[str, dict], # type: ignore[type-arg]
272
333
  attr_table: dict | None = None, # type: ignore[type-arg]
273
334
  ) -> AttrDict: # is: SlottedAttrDict
335
+ """Create a new AttrDict instance with a slotted subclass.
336
+
337
+ :param main_table: The primary mapping of codes to slugs.
338
+ :param attr_table: Optional additional attributes to attach to the instance.
339
+ :return: An instance of a dynamic AttrDict subclass.
340
+ """
274
341
  if attr_table is None:
275
342
  attr_table = {}
276
343
 
@@ -295,6 +362,8 @@ def attr_dict_factory(
295
362
  # slugs for device/zone entity klasses, used by 0005/000C
296
363
  @verify(EnumCheck.UNIQUE)
297
364
  class DevRole(StrEnum):
365
+ """Slugs for device/zone entity classes, used by commands 0005/000C."""
366
+
298
367
  #
299
368
  # Generic device/zone classes
300
369
  ACT = "ACT" # Generic heating zone actuator group
@@ -345,6 +414,8 @@ DEV_ROLE_MAP = attr_dict_factory(
345
414
  # slugs for device entity types, used in device_ids
346
415
  @verify(EnumCheck.UNIQUE)
347
416
  class DevType(StrEnum):
417
+ """Slugs for device entity types, used in device_ids."""
418
+
348
419
  #
349
420
  # Promotable/Generic devices
350
421
  DEV = "DEV" # xx: Promotable device
@@ -457,6 +528,8 @@ DEV_TYPE_MAP = attr_dict_factory(
457
528
 
458
529
  # slugs for zone entity klasses, used by 0005/000C
459
530
  class ZoneRole(StrEnum):
531
+ """Slugs for zone entity classes, used by commands 0005/000C."""
532
+
460
533
  #
461
534
  # Generic device/zone classes
462
535
  ACT = "ACT" # Generic heating zone actuator group
@@ -645,6 +718,8 @@ MESSAGE_REGEX = re.compile(f"^{r} {v} {r} {d} {d} {d} {c} {l} {p}$")
645
718
 
646
719
  # Used by 0418/system_fault parser
647
720
  class FaultDeviceClass(StrEnum):
721
+ """Device classes for system faults."""
722
+
648
723
  CONTROLLER = "controller"
649
724
  SENSOR = "sensor"
650
725
  SETPOINT = "setpoint"
@@ -666,6 +741,8 @@ FAULT_DEVICE_CLASS: Final[dict[str, FaultDeviceClass]] = {
666
741
 
667
742
 
668
743
  class FaultState(StrEnum):
744
+ """States for system faults."""
745
+
669
746
  FAULT = "fault"
670
747
  RESTORE = "restore"
671
748
  UNKNOWN_C0 = "unknown_c0"
@@ -680,6 +757,8 @@ FAULT_STATE: Final[dict[str, FaultState]] = { # a bitmap?
680
757
 
681
758
 
682
759
  class FaultType(StrEnum):
760
+ """Types of system faults."""
761
+
683
762
  SYSTEM_FAULT = "system_fault"
684
763
  MAINS_LOW = "mains_low"
685
764
  BATTERY_LOW = "battery_low"
@@ -703,6 +782,8 @@ FAULT_TYPE: Final[dict[str, FaultType]] = {
703
782
 
704
783
 
705
784
  class SystemType(StrEnum):
785
+ """System types (e.g. Evohome, Hometronics)."""
786
+
706
787
  CHRONOTHERM = "chronotherm"
707
788
  EVOHOME = "evohome"
708
789
  HOMETRONICS = "hometronics"
@@ -742,6 +823,8 @@ FAN_RATE: Final = "fan_rate" # percentage, 0.0 - 1.0 # deprecated, use SZ_FAN_
742
823
  # Below, verbs & codes - can use Verb/Code/Index for mypy type checking
743
824
  @verify(EnumCheck.UNIQUE)
744
825
  class VerbT(StrEnum):
826
+ """Protocol verbs (message types)."""
827
+
745
828
  I_ = " I"
746
829
  RQ = "RQ"
747
830
  RP = "RP"
@@ -756,6 +839,8 @@ W_: Final = VerbT.W_
756
839
 
757
840
  @verify(EnumCheck.UNIQUE)
758
841
  class MsgId(StrEnum):
842
+ """Message identifiers."""
843
+
759
844
  _00 = "00"
760
845
  _03 = "03"
761
846
  _06 = "06"
@@ -791,6 +876,8 @@ class MsgId(StrEnum):
791
876
  # StrEnum is intended to include all known codes, see: test suite, code schema in ramses.py
792
877
  @verify(EnumCheck.UNIQUE)
793
878
  class Code(StrEnum):
879
+ """Protocol command codes."""
880
+
794
881
  _0001 = "0001"
795
882
  _0002 = "0002"
796
883
  _0004 = "0004"
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(