ramses-rf 0.22.2__py3-none-any.whl → 0.51.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.
Files changed (72) hide show
  1. ramses_cli/__init__.py +18 -0
  2. ramses_cli/client.py +597 -0
  3. ramses_cli/debug.py +20 -0
  4. ramses_cli/discovery.py +405 -0
  5. ramses_cli/utils/cat_slow.py +17 -0
  6. ramses_cli/utils/convert.py +60 -0
  7. ramses_rf/__init__.py +31 -10
  8. ramses_rf/binding_fsm.py +787 -0
  9. ramses_rf/const.py +124 -105
  10. ramses_rf/database.py +297 -0
  11. ramses_rf/device/__init__.py +69 -39
  12. ramses_rf/device/base.py +187 -376
  13. ramses_rf/device/heat.py +540 -552
  14. ramses_rf/device/hvac.py +286 -171
  15. ramses_rf/dispatcher.py +153 -177
  16. ramses_rf/entity_base.py +478 -361
  17. ramses_rf/exceptions.py +82 -0
  18. ramses_rf/gateway.py +378 -514
  19. ramses_rf/helpers.py +57 -19
  20. ramses_rf/py.typed +0 -0
  21. ramses_rf/schemas.py +148 -194
  22. ramses_rf/system/__init__.py +16 -23
  23. ramses_rf/system/faultlog.py +363 -0
  24. ramses_rf/system/heat.py +295 -302
  25. ramses_rf/system/schedule.py +312 -198
  26. ramses_rf/system/zones.py +318 -238
  27. ramses_rf/version.py +2 -8
  28. ramses_rf-0.51.1.dist-info/METADATA +72 -0
  29. ramses_rf-0.51.1.dist-info/RECORD +55 -0
  30. {ramses_rf-0.22.2.dist-info → ramses_rf-0.51.1.dist-info}/WHEEL +1 -2
  31. ramses_rf-0.51.1.dist-info/entry_points.txt +2 -0
  32. {ramses_rf-0.22.2.dist-info → ramses_rf-0.51.1.dist-info/licenses}/LICENSE +1 -1
  33. ramses_tx/__init__.py +160 -0
  34. {ramses_rf/protocol → ramses_tx}/address.py +65 -59
  35. ramses_tx/command.py +1454 -0
  36. ramses_tx/const.py +903 -0
  37. ramses_tx/exceptions.py +92 -0
  38. {ramses_rf/protocol → ramses_tx}/fingerprints.py +56 -15
  39. {ramses_rf/protocol → ramses_tx}/frame.py +132 -131
  40. ramses_tx/gateway.py +338 -0
  41. ramses_tx/helpers.py +883 -0
  42. {ramses_rf/protocol → ramses_tx}/logger.py +67 -53
  43. {ramses_rf/protocol → ramses_tx}/message.py +155 -191
  44. ramses_tx/opentherm.py +1260 -0
  45. ramses_tx/packet.py +210 -0
  46. ramses_tx/parsers.py +2957 -0
  47. ramses_tx/protocol.py +801 -0
  48. ramses_tx/protocol_fsm.py +672 -0
  49. ramses_tx/py.typed +0 -0
  50. {ramses_rf/protocol → ramses_tx}/ramses.py +262 -185
  51. {ramses_rf/protocol → ramses_tx}/schemas.py +150 -133
  52. ramses_tx/transport.py +1471 -0
  53. ramses_tx/typed_dicts.py +492 -0
  54. ramses_tx/typing.py +181 -0
  55. ramses_tx/version.py +4 -0
  56. ramses_rf/discovery.py +0 -398
  57. ramses_rf/protocol/__init__.py +0 -59
  58. ramses_rf/protocol/backports.py +0 -42
  59. ramses_rf/protocol/command.py +0 -1561
  60. ramses_rf/protocol/const.py +0 -697
  61. ramses_rf/protocol/exceptions.py +0 -111
  62. ramses_rf/protocol/helpers.py +0 -390
  63. ramses_rf/protocol/opentherm.py +0 -1170
  64. ramses_rf/protocol/packet.py +0 -235
  65. ramses_rf/protocol/parsers.py +0 -2673
  66. ramses_rf/protocol/protocol.py +0 -613
  67. ramses_rf/protocol/transport.py +0 -1011
  68. ramses_rf/protocol/version.py +0 -10
  69. ramses_rf/system/hvac.py +0 -82
  70. ramses_rf-0.22.2.dist-info/METADATA +0 -64
  71. ramses_rf-0.22.2.dist-info/RECORD +0 -42
  72. ramses_rf-0.22.2.dist-info/top_level.txt +0 -1
