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.
- ramses_cli/__init__.py +18 -0
- ramses_cli/client.py +597 -0
- ramses_cli/debug.py +20 -0
- ramses_cli/discovery.py +405 -0
- ramses_cli/utils/cat_slow.py +17 -0
- ramses_cli/utils/convert.py +60 -0
- ramses_rf/__init__.py +31 -10
- ramses_rf/binding_fsm.py +787 -0
- ramses_rf/const.py +124 -105
- ramses_rf/database.py +297 -0
- ramses_rf/device/__init__.py +69 -39
- ramses_rf/device/base.py +187 -376
- ramses_rf/device/heat.py +540 -552
- ramses_rf/device/hvac.py +286 -171
- ramses_rf/dispatcher.py +153 -177
- ramses_rf/entity_base.py +478 -361
- ramses_rf/exceptions.py +82 -0
- ramses_rf/gateway.py +377 -513
- ramses_rf/helpers.py +57 -19
- ramses_rf/py.typed +0 -0
- ramses_rf/schemas.py +148 -194
- ramses_rf/system/__init__.py +16 -23
- ramses_rf/system/faultlog.py +363 -0
- ramses_rf/system/heat.py +295 -302
- ramses_rf/system/schedule.py +312 -198
- ramses_rf/system/zones.py +318 -238
- ramses_rf/version.py +2 -8
- ramses_rf-0.51.1.dist-info/METADATA +72 -0
- ramses_rf-0.51.1.dist-info/RECORD +55 -0
- {ramses_rf-0.22.40.dist-info → ramses_rf-0.51.1.dist-info}/WHEEL +1 -2
- ramses_rf-0.51.1.dist-info/entry_points.txt +2 -0
- {ramses_rf-0.22.40.dist-info → ramses_rf-0.51.1.dist-info/licenses}/LICENSE +1 -1
- ramses_tx/__init__.py +160 -0
- {ramses_rf/protocol → ramses_tx}/address.py +65 -59
- ramses_tx/command.py +1454 -0
- ramses_tx/const.py +903 -0
- ramses_tx/exceptions.py +92 -0
- {ramses_rf/protocol → ramses_tx}/fingerprints.py +56 -15
- {ramses_rf/protocol → ramses_tx}/frame.py +132 -131
- ramses_tx/gateway.py +338 -0
- ramses_tx/helpers.py +883 -0
- {ramses_rf/protocol → ramses_tx}/logger.py +67 -53
- {ramses_rf/protocol → ramses_tx}/message.py +155 -191
- ramses_tx/opentherm.py +1260 -0
- ramses_tx/packet.py +210 -0
- {ramses_rf/protocol → ramses_tx}/parsers.py +1266 -1003
- ramses_tx/protocol.py +801 -0
- ramses_tx/protocol_fsm.py +672 -0
- ramses_tx/py.typed +0 -0
- {ramses_rf/protocol → ramses_tx}/ramses.py +262 -185
- {ramses_rf/protocol → ramses_tx}/schemas.py +150 -133
- ramses_tx/transport.py +1471 -0
- ramses_tx/typed_dicts.py +492 -0
- ramses_tx/typing.py +181 -0
- ramses_tx/version.py +4 -0
- ramses_rf/discovery.py +0 -398
- ramses_rf/protocol/__init__.py +0 -59
- ramses_rf/protocol/backports.py +0 -42
- ramses_rf/protocol/command.py +0 -1576
- ramses_rf/protocol/const.py +0 -697
- ramses_rf/protocol/exceptions.py +0 -111
- ramses_rf/protocol/helpers.py +0 -390
- ramses_rf/protocol/opentherm.py +0 -1170
- ramses_rf/protocol/packet.py +0 -235
- ramses_rf/protocol/protocol.py +0 -613
- ramses_rf/protocol/transport.py +0 -1011
- ramses_rf/protocol/version.py +0 -10
- ramses_rf/system/hvac.py +0 -82
- ramses_rf-0.22.40.dist-info/METADATA +0 -64
- ramses_rf-0.22.40.dist-info/RECORD +0 -42
- ramses_rf-0.22.40.dist-info/top_level.txt +0 -1
ramses_rf/binding_fsm.py
ADDED
|
@@ -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
|
+
)
|