ramses-rf 0.52.4__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_cli/client.py +168 -54
- ramses_cli/debug.py +1 -1
- ramses_cli/py.typed +0 -0
- ramses_cli/utils/convert.py +2 -2
- ramses_rf/__init__.py +2 -0
- ramses_rf/database.py +40 -17
- ramses_rf/device/base.py +14 -3
- ramses_rf/device/heat.py +1 -1
- ramses_rf/device/hvac.py +24 -21
- ramses_rf/entity_base.py +9 -7
- ramses_rf/gateway.py +214 -27
- ramses_rf/schemas.py +2 -1
- ramses_rf/system/zones.py +22 -2
- ramses_rf/version.py +1 -1
- {ramses_rf-0.52.4.dist-info → ramses_rf-0.53.0.dist-info}/METADATA +1 -1
- ramses_rf-0.53.0.dist-info/RECORD +56 -0
- {ramses_rf-0.52.4.dist-info → ramses_rf-0.53.0.dist-info}/WHEEL +1 -1
- {ramses_rf-0.52.4.dist-info → ramses_rf-0.53.0.dist-info}/licenses/LICENSE +1 -1
- ramses_tx/address.py +21 -6
- ramses_tx/command.py +19 -3
- ramses_tx/const.py +110 -23
- ramses_tx/helpers.py +30 -10
- ramses_tx/message.py +11 -5
- ramses_tx/packet.py +13 -5
- ramses_tx/parsers.py +1039 -16
- ramses_tx/protocol.py +112 -23
- ramses_tx/protocol_fsm.py +28 -10
- ramses_tx/schemas.py +2 -2
- ramses_tx/transport.py +529 -47
- ramses_tx/version.py +1 -1
- ramses_rf-0.52.4.dist-info/RECORD +0 -55
- {ramses_rf-0.52.4.dist-info → ramses_rf-0.53.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
ramses_cli/__init__.py,sha256=uvGzWqOf4avvgzxJNSLFWEelIWqSZ-AeLAZzg5x58bc,397
|
|
2
|
+
ramses_cli/client.py,sha256=OpsVfrdwChrBKia_yfAlI60ERMg7KAk3BWRpQyY3AWA,24762
|
|
3
|
+
ramses_cli/debug.py,sha256=PLcz-3PjUiMVqtD_p6VqTA92eHUM58lOBFXh_qgQ_wA,576
|
|
4
|
+
ramses_cli/discovery.py,sha256=MWoahBnAAVzfK2S7EDLsY2WYqN_ZK9L-lktrj8_4cb0,12978
|
|
5
|
+
ramses_cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
ramses_cli/utils/cat_slow.py,sha256=AhUpM5gnegCitNKU-JGHn-DrRzSi-49ZR1Qw6lxe_t8,607
|
|
7
|
+
ramses_cli/utils/convert.py,sha256=N3LxGe3_0pclijtmYW-ChqCuPTzbkoJA4XNAnoSnBk0,1806
|
|
8
|
+
ramses_rf/__init__.py,sha256=AXsCK1Eh9FWeAI9D_zY_2KB0dqrTb9a5TNY1NvyQaDM,1271
|
|
9
|
+
ramses_rf/binding_fsm.py,sha256=fuqvcc9YW-wr8SPH8zadpPqrHAvzl_eeWF-IBtlLppY,26632
|
|
10
|
+
ramses_rf/const.py,sha256=L3z31CZ-xqno6oZp_h-67CB_5tDDqTwSWXsqRtsjMcs,5460
|
|
11
|
+
ramses_rf/database.py,sha256=9nY1Wt2hjkBoDvDqopzPP3mPSH5-Q5XHPQuJnM2GHOw,21362
|
|
12
|
+
ramses_rf/dispatcher.py,sha256=YjEU-QrBLo9IfoEhJo2ikg_FxOaMYoWvzelr9Vi-JZ8,11398
|
|
13
|
+
ramses_rf/entity_base.py,sha256=6jsHQD_5buaV56rF6nTgzPKOLsXFGVly9EHAeigqeRg,59039
|
|
14
|
+
ramses_rf/exceptions.py,sha256=mt_T7irqHSDKir6KLaf6oDglUIdrw0S40JbOrWJk5jc,3657
|
|
15
|
+
ramses_rf/gateway.py,sha256=KWE7ZkHGPcZFp9eaKN9zXchkykMJLEx_k5Le99or5lQ,30064
|
|
16
|
+
ramses_rf/helpers.py,sha256=TNk_QkpIOB3alOp1sqnA9LOzi4fuDCeapNlW3zTzNas,4250
|
|
17
|
+
ramses_rf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
+
ramses_rf/schemas.py,sha256=X1GAK3kttuLMiSCUDY2s-85fgBxPeU8xiDa6gJ1I5mY,13543
|
|
19
|
+
ramses_rf/version.py,sha256=FlL_C6hC_miMOJxgK12zDE2CvaAfRA3KoLOL1_U-7QE,125
|
|
20
|
+
ramses_rf/device/__init__.py,sha256=sUbH5dhbYFXSoM_TPFRutpRutBRpup7_cQ9smPtDTy8,4858
|
|
21
|
+
ramses_rf/device/base.py,sha256=Tu5I8Lj7KplfRsIBQAYjilS6YPgTyjpU8qgKugMR2Jk,18281
|
|
22
|
+
ramses_rf/device/heat.py,sha256=T-ENFzH1AEOd0m4-TgV-Qk0TnLNir_pYmyKlJtxy6NI,54538
|
|
23
|
+
ramses_rf/device/hvac.py,sha256=vdgiPiLtCAGr7CVsGhQl6XuAFkyYdQSE_2AEdCmRl2I,48502
|
|
24
|
+
ramses_rf/system/__init__.py,sha256=uZLKio3gLlBzePa2aDQ1nxkcp1YXOGrn6iHTG8LiNIw,711
|
|
25
|
+
ramses_rf/system/faultlog.py,sha256=GdGmVGT3137KsTlV_nhccgIFEmYu6DFsLTn4S-8JSok,12799
|
|
26
|
+
ramses_rf/system/heat.py,sha256=qQmzgmyHy2x87gHAstn0ee7ZVVOq-GJIfDxCrC-6gFU,39254
|
|
27
|
+
ramses_rf/system/schedule.py,sha256=Ts6tdZPTQLV5NkgwA73tPa5QUsnZNIIuYoKC-8VsXDk,18808
|
|
28
|
+
ramses_rf/system/zones.py,sha256=qMv7CuvZUKBNLpPRXgFA70NFRStgVJcw062h5mc3Y8Q,37034
|
|
29
|
+
ramses_tx/__init__.py,sha256=sqnjM7pUGJDmec6igTtKViSB8FLX49B5gwhAmcY9ERY,3596
|
|
30
|
+
ramses_tx/address.py,sha256=IuwUwZxykn3fP1UCRcv4D-zbTICBe2FJjDAFX5X6VoI,9108
|
|
31
|
+
ramses_tx/command.py,sha256=drxmpdM4YgyPg4h0QIr1ouxK9QjfeLVgnFpDRox0CCY,125652
|
|
32
|
+
ramses_tx/const.py,sha256=gmRQ59V5AJx2TlACL3tDSBy5VpvEOVbQfG0z7WjFCKE,32941
|
|
33
|
+
ramses_tx/exceptions.py,sha256=FJSU9YkvpKjs3yeTqUJX1o3TPFSe_B01gRGIh9b3PNc,2632
|
|
34
|
+
ramses_tx/fingerprints.py,sha256=nfftA1E62HQnb-eLt2EqjEi_la0DAoT0wt-PtTMie0s,11974
|
|
35
|
+
ramses_tx/frame.py,sha256=GzNsXr15YLeidJYGtk_xPqsZQh4ehDDlUCtT6rTDhT8,22046
|
|
36
|
+
ramses_tx/gateway.py,sha256=Fl6EqAUU2DnLOiA2_87sS7VPBEyxA1a_ICCmg55WMMA,11494
|
|
37
|
+
ramses_tx/helpers.py,sha256=VIPSw0lrDrE3S92YchzeRo7G2AlQ_vE8OuYUF-rlpQw,33616
|
|
38
|
+
ramses_tx/logger.py,sha256=1iKRHKUaqHqGd76CkE_6mCVR0sYODtxshRRwfY61fTk,10426
|
|
39
|
+
ramses_tx/message.py,sha256=HUSNiBgGlnWEBeBqb4K28GcU5oB7RbS9a0JC2p9SGe4,13676
|
|
40
|
+
ramses_tx/opentherm.py,sha256=58PXz9l5x8Ou6Fm3y-R_UnGHCYahoi2RKIDdYStUMzk,42378
|
|
41
|
+
ramses_tx/packet.py,sha256=_nzuInS_WhdOI26SYvgsdDqIaDvVNguc2YDwdPOVCbU,7661
|
|
42
|
+
ramses_tx/parsers.py,sha256=qQz8VOKaH26MUvo9AfUwhrnbH7VwXLxFc2gKJYvjXvY,148571
|
|
43
|
+
ramses_tx/protocol.py,sha256=nBPKCD1tcGp_FiX0qhsY0XoGO_h87w5cYywBjSpum4w,33048
|
|
44
|
+
ramses_tx/protocol_fsm.py,sha256=o9vLvlXor3LkPgsY1zii5P1R01GzYLf_PECDdoxtC24,27520
|
|
45
|
+
ramses_tx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
46
|
+
ramses_tx/ramses.py,sha256=vp748Tf_a-56OMM8CWDA2ZktRfTuj0QVyPRcnsOstSM,53983
|
|
47
|
+
ramses_tx/schemas.py,sha256=Hrmf_q9bAZtkKJzGu6GtUO0QV_-K9i4L99EzGWR13eE,13408
|
|
48
|
+
ramses_tx/transport.py,sha256=Tk2CPAu9W0T4HCe6Q2VUYex2bFNZlyYsluP7iLf0rdQ,76277
|
|
49
|
+
ramses_tx/typed_dicts.py,sha256=w-0V5t2Q3GiNUOrRAWiW9GtSwbta_7luME6GfIb1zhI,10869
|
|
50
|
+
ramses_tx/typing.py,sha256=eF2SlPWhNhEFQj6WX2AhTXiyRQVXYnFutiepllYl2rI,5042
|
|
51
|
+
ramses_tx/version.py,sha256=DToXw6Td-r5_4JxS4fbBBGkajaXoEj8xCJOh7vaNcQI,123
|
|
52
|
+
ramses_rf-0.53.0.dist-info/METADATA,sha256=YkSNqr4RWgOsSi-z8SO5H4LEbzOI87IVYurjHWpUgRc,4179
|
|
53
|
+
ramses_rf-0.53.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
54
|
+
ramses_rf-0.53.0.dist-info/entry_points.txt,sha256=NnyK29baOCNg8DinPYiZ368h7MTH7bgTW26z2A1NeIE,50
|
|
55
|
+
ramses_rf-0.53.0.dist-info/licenses/LICENSE,sha256=ptVutrtSMr7X-ek6LduiD8Cce4JsNn_8sR8MYlm-fvo,1086
|
|
56
|
+
ramses_rf-0.53.0.dist-info/RECORD,,
|
ramses_tx/address.py
CHANGED
|
@@ -39,7 +39,12 @@ class Address:
|
|
|
39
39
|
_SLUG = None
|
|
40
40
|
|
|
41
41
|
def __init__(self, device_id: DeviceIdT) -> None:
|
|
42
|
-
"""Create an address from a valid device
|
|
42
|
+
"""Create an address from a valid device ID.
|
|
43
|
+
|
|
44
|
+
:param device_id: The RAMSES II device ID (e.g., '01:123456')
|
|
45
|
+
:type device_id: DeviceIdT
|
|
46
|
+
:raises ValueError: If the device_id is not a valid format.
|
|
47
|
+
"""
|
|
43
48
|
|
|
44
49
|
# if device_id is None:
|
|
45
50
|
# device_id = NON_DEVICE_ID
|
|
@@ -91,7 +96,15 @@ class Address:
|
|
|
91
96
|
|
|
92
97
|
@classmethod
|
|
93
98
|
def convert_from_hex(cls, device_hex: str, friendly_id: bool = False) -> str:
|
|
94
|
-
"""Convert
|
|
99
|
+
"""Convert a 6-character hex string to a device ID.
|
|
100
|
+
|
|
101
|
+
:param device_hex: The hex string to convert (e.g., '06368E')
|
|
102
|
+
:type device_hex: str
|
|
103
|
+
:param friendly_id: If True, returns a named ID (e.g., 'CTL:145038'), defaults to False
|
|
104
|
+
:type friendly_id: bool
|
|
105
|
+
:return: The formatted device ID string
|
|
106
|
+
:rtype: str
|
|
107
|
+
"""
|
|
95
108
|
|
|
96
109
|
if device_hex == "FFFFFE": # aka '63:262142'
|
|
97
110
|
return ">null dev<" if friendly_id else ALL_DEVICE_ID
|
|
@@ -191,11 +204,13 @@ def is_valid_dev_id(value: str, dev_class: None | str = None) -> bool:
|
|
|
191
204
|
|
|
192
205
|
@lru_cache(maxsize=256) # there is definite benefit in caching this
|
|
193
206
|
def pkt_addrs(addr_fragment: str) -> tuple[Address, Address, Address, Address, Address]:
|
|
194
|
-
"""
|
|
195
|
-
|
|
196
|
-
returns: src_addr, dst_addr, addr_0, addr_1, addr_2
|
|
207
|
+
"""Parse address fields from a 30-character address fragment.
|
|
197
208
|
|
|
198
|
-
|
|
209
|
+
:param addr_fragment: The 30-char fragment (e.g., '01:078710 --:------ 01:144246')
|
|
210
|
+
:type addr_fragment: str
|
|
211
|
+
:return: A tuple of (src_addr, dst_addr, addr_0, addr_1, addr_2)
|
|
212
|
+
:rtype: tuple[Address, Address, Address, Address, Address]
|
|
213
|
+
:raises PacketAddrSetInvalid: If the address fields are not valid.
|
|
199
214
|
"""
|
|
200
215
|
# for debug: print(pkt_addrs.cache_info())
|
|
201
216
|
|
ramses_tx/command.py
CHANGED
|
@@ -1866,7 +1866,7 @@ class Command(Frame):
|
|
|
1866
1866
|
The actual number of available zones depends on the controller configuration.
|
|
1867
1867
|
Requesting a non-existent zone will typically result in no response.
|
|
1868
1868
|
"""
|
|
1869
|
-
return cls.from_attrs(
|
|
1869
|
+
return cls.from_attrs(RQ, ctl_id, Code._2309, _check_idx(zone_idx))
|
|
1870
1870
|
|
|
1871
1871
|
@classmethod # constructor for W|2309
|
|
1872
1872
|
def set_zone_setpoint(
|
|
@@ -2254,7 +2254,13 @@ class Command(Frame):
|
|
|
2254
2254
|
|
|
2255
2255
|
@classmethod # constructor for RQ|2E04
|
|
2256
2256
|
def get_system_mode(cls, ctl_id: DeviceIdT | str) -> Command:
|
|
2257
|
-
"""
|
|
2257
|
+
"""Get the mode of a system (c.f. parser_2e04).
|
|
2258
|
+
|
|
2259
|
+
:param ctl_id: The device ID of the controller
|
|
2260
|
+
:type ctl_id: DeviceIdT | str
|
|
2261
|
+
:return: A Command object for the RQ|2E04 message
|
|
2262
|
+
:rtype: Command
|
|
2263
|
+
"""
|
|
2258
2264
|
|
|
2259
2265
|
return cls.from_attrs(RQ, ctl_id, Code._2E04, FF)
|
|
2260
2266
|
|
|
@@ -2453,7 +2459,17 @@ class Command(Frame):
|
|
|
2453
2459
|
datetime: dt | str,
|
|
2454
2460
|
is_dst: bool = False,
|
|
2455
2461
|
) -> Command:
|
|
2456
|
-
"""
|
|
2462
|
+
"""Set the datetime of a system (c.f. parser_313f).
|
|
2463
|
+
|
|
2464
|
+
:param ctl_id: The device ID of the controller
|
|
2465
|
+
:type ctl_id: DeviceIdT | str
|
|
2466
|
+
:param datetime: The target date and time
|
|
2467
|
+
:type datetime: dt | str
|
|
2468
|
+
:param is_dst: Whether Daylight Saving Time is active, defaults to False
|
|
2469
|
+
:type is_dst: bool
|
|
2470
|
+
:return: A Command object for the W|313F message
|
|
2471
|
+
:rtype: Command
|
|
2472
|
+
"""
|
|
2457
2473
|
# .W --- 30:185469 01:037519 --:------ 313F 009 0060003A0C1B0107E5
|
|
2458
2474
|
|
|
2459
2475
|
dt_str = hex_from_dtm(datetime, is_dst=is_dst, incl_seconds=True)
|
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()))
|
|
@@ -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
|
-
"""
|
|
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/helpers.py
CHANGED
|
@@ -131,28 +131,36 @@ file_time = _FILE_TIME()
|
|
|
131
131
|
def timestamp() -> float:
|
|
132
132
|
"""Return the number of seconds since the Unix epoch.
|
|
133
133
|
|
|
134
|
-
|
|
134
|
+
This function attempts to return a high-precision value, using specific
|
|
135
|
+
system calls on Windows if available.
|
|
136
|
+
:return: The current timestamp in seconds.
|
|
137
|
+
:rtype: float
|
|
135
138
|
"""
|
|
136
139
|
|
|
137
140
|
# see: https://www.python.org/dev/peps/pep-0564/
|
|
138
|
-
if sys.platform
|
|
141
|
+
if sys.platform == "win32":
|
|
142
|
+
# Windows uses a different epoch (1601-01-01)
|
|
143
|
+
ctypes.windll.kernel32.GetSystemTimePreciseAsFileTime(ctypes.byref(file_time))
|
|
144
|
+
_time = (file_time.dwLowDateTime + (file_time.dwHighDateTime << 32)) / 1e7
|
|
145
|
+
return float(_time - 134774 * 24 * 60 * 60)
|
|
146
|
+
else:
|
|
147
|
+
# Linux/macOS uses the Unix epoch (1970-01-01)
|
|
139
148
|
return time.time_ns() / 1e9
|
|
140
149
|
|
|
141
|
-
# otherwise, is since 1601-01-01T00:00:00Z
|
|
142
|
-
ctypes.windll.kernel32.GetSystemTimePreciseAsFileTime(ctypes.byref(file_time)) # type: ignore[unreachable]
|
|
143
|
-
_time = (file_time.dwLowDateTime + (file_time.dwHighDateTime << 32)) / 1e7
|
|
144
|
-
return _time - 134774 * 24 * 60 * 60
|
|
145
|
-
|
|
146
150
|
|
|
147
151
|
def dt_now() -> dt:
|
|
148
152
|
"""Return the current datetime as a local/naive datetime object.
|
|
149
153
|
|
|
150
154
|
This is slower, but potentially more accurate, than dt.now(), and is used mainly for
|
|
151
155
|
packet timestamps.
|
|
156
|
+
|
|
157
|
+
:return: The current local datetime.
|
|
158
|
+
:rtype: dt
|
|
152
159
|
"""
|
|
153
160
|
if sys.platform == "win32":
|
|
154
161
|
return dt.fromtimestamp(timestamp())
|
|
155
|
-
|
|
162
|
+
else:
|
|
163
|
+
return dt.now()
|
|
156
164
|
|
|
157
165
|
|
|
158
166
|
def dt_str() -> str:
|
|
@@ -369,7 +377,14 @@ def hex_from_str(value: str) -> str:
|
|
|
369
377
|
|
|
370
378
|
|
|
371
379
|
def hex_to_temp(value: HexStr4) -> bool | float | None: # TODO: remove bool
|
|
372
|
-
"""Convert a 2's complement
|
|
380
|
+
"""Convert a 4-byte 2's complement hex string to a float temperature ('C).
|
|
381
|
+
|
|
382
|
+
:param value: The 4-character hex string (e.g., '07D0')
|
|
383
|
+
:type value: HexStr4
|
|
384
|
+
:return: The temperature in Celsius, or None if N/A
|
|
385
|
+
:rtype: float | None
|
|
386
|
+
:raises ValueError: If input is not a 4-char hex string or temperature is invalid.
|
|
387
|
+
"""
|
|
373
388
|
if not isinstance(value, str) or len(value) != 4:
|
|
374
389
|
raise ValueError(f"Invalid value: {value}, is not a 4-char hex string")
|
|
375
390
|
if value == "31FF": # means: N/A (== 127.99, 2s complement), signed?
|
|
@@ -488,13 +503,18 @@ AIR_QUALITY_BASIS: dict[str, str] = {
|
|
|
488
503
|
|
|
489
504
|
# 31DA[2:6] and 12C8[2:6]
|
|
490
505
|
def parse_air_quality(value: HexStr4) -> PayDictT.AIR_QUALITY:
|
|
491
|
-
"""Return the air quality
|
|
506
|
+
"""Return the air quality percentage (0.0 to 1.0) and its basis.
|
|
492
507
|
|
|
493
508
|
The basis of the air quality level should be one of: VOC, CO2 or relative humidity.
|
|
494
509
|
If air_quality is EF, air_quality_basis should be 00.
|
|
495
510
|
|
|
496
511
|
The sensor value is None if there is no sensor present (is not an error).
|
|
497
512
|
The dict does not include the key if there is a sensor fault.
|
|
513
|
+
|
|
514
|
+
:param value: The 4-character hex string encoding quality and basis
|
|
515
|
+
:type value: HexStr4
|
|
516
|
+
:return: A dictionary containing the air quality and its basis (e.g., CO2, VOC)
|
|
517
|
+
:rtype: PayDictT.AIR_QUALITY
|
|
498
518
|
""" # VOC: Volatile organic compounds
|
|
499
519
|
|
|
500
520
|
# TODO: remove this as API used only internally...
|
ramses_tx/message.py
CHANGED
|
@@ -54,7 +54,9 @@ class MessageBase:
|
|
|
54
54
|
def __init__(self, pkt: Packet) -> None:
|
|
55
55
|
"""Create a message from a valid packet.
|
|
56
56
|
|
|
57
|
-
:
|
|
57
|
+
:param pkt: The packet to process into a message
|
|
58
|
+
:type pkt: Packet
|
|
59
|
+
:raises PacketInvalid: If the packet payload cannot be parsed.
|
|
58
60
|
"""
|
|
59
61
|
|
|
60
62
|
self._pkt = pkt
|
|
@@ -359,10 +361,14 @@ def re_compile_re_match(regex: str, string: str) -> bool: # Optional[Match[Any]
|
|
|
359
361
|
|
|
360
362
|
|
|
361
363
|
def _check_msg_payload(msg: MessageBase, payload: str) -> None:
|
|
362
|
-
"""Validate
|
|
363
|
-
|
|
364
|
-
:
|
|
365
|
-
|
|
364
|
+
"""Validate a packet's payload against its verb/code pair.
|
|
365
|
+
|
|
366
|
+
:param msg: The message object being validated
|
|
367
|
+
:type msg: MessageBase
|
|
368
|
+
:param payload: The raw hex payload string
|
|
369
|
+
:type payload: str
|
|
370
|
+
:raises PacketInvalid: If the code is unknown or verb/code pair is invalid.
|
|
371
|
+
:raises PacketPayloadInvalid: If the payload does not match the expected regex.
|
|
366
372
|
"""
|
|
367
373
|
|
|
368
374
|
_ = repr(msg._pkt) # HACK: ? raise InvalidPayloadError
|
ramses_tx/packet.py
CHANGED
|
@@ -48,9 +48,14 @@ class Packet(Frame):
|
|
|
48
48
|
_rssi: str
|
|
49
49
|
|
|
50
50
|
def __init__(self, dtm: dt, frame: str, **kwargs: Any) -> None:
|
|
51
|
-
"""Create a packet from a
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
"""Create a packet from a raw frame string.
|
|
52
|
+
|
|
53
|
+
:param dtm: The timestamp when the packet was received
|
|
54
|
+
:type dtm: dt
|
|
55
|
+
:param frame: The raw frame string, typically including RSSI
|
|
56
|
+
:type frame: str
|
|
57
|
+
:param kwargs: Metadata including 'comment', 'err_msg', or 'raw_frame'
|
|
58
|
+
:raises PacketInvalid: If the frame content is malformed.
|
|
54
59
|
"""
|
|
55
60
|
|
|
56
61
|
super().__init__(frame[4:]) # remove RSSI
|
|
@@ -156,9 +161,12 @@ class Packet(Frame):
|
|
|
156
161
|
|
|
157
162
|
# TODO: remove None as a possible return value
|
|
158
163
|
def pkt_lifespan(pkt: Packet) -> td: # import OtbGateway??
|
|
159
|
-
"""Return the
|
|
164
|
+
"""Return the lifespan of a packet before it expires.
|
|
160
165
|
|
|
161
|
-
|
|
166
|
+
:param pkt: The packet instance to evaluate
|
|
167
|
+
:type pkt: Packet
|
|
168
|
+
:return: The duration the packet's data remains valid
|
|
169
|
+
:rtype: td
|
|
162
170
|
"""
|
|
163
171
|
|
|
164
172
|
if pkt.verb in (RQ, W_):
|