ramses-rf 0.22.40__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 (71) 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 +377 -513
  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.40.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.40.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_rf/protocol → ramses_tx}/parsers.py +1266 -1003
  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 -1576
  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/protocol.py +0 -613
  66. ramses_rf/protocol/transport.py +0 -1011
  67. ramses_rf/protocol/version.py +0 -10
  68. ramses_rf/system/hvac.py +0 -82
  69. ramses_rf-0.22.40.dist-info/METADATA +0 -64
  70. ramses_rf-0.22.40.dist-info/RECORD +0 -42
  71. ramses_rf-0.22.40.dist-info/top_level.txt +0 -1
@@ -0,0 +1,787 @@
1
+ #!/usr/bin/env python3
2
+ """RAMSES RF - a RAMSES-II protocol decoder & analyser.
3
+
4
+ Base for all devices.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ import re
12
+ from enum import StrEnum
13
+ from typing import TYPE_CHECKING, Final
14
+
15
+ import voluptuous as vol
16
+
17
+ from ramses_tx import (
18
+ ALL_DEV_ADDR,
19
+ ALL_DEVICE_ID,
20
+ Command,
21
+ DevType,
22
+ Message,
23
+ Priority,
24
+ QosParams,
25
+ )
26
+
27
+ from . import exceptions as exc
28
+
29
+ from .const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
30
+ I_,
31
+ RP,
32
+ RQ,
33
+ W_,
34
+ Code,
35
+ )
36
+
37
+ if TYPE_CHECKING:
38
+ from collections.abc import Iterable
39
+
40
+ from ramses_tx import IndexT, Packet
41
+
42
+ from .device.base import Fakeable
43
+
44
+ #
45
+ # NOTE: All debug flags should be False for deployment to end-users
46
+ _DBG_DISABLE_PHASE_ASSERTS: Final[bool] = False
47
+ _DBG_MAINTAIN_STATE_CHAIN: Final[bool] = False # maintain Context._prev_state
48
+
49
+ _LOGGER = logging.getLogger(__name__)
50
+
51
+
52
+ SZ_RESPONDENT: Final = "respondent"
53
+ SZ_SUPPLICANT: Final = "supplicant"
54
+ SZ_IS_DORMANT: Final = "is_dormant"
55
+
56
+
57
+ CONFIRM_RETRY_LIMIT: Final[int] = (
58
+ 3 # automatically Bound, from Confirming > this # of sends
59
+ )
60
+ SENDING_RETRY_LIMIT: Final[int] = (
61
+ 3 # fail Offering/Accepting if no response > this # of sends
62
+ )
63
+
64
+ CONFIRM_TIMEOUT_SECS: Final[float] = (
65
+ 3 # automatically Bound, from BoundAccepted > this # of seconds
66
+ )
67
+ WAITING_TIMEOUT_SECS: Final[float] = (
68
+ 5 # fail Listen/Offer/Accept if no pkt rcvd > this # of seconds
69
+ )
70
+
71
+ # raise a BindTimeoutError if expected Pkt is not received before this number of seconds
72
+ _TENDER_WAIT_TIME: Final[float] = WAITING_TIMEOUT_SECS # resp. listening for Offer
73
+ _ACCEPT_WAIT_TIME: Final[float] = (
74
+ WAITING_TIMEOUT_SECS # supp. sent Offer, expecting Accept
75
+ )
76
+ _AFFIRM_WAIT_TIME: Final[float] = (
77
+ CONFIRM_TIMEOUT_SECS # resp. sent Accept, expecting Confirm
78
+ )
79
+ _RATIFY_WAIT_TIME: Final[float] = (
80
+ CONFIRM_TIMEOUT_SECS # resp. rcvd Confirm, expecting Ratify (10E0)
81
+ )
82
+
83
+
84
+ BINDING_QOS = QosParams(
85
+ max_retries=SENDING_RETRY_LIMIT,
86
+ timeout=WAITING_TIMEOUT_SECS * 2,
87
+ wait_for_reply=False,
88
+ )
89
+
90
+
91
+ class Vendor(StrEnum):
92
+ CLIMARAD = "climarad"
93
+ ITHO = "itho"
94
+ NUAIRE = "nuaire"
95
+ ORCON = "orcon"
96
+ VASCO = "vasco"
97
+ DEFAULT = "default"
98
+
99
+
100
+ SZ_CLASS: Final = "class"
101
+ SZ_VENDOR: Final = "vendor"
102
+ SZ_TENDER: Final = "tender"
103
+ SZ_AFFIRM: Final = "affirm"
104
+ SZ_RATIFY: Final = "ratify"
105
+
106
+ # VOL_SUPPLICANT_ID = vol.Match(re.compile(r"^03:[0-9]{6}$"))
107
+ VOL_CODE_REGEX = vol.Match(re.compile(r"^[0-9A-F]{4}$"))
108
+ VOL_OEM_ID_REGEX = vol.Match(re.compile(r"^[0-9A-F]{2}$"))
109
+
110
+ VOL_TENDER_CODES = vol.All(
111
+ {vol.Required(VOL_CODE_REGEX, default="00"): VOL_OEM_ID_REGEX},
112
+ vol.Length(min=1),
113
+ )
114
+
115
+ VOL_SUPPLICANT = vol.Schema(
116
+ {
117
+ vol.Required(SZ_CLASS): vol.Any(DevType.THM.value, DevType.DHW.value),
118
+ vol.Optional(SZ_VENDOR, default="honeywell"): vol.Any(
119
+ "honeywell", "resideo", *(m.value for m in Vendor)
120
+ ),
121
+ vol.Optional(SZ_TENDER): VOL_TENDER_CODES,
122
+ vol.Optional(SZ_AFFIRM, default={}): vol.Any({}),
123
+ vol.Optional(SZ_RATIFY, default=None): vol.Any(None),
124
+ },
125
+ extra=vol.PREVENT_EXTRA,
126
+ )
127
+
128
+
129
+ class BindPhase(StrEnum):
130
+ TENDER = "offer"
131
+ ACCEPT = "accept"
132
+ AFFIRM = "confirm"
133
+ RATIFY = "addenda" # Code._10E0
134
+
135
+
136
+ class BindRole(StrEnum):
137
+ RESPONDENT = "respondent"
138
+ SUPPLICANT = "supplicant"
139
+ IS_DORMANT = "is_dormant"
140
+ IS_UNKNOWN = "is_unknown"
141
+
142
+
143
+ SCHEME_LOOKUP = {
144
+ Vendor.ITHO: {"oem_code": "01"},
145
+ Vendor.NUAIRE: {"oem_code": "6C"},
146
+ Vendor.CLIMARAD: {"oem_code": "65"},
147
+ Vendor.VASCO: {"oem_code": "66"},
148
+ Vendor.ORCON: {"oem_code": "67", "offer_to": ALL_DEVICE_ID},
149
+ Vendor.DEFAULT: {"oem_code": None},
150
+ }
151
+
152
+
153
+ #
154
+
155
+
156
+ class BindContextBase:
157
+ """The context is the Device class. It should be initiated with a default state."""
158
+
159
+ _attr_role = BindRole.IS_UNKNOWN
160
+
161
+ _is_respondent: bool | None # if binding, is either: respondent or supplicant
162
+ _state: BindStateBase = None # type: ignore[assignment]
163
+
164
+ def __init__(self, dev: Fakeable) -> None:
165
+ self._dev = dev
166
+ self._loop = asyncio.get_running_loop()
167
+ self._fut: asyncio.Future[Message] | None = None
168
+
169
+ self.set_state(DevIsNotBinding)
170
+
171
+ def __repr__(self) -> str:
172
+ return f"{self._dev.id} ({self.role}): {self.state!r}"
173
+
174
+ def __str__(self) -> str:
175
+ return f"{self._dev.id}: {self.state}"
176
+
177
+ def set_state(
178
+ self, state: type[BindStateBase], result: asyncio.Future[Message] | None = None
179
+ ) -> None:
180
+ """Transition the State of the Context, and process the result, if any."""
181
+
182
+ # if False and result:
183
+ # try:
184
+ # self._fut.set_result(result.result())
185
+ # except exc.BindingError as err:
186
+ # self._fut.set_result(err)
187
+
188
+ if _DBG_MAINTAIN_STATE_CHAIN: # HACK for debugging
189
+ # if prev_state in (None, )
190
+ prev_state = self._state
191
+
192
+ self._state = state(self)
193
+ if not self.is_binding:
194
+ self._is_respondent = None
195
+ elif state is RespIsWaitingForOffer:
196
+ self._is_respondent = True
197
+ elif state is SuppSendOfferWaitForAccept:
198
+ self._is_respondent = False
199
+
200
+ if _DBG_MAINTAIN_STATE_CHAIN: # HACK for debugging
201
+ setattr(self._state, "_prev_state", prev_state) # noqa: B010
202
+
203
+ @property
204
+ def state(self) -> BindStateBase:
205
+ """Return the State (phase) of the Context."""
206
+ return self._state
207
+
208
+ @property
209
+ def role(self) -> BindRole:
210
+ if self._is_respondent is True:
211
+ return BindRole.RESPONDENT
212
+ if self._is_respondent is False:
213
+ return BindRole.SUPPLICANT
214
+ return BindRole.IS_DORMANT
215
+
216
+ # TODO: Should remain is_binding until after 10E0 rcvd (if one expected)?
217
+ @property
218
+ def is_binding(self) -> bool:
219
+ """Return True if currently participating in a binding process."""
220
+ return not isinstance(self.state, _IS_NOT_BINDING_STATES)
221
+
222
+ def rcvd_msg(self, msg: Message) -> None:
223
+ """Pass relevant Messages through to the state processor."""
224
+ if msg.code in (Code._1FC9, Code._10E0):
225
+ self.state.rcvd_msg(msg)
226
+
227
+ def sent_cmd(self, cmd: Command) -> None:
228
+ """Pass relevant Commands through to the state processor."""
229
+ if cmd.code in (Code._1FC9, Code._10E0):
230
+ self.state.send_cmd(cmd)
231
+
232
+
233
+ class BindContextRespondent(BindContextBase):
234
+ """The binding Context for a Respondent."""
235
+
236
+ _attr_role = BindRole.RESPONDENT
237
+
238
+ async def wait_for_binding_request(
239
+ self,
240
+ accept_codes: Iterable[Code],
241
+ /,
242
+ *,
243
+ idx: IndexT = "00",
244
+ require_ratify: bool = False,
245
+ ) -> tuple[Packet, Packet, Packet, Packet | None]:
246
+ """Device starts binding as a Respondent, by listening for an Offer.
247
+
248
+ Returns the Supplicant's Offer or raise an exception if the binding is
249
+ unsuccessful (BindError).
250
+ """
251
+
252
+ if self.is_binding:
253
+ raise exc.BindingFsmError(
254
+ f"{self}: bad State for bindings as a Respondent (is already binding)"
255
+ )
256
+ self.set_state(RespIsWaitingForOffer) # self._is_respondent = True
257
+
258
+ # Step R1: Respondent expects an Offer
259
+ tender = await self._wait_for_offer()
260
+
261
+ # Step R2: Respondent expects a Confirm after sending an Accept (accepts Offer)
262
+ accept = await self._accept_offer(tender, accept_codes, idx=idx)
263
+ affirm = await self._wait_for_confirm(accept)
264
+
265
+ # Step R3: Respondent expects an Addenda (optional)
266
+ if require_ratify: # TODO: not recvd as sent to 63:262142
267
+ self.set_state(RespIsWaitingForAddenda) # HACK: easiest way
268
+ ratify = await self._wait_for_addenda(accept) # may: exc.BindingFlowFailed:
269
+ else:
270
+ ratify = None
271
+
272
+ # self._set_as_bound(tender, accept, affirm, ratify)
273
+ return tender._pkt, accept, affirm._pkt, (ratify._pkt if ratify else None)
274
+
275
+ async def _wait_for_offer(self, timeout: float = _TENDER_WAIT_TIME) -> Message:
276
+ """Resp waits timeout seconds for an Offer to arrive & returns it."""
277
+ return await self.state.wait_for_offer(timeout)
278
+
279
+ async def _accept_offer(
280
+ self, tender: Message, codes: Iterable[Code], idx: IndexT = "00"
281
+ ) -> Packet:
282
+ """Resp sends an Accept on the basis of a rcvd Offer & returns the Confirm."""
283
+
284
+ cmd = Command.put_bind(W_, self._dev.id, codes, dst_id=tender.src.id, idx=idx)
285
+ if not _DBG_DISABLE_PHASE_ASSERTS: # TODO: should be in test suite
286
+ assert Message._from_cmd(cmd).payload["phase"] == BindPhase.ACCEPT
287
+
288
+ pkt: Packet = await self._dev._async_send_cmd( # type: ignore[assignment]
289
+ cmd, priority=Priority.HIGH, qos=BINDING_QOS
290
+ )
291
+
292
+ self.state.cast_accept_offer()
293
+ return pkt
294
+
295
+ async def _wait_for_confirm(
296
+ self,
297
+ accept: Packet,
298
+ timeout: float = _AFFIRM_WAIT_TIME,
299
+ ) -> Message:
300
+ """Resp waits timeout seconds for a Confirm to arrive & returns it."""
301
+ return await self.state.wait_for_confirm(timeout)
302
+
303
+ async def _wait_for_addenda(
304
+ self,
305
+ accept: Packet,
306
+ timeout: float = _RATIFY_WAIT_TIME,
307
+ ) -> Message:
308
+ """Resp waits timeout seconds for an Addenda to arrive & returns it."""
309
+ return await self.state.wait_for_addenda(timeout)
310
+
311
+
312
+ class BindContextSupplicant(BindContextBase):
313
+ """The binding Context for a Supplicant."""
314
+
315
+ _attr_role = BindRole.SUPPLICANT
316
+
317
+ async def initiate_binding_process(
318
+ self,
319
+ offer_codes: Iterable[Code],
320
+ /,
321
+ *,
322
+ confirm_code: Code | None = None,
323
+ ratify_cmd: Command | None = None,
324
+ ) -> tuple[Packet, Packet, Packet, Packet | None]:
325
+ """Device starts binding as a Supplicant, by sending an Offer.
326
+
327
+ Returns the Respondent's Accept, or raise an exception if the binding is
328
+ unsuccessful (BindError).
329
+ """
330
+
331
+ if self.is_binding:
332
+ raise exc.BindingFsmError(
333
+ f"{self}: bad State for binding as a Supplicant (is already binding)"
334
+ )
335
+ self.set_state(SuppSendOfferWaitForAccept) # self._is_respondent = False
336
+
337
+ oem_code = ratify_cmd.payload[14:16] if ratify_cmd else None
338
+
339
+ # Step S1: Supplicant sends an Offer (makes Offer) and expects an Accept
340
+ tender = await self._make_offer(offer_codes, oem_code=oem_code)
341
+ accept = await self._wait_for_accept(tender)
342
+
343
+ # Step S2: Supplicant sends a Confirm (confirms Accept)
344
+ affirm = await self._confirm_accept(accept, confirm_code=confirm_code)
345
+
346
+ # Step S3: Supplicant sends an Addenda (optional)
347
+ if oem_code:
348
+ self.set_state(SuppIsReadyToSendAddenda) # HACK: easiest way
349
+ ratify = await self._cast_addenda(accept, ratify_cmd) # type: ignore[arg-type]
350
+ else:
351
+ ratify = None
352
+
353
+ # self._set_as_bound(tender, accept, affirm, ratify)
354
+ return tender, accept._pkt, affirm, ratify
355
+
356
+ async def _make_offer(
357
+ self,
358
+ codes: Iterable[Code],
359
+ oem_code: str | None = None,
360
+ ) -> Packet:
361
+ """Supp sends an Offer & returns the corresponding Packet."""
362
+ # if oem_code, send an 10E0
363
+
364
+ # state = self.state
365
+ cmd = Command.put_bind(
366
+ I_, self._dev.id, codes, dst_id=self._dev.id, oem_code=oem_code
367
+ )
368
+ if not _DBG_DISABLE_PHASE_ASSERTS: # TODO: should be in test suite
369
+ assert Message._from_cmd(cmd).payload["phase"] == BindPhase.TENDER
370
+
371
+ pkt: Packet = await self._dev._async_send_cmd( # type: ignore[assignment]
372
+ cmd, priority=Priority.HIGH, qos=BINDING_QOS
373
+ )
374
+
375
+ # await state._fut
376
+ self.state.cast_offer()
377
+ return pkt
378
+
379
+ async def _wait_for_accept(
380
+ self,
381
+ tender: Packet,
382
+ timeout: float = _ACCEPT_WAIT_TIME,
383
+ ) -> Message:
384
+ """Supp waits timeout seconds for an Accept to arrive & returns it."""
385
+ return await self.state.wait_for_accept(timeout)
386
+
387
+ async def _confirm_accept(
388
+ self, accept: Message, confirm_code: Code | None = None
389
+ ) -> Packet:
390
+ """Supp casts a Confirm on the basis of a rcvd Accept & returns the Confirm."""
391
+
392
+ idx = accept._pkt.payload[:2] # HACK assumes all idx same
393
+
394
+ cmd = Command.put_bind(
395
+ I_, self._dev.id, confirm_code, dst_id=accept.src.id, idx=idx
396
+ )
397
+ if not _DBG_DISABLE_PHASE_ASSERTS: # TODO: should be in test suite
398
+ assert Message._from_cmd(cmd).payload["phase"] == BindPhase.AFFIRM
399
+
400
+ pkt: Packet = await self._dev._async_send_cmd( # type: ignore[assignment]
401
+ cmd, priority=Priority.HIGH, qos=BINDING_QOS
402
+ )
403
+
404
+ await self.state.cast_confirm_accept()
405
+ return pkt
406
+
407
+ async def _cast_addenda(self, accept: Message, cmd: Command) -> Packet:
408
+ """Supp casts an Addenda (the final 10E0 command)."""
409
+
410
+ pkt: Packet = await self._dev._async_send_cmd( # type: ignore[assignment]
411
+ cmd, priority=Priority.HIGH, qos=BINDING_QOS
412
+ )
413
+
414
+ await self.state.cast_addenda()
415
+ return pkt
416
+
417
+
418
+ class BindContext(BindContextRespondent, BindContextSupplicant):
419
+ _attr_role = BindRole.IS_UNKNOWN
420
+
421
+
422
+ #
423
+
424
+
425
+ class BindStateBase:
426
+ _attr_role = BindRole.IS_UNKNOWN
427
+
428
+ _cmds_sent: int = 0 # num of bind cmds sent
429
+ _pkts_rcvd: int = 0 # num of bind pkts rcvd (incl. any echos of sender's own cmd)
430
+
431
+ _has_wait_timer: bool = False
432
+ _retry_limit: int = SENDING_RETRY_LIMIT
433
+ _timer_handle: asyncio.TimerHandle
434
+
435
+ _next_ctx_state: type[BindStateBase] # next state, if successful transition
436
+
437
+ def __init__(self, context: BindContextBase) -> None:
438
+ self._context = context
439
+ self._loop = context._loop
440
+
441
+ self._fut = self._loop.create_future()
442
+ _LOGGER.debug(f"{self}: Changing state from: {self._context.state} to: {self}")
443
+
444
+ if self._has_wait_timer:
445
+ self._timer_handle = self._loop.call_later(
446
+ WAITING_TIMEOUT_SECS,
447
+ self._handle_wait_timer_expired,
448
+ WAITING_TIMEOUT_SECS,
449
+ )
450
+
451
+ def __repr__(self) -> str:
452
+ return f"{self.__class__.__name__} (tx={self._cmds_sent})"
453
+
454
+ def __str__(self) -> str:
455
+ return self.__class__.__name__
456
+
457
+ @property
458
+ def context(self) -> BindContextBase:
459
+ return self._context
460
+
461
+ async def _wait_for_fut_result(self, timeout: float) -> Message:
462
+ """Wait timeout seconds for an expected event to occur.
463
+
464
+ The expected event is defined by the State's sent_cmd, rcvd_msg methods.
465
+ """
466
+ try:
467
+ await asyncio.wait_for(self._fut, timeout)
468
+ except TimeoutError:
469
+ self._handle_wait_timer_expired(timeout)
470
+ else:
471
+ self._set_context_state(self._next_ctx_state)
472
+ result: Message = self._fut.result() # may raise exception
473
+ return result
474
+
475
+ def _handle_wait_timer_expired(self, timeout: float) -> None:
476
+ """Process an overrun of the wait timer when waiting for a Message."""
477
+
478
+ msg = (
479
+ f"{self._context}: Failed to transition to {self._next_ctx_state}: "
480
+ f"expected message not received after {timeout} secs"
481
+ )
482
+
483
+ _LOGGER.warning(msg)
484
+ self._fut.set_exception(exc.BindingFlowFailed(msg))
485
+ self._set_context_state(DevHasFailedBinding)
486
+
487
+ def _set_context_state(self, next_state: type[BindStateBase]) -> None:
488
+ if not self._fut.done(): # if not BindRetryError, BindTimeoutError, msg
489
+ raise exc.BindingFsmError # or: self._fut.set_exception()
490
+ self._context.set_state(next_state, result=self._fut)
491
+
492
+ def send_cmd(self, cmd: Command) -> None:
493
+ raise NotImplementedError
494
+
495
+ def rcvd_msg(self, msg: Message) -> None:
496
+ raise NotImplementedError
497
+
498
+ @staticmethod
499
+ def is_phase(cmd: Command | Packet, phase: BindPhase) -> bool:
500
+ if phase == BindPhase.RATIFY:
501
+ return cmd.verb == I_ and cmd.code == Code._10E0
502
+ if cmd.code != Code._1FC9:
503
+ return False
504
+ if phase == BindPhase.TENDER:
505
+ return cmd.verb == I_ and cmd.dst in (cmd.src, ALL_DEV_ADDR)
506
+ if phase == BindPhase.ACCEPT:
507
+ return cmd.verb == W_ and cmd.dst is not cmd.src
508
+ # if phase == BindPhase.AFFIRM:
509
+ return cmd.verb == I_ and cmd.dst not in (cmd.src, ALL_DEV_ADDR)
510
+
511
+ # Respondent State APIs...
512
+ async def wait_for_offer(self, timeout: float | None = None) -> Message:
513
+ raise exc.BindingFsmError(
514
+ f"{self._context!r}: shouldn't wait_for_offer() from this State"
515
+ )
516
+
517
+ def cast_accept_offer(self) -> None:
518
+ raise exc.BindingFsmError(
519
+ f"{self._context!r}: shouldn't accept_offer() from this State"
520
+ )
521
+
522
+ async def wait_for_confirm(self, timeout: float | None = None) -> Message:
523
+ raise exc.BindingFsmError(
524
+ f"{self._context!r}: shouldn't wait_for_confirm() from this State"
525
+ )
526
+
527
+ async def wait_for_addenda(self, timeout: float | None = None) -> Message:
528
+ raise exc.BindingFsmError(
529
+ f"{self._context!r}: shouldn't wait_for_addenda() from this State"
530
+ )
531
+
532
+ # Supplicant State APIs...
533
+ def cast_offer(self, timeout: float | None = None) -> None:
534
+ raise exc.BindingFsmError(
535
+ f"{self._context!r}: shouldn't make_offer() from this State"
536
+ )
537
+
538
+ async def wait_for_accept(self, timeout: float | None = None) -> Message:
539
+ raise exc.BindingFsmError(
540
+ f"{self._context!r}: shouldn't wait_for_accept() from this State"
541
+ )
542
+
543
+ async def cast_confirm_accept(self, timeout: float | None = None) -> Message:
544
+ raise exc.BindingFsmError(
545
+ f"{self._context!r}: shouldn't confirm_accept() from this State"
546
+ )
547
+
548
+ async def cast_addenda(self, timeout: float | None = None) -> Message:
549
+ raise exc.BindingFsmError(
550
+ f"{self._context!r}: shouldn't cast_addenda() from this State"
551
+ )
552
+
553
+
554
+ class _DevIsWaitingForMsg(BindStateBase):
555
+ """Device waits until it receives the anticipated Packet (Offer or Addenda).
556
+
557
+ Failure occurs when the timer expires (timeout) before receiving the Packet.
558
+ """
559
+
560
+ _expected_pkt_phase: BindPhase
561
+
562
+ _wait_timer_limit: float = 5.1 # WAITING_TIMEOUT_SECS
563
+
564
+ def __init__(self, context: BindContextBase) -> None:
565
+ super().__init__(context)
566
+
567
+ self._timer_handle = self._loop.call_later(
568
+ self._wait_timer_limit,
569
+ self._handle_wait_timer_expired,
570
+ self._wait_timer_limit,
571
+ )
572
+
573
+ def _set_context_state(self, next_state: type[BindStateBase]) -> None:
574
+ if self._timer_handle:
575
+ self._timer_handle.cancel()
576
+ super()._set_context_state(next_state)
577
+
578
+ def rcvd_msg(self, msg: Message) -> None:
579
+ """If the msg is the waited-for pkt, transition to the next state."""
580
+ if self.is_phase(msg._pkt, self._expected_pkt_phase):
581
+ self._fut.set_result(msg)
582
+
583
+
584
+ class _DevIsReadyToSendCmd(BindStateBase):
585
+ """Device sends a Command (Confirm, Addenda) that wouldn't result in a reply Packet.
586
+
587
+ Failure occurs when the retry limit is exceeded before receiving a Command echo.
588
+ """
589
+
590
+ _expected_cmd_phase: BindPhase
591
+
592
+ _send_retry_limit: int = 0 # retries dont include the first send
593
+ _send_retry_timer: float = 0.8 # retry if no echo received before timeout
594
+
595
+ def __init__(self, context: BindContextBase) -> None:
596
+ super().__init__(context)
597
+
598
+ self._cmd: Command | None = None
599
+ self._cmds_sent: int = 0
600
+
601
+ def _retries_exceeded(self) -> None:
602
+ """Process an overrun of the retry limit when sending a Command."""
603
+
604
+ msg = (
605
+ f"{self._context}: Failed to transition to {self._next_ctx_state}: "
606
+ f"{self._expected_cmd_phase} command echo not received after "
607
+ f"{self._retry_limit} retries"
608
+ )
609
+
610
+ _LOGGER.warning(msg)
611
+ self._fut.set_exception(exc.BindingFlowFailed(msg))
612
+ self._set_context_state(DevHasFailedBinding)
613
+
614
+ def send_cmd(self, cmd: Command) -> None:
615
+ """If sending a cmd, expect the corresponding echo."""
616
+
617
+ if not self.is_phase(cmd, self._expected_cmd_phase):
618
+ return
619
+
620
+ if self._cmds_sent > self._send_retry_limit:
621
+ self._retries_exceeded()
622
+ self._cmds_sent += 1
623
+ self._cmd = self._cmd or cmd
624
+
625
+ def rcvd_msg(self, msg: Message) -> None:
626
+ """If the msg is the echo of the sent cmd, transition to the next state."""
627
+ if self._cmd and msg._pkt == self._cmd:
628
+ self._fut.set_result(msg)
629
+
630
+
631
+ class _DevSendCmdUntilReply(_DevIsWaitingForMsg, _DevIsReadyToSendCmd):
632
+ """Device sends a Command (Offer, Accept), until it gets the expected reply Packet.
633
+
634
+ Failure occurs when the timer expires (timeout) or the retry limit is exceeded
635
+ before receiving a reply Packet.
636
+ """
637
+
638
+ def rcvd_msg(self, msg: Message) -> None:
639
+ """If the msg is the expected reply, transition to the next state."""
640
+ # if self._cmd and msg._pkt == self._cmd: # the echo
641
+ # self._set_context_state(self._next_ctx_state)
642
+ if self.is_phase(msg._pkt, self._expected_pkt_phase):
643
+ self._fut.set_result(msg)
644
+
645
+
646
+ class DevHasFailedBinding(BindStateBase):
647
+ """Device has failed binding."""
648
+
649
+ _attr_role = BindRole.IS_UNKNOWN
650
+
651
+
652
+ class DevIsNotBinding(BindStateBase):
653
+ """Device is not binding."""
654
+
655
+ _attr_role = BindRole.IS_DORMANT
656
+
657
+
658
+ #
659
+
660
+
661
+ class RespHasBoundAsRespondent(BindStateBase):
662
+ """Respondent has received an Offer (+/- an Addenda) & has nothing more to do."""
663
+
664
+ _attr_role = BindRole.IS_DORMANT
665
+
666
+
667
+ class RespIsWaitingForAddenda(_DevIsWaitingForMsg, BindStateBase):
668
+ """Respondent has received a Confirm & is waiting for an Addenda."""
669
+
670
+ _attr_role = BindRole.RESPONDENT
671
+
672
+ _expected_pkt_phase: BindPhase = BindPhase.RATIFY
673
+ _next_ctx_state: type[BindStateBase] = RespHasBoundAsRespondent
674
+
675
+ async def wait_for_addenda(self, timeout: float | None = None) -> Message:
676
+ return await self._wait_for_fut_result(timeout or _RATIFY_WAIT_TIME)
677
+
678
+
679
+ class RespSendAcceptWaitForConfirm(_DevSendCmdUntilReply, BindStateBase):
680
+ """Respondent is ready to send an Accept & will expect a Confirm."""
681
+
682
+ _attr_role = BindRole.RESPONDENT
683
+
684
+ _expected_cmd_phase: BindPhase = BindPhase.ACCEPT
685
+ _expected_pkt_phase: BindPhase = BindPhase.AFFIRM
686
+ _next_ctx_state: type[BindStateBase] = (
687
+ RespHasBoundAsRespondent # or: RespIsWaitingForAddenda
688
+ )
689
+
690
+ def cast_accept_offer(self) -> None:
691
+ """Ignore any received Offer, other than the first."""
692
+ pass
693
+
694
+ async def wait_for_confirm(self, timeout: float | None = None) -> Message:
695
+ return await self._wait_for_fut_result(timeout or _AFFIRM_WAIT_TIME)
696
+
697
+
698
+ class RespIsWaitingForOffer(_DevIsWaitingForMsg, BindStateBase):
699
+ """Respondent is waiting for an Offer."""
700
+
701
+ _attr_role = BindRole.RESPONDENT
702
+
703
+ _expected_pkt_phase: BindPhase = BindPhase.TENDER
704
+ _next_ctx_state: type[BindStateBase] = RespSendAcceptWaitForConfirm
705
+
706
+ async def wait_for_offer(self, timeout: float | None = None) -> Message:
707
+ return await self._wait_for_fut_result(timeout or _TENDER_WAIT_TIME)
708
+
709
+
710
+ #
711
+
712
+
713
+ class SuppHasBoundAsSupplicant(BindStateBase):
714
+ """Supplicant has sent a Confirm (+/- an Addenda) & has nothing more to do."""
715
+
716
+ _attr_role = BindRole.IS_DORMANT
717
+
718
+
719
+ class SuppIsReadyToSendAddenda(
720
+ _DevIsReadyToSendCmd, BindStateBase
721
+ ): # send until echo, max_retry=1
722
+ """Supplicant has sent a Confirm & is ready to send an Addenda."""
723
+
724
+ _attr_role = BindRole.SUPPLICANT
725
+
726
+ _expected_cmd_phase: BindPhase = BindPhase.RATIFY
727
+ _next_ctx_state: type[BindStateBase] = SuppHasBoundAsSupplicant
728
+
729
+ async def cast_addenda(self, timeout: float | None = None) -> Message:
730
+ return await self._wait_for_fut_result(timeout or _ACCEPT_WAIT_TIME)
731
+
732
+
733
+ class SuppIsReadyToSendConfirm(
734
+ _DevIsReadyToSendCmd, BindStateBase
735
+ ): # send until echo, max_retry=1
736
+ """Supplicant has received an Accept & is ready to send a Confirm."""
737
+
738
+ _attr_role = BindRole.SUPPLICANT
739
+
740
+ _expected_cmd_phase: BindPhase = BindPhase.AFFIRM
741
+ _next_ctx_state: type[BindStateBase] = (
742
+ SuppHasBoundAsSupplicant # or: SuppIsReadyToSendAddenda
743
+ )
744
+
745
+ async def cast_confirm_accept(self, timeout: float | None = None) -> Message:
746
+ return await self._wait_for_fut_result(timeout or _ACCEPT_WAIT_TIME)
747
+
748
+
749
+ class SuppSendOfferWaitForAccept(_DevSendCmdUntilReply, BindStateBase):
750
+ """Supplicant is ready to send an Offer & will expect an Accept."""
751
+
752
+ _attr_role = BindRole.SUPPLICANT
753
+
754
+ _expected_cmd_phase: BindPhase = BindPhase.TENDER
755
+ _expected_pkt_phase: BindPhase = BindPhase.ACCEPT
756
+ _next_ctx_state: type[BindStateBase] = SuppIsReadyToSendConfirm
757
+
758
+ def cast_offer(self, timeout: float | None = None) -> None:
759
+ pass
760
+
761
+ async def wait_for_accept(self, timeout: float | None = None) -> Message:
762
+ return await self._wait_for_fut_result(timeout or _ACCEPT_WAIT_TIME)
763
+
764
+
765
+ #
766
+
767
+
768
+ class _BindStates: # used for test suite
769
+ IS_IDLE_DEVICE = DevIsNotBinding # may send Offer
770
+ NEEDING_TENDER = RespIsWaitingForOffer # receives Offer, sends Accept
771
+ NEEDING_ACCEPT = SuppSendOfferWaitForAccept # receives Accept, sends
772
+ NEEDING_AFFIRM = RespSendAcceptWaitForConfirm
773
+ TO_SEND_AFFIRM = SuppIsReadyToSendConfirm
774
+ NEEDING_RATIFY = RespIsWaitingForAddenda # Optional: has sent Confirm
775
+ TO_SEND_RATIFY = SuppIsReadyToSendAddenda # Optional
776
+ HAS_BOUND_RESP = RespHasBoundAsRespondent
777
+ HAS_BOUND_SUPP = SuppHasBoundAsSupplicant
778
+ IS_FAILED_RESP = DevHasFailedBinding
779
+ IS_FAILED_SUPP = DevHasFailedBinding
780
+
781
+
782
+ _IS_NOT_BINDING_STATES = (
783
+ DevHasFailedBinding,
784
+ DevIsNotBinding,
785
+ RespHasBoundAsRespondent,
786
+ SuppHasBoundAsSupplicant,
787
+ )