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

1#!/usr/bin/env python3 

2"""RAMSES RF - a RAMSES-II protocol decoder & analyser. 

3 

4Base for all devices. 

5""" 

6 

7from __future__ import annotations 

8 

9import asyncio 

10import logging 

11import re 

12from enum import StrEnum 

13from typing import TYPE_CHECKING, Final 

14 

15import voluptuous as vol 

16 

17from ramses_tx import ( 

18 ALL_DEV_ADDR, 

19 ALL_DEVICE_ID, 

20 Command, 

21 DevType, 

22 Message, 

23 Priority, 

24 QosParams, 

25) 

26 

27from . import exceptions as exc 

28 

29from .const import ( # noqa: F401, isort: skip, pylint: disable=unused-import 

30 I_, 

31 RP, 

32 RQ, 

33 W_, 

34 Code, 

35) 

36 

37if 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 

52SZ_RESPONDENT: Final = "respondent" 

53SZ_SUPPLICANT: Final = "supplicant" 

54SZ_IS_DORMANT: Final = "is_dormant" 

55 

56 

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) 

63 

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) 

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 

84BINDING_QOS = QosParams( 

85 max_retries=SENDING_RETRY_LIMIT, 

86 timeout=WAITING_TIMEOUT_SECS * 2, 

87 wait_for_reply=False, 

88) 

89 

90 

91class Vendor(StrEnum): 

92 CLIMARAD = "climarad" 

93 ITHO = "itho" 

94 NUAIRE = "nuaire" 

95 ORCON = "orcon" 

96 VASCO = "vasco" 

97 DEFAULT = "default" 

98 

99 

100SZ_CLASS: Final = "class" 

101SZ_VENDOR: Final = "vendor" 

102SZ_TENDER: Final = "tender" 

103SZ_AFFIRM: Final = "affirm" 

104SZ_RATIFY: Final = "ratify" 

105 

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}$")) 

109 

110VOL_TENDER_CODES = vol.All( 

111 {vol.Required(VOL_CODE_REGEX, default="00"): VOL_OEM_ID_REGEX}, 

112 vol.Length(min=1), 

113) 

114 

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) 

127 

128 

129class BindPhase(StrEnum): 

130 TENDER = "offer" 

131 ACCEPT = "accept" 

132 AFFIRM = "confirm" 

133 RATIFY = "addenda" # Code._10E0 

134 

135 

136class BindRole(StrEnum): 

137 RESPONDENT = "respondent" 

138 SUPPLICANT = "supplicant" 

139 IS_DORMANT = "is_dormant" 

140 IS_UNKNOWN = "is_unknown" 

141 

142 

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} 

151 

152 

153# 

154 

155 

156class 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 # Ensure prev_state is always available, not only during debugging 

182 prev_state = self._state 

183 

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) 

189 

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 

197 

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 ) 

205 

206 if _DBG_MAINTAIN_STATE_CHAIN: # HACK for debugging 

207 setattr(self._state, "_prev_state", prev_state) # noqa: B010 

208 

209 @property 

210 def state(self) -> BindStateBase: 

211 """Return the State (phase) of the Context.""" 

212 return self._state 

213 

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 

221 

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) 

227 

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) 

232 

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) 

237 

238 

239class BindContextRespondent(BindContextBase): 

240 """The binding Context for a Respondent.""" 

241 

242 _attr_role = BindRole.RESPONDENT 

243 

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. 

253 

254 Returns the Supplicant's Offer or raise an exception if the binding is 

255 unsuccessful (BindError). 

256 """ 

257 

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 

263 

264 # Step R1: Respondent expects an Offer 

265 tender = await self._wait_for_offer() 

266 

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) 

270 

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 

277 

278 # self._set_as_bound(tender, accept, affirm, ratify) 

279 return tender._pkt, accept, affirm._pkt, (ratify._pkt if ratify else None) 

280 

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) 

284 

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.""" 

289 

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 

293 

294 pkt: Packet = await self._dev._async_send_cmd( # type: ignore[assignment] 

295 cmd, priority=Priority.HIGH, qos=BINDING_QOS 

296 ) 

297 

298 self.state.cast_accept_offer() 

299 return pkt 

300 

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) 

308 

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) 

316 

317 

318class BindContextSupplicant(BindContextBase): 

319 """The binding Context for a Supplicant.""" 

320 

321 _attr_role = BindRole.SUPPLICANT 

322 

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. 

332 

333 Returns the Respondent's Accept, or raise an exception if the binding is 

334 unsuccessful (BindError). 

335 """ 

336 

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 

342 

343 oem_code = ratify_cmd.payload[14:16] if ratify_cmd else None 

344 

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) 

348 

349 # Step S2: Supplicant sends a Confirm (confirms Accept) 

350 affirm = await self._confirm_accept(accept, confirm_code=confirm_code) 

351 

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 

358 

359 # self._set_as_bound(tender, accept, affirm, ratify) 

360 return tender, accept._pkt, affirm, ratify 

361 

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 

369 

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 

376 

377 pkt: Packet = await self._dev._async_send_cmd( # type: ignore[assignment] 

378 cmd, priority=Priority.HIGH, qos=BINDING_QOS 

379 ) 

380 

381 # await state._fut 

382 self.state.cast_offer() 

383 return pkt 

384 

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) 

392 

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.""" 