@@ -1,99 +1,78 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
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 .const import (
18
- DEV_TYPE_MAP,
19
- SZ_DHW_IDX,
20
- SZ_DOMAIN_ID,
21
- SZ_LOG_IDX,
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
- DEV_MODE = __dev_mode__ # and False
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
- CANT_EXPIRE = -1
68
- IS_EXPIRING = 0.8 # expected lifetime == 1.0
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, gwy, pkt: Packet) -> None:
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
- self._gwy = gwy
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: str = pkt.verb
68
+ self.verb: VerbT = pkt.verb
86
69
  self.seqn: str = pkt.seqn
87
- self.code: str = pkt.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 = display_name(self.src)
130
- name_1 = "" if self.dst is self.src else display_name(self.dst)
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 = display_name(self.src)
98
+ name_1 = self._name(self.src)
134
99
 
135
- _format = MSG_FORMAT_18 if self._gwy.config.use_aliases else MSG_FORMAT_10
136
- self._str = _format.format(
137
- name_0, name_1, self.verb, self.code_name, ctx(self._pkt), self.payload
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): # Union[dict, list[dict]]:
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._pkt._idx in (True, False) or self.code in CODE_IDX_COMPLEX:
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 True and not {self.src.type, self.dst.type} & {
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
- True
225
- and self.src.type == self.dst.type
226
- and self.src.type
227
- not in (
228
- DEV_TYPE_MAP.CTL,
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
- index_name = IDX_NAMES.get(
265
- self.code, SZ_DOMAIN_ID if self._pkt._idx[:1] == "F" else SZ_ZONE_IDX
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
- @property
271
- def _expired(self) -> bool:
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 = PAYLOAD_PARSERS.get(self.code, parser_unknown)(
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 InvalidPacketError as exc:
343
- (_LOGGER.exception if DEV_MODE else _LOGGER.warning)(
344
- "%s < %s", self._pkt, exc
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 exc:
268
+ except AssertionError as err:
349
269
  # beware: HGI80 can send 'odd' but parseable packets +/- get invalid reply
350
- (
351
- _LOGGER.exception
352
- if DEV_MODE and self.src.type != DEV_TYPE_MAP.HGI # DEX
353
- else _LOGGER.exception
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"{exc.__class__.__name__}({exc})"
275
+ "%s < Coding error: %s", self._pkt, f"{err.__class__.__name__}({err})"
360
276
  )
361
- raise InvalidPacketError from exc
277
+ raise exc.PacketInvalid from err
362
278
 
363
- except NotImplementedError as exc: # parser_unknown (unknown packet code)
279
+ except NotImplementedError as err: # parser_unknown (unknown packet code)
364
280
  _LOGGER.warning("%s < Unknown packet code (cannot parse)", self._pkt)
365
- raise InvalidPacketError from exc
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.complile, _MAXCACHE = 512
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: Message, payload: str) -> None:
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, otherwise simply return.
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
- _ = repr(msg._pkt) # HACK: ? raise InvalidPayloadError
387
-
388
- if msg.code not in CODES_SCHEMA:
389
- raise InvalidPacketError(f"Unknown code: {msg.code}")
390
-
391
- try:
392
- regex = CODES_SCHEMA[msg.code][msg.verb]
393
- except KeyError:
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}")