Coverage for src/ramses_rf/binding_fsm.py: 55%
339 statements
« prev ^ index » next coverage.py v7.11.3, created at 2026-01-05 21:46 +0100
« prev ^ index » next coverage.py v7.11.3, created at 2026-01-05 21:46 +0100
1#!/usr/bin/env python3
2"""RAMSES RF - a RAMSES-II protocol decoder & analyser.
4Base for all devices.
5"""
7from __future__ import annotations
9import asyncio
10import logging
11import re
12from enum import StrEnum
13from typing import TYPE_CHECKING, Final
15import voluptuous as vol
17from ramses_tx import (
18 ALL_DEV_ADDR,
19 ALL_DEVICE_ID,
20 Command,
21 DevType,
22 Message,
23 Priority,
24 QosParams,
25)
27from . import exceptions as exc
29from .const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
30 I_,
31 RP,
32 RQ,
33 W_,
34 Code,
35)
37if TYPE_CHECKING:
38 from collections.abc import Iterable
40 from ramses_tx import IndexT, Packet
42 from .device.base import Fakeable
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
49_LOGGER = logging.getLogger(__name__)
52SZ_RESPONDENT: Final = "respondent"
53SZ_SUPPLICANT: Final = "supplicant"
54SZ_IS_DORMANT: Final = "is_dormant"
57CONFIRM_RETRY_LIMIT: Final[int] = (
58 3 # automatically Bound, from Confirming > this # of sends
59)
60SENDING_RETRY_LIMIT: Final[int] = (
61 3 # fail Offering/Accepting if no response > this # of sends
62)
64CONFIRM_TIMEOUT_SECS: Final[float] = (
65 3 # automatically Bound, from BoundAccepted > this # of seconds
66)
67WAITING_TIMEOUT_SECS: Final[float] = (
68 5 # fail Listen/Offer/Accept if no pkt rcvd > this # of seconds
69)
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)
84BINDING_QOS = QosParams(
85 max_retries=SENDING_RETRY_LIMIT,
86 timeout=WAITING_TIMEOUT_SECS * 2,
87 wait_for_reply=False,
88)
91class Vendor(StrEnum):
92 CLIMARAD = "climarad"
93 ITHO = "itho"
94 NUAIRE = "nuaire"
95 ORCON = "orcon"
96 VASCO = "vasco"
97 DEFAULT = "default"
100SZ_CLASS: Final = "class"
101SZ_VENDOR: Final = "vendor"
102SZ_TENDER: Final = "tender"
103SZ_AFFIRM: Final = "affirm"
104SZ_RATIFY: Final = "ratify"
106# VOL_SUPPLICANT_ID = vol.Match(re.compile(r"^03:[0-9]{6}$"))
107VOL_CODE_REGEX = vol.Match(re.compile(r"^[0-9A-F]{4}$"))
108VOL_OEM_ID_REGEX = vol.Match(re.compile(r"^[0-9A-F]{2}$"))
110VOL_TENDER_CODES = vol.All(
111 {vol.Required(VOL_CODE_REGEX, default="00"): VOL_OEM_ID_REGEX},
112 vol.Length(min=1),
113)
115VOL_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)
129class BindPhase(StrEnum):
130 TENDER = "offer"
131 ACCEPT = "accept"
132 AFFIRM = "confirm"
133 RATIFY = "addenda" # Code._10E0
136class BindRole(StrEnum):
137 RESPONDENT = "respondent"
138 SUPPLICANT = "supplicant"
139 IS_DORMANT = "is_dormant"
140 IS_UNKNOWN = "is_unknown"
143SCHEME_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}
153#
156class BindContextBase:
157 """The context is the Device class. It should be initiated with a default state."""
159 _attr_role = BindRole.IS_UNKNOWN
161 _is_respondent: bool | None # if binding, is either: respondent or supplicant
162 _state: BindStateBase = None # type: ignore[assignment]
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
169 self.set_state(DevIsNotBinding)
171 def __repr__(self) -> str:
172 return f"{self._dev.id} ({self.role}): {self.state!r}"
174 def __str__(self) -> str:
175 return f"{self._dev.id}: {self.state}"
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 # Ensure prev_state is always available, not only during debugging
182 prev_state = self._state
184 # if False and result:
185 # try:
186 # self._fut.set_result(result.result())
187 # except exc.BindingError as err:
188 # self._fut.set_result(err)
190 self._state = state(self)
191 if not self.is_binding:
192 self._is_respondent = None
193 elif state is RespIsWaitingForOffer:
194 self._is_respondent = True
195 elif state is SuppSendOfferWaitForAccept:
196 self._is_respondent = False
198 # Log binding completion transitions
199 if isinstance(
200 self._state, (RespHasBoundAsRespondent, SuppHasBoundAsSupplicant)
201 ):
202 _LOGGER.info(
203 f"{self._dev.id}: Binding process completed: {type(prev_state).__name__} -> {state.__name__} (role: {self.role})"
204 )
206 if _DBG_MAINTAIN_STATE_CHAIN: # HACK for debugging
207 setattr(self._state, "_prev_state", prev_state) # noqa: B010
209 @property
210 def state(self) -> BindStateBase:
211 """Return the State (phase) of the Context."""
212 return self._state
214 @property
215 def role(self) -> BindRole:
216 if self._is_respondent is True:
217 return BindRole.RESPONDENT
218 if self._is_respondent is False:
219 return BindRole.SUPPLICANT
220 return BindRole.IS_DORMANT
222 # TODO: Should remain is_binding until after 10E0 rcvd (if one expected)?
223 @property
224 def is_binding(self) -> bool:
225 """Return True if currently participating in a binding process."""
226 return not isinstance(self.state, _IS_NOT_BINDING_STATES)
228 def rcvd_msg(self, msg: Message) -> None:
229 """Pass relevant Messages through to the state processor."""
230 if msg.code in (Code._1FC9, Code._10E0):
231 self.state.rcvd_msg(msg)
233 def sent_cmd(self, cmd: Command) -> None:
234 """Pass relevant Commands through to the state processor."""
235 if cmd.code in (Code._1FC9, Code._10E0):
236 self.state.send_cmd(cmd)
239class BindContextRespondent(BindContextBase):
240 """The binding Context for a Respondent."""
242 _attr_role = BindRole.RESPONDENT
244 async def wait_for_binding_request(
245 self,
246 accept_codes: Iterable[Code],
247 /,
248 *,
249 idx: IndexT = "00",
250 require_ratify: bool = False,
251 ) -> tuple[Packet, Packet, Packet, Packet | None]:
252 """Device starts binding as a Respondent, by listening for an Offer.
254 Returns the Supplicant's Offer or raise an exception if the binding is
255 unsuccessful (BindError).
256 """
258 if self.is_binding:
259 raise exc.BindingFsmError(
260 f"{self}: bad State for bindings as a Respondent (is already binding)"
261 )
262 self.set_state(RespIsWaitingForOffer) # self._is_respondent = True
264 # Step R1: Respondent expects an Offer
265 tender = await self._wait_for_offer()
267 # Step R2: Respondent expects a Confirm after sending an Accept (accepts Offer)
268 accept = await self._accept_offer(tender, accept_codes, idx=idx)
269 affirm = await self._wait_for_confirm(accept)
271 # Step R3: Respondent expects an Addenda (optional)
272 if require_ratify: # TODO: not recvd as sent to 63:262142
273 self.set_state(RespIsWaitingForAddenda) # HACK: easiest way
274 ratify = await self._wait_for_addenda(accept) # may: exc.BindingFlowFailed:
275 else:
276 ratify = None
278 # self._set_as_bound(tender, accept, affirm, ratify)
279 return tender._pkt, accept, affirm._pkt, (ratify._pkt if ratify else None)
281 async def _wait_for_offer(self, timeout: float = _TENDER_WAIT_TIME) -> Message:
282 """Resp waits timeout seconds for an Offer to arrive & returns it."""
283 return await self.state.wait_for_offer(timeout)
285 async def _accept_offer(
286 self, tender: Message, codes: Iterable[Code], idx: IndexT = "00"
287 ) -> Packet:
288 """Resp sends an Accept on the basis of a rcvd Offer & returns the Confirm."""
290 cmd = Command.put_bind(W_, self._dev.id, codes, dst_id=tender.src.id, idx=idx)
291 if not _DBG_DISABLE_PHASE_ASSERTS: # TODO: should be in test suite
292 assert Message._from_cmd(cmd).payload["phase"] == BindPhase.ACCEPT
294 pkt: Packet = await self._dev._async_send_cmd( # type: ignore[assignment]
295 cmd, priority=Priority.HIGH, qos=BINDING_QOS
296 )
298 self.state.cast_accept_offer()
299 return pkt
301 async def _wait_for_confirm(
302 self,
303 accept: Packet,
304 timeout: float = _AFFIRM_WAIT_TIME,
305 ) -> Message:
306 """Resp waits timeout seconds for a Confirm to arrive & returns it."""
307 return await self.state.wait_for_confirm(timeout)
309 async def _wait_for_addenda(
310 self,
311 accept: Packet,
312 timeout: float = _RATIFY_WAIT_TIME,
313 ) -> Message:
314 """Resp waits timeout seconds for an Addenda to arrive & returns it."""
315 return await self.state.wait_for_addenda(timeout)
318class BindContextSupplicant(BindContextBase):
319 """The binding Context for a Supplicant."""
321 _attr_role = BindRole.SUPPLICANT
323 async def initiate_binding_process(
324 self,
325 offer_codes: Iterable[Code],
326 /,
327 *,
328 confirm_code: Code | None = None,
329 ratify_cmd: Command | None = None,
330 ) -> tuple[Packet, Packet, Packet, Packet | None]:
331 """Device starts binding as a Supplicant, by sending an Offer.
333 Returns the Respondent's Accept, or raise an exception if the binding is
334 unsuccessful (BindError).
335 """
337 if self.is_binding:
338 raise exc.BindingFsmError(
339 f"{self}: bad State for binding as a Supplicant (is already binding)"
340 )
341 self.set_state(SuppSendOfferWaitForAccept) # self._is_respondent = False
343 oem_code = ratify_cmd.payload[14:16] if ratify_cmd else None
345 # Step S1: Supplicant sends an Offer (makes Offer) and expects an Accept
346 tender = await self._make_offer(offer_codes, oem_code=oem_code)
347 accept = await self._wait_for_accept(tender)
349 # Step S2: Supplicant sends a Confirm (confirms Accept)
350 affirm = await self._confirm_accept(accept, confirm_code=confirm_code)
352 # Step S3: Supplicant sends an Addenda (optional)
353 if oem_code:
354 self.set_state(SuppIsReadyToSendAddenda) # HACK: easiest way
355 ratify = await self._cast_addenda(accept, ratify_cmd) # type: ignore[arg-type]
356 else:
357 ratify = None
359 # self._set_as_bound(tender, accept, affirm, ratify)
360 return tender, accept._pkt, affirm, ratify
362 async def _make_offer(
363 self,
364 codes: Iterable[Code],
365 oem_code: str | None = None,
366 ) -> Packet:
367 """Supp sends an Offer & returns the corresponding Packet."""
368 # if oem_code, send an 10E0
370 # state = self.state
371 cmd = Command.put_bind(
372 I_, self._dev.id, codes, dst_id=self._dev.id, oem_code=oem_code
373 )
374 if not _DBG_DISABLE_PHASE_ASSERTS: # TODO: should be in test suite
375 assert Message._from_cmd(cmd).payload["phase"] == BindPhase.TENDER
377 pkt: Packet = await self._dev._async_send_cmd( # type: ignore[assignment]
378 cmd, priority=Priority.HIGH, qos=BINDING_QOS
379 )
381 # await state._fut
382 self.state.cast_offer()
383 return pkt
385 async def _wait_for_accept(
386 self,
387 tender: Packet,
388 timeout: float = _ACCEPT_WAIT_TIME,
389 ) -> Message:
390 """Supp waits timeout seconds for an Accept to arrive & returns it."""
391 return await self.state.wait_for_accept(timeout)
393 async def _confirm_accept(
394 self, accept: Message, confirm_code: Code | None = None
395 ) -> Packet:
396 """Supp casts a Confirm on the basis of a rcvd Accept & returns the Confirm."""
398 idx = accept._pkt.payload[:2] # HACK assumes all idx same
400 cmd = Command.put_bind(
401 I_, self._dev.id, confirm_code, dst_id=accept.src.id, idx=idx
402 )
403 if not _DBG_DISABLE_PHASE_ASSERTS: # TODO: should be in test suite
404 assert Message._from_cmd(cmd).payload["phase"] == BindPhase.AFFIRM
406 pkt: Packet = await self._dev._async_send_cmd( # type: ignore[assignment]
407 cmd, priority=Priority.HIGH, qos=BINDING_QOS
408 )
410 await self.state.cast_confirm_accept()
411 return pkt
413 async def _cast_addenda(self, accept: Message, cmd: Command) -> Packet:
414 """Supp casts an Addenda (the final 10E0 command)."""
416 pkt: Packet = await self._dev._async_send_cmd( # type: ignore[assignment]
417 cmd, priority=Priority.HIGH, qos=BINDING_QOS
418 )
420 await self.state.cast_addenda()
421 return pkt
424class BindContext(BindContextRespondent, BindContextSupplicant):
425 _attr_role = BindRole.IS_UNKNOWN
428#
431class BindStateBase:
432 _attr_role = BindRole.IS_UNKNOWN
434 _cmds_sent: int = 0 # num of bind cmds sent
435 _pkts_rcvd: int = 0 # num of bind pkts rcvd (incl. any echos of sender's own cmd)
437 _has_wait_timer: bool = False
438 _retry_limit: int = SENDING_RETRY_LIMIT
439 _timer_handle: asyncio.TimerHandle
441 _next_ctx_state: type[BindStateBase] # next state, if successful transition
443 def __init__(self, context: BindContextBase) -> None:
444 self._context = context
445 self._loop = context._loop
447 self._fut = self._loop.create_future()
448 _LOGGER.debug(f"{self}: Changing state from: {self._context.state} to: {self}")
450 if self._has_wait_timer:
451 self._timer_handle = self._loop.call_later(
452 WAITING_TIMEOUT_SECS,
453 self._handle_wait_timer_expired,
454 WAITING_TIMEOUT_SECS,
455 )
457 def __repr__(self) -> str:
458 return f"{self.__class__.__name__} (tx={self._cmds_sent})"
460 def __str__(self) -> str:
461 return self.__class__.__name__
463 @property
464 def context(self) -> BindContextBase:
465 return self._context
467 async def _wait_for_fut_result(self, timeout: float) -> Message:
468 """Wait timeout seconds for an expected event to occur.
470 The expected event is defined by the State's sent_cmd, rcvd_msg methods.
471 """
472 try:
473 await asyncio.wait_for(self._fut, timeout)
474 except TimeoutError:
475 self._handle_wait_timer_expired(timeout)
476 else:
477 self._set_context_state(self._next_ctx_state)
478 result: Message = self._fut.result() # may raise exception
479 return result
481 def _handle_wait_timer_expired(self, timeout: float) -> None:
482 """Process an overrun of the wait timer when waiting for a Message."""
484 msg = (
485 f"{self._context}: Failed to transition to {self._next_ctx_state}: "
486 f"expected message not received after {timeout} secs"
487 )
489 _LOGGER.warning(msg)
490 self._fut.set_exception(exc.BindingFlowFailed(msg))
491 self._set_context_state(DevHasFailedBinding)
493 def _set_context_state(self, next_state: type[BindStateBase]) -> None:
494 if not self._fut.done(): # if not BindRetryError, BindTimeoutError, msg
495 raise exc.BindingFsmError # or: self._fut.set_exception()
496 self._context.set_state(next_state, result=self._fut)
498 def send_cmd(self, cmd: Command) -> None:
499 raise NotImplementedError
501 def rcvd_msg(self, msg: Message) -> None:
502 raise NotImplementedError
504 @staticmethod
505 def is_phase(cmd: Command | Packet, phase: BindPhase) -> bool:
506 if phase == BindPhase.RATIFY:
507 return cmd.verb == I_ and cmd.code == Code._10E0
508 if cmd.code != Code._1FC9:
509 return False
510 if phase == BindPhase.TENDER:
511 return cmd.verb == I_ and cmd.dst in (cmd.src, ALL_DEV_ADDR)
512 if phase == BindPhase.ACCEPT:
513 return cmd.verb == W_ and cmd.dst is not cmd.src
514 # if phase == BindPhase.AFFIRM:
515 return cmd.verb == I_ and cmd.dst not in (cmd.src, ALL_DEV_ADDR)
517 # Respondent State APIs...
518 async def wait_for_offer(self, timeout: float | None = None) -> Message:
519 raise exc.BindingFsmError(
520 f"{self._context!r}: shouldn't wait_for_offer() from this State"
521 )
523 def cast_accept_offer(self) -> None:
524 raise exc.BindingFsmError(
525 f"{self._context!r}: shouldn't accept_offer() from this State"
526 )
528 async def wait_for_confirm(self, timeout: float | None = None) -> Message:
529 raise exc.BindingFsmError(
530 f"{self._context!r}: shouldn't wait_for_confirm() from this State"
531 )
533 async def wait_for_addenda(self, timeout: float | None = None) -> Message:
534 raise exc.BindingFsmError(
535 f"{self._context!r}: shouldn't wait_for_addenda() from this State"
536 )
538 # Supplicant State APIs...
539 def cast_offer(self, timeout: float | None = None) -> None:
540 raise exc.BindingFsmError(
541 f"{self._context!r}: shouldn't make_offer() from this State"
542 )
544 async def wait_for_accept(self, timeout: float | None = None) -> Message:
545 raise exc.BindingFsmError(
546 f"{self._context!r}: shouldn't wait_for_accept() from this State"
547 )
549 async def cast_confirm_accept(self, timeout: float | None = None) -> Message:
550 raise exc.BindingFsmError(
551 f"{self._context!r}: shouldn't confirm_accept() from this State"
552 )
554 async def cast_addenda(self, timeout: float | None = None) -> Message:
555 raise exc.BindingFsmError(
556 f"{self._context!r}: shouldn't cast_addenda() from this State"
557 )
560class _DevIsWaitingForMsg(BindStateBase):
561 """Device waits until it receives the anticipated Packet (Offer or Addenda).
563 Failure occurs when the timer expires (timeout) before receiving the Packet.
564 """
566 _expected_pkt_phase: BindPhase
568 _wait_timer_limit: float = 5.1 # WAITING_TIMEOUT_SECS
570 def __init__(self, context: BindContextBase) -> None:
571 super().__init__(context)
573 self._timer_handle = self._loop.call_later(
574 self._wait_timer_limit,
575 self._handle_wait_timer_expired,
576 self._wait_timer_limit,
577 )
579 def _set_context_state(self, next_state: type[BindStateBase]) -> None:
580 if self._timer_handle:
581 self._timer_handle.cancel()
582 super()._set_context_state(next_state)
584 def rcvd_msg(self, msg: Message) -> None:
585 """If the msg is the waited-for pkt, transition to the next state."""
586 if self.is_phase(msg._pkt, self._expected_pkt_phase):
587 self._fut.set_result(msg)
590class _DevIsReadyToSendCmd(BindStateBase):
591 """Device sends a Command (Confirm, Addenda) that wouldn't result in a reply Packet.
593 Failure occurs when the retry limit is exceeded before receiving a Command echo.
594 """
596 _expected_cmd_phase: BindPhase
598 _send_retry_limit: int = 0 # retries dont include the first send
599 _send_retry_timer: float = 0.8 # retry if no echo received before timeout
601 def __init__(self, context: BindContextBase) -> None:
602 super().__init__(context)
604 self._cmd: Command | None = None
605 self._cmds_sent: int = 0
607 def _retries_exceeded(self) -> None:
608 """Process an overrun of the retry limit when sending a Command."""
610 msg = (
611 f"{self._context}: Failed to transition to {self._next_ctx_state}: "
612 f"{self._expected_cmd_phase} command echo not received after "
613 f"{self._retry_limit} retries"
614 )
616 _LOGGER.warning(msg)
617 self._fut.set_exception(exc.BindingFlowFailed(msg))
618 self._set_context_state(DevHasFailedBinding)
620 def send_cmd(self, cmd: Command) -> None:
621 """If sending a cmd, expect the corresponding echo."""
623 if not self.is_phase(cmd, self._expected_cmd_phase):
624 return
626 if self._cmds_sent > self._send_retry_limit:
627 self._retries_exceeded()
628 self._cmds_sent += 1
629 self._cmd = self._cmd or cmd
631 def rcvd_msg(self, msg: Message) -> None:
632 """If the msg is the echo of the sent cmd, transition to the next state."""
633 if self._cmd and msg._pkt == self._cmd:
634 self._fut.set_result(msg)
637class _DevSendCmdUntilReply(_DevIsWaitingForMsg, _DevIsReadyToSendCmd):
638 """Device sends a Command (Offer, Accept), until it gets the expected reply Packet.
640 Failure occurs when the timer expires (timeout) or the retry limit is exceeded
641 before receiving a reply Packet.
642 """
644 def rcvd_msg(self, msg: Message) -> None:
645 """If the msg is the expected reply, transition to the next state."""
646 # if self._cmd and msg._pkt == self._cmd: # the echo
647 # self._set_context_state(self._next_ctx_state)
648 if self.is_phase(msg._pkt, self._expected_pkt_phase):
649 self._fut.set_result(msg)
652class DevHasFailedBinding(BindStateBase):
653 """Device has failed binding."""
655 _attr_role = BindRole.IS_UNKNOWN
658class DevIsNotBinding(BindStateBase):
659 """Device is not binding."""
661 _attr_role = BindRole.IS_DORMANT
664#
667class RespHasBoundAsRespondent(BindStateBase):
668 """Respondent has received an Offer (+/- an Addenda) & has nothing more to do."""
670 _attr_role = BindRole.IS_DORMANT
672 def __init__(self, context: BindContextBase) -> None:
673 super().__init__(context)
674 _LOGGER.info(f"{context._dev.id}: Binding completed as respondent")
677class RespIsWaitingForAddenda(_DevIsWaitingForMsg, BindStateBase):
678 """Respondent has received a Confirm & is waiting for an Addenda."""
680 _attr_role = BindRole.RESPONDENT
682 _expected_pkt_phase: BindPhase = BindPhase.RATIFY
683 _next_ctx_state: type[BindStateBase] = RespHasBoundAsRespondent
685 async def wait_for_addenda(self, timeout: float | None = None) -> Message:
686 return await self._wait_for_fut_result(timeout or _RATIFY_WAIT_TIME)
689class RespSendAcceptWaitForConfirm(_DevSendCmdUntilReply, BindStateBase):
690 """Respondent is ready to send an Accept & will expect a Confirm."""
692 _attr_role = BindRole.RESPONDENT
694 _expected_cmd_phase: BindPhase = BindPhase.ACCEPT
695 _expected_pkt_phase: BindPhase = BindPhase.AFFIRM
696 _next_ctx_state: type[BindStateBase] = (
697 RespHasBoundAsRespondent # or: RespIsWaitingForAddenda
698 )
700 def cast_accept_offer(self) -> None:
701 """Ignore any received Offer, other than the first."""
702 pass
704 async def wait_for_confirm(self, timeout: float | None = None) -> Message:
705 return await self._wait_for_fut_result(timeout or _AFFIRM_WAIT_TIME)
708class RespIsWaitingForOffer(_DevIsWaitingForMsg, BindStateBase):
709 """Respondent is waiting for an Offer."""
711 _attr_role = BindRole.RESPONDENT
713 _expected_pkt_phase: BindPhase = BindPhase.TENDER
714 _next_ctx_state: type[BindStateBase] = RespSendAcceptWaitForConfirm
716 async def wait_for_offer(self, timeout: float | None = None) -> Message:
717 return await self._wait_for_fut_result(timeout or _TENDER_WAIT_TIME)
720#
723class SuppHasBoundAsSupplicant(BindStateBase):
724 """Supplicant has sent a Confirm (+/- an Addenda) & has nothing more to do."""
726 _attr_role = BindRole.IS_DORMANT
728 def __init__(self, context: BindContextBase) -> None:
729 super().__init__(context)
730 _LOGGER.info(f"{context._dev.id}: Binding completed as supplicant")
733class SuppIsReadyToSendAddenda(
734 _DevIsReadyToSendCmd, BindStateBase
735): # send until echo, max_retry=1
736 """Supplicant has sent a Confirm & is ready to send an Addenda."""
738 _attr_role = BindRole.SUPPLICANT
740 _expected_cmd_phase: BindPhase = BindPhase.RATIFY
741 _next_ctx_state: type[BindStateBase] = SuppHasBoundAsSupplicant
743 async def cast_addenda(self, timeout: float | None = None) -> Message:
744 return await self._wait_for_fut_result(timeout or _ACCEPT_WAIT_TIME)
747class SuppIsReadyToSendConfirm(
748 _DevIsReadyToSendCmd, BindStateBase
749): # send until echo, max_retry=1
750 """Supplicant has received an Accept & is ready to send a Confirm."""
752 _attr_role = BindRole.SUPPLICANT
754 _expected_cmd_phase: BindPhase = BindPhase.AFFIRM
755 _next_ctx_state: type[BindStateBase] = (
756 SuppHasBoundAsSupplicant # or: SuppIsReadyToSendAddenda
757 )
759 async def cast_confirm_accept(self, timeout: float | None = None) -> Message:
760 return await self._wait_for_fut_result(timeout or _ACCEPT_WAIT_TIME)
763class SuppSendOfferWaitForAccept(_DevSendCmdUntilReply, BindStateBase):
764 """Supplicant is ready to send an Offer & will expect an Accept."""
766 _attr_role = BindRole.SUPPLICANT
768 _expected_cmd_phase: BindPhase = BindPhase.TENDER
769 _expected_pkt_phase: BindPhase = BindPhase.ACCEPT
770 _next_ctx_state: type[BindStateBase] = SuppIsReadyToSendConfirm
772 def cast_offer(self, timeout: float | None = None) -> None:
773 pass
775 async def wait_for_accept(self, timeout: float | None = None) -> Message:
776 return await self._wait_for_fut_result(timeout or _ACCEPT_WAIT_TIME)
779#
782class _BindStates: # used for test suite
783 IS_IDLE_DEVICE = DevIsNotBinding # may send Offer
784 NEEDING_TENDER = RespIsWaitingForOffer # receives Offer, sends Accept
785 NEEDING_ACCEPT = SuppSendOfferWaitForAccept # receives Accept, sends
786 NEEDING_AFFIRM = RespSendAcceptWaitForConfirm
787 TO_SEND_AFFIRM = SuppIsReadyToSendConfirm
788 NEEDING_RATIFY = RespIsWaitingForAddenda # Optional: has sent Confirm
789 TO_SEND_RATIFY = SuppIsReadyToSendAddenda # Optional
790 HAS_BOUND_RESP = RespHasBoundAsRespondent
791 HAS_BOUND_SUPP = SuppHasBoundAsSupplicant
792 IS_FAILED_RESP = DevHasFailedBinding
793 IS_FAILED_SUPP = DevHasFailedBinding
796_IS_NOT_BINDING_STATES = (
797 DevHasFailedBinding,
798 DevIsNotBinding,
799 RespHasBoundAsRespondent,
800 SuppHasBoundAsSupplicant,
801)