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_cli/__init__.py +1 -1
- ramses_cli/client.py +178 -55
- ramses_cli/discovery.py +12 -28
- ramses_cli/py.typed +0 -0
- ramses_rf/__init__.py +2 -0
- ramses_rf/database.py +6 -3
- ramses_rf/device/heat.py +7 -5
- ramses_rf/entity_base.py +10 -4
- ramses_rf/gateway.py +206 -29
- ramses_rf/schemas.py +1 -0
- ramses_rf/system/zones.py +3 -2
- ramses_rf/version.py +1 -1
- {ramses_rf-0.52.5.dist-info → ramses_rf-0.53.1.dist-info}/METADATA +1 -1
- {ramses_rf-0.52.5.dist-info → ramses_rf-0.53.1.dist-info}/RECORD +28 -27
- {ramses_rf-0.52.5.dist-info → ramses_rf-0.53.1.dist-info}/licenses/LICENSE +1 -1
- ramses_tx/command.py +1 -1
- ramses_tx/const.py +113 -24
- ramses_tx/gateway.py +3 -1
- ramses_tx/helpers.py +1 -1
- ramses_tx/message.py +29 -16
- ramses_tx/parsers.py +2 -2
- ramses_tx/protocol.py +22 -8
- ramses_tx/protocol_fsm.py +28 -10
- ramses_tx/schemas.py +2 -2
- ramses_tx/transport.py +503 -42
- ramses_tx/version.py +1 -1
- {ramses_rf-0.52.5.dist-info → ramses_rf-0.53.1.dist-info}/WHEEL +0 -0
- {ramses_rf-0.52.5.dist-info → ramses_rf-0.53.1.dist-info}/entry_points.txt +0 -0
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
34
|
+
#: For a command to be re-sent (not incl. 1st send)
|
|
35
|
+
MAX_RETRY_LIMIT: Final[int] = 3
|
|
26
36
|
|
|
27
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
raise TypeError(f"'{
|
|
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__
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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(
|
|
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
|
-
"""
|
|
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
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
140
|
+
"""
|
|
141
|
+
:return: the payload.
|
|
142
|
+
"""
|
|
137
143
|
return self._payload
|
|
138
144
|
|
|
139
145
|
@property
|
|
140
146
|
def _has_payload(self) -> bool:
|
|
141
|
-
"""
|
|
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
|
|
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
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
327
|
-
return
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
393
|
-
|
|
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)
|
|
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(
|
|
149
|
+
_LOGGER.warning(
|
|
150
|
+
f"Timeout expired waiting for echo: {self} (delay={delay})"
|
|
151
|
+
)
|
|
150
152
|
else: # isinstance(self._state, WantRply):
|
|
151
|
-
_LOGGER.warning(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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("
|
|
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("
|
|
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
|
|
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
|
|
317
|
+
extra=vol.PREVENT_EXTRA, # Always prevent extra keys
|
|
318
318
|
)
|
|
319
319
|
|
|
320
320
|
SCH_TRAITS = vol.Any(
|