Coverage for src/ramses_rf/binding_fsm.py: 55%

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 # 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

239 class 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

318 class 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

424 class BindContext(BindContextRespondent, BindContextSupplicant):

425 _attr_role = BindRole.IS_UNKNOWN

426

427

428#

429

430

431 class 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

560 class _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

590 class _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

637 class _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

652 class DevHasFailedBinding(BindStateBase):

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

654

655 _attr_role = BindRole.IS_UNKNOWN

656

657

658 class DevIsNotBinding(BindStateBase):

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

660

661 _attr_role = BindRole.IS_DORMANT

662

663

664#

665

666

667 class 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

677 class 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

689 class 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

708 class 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

723 class 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

733 class 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

747 class 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

763 class 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

782 class _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)