397 

398 idx = accept._pkt.payload[:2] # HACK assumes all idx same 

399 

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 

405 

406 pkt: Packet = await self._dev._async_send_cmd( # type: ignore[assignment] 

407 cmd, priority=Priority.HIGH, qos=BINDING_QOS 

408 ) 

409 

410 await self.state.cast_confirm_accept() 

411 return pkt 

412 

413 async def _cast_addenda(self, accept: Message, cmd: Command) -> Packet: 

414 """Supp casts an Addenda (the final 10E0 command).""" 

415 

416 pkt: Packet = await self._dev._async_send_cmd( # type: ignore[assignment] 

417 cmd, priority=Priority.HIGH, qos=BINDING_QOS 

418 ) 

419 

420 await self.state.cast_addenda() 

421 return pkt 

422 

423 

424class BindContext(BindContextRespondent, BindContextSupplicant): 

425 _attr_role = BindRole.IS_UNKNOWN 

426 

427 

428# 

429 

430 

431class BindStateBase: 

432 _attr_role = BindRole.IS_UNKNOWN 

433 

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) 

436 

437 _has_wait_timer: bool = False 

438 _retry_limit: int = SENDING_RETRY_LIMIT 

439 _timer_handle: asyncio.TimerHandle 

440 

441 _next_ctx_state: type[BindStateBase] # next state, if successful transition 

442 

443 def __init__(self, context: BindContextBase) -> None: 

444 self._context = context 

445 self._loop = context._loop 

446 

447 self._fut = self._loop.create_future() 

448 _LOGGER.debug(f"{self}: Changing state from: {self._context.state} to: {self}") 

449 

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 ) 

456 

457 def __repr__(self) -> str: 

458 return f"{self.__class__.__name__} (tx={self._cmds_sent})" 

459 

460 def __str__(self) -> str: 

461 return self.__class__.__name__ 

462 

463 @property 

464 def context(self) -> BindContextBase: 

465 return self._context 

466 

467 async def _wait_for_fut_result(self, timeout: float) -> Message: 

468 """Wait timeout seconds for an expected event to occur. 

469 

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 

480 

481 def _handle_wait_timer_expired(self, timeout: float) -> None: 

482 """Process an overrun of the wait timer when waiting for a Message.""" 

483 

484 msg = ( 

485 f"{self._context}: Failed to transition to {self._next_ctx_state}: " 

486 f"expected message not received after {timeout} secs" 

487 ) 

488 

489 _LOGGER.warning(msg) 

490 self._fut.set_exception(exc.BindingFlowFailed(msg)) 

491 self._set_context_state(DevHasFailedBinding) 

492 

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) 

497 

498 def send_cmd(self, cmd: Command) -> None: 

499 raise NotImplementedError 

500 

501 def rcvd_msg(self, msg: Message) -> None: 

502 raise NotImplementedError 

503 

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) 

516 

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 ) 

522 

523 def cast_accept_offer(self) -> None: 

524 raise exc.BindingFsmError( 

525 f"{self._context!r}: shouldn't accept_offer() from this State" 

526 ) 

527 

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 ) 

532 

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 ) 

537 

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 ) 

543 

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 ) 

548 

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 ) 

553 

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 ) 

558 

559 

560class _DevIsWaitingForMsg(BindStateBase): 

561 """Device waits until it receives the anticipated Packet (Offer or Addenda). 

562 

563 Failure occurs when the timer expires (timeout) before receiving the Packet. 

564 """ 

565 

566 _expected_pkt_phase: BindPhase 

567 

568 _wait_timer_limit: float = 5.1 # WAITING_TIMEOUT_SECS 

569 

570 def __init__(self, context: BindContextBase) -> None: 

571 super().__init__(context) 

572 

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 ) 

578 

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) 

583 

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) 

588 

589 

590class _DevIsReadyToSendCmd(BindStateBase): 

591 """Device sends a Command (Confirm, Addenda) that wouldn't result in a reply Packet. 

592 

593 Failure occurs when the retry limit is exceeded before receiving a Command echo. 

594 """ 

595 

596 _expected_cmd_phase: BindPhase 

597 

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 

600 

601 def __init__(self, context: BindContextBase) -> None: 

602 super().__init__(context) 

603 

604 self._cmd: Command | None = None 

605 self._cmds_sent: int = 0 

606 

607 def _retries_exceeded(self) -> None: 

608 """Process an overrun of the retry limit when sending a Command.""" 

609 

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 ) 

615 

616 _LOGGER.warning(msg) 

617 self._fut.set_exception(exc.BindingFlowFailed(msg)) 

618 self._set_context_state(DevHasFailedBinding) 

619 

620 def send_cmd(self, cmd: Command) -> None: 

621 """If sending a cmd, expect the corresponding echo.""" 

622 

623 if not self.is_phase(cmd, self._expected_cmd_phase): 

624 return 

625 

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 

630 

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) 

635 

636 

637class _DevSendCmdUntilReply(_DevIsWaitingForMsg, _DevIsReadyToSendCmd): 

638 """Device sends a Command (Offer, Accept), until it gets the expected reply Packet. 

