ramses-rf 0.22.40__py3-none-any.whl → 0.51.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ramses_cli/__init__.py +18 -0
- ramses_cli/client.py +597 -0
- ramses_cli/debug.py +20 -0
- ramses_cli/discovery.py +405 -0
- ramses_cli/utils/cat_slow.py +17 -0
- ramses_cli/utils/convert.py +60 -0
- ramses_rf/__init__.py +31 -10
- ramses_rf/binding_fsm.py +787 -0
- ramses_rf/const.py +124 -105
- ramses_rf/database.py +297 -0
- ramses_rf/device/__init__.py +69 -39
- ramses_rf/device/base.py +187 -376
- ramses_rf/device/heat.py +540 -552
- ramses_rf/device/hvac.py +279 -171
- ramses_rf/dispatcher.py +153 -177
- ramses_rf/entity_base.py +478 -361
- ramses_rf/exceptions.py +82 -0
- ramses_rf/gateway.py +377 -513
- ramses_rf/helpers.py +57 -19
- ramses_rf/py.typed +0 -0
- ramses_rf/schemas.py +148 -194
- ramses_rf/system/__init__.py +16 -23
- ramses_rf/system/faultlog.py +363 -0
- ramses_rf/system/heat.py +295 -302
- ramses_rf/system/schedule.py +312 -198
- ramses_rf/system/zones.py +318 -238
- ramses_rf/version.py +2 -8
- ramses_rf-0.51.2.dist-info/METADATA +72 -0
- ramses_rf-0.51.2.dist-info/RECORD +55 -0
- {ramses_rf-0.22.40.dist-info → ramses_rf-0.51.2.dist-info}/WHEEL +1 -2
- ramses_rf-0.51.2.dist-info/entry_points.txt +2 -0
- {ramses_rf-0.22.40.dist-info → ramses_rf-0.51.2.dist-info/licenses}/LICENSE +1 -1
- ramses_tx/__init__.py +160 -0
- {ramses_rf/protocol → ramses_tx}/address.py +65 -59
- ramses_tx/command.py +1454 -0
- ramses_tx/const.py +903 -0
- ramses_tx/exceptions.py +92 -0
- {ramses_rf/protocol → ramses_tx}/fingerprints.py +56 -15
- {ramses_rf/protocol → ramses_tx}/frame.py +132 -131
- ramses_tx/gateway.py +338 -0
- ramses_tx/helpers.py +883 -0
- {ramses_rf/protocol → ramses_tx}/logger.py +67 -53
- {ramses_rf/protocol → ramses_tx}/message.py +155 -191
- ramses_tx/opentherm.py +1260 -0
- ramses_tx/packet.py +210 -0
- {ramses_rf/protocol → ramses_tx}/parsers.py +1266 -1003
- ramses_tx/protocol.py +801 -0
- ramses_tx/protocol_fsm.py +672 -0
- ramses_tx/py.typed +0 -0
- {ramses_rf/protocol → ramses_tx}/ramses.py +262 -185
- {ramses_rf/protocol → ramses_tx}/schemas.py +150 -133
- ramses_tx/transport.py +1471 -0
- ramses_tx/typed_dicts.py +492 -0
- ramses_tx/typing.py +181 -0
- ramses_tx/version.py +4 -0
- ramses_rf/discovery.py +0 -398
- ramses_rf/protocol/__init__.py +0 -59
- ramses_rf/protocol/backports.py +0 -42
- ramses_rf/protocol/command.py +0 -1576
- ramses_rf/protocol/const.py +0 -697
- ramses_rf/protocol/exceptions.py +0 -111
- ramses_rf/protocol/helpers.py +0 -390
- ramses_rf/protocol/opentherm.py +0 -1170
- ramses_rf/protocol/packet.py +0 -235
- ramses_rf/protocol/protocol.py +0 -613
- ramses_rf/protocol/transport.py +0 -1011
- ramses_rf/protocol/version.py +0 -10
- ramses_rf/system/hvac.py +0 -82
- ramses_rf-0.22.40.dist-info/METADATA +0 -64
- ramses_rf-0.22.40.dist-info/RECORD +0 -42
- ramses_rf-0.22.40.dist-info/top_level.txt +0 -1
|
@@ -1,99 +1,78 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
|
|
3
|
-
#
|
|
4
|
-
"""RAMSES RF - a RAMSES-II protocol decoder & analyser.
|
|
2
|
+
"""RAMSES RF - Decode/process a message (payload into JSON)."""
|
|
5
3
|
|
|
6
|
-
Decode/process a message (payload into JSON).
|
|
7
|
-
"""
|
|
8
4
|
from __future__ import annotations
|
|
9
5
|
|
|
10
6
|
import logging
|
|
11
7
|
import re
|
|
12
|
-
from datetime import datetime as dt
|
|
13
|
-
from datetime import timedelta as td
|
|
8
|
+
from datetime import datetime as dt, timedelta as td
|
|
14
9
|
from functools import lru_cache
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
15
11
|
|
|
12
|
+
from . import exceptions as exc
|
|
16
13
|
from .address import Address
|
|
17
|
-
from .
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
SZ_UFH_IDX,
|
|
23
|
-
SZ_ZONE_IDX,
|
|
24
|
-
__dev_mode__,
|
|
25
|
-
)
|
|
26
|
-
from .exceptions import InvalidPacketError, InvalidPayloadError
|
|
27
|
-
from .packet import Packet, fraction_expired
|
|
28
|
-
from .parsers import PAYLOAD_PARSERS, parser_unknown
|
|
29
|
-
from .ramses import CODE_IDX_COMPLEX, CODES_SCHEMA, RQ_IDX_COMPLEX
|
|
30
|
-
from .schemas import SZ_ALIAS
|
|
14
|
+
from .command import Command
|
|
15
|
+
from .const import DEV_TYPE_MAP, SZ_DHW_IDX, SZ_DOMAIN_ID, SZ_UFH_IDX, SZ_ZONE_IDX
|
|
16
|
+
from .packet import Packet
|
|
17
|
+
from .parsers import parse_payload
|
|
18
|
+
from .ramses import CODE_IDX_ARE_COMPLEX, CODES_SCHEMA, RQ_IDX_COMPLEX
|
|
31
19
|
|
|
32
20
|
# TODO:
|
|
33
21
|
# long-format msg.__str__ - alias columns don't line up
|
|
34
22
|
|
|
35
23
|
|
|
36
|
-
# skipcq: PY-W2000
|
|
37
24
|
from .const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
|
|
38
25
|
I_,
|
|
39
26
|
RP,
|
|
40
27
|
RQ,
|
|
41
28
|
W_,
|
|
42
|
-
F9,
|
|
43
|
-
FA,
|
|
44
|
-
FC,
|
|
45
|
-
FF,
|
|
46
29
|
Code,
|
|
47
30
|
)
|
|
48
31
|
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from ramses_rf import Gateway
|
|
34
|
+
|
|
35
|
+
from .const import IndexT, VerbT # noqa: F401, pylint: disable=unused-import
|
|
36
|
+
|
|
49
37
|
|
|
50
38
|
__all__ = ["Message"]
|
|
51
39
|
|
|
40
|
+
|
|
52
41
|
CODE_NAMES = {k: v["name"] for k, v in CODES_SCHEMA.items()}
|
|
53
42
|
|
|
54
43
|
MSG_FORMAT_10 = "|| {:10s} | {:10s} | {:2s} | {:16s} | {:^4s} || {}"
|
|
55
|
-
MSG_FORMAT_18 = "|| {:18s} | {:18s} | {:2s} | {:16s} | {:^4s} || {}"
|
|
56
44
|
|
|
57
|
-
|
|
45
|
+
_TD_SECS_003 = td(seconds=3)
|
|
58
46
|
|
|
59
|
-
_LOGGER = logging.getLogger(__name__)
|
|
60
|
-
if DEV_MODE:
|
|
61
|
-
_LOGGER.setLevel(logging.DEBUG)
|
|
62
47
|
|
|
48
|
+
_LOGGER = logging.getLogger(__name__)
|
|
63
49
|
|
|
64
|
-
class Message:
|
|
65
|
-
"""The message class; will trap/log all invalid MSGs appropriately."""
|
|
66
50
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
HAS_EXPIRED = 2.0 # incl. any value >= HAS_EXPIRED
|
|
51
|
+
class MessageBase:
|
|
52
|
+
"""The Message class; will trap/log invalid msgs."""
|
|
70
53
|
|
|
71
|
-
def __init__(self,
|
|
54
|
+
def __init__(self, pkt: Packet) -> None:
|
|
72
55
|
"""Create a message from a valid packet.
|
|
73
56
|
|
|
74
57
|
Will raise InvalidPacketError if it is invalid.
|
|
75
58
|
"""
|
|
76
|
-
|
|
59
|
+
|
|
77
60
|
self._pkt = pkt
|
|
78
61
|
|
|
79
|
-
self.src = pkt.src
|
|
80
|
-
self.dst = pkt.dst
|
|
81
|
-
self._addrs = pkt._addrs
|
|
62
|
+
self.src: Address = pkt.src
|
|
63
|
+
self.dst: Address = pkt.dst
|
|
64
|
+
self._addrs: tuple[Address, Address, Address] = pkt._addrs
|
|
82
65
|
|
|
83
66
|
self.dtm: dt = pkt.dtm
|
|
84
67
|
|
|
85
|
-
self.verb:
|
|
68
|
+
self.verb: VerbT = pkt.verb
|
|
86
69
|
self.seqn: str = pkt.seqn
|
|
87
|
-
self.code:
|
|
70
|
+
self.code: Code = pkt.code
|
|
88
71
|
self.len: int = pkt._len
|
|
89
72
|
|
|
90
|
-
self.code_name = CODE_NAMES.get(self.code, f"unknown_{self.code}")
|
|
91
|
-
|
|
92
73
|
self._payload = self._validate(self._pkt.payload) # ? raise InvalidPacketError
|
|
93
74
|
|
|
94
75
|
self._str: str = None # type: ignore[assignment]
|
|
95
|
-
self._fraction_expired: float = None # type: ignore[assignment]
|
|
96
|
-
# self._is_fragment: bool = None # type: ignore[assignment]
|
|
97
76
|
|
|
98
77
|
def __repr__(self) -> str:
|
|
99
78
|
"""Return an unambiguous string representation of this object."""
|
|
@@ -102,43 +81,29 @@ class Message:
|
|
|
102
81
|
def __str__(self) -> str:
|
|
103
82
|
"""Return a brief readable string representation of this object."""
|
|
104
83
|
|
|
105
|
-
def ctx(pkt) -> str:
|
|
106
|
-
ctx = {True: "[..]", False: "", None: "??"}.get(pkt._ctx, pkt._ctx)
|
|
107
|
-
if not ctx and pkt.payload[:2] not in ("00", FF):
|
|
84
|
+
def ctx(pkt: Packet) -> str:
|
|
85
|
+
ctx = {True: "[..]", False: "", None: "??"}.get(pkt._ctx, pkt._ctx) # type: ignore[arg-type]
|
|
86
|
+
if not ctx and pkt.payload[:2] not in ("00", "FF"):
|
|
108
87
|
return f"({pkt.payload[:2]})"
|
|
109
88
|
return ctx
|
|
110
89
|
|
|
111
|
-
def display_name(addr: Address) -> str:
|
|
112
|
-
"""Return a friendly name for an Address, or a Device.
|
|
113
|
-
|
|
114
|
-
Use the alias, if one exists, or use a slug instead of a device type.
|
|
115
|
-
"""
|
|
116
|
-
|
|
117
|
-
try:
|
|
118
|
-
if self._gwy.config.use_aliases:
|
|
119
|
-
return self._gwy._include[addr.id][SZ_ALIAS][:18]
|
|
120
|
-
else:
|
|
121
|
-
return f"{self._gwy.device_by_id[addr.id]._SLUG}:{addr.id[3:]}"
|
|
122
|
-
except KeyError:
|
|
123
|
-
return f" {addr.id}"
|
|
124
|
-
|
|
125
90
|
if self._str is not None:
|
|
126
91
|
return self._str
|
|
127
92
|
|
|
128
|
-
if self.src.id == self._addrs[0].id:
|
|
129
|
-
name_0 =
|
|
130
|
-
name_1 = "" if self.dst is self.src else
|
|
93
|
+
if self.src.id == self._addrs[0].id: # type: ignore[unreachable]
|
|
94
|
+
name_0 = self._name(self.src)
|
|
95
|
+
name_1 = "" if self.dst is self.src else self._name(self.dst)
|
|
131
96
|
else:
|
|
132
97
|
name_0 = ""
|
|
133
|
-
name_1 =
|
|
98
|
+
name_1 = self._name(self.src)
|
|
134
99
|
|
|
135
|
-
|
|
136
|
-
self._str =
|
|
137
|
-
name_0, name_1, self.verb,
|
|
100
|
+
code_name = CODE_NAMES.get(self.code, f"unknown_{self.code}")
|
|
101
|
+
self._str = MSG_FORMAT_10.format(
|
|
102
|
+
name_0, name_1, self.verb, code_name, ctx(self._pkt), self.payload
|
|
138
103
|
)
|
|
139
104
|
return self._str
|
|
140
105
|
|
|
141
|
-
def __eq__(self, other) -> bool:
|
|
106
|
+
def __eq__(self, other: object) -> bool:
|
|
142
107
|
if not isinstance(other, Message):
|
|
143
108
|
return NotImplemented
|
|
144
109
|
return (self.src, self.dst, self.verb, self.code, self._pkt.payload) == (
|
|
@@ -149,13 +114,17 @@ class Message:
|
|
|
149
114
|
other._pkt.payload,
|
|
150
115
|
)
|
|
151
116
|
|
|
152
|
-
def __lt__(self, other) -> bool:
|
|
117
|
+
def __lt__(self, other: object) -> bool:
|
|
153
118
|
if not isinstance(other, Message):
|
|
154
119
|
return NotImplemented
|
|
155
120
|
return self.dtm < other.dtm
|
|
156
121
|
|
|
122
|
+
def _name(self, addr: Address) -> str:
|
|
123
|
+
"""Return a friendly name for an Address, or a Device."""
|
|
124
|
+
return f" {addr.id}" # can't do 'CTL:123456' instead of ' 01:123456'
|
|
125
|
+
|
|
157
126
|
@property
|
|
158
|
-
def payload(self): #
|
|
127
|
+
def payload(self): # type: ignore[no-untyped-def] # FIXME -> dict | list:
|
|
159
128
|
"""Return the payload."""
|
|
160
129
|
return self._payload
|
|
161
130
|
|
|
@@ -172,10 +141,10 @@ class Message:
|
|
|
172
141
|
def _has_array(self) -> bool:
|
|
173
142
|
"""Return True if the message's raw payload is an array."""
|
|
174
143
|
|
|
175
|
-
return self._pkt._has_array
|
|
144
|
+
return bool(self._pkt._has_array)
|
|
176
145
|
|
|
177
146
|
@property
|
|
178
|
-
def _idx(self) -> dict:
|
|
147
|
+
def _idx(self) -> dict[str, str]:
|
|
179
148
|
"""Return the domain_id/zone_idx/other_idx of a message payload, if any.
|
|
180
149
|
|
|
181
150
|
Used to identify the zone/domain that a message applies to. Returns an empty
|
|
@@ -186,7 +155,6 @@ class Message:
|
|
|
186
155
|
|
|
187
156
|
IDX_NAMES = {
|
|
188
157
|
Code._0002: "other_idx", # non-evohome: hometronics
|
|
189
|
-
Code._0418: SZ_LOG_IDX,
|
|
190
158
|
Code._10A0: SZ_DHW_IDX, # can be 2 DHW zones per system, albeit unusual
|
|
191
159
|
Code._1260: SZ_DHW_IDX, # can be 2 DHW zones per system, albeit unusual
|
|
192
160
|
Code._1F41: SZ_DHW_IDX, # can be 2 DHW zones per system, albeit unusual
|
|
@@ -198,7 +166,11 @@ class Message:
|
|
|
198
166
|
Code._3220: "msg_id",
|
|
199
167
|
} # ALSO: SZ_DOMAIN_ID, SZ_ZONE_IDX
|
|
200
168
|
|
|
201
|
-
if self.
|
|
169
|
+
if self.code in (Code._31D9, Code._31DA): # shouldn't be needed?
|
|
170
|
+
assert isinstance(self._pkt._idx, str) # mypy hint
|
|
171
|
+
return {"hvac_id": self._pkt._idx}
|
|
172
|
+
|
|
173
|
+
if self._pkt._idx in (True, False) or self.code in CODE_IDX_ARE_COMPLEX:
|
|
202
174
|
return {} # above was: CODE_IDX_COMPLEX + (Code._3150):
|
|
203
175
|
|
|
204
176
|
if self.code in (Code._3220,): # FIXME: should be _SIMPLE
|
|
@@ -207,7 +179,7 @@ class Message:
|
|
|
207
179
|
# .I 068 03:201498 --:------ 03:201498 30C9 003 0106D6 # rare
|
|
208
180
|
|
|
209
181
|
# .I --- 00:034798 --:------ 12:126457 2309 003 0201F4
|
|
210
|
-
if
|
|
182
|
+
if not {self.src.type, self.dst.type} & {
|
|
211
183
|
DEV_TYPE_MAP.CTL,
|
|
212
184
|
DEV_TYPE_MAP.UFC,
|
|
213
185
|
DEV_TYPE_MAP.HCW, # ?remove (see above, rare)
|
|
@@ -215,22 +187,17 @@ class Message:
|
|
|
215
187
|
DEV_TYPE_MAP.HGI,
|
|
216
188
|
DEV_TYPE_MAP.DT2,
|
|
217
189
|
DEV_TYPE_MAP.PRG,
|
|
218
|
-
}: # DEX
|
|
190
|
+
}: # FIXME: DEX should be deprecated to use device type rather than class
|
|
219
191
|
assert self._pkt._idx == "00", "What!! (AA)"
|
|
220
192
|
return {}
|
|
221
193
|
|
|
222
194
|
# .I 035 --:------ --:------ 12:126457 30C9 003 017FFF
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
DEV_TYPE_MAP.UFC,
|
|
230
|
-
DEV_TYPE_MAP.HCW, # ?remove (see above, rare)
|
|
231
|
-
DEV_TYPE_MAP.HGI,
|
|
232
|
-
DEV_TYPE_MAP.PRG,
|
|
233
|
-
)
|
|
195
|
+
if self.src.type == self.dst.type and self.src.type not in (
|
|
196
|
+
DEV_TYPE_MAP.CTL,
|
|
197
|
+
DEV_TYPE_MAP.UFC,
|
|
198
|
+
DEV_TYPE_MAP.HCW, # ?remove (see above, rare)
|
|
199
|
+
DEV_TYPE_MAP.HGI,
|
|
200
|
+
DEV_TYPE_MAP.PRG,
|
|
234
201
|
): # DEX
|
|
235
202
|
assert self._pkt._idx == "00", "What!! (AB)"
|
|
236
203
|
return {}
|
|
@@ -259,60 +226,17 @@ class Message:
|
|
|
259
226
|
# TODO: also 000C (but is a complex idx)
|
|
260
227
|
# TODO: also 3150 (when not domain, and will be array if so)
|
|
261
228
|
if self.code in (Code._000A, Code._2309) and self.src.type == DEV_TYPE_MAP.UFC:
|
|
229
|
+
assert isinstance(self._pkt._idx, str) # mypy hint
|
|
262
230
|
return {IDX_NAMES[Code._22C9]: self._pkt._idx}
|
|
263
231
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
)
|
|
232
|
+
assert isinstance(self._pkt._idx, str) # mypy check
|
|
233
|
+
idx_name = SZ_DOMAIN_ID if self._pkt._idx[:1] == "F" else SZ_ZONE_IDX
|
|
234
|
+
index_name = IDX_NAMES.get(self.code, idx_name)
|
|
267
235
|
|
|
268
236
|
return {index_name: self._pkt._idx}
|
|
269
237
|
|
|
270
|
-
|
|
271
|
-
def
|
|
272
|
-
"""Return True if the message is dated (or False otherwise)."""
|
|
273
|
-
|
|
274
|
-
if self._fraction_expired is not None:
|
|
275
|
-
if self._fraction_expired == self.CANT_EXPIRE:
|
|
276
|
-
return False
|
|
277
|
-
if self._fraction_expired > self.HAS_EXPIRED * 2:
|
|
278
|
-
return True
|
|
279
|
-
|
|
280
|
-
prev_fraction = self._fraction_expired
|
|
281
|
-
|
|
282
|
-
if self.code == Code._1F09 and self.verb != RQ:
|
|
283
|
-
# RQs won't have remaining_seconds, RP/Ws have only partial cycle times
|
|
284
|
-
self._fraction_expired = fraction_expired(
|
|
285
|
-
self._gwy._dt_now() - self.dtm,
|
|
286
|
-
td(seconds=self.payload["remaining_seconds"]),
|
|
287
|
-
)
|
|
288
|
-
else: # self._pkt._expired can be False (doesn't expire), wont be 0
|
|
289
|
-
self._fraction_expired = self._pkt._expired or self.CANT_EXPIRE
|
|
290
|
-
|
|
291
|
-
if self._fraction_expired < self.HAS_EXPIRED:
|
|
292
|
-
return False
|
|
293
|
-
|
|
294
|
-
# TODO: should renew?
|
|
295
|
-
|
|
296
|
-
# only log expired packets once
|
|
297
|
-
if prev_fraction is None or prev_fraction < self.HAS_EXPIRED:
|
|
298
|
-
if (
|
|
299
|
-
self.code == Code._1F09
|
|
300
|
-
and self.verb != I_
|
|
301
|
-
or self.code in (Code._0016, Code._3120, Code._313F)
|
|
302
|
-
or self._gwy._engine_state is not None # restoring from pkt log
|
|
303
|
-
):
|
|
304
|
-
_logger = _LOGGER.info
|
|
305
|
-
else:
|
|
306
|
-
_logger = _LOGGER.warning if DEV_MODE else _LOGGER.info
|
|
307
|
-
_logger(f"{self!r} # has expired ({self._fraction_expired * 100:1.0f}%)")
|
|
308
|
-
|
|
309
|
-
# elif self._fraction_expired >= self.IS_EXPIRING: # this could log multiple times
|
|
310
|
-
# _LOGGER.error("%s # is expiring", self._pkt)
|
|
311
|
-
|
|
312
|
-
# and self.dtm >= self._gwy._dt_now() - td(days=7) # TODO: should be none >7d?
|
|
313
|
-
return self._fraction_expired > self.HAS_EXPIRED
|
|
314
|
-
|
|
315
|
-
def _validate(self, raw_payload) -> dict | list: # TODO: needs work
|
|
238
|
+
# TODO: needs work...
|
|
239
|
+
def _validate(self, raw_payload: str) -> dict | list[dict]: # type: ignore[type-arg]
|
|
316
240
|
"""Validate the message, and parse the payload if so.
|
|
317
241
|
|
|
318
242
|
Raise an exception (InvalidPacketError) if it is not valid.
|
|
@@ -328,9 +252,7 @@ class Message:
|
|
|
328
252
|
# _LOGGER.error("%s", msg)
|
|
329
253
|
return {}
|
|
330
254
|
|
|
331
|
-
result =
|
|
332
|
-
self._pkt.payload, self
|
|
333
|
-
)
|
|
255
|
+
result = parse_payload(self)
|
|
334
256
|
|
|
335
257
|
if isinstance(result, list):
|
|
336
258
|
return result
|
|
@@ -339,71 +261,113 @@ class Message:
|
|
|
339
261
|
|
|
340
262
|
raise TypeError(f"Invalid payload type: {type(result)}")
|
|
341
263
|
|
|
342
|
-
except
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
)
|
|
346
|
-
raise exc
|
|
264
|
+
except exc.PacketInvalid as err:
|
|
265
|
+
_LOGGER.warning("%s < %s", self._pkt, err)
|
|
266
|
+
raise err
|
|
347
267
|
|
|
348
|
-
except AssertionError as
|
|
268
|
+
except AssertionError as err:
|
|
349
269
|
# beware: HGI80 can send 'odd' but parseable packets +/- get invalid reply
|
|
350
|
-
(
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
)("%s < %s", self._pkt, f"{exc.__class__.__name__}({exc})")
|
|
355
|
-
raise InvalidPacketError(exc)
|
|
356
|
-
|
|
357
|
-
except (AttributeError, LookupError, TypeError, ValueError) as exc: # TODO: dev
|
|
270
|
+
_LOGGER.exception("%s < %s", self._pkt, f"{err.__class__.__name__}({err})")
|
|
271
|
+
raise exc.PacketInvalid("Bad packet") from err
|
|
272
|
+
|
|
273
|
+
except (AttributeError, LookupError, TypeError, ValueError) as err: # TODO: dev
|
|
358
274
|
_LOGGER.exception(
|
|
359
|
-
"%s < Coding error: %s", self._pkt, f"{
|
|
275
|
+
"%s < Coding error: %s", self._pkt, f"{err.__class__.__name__}({err})"
|
|
360
276
|
)
|
|
361
|
-
raise
|
|
277
|
+
raise exc.PacketInvalid from err
|
|
362
278
|
|
|
363
|
-
except NotImplementedError as
|
|
279
|
+
except NotImplementedError as err: # parser_unknown (unknown packet code)
|
|
364
280
|
_LOGGER.warning("%s < Unknown packet code (cannot parse)", self._pkt)
|
|
365
|
-
raise
|
|
281
|
+
raise exc.PacketInvalid from err
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class Message(MessageBase): # add _expired attr
|
|
285
|
+
"""Extend the Message class, so is useful to a stateful Gateway.
|
|
286
|
+
|
|
287
|
+
Adds _expired attr to the Message class.
|
|
288
|
+
"""
|
|
289
|
+
|
|
290
|
+
CANT_EXPIRE = -1 # sentinel value for fraction_expired
|
|
291
|
+
|
|
292
|
+
HAS_EXPIRED = 2.0 # fraction_expired >= HAS_EXPIRED
|
|
293
|
+
# .HAS_DIED = 1.0 # fraction_expired >= 1.0 (is expected lifespan)
|
|
294
|
+
IS_EXPIRING = 0.8 # fraction_expired >= 0.8 (and < HAS_EXPIRED)
|
|
295
|
+
|
|
296
|
+
_gwy: Gateway
|
|
297
|
+
_fraction_expired: float | None = None
|
|
298
|
+
|
|
299
|
+
@classmethod
|
|
300
|
+
def _from_cmd(cls, cmd: Command, dtm: dt | None = None) -> Message:
|
|
301
|
+
"""Create a Message from a Command."""
|
|
302
|
+
return cls(Packet._from_cmd(cmd, dtm=dtm))
|
|
303
|
+
|
|
304
|
+
@classmethod
|
|
305
|
+
def _from_pkt(cls, pkt: Packet) -> Message:
|
|
306
|
+
"""Create a Message from a Packet."""
|
|
307
|
+
return cls(pkt)
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def _expired(self) -> bool:
|
|
311
|
+
"""Return True if the message is dated (or False otherwise)."""
|
|
312
|
+
# fraction_expired = (dt_now - self.dtm - _TD_SECONDS_003) / self._pkt._lifespan
|
|
313
|
+
# TODO: keep none >7d, even 10E0, etc.
|
|
314
|
+
|
|
315
|
+
def fraction_expired(lifespan: td) -> float:
|
|
316
|
+
"""Return the packet's age as fraction of its 'normal' life span."""
|
|
317
|
+
return (self._gwy._dt_now() - self.dtm - _TD_SECS_003) / lifespan
|
|
318
|
+
|
|
319
|
+
# 1. Look for easy win...
|
|
320
|
+
if self._fraction_expired is not None:
|
|
321
|
+
if self._fraction_expired == self.CANT_EXPIRE:
|
|
322
|
+
return False
|
|
323
|
+
if self._fraction_expired >= self.HAS_EXPIRED:
|
|
324
|
+
return True
|
|
325
|
+
|
|
326
|
+
# 2. Need to update the fraction_expired...
|
|
327
|
+
if self.code == Code._1F09 and self.verb != RQ: # sync_cycle is a special case
|
|
328
|
+
# RQs won't have remaining_seconds, RP/Ws have only partial cycle times
|
|
329
|
+
self._fraction_expired = fraction_expired(
|
|
330
|
+
td(seconds=self.payload["remaining_seconds"]),
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
elif self._pkt._lifespan is False: # Can't expire
|
|
334
|
+
self._fraction_expired = self.CANT_EXPIRE
|
|
335
|
+
|
|
336
|
+
elif self._pkt._lifespan is True: # Can't expire
|
|
337
|
+
raise NotImplementedError
|
|
338
|
+
|
|
339
|
+
else:
|
|
340
|
+
self._fraction_expired = fraction_expired(self._pkt._lifespan)
|
|
341
|
+
|
|
342
|
+
return self._fraction_expired >= self.HAS_EXPIRED
|
|
366
343
|
|
|
367
344
|
|
|
368
345
|
@lru_cache(maxsize=256)
|
|
369
346
|
def re_compile_re_match(regex: str, string: str) -> bool: # Optional[Match[Any]]
|
|
370
347
|
# TODO: confirm this does speed things up
|
|
371
|
-
# Python has its own caching of re.
|
|
348
|
+
# Python has its own caching of re.compile, _MAXCACHE = 512
|
|
372
349
|
# https://github.com/python/cpython/blob/3.10/Lib/re.py
|
|
373
350
|
return re.compile(regex).match(string) # type: ignore[return-value]
|
|
374
351
|
|
|
375
352
|
|
|
376
|
-
def _check_msg_payload(msg:
|
|
353
|
+
def _check_msg_payload(msg: MessageBase, payload: str) -> None:
|
|
377
354
|
"""Validate the packet's payload against its verb/code pair.
|
|
378
355
|
|
|
379
|
-
Raise an InvalidPayloadError if the payload is invalid
|
|
380
|
-
|
|
381
|
-
The HGI80-compatible devices can do what they like, but a warning is logged.
|
|
382
|
-
Some parsers may also raise InvalidPayloadError (e.g. 3220), albeit later on.
|
|
356
|
+
Raise an InvalidPayloadError if the payload is seen as invalid. Such payloads may
|
|
357
|
+
actually be valid, in which case the rules (likely the regex) will need updating.
|
|
383
358
|
"""
|
|
384
359
|
|
|
360
|
+
_ = repr(msg._pkt) # HACK: ? raise InvalidPayloadError
|
|
361
|
+
|
|
362
|
+
if msg.code not in CODES_SCHEMA:
|
|
363
|
+
raise exc.PacketInvalid(f"Unknown code: {msg.code}")
|
|
364
|
+
|
|
385
365
|
try:
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
raise InvalidPacketError(f"Unknown verb/code pair: {msg.verb}/{msg.code}")
|
|
395
|
-
|
|
396
|
-
if not re_compile_re_match(regex, payload):
|
|
397
|
-
raise InvalidPayloadError(f"Payload doesn't match '{regex}': {payload}")
|
|
398
|
-
|
|
399
|
-
except InvalidPacketError as exc: # incl. InvalidPayloadError
|
|
400
|
-
# HGI80s can do what they like...
|
|
401
|
-
if msg.src.type != DEV_TYPE_MAP.HGI:
|
|
402
|
-
raise
|
|
403
|
-
if not msg._gwy.pkt_protocol or (
|
|
404
|
-
hgi_id := msg._gwy.pkt_protocol._hgi80.get("device_id") is None
|
|
405
|
-
):
|
|
406
|
-
_LOGGER.warning(f"{msg!r} < {exc}")
|
|
407
|
-
return
|
|
408
|
-
elif msg.src.id != hgi_id:
|
|
409
|
-
raise
|
|
366
|
+
regex = CODES_SCHEMA[msg.code][msg.verb]
|
|
367
|
+
except KeyError:
|
|
368
|
+
raise exc.PacketInvalid(
|
|
369
|
+
f"Unknown verb/code pair: {msg.verb}/{msg.code}"
|
|
370
|
+
) from None
|
|
371
|
+
|
|
372
|
+
if not re_compile_re_match(regex, payload):
|
|
373
|
+
raise exc.PacketPayloadInvalid(f"Payload doesn't match '{regex}': {payload}")
|