639 

640 Failure occurs when the timer expires (timeout) or the retry limit is exceeded 

641 before receiving a reply Packet. 

642 """ 

643 

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) 

650 

651 

652class DevHasFailedBinding(BindStateBase): 

653 """Device has failed binding.""" 

654 

655 _attr_role = BindRole.IS_UNKNOWN 

656 

657 

658class DevIsNotBinding(BindStateBase): 

659 """Device is not binding.""" 

660 

661 _attr_role = BindRole.IS_DORMANT 

662 

663 

664# 

665 

666 

667class RespHasBoundAsRespondent(BindStateBase): 

668 """Respondent has received an Offer (+/- an Addenda) & has nothing more to do.""" 

669 

670 _attr_role = BindRole.IS_DORMANT 

671 

672 def __init__(self, context: BindContextBase) -> None: 

673 super().__init__(context) 

674 _LOGGER.info(f"{context._dev.id}: Binding completed as respondent") 

675 

676 

677class RespIsWaitingForAddenda(_DevIsWaitingForMsg, BindStateBase): 

678 """Respondent has received a Confirm & is waiting for an Addenda.""" 

679 

680 _attr_role = BindRole.RESPONDENT 

681 

682 _expected_pkt_phase: BindPhase = BindPhase.RATIFY 

683 _next_ctx_state: type[BindStateBase] = RespHasBoundAsRespondent 

684 

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) 

687 

688 

689class RespSendAcceptWaitForConfirm(_DevSendCmdUntilReply, BindStateBase): 

690 """Respondent is ready to send an Accept & will expect a Confirm.""" 

691 

692 _attr_role = BindRole.RESPONDENT 

693 

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 ) 

699 

700 def cast_accept_offer(self) -> None: 

701 """Ignore any received Offer, other than the first.""" 

702 pass 

703 

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) 

706 

707 

708class RespIsWaitingForOffer(_DevIsWaitingForMsg, BindStateBase): 

709 """Respondent is waiting for an Offer.""" 

710 

711 _attr_role = BindRole.RESPONDENT 

712 

713 _expected_pkt_phase: BindPhase = BindPhase.TENDER 

714 _next_ctx_state: type[BindStateBase] = RespSendAcceptWaitForConfirm 

715 

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) 

718 

719 

720# 

721 

722 

723class SuppHasBoundAsSupplicant(BindStateBase): 

724 """Supplicant has sent a Confirm (+/- an Addenda) & has nothing more to do.""" 

725 

726 _attr_role = BindRole.IS_DORMANT 

727 

728 def __init__(self, context: BindContextBase) -> None: 

729 super().__init__(context) 

730 _LOGGER.info(f"{context._dev.id}: Binding completed as supplicant") 

731 

732 

733class SuppIsReadyToSendAddenda( 

734 _DevIsReadyToSendCmd, BindStateBase 

735): # send until echo, max_retry=1 

736 """Supplicant has sent a Confirm & is ready to send an Addenda.""" 

737 

738 _attr_role = BindRole.SUPPLICANT 

739 

740 _expected_cmd_phase: BindPhase = BindPhase.RATIFY 

741 _next_ctx_state: type[BindStateBase] = SuppHasBoundAsSupplicant 

742 

743 async def cast_addenda(self, timeout: float | None = None) -> Message: 

744 return await self._wait_for_fut_result(timeout or _ACCEPT_WAIT_TIME) 

745 

746 

747class SuppIsReadyToSendConfirm( 

748 _DevIsReadyToSendCmd, BindStateBase 

749): # send until echo, max_retry=1 

750 """Supplicant has received an Accept & is ready to send a Confirm.""" 

751 

752 _attr_role = BindRole.SUPPLICANT 

753 

754 _expected_cmd_phase: BindPhase = BindPhase.AFFIRM 

755 _next_ctx_state: type[BindStateBase] = ( 

756 SuppHasBoundAsSupplicant # or: SuppIsReadyToSendAddenda 

757 ) 

758 

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) 

761 

762 

763class SuppSendOfferWaitForAccept(_DevSendCmdUntilReply, BindStateBase): 

764 """Supplicant is ready to send an Offer & will expect an Accept.""" 

765 

766 _attr_role = BindRole.SUPPLICANT 

767 

768 _expected_cmd_phase: BindPhase = BindPhase.TENDER 

769 _expected_pkt_phase: BindPhase = BindPhase.ACCEPT 

770 _next_ctx_state: type[BindStateBase] = SuppIsReadyToSendConfirm 

771 

772 def cast_offer(self, timeout: float | None = None) -> None: 

773 pass 

774 

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) 

777 

778 

779# 

780 

781 

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 

794 

795 

796_IS_NOT_BINDING_STATES = ( 

797 DevHasFailedBinding, 

798 DevIsNotBinding, 

799 RespHasBoundAsRespondent, 

800 SuppHasBoundAsSupplicant, 

801)