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
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""RAMSES RF - RAMSES-II compatible packet protocol finite state machine."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import Callable, Coroutine
|
|
9
|
+
from datetime import datetime as dt
|
|
10
|
+
from queue import Empty, Full, PriorityQueue
|
|
11
|
+
from threading import Lock
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Final, TypeAlias
|
|
13
|
+
|
|
14
|
+
from . import exceptions as exc
|
|
15
|
+
from .address import HGI_DEVICE_ID
|
|
16
|
+
from .command import Command
|
|
17
|
+
from .const import (
|
|
18
|
+
DEFAULT_BUFFER_SIZE,
|
|
19
|
+
DEFAULT_ECHO_TIMEOUT,
|
|
20
|
+
DEFAULT_RPLY_TIMEOUT,
|
|
21
|
+
MAX_RETRY_LIMIT,
|
|
22
|
+
MAX_SEND_TIMEOUT,
|
|
23
|
+
Code,
|
|
24
|
+
Priority,
|
|
25
|
+
)
|
|
26
|
+
from .packet import Packet
|
|
27
|
+
from .typing import QosParams
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from .protocol import RamsesProtocolT
|
|
31
|
+
from .transport import RamsesTransportT
|
|
32
|
+
from .typing import ExceptionT
|
|
33
|
+
|
|
34
|
+
#
|
|
35
|
+
# NOTE: All debug flags should be False for deployment to end-users
|
|
36
|
+
_DBG_MAINTAIN_STATE_CHAIN: Final[bool] = False # maintain Context._prev_state
|
|
37
|
+
_DBG_USE_STRICT_TRANSITIONS: Final[bool] = False
|
|
38
|
+
|
|
39
|
+
_LOGGER = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
#######################################################################################
|
|
43
|
+
|
|
44
|
+
_FutureT: TypeAlias = asyncio.Future[Packet]
|
|
45
|
+
_QueueEntryT: TypeAlias = tuple[Priority, dt, Command, QosParams, _FutureT]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ProtocolContext:
|
|
49
|
+
SEND_TIMEOUT_LIMIT = MAX_SEND_TIMEOUT
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
protocol: RamsesProtocolT,
|
|
54
|
+
/,
|
|
55
|
+
*,
|
|
56
|
+
echo_timeout: float = DEFAULT_ECHO_TIMEOUT,
|
|
57
|
+
reply_timeout: float = DEFAULT_RPLY_TIMEOUT,
|
|
58
|
+
max_retry_limit: int = MAX_RETRY_LIMIT,
|
|
59
|
+
max_buffer_size: int = DEFAULT_BUFFER_SIZE,
|
|
60
|
+
) -> None:
|
|
61
|
+
self._protocol = protocol
|
|
62
|
+
self.echo_timeout = echo_timeout
|
|
63
|
+
self.reply_timeout = reply_timeout
|
|
64
|
+
self.max_retry_limit = min(max_retry_limit, MAX_RETRY_LIMIT)
|
|
65
|
+
self.max_buffer_size = min(max_buffer_size, DEFAULT_BUFFER_SIZE)
|
|
66
|
+
|
|
67
|
+
self._loop = protocol._loop
|
|
68
|
+
self._lock = Lock() # FIXME: threading lock, or asyncio lock?
|
|
69
|
+
self._fut: _FutureT | None = None
|
|
70
|
+
self._que: PriorityQueue[_QueueEntryT] = PriorityQueue(
|
|
71
|
+
maxsize=self.max_buffer_size
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
self._expiry_timer: asyncio.Task[None] | None = None
|
|
75
|
+
self._multiplier = 0
|
|
76
|
+
self._state: _ProtocolStateT = None # type: ignore[assignment]
|
|
77
|
+
|
|
78
|
+
# TODO: pass this over as an instance parameter
|
|
79
|
+
self._send_fnc: Callable[[Command], Coroutine[Any, Any, None]] = None # type: ignore[assignment]
|
|
80
|
+
|
|
81
|
+
self._cmd: Command | None = None
|
|
82
|
+
self._qos: QosParams | None = None
|
|
83
|
+
self._cmd_tx_count: int = 0 # was: None
|
|
84
|
+
self._cmd_tx_limit: int = 0
|
|
85
|
+
|
|
86
|
+
self.set_state(Inactive)
|
|
87
|
+
|
|
88
|
+
def __repr__(self) -> str:
|
|
89
|
+
msg = f"<ProtocolContext state={repr(self._state)[21:-1]}"
|
|
90
|
+
if self._cmd is None:
|
|
91
|
+
return msg + ">"
|
|
92
|
+
if self._cmd_tx_count == 0: # was: is None
|
|
93
|
+
return msg + ", tx_count=0/0>"
|
|
94
|
+
return msg + f", tx_count={self._cmd_tx_count}/{self._cmd_tx_limit}>"
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def state(self) -> _ProtocolStateT:
|
|
98
|
+
return self._state
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def is_sending(self) -> bool: # TODO: remove asserts
|
|
102
|
+
if isinstance(self._state, WantEcho | WantRply):
|
|
103
|
+
assert self._cmd is not None, f"{self}: Coding error" # mypy hint
|
|
104
|
+
assert self._qos is not None, f"{self}: Coding error" # mypy hint
|
|
105
|
+
assert self._fut is not None, f"{self}: Coding error" # mypy hint
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
assert self._cmd is None, f"{self}: Coding error" # mypy hint
|
|
109
|
+
assert self._qos is None, f"{self}: Coding error" # mypy hint
|
|
110
|
+
assert self._fut is None or self._fut.done(), (
|
|
111
|
+
f"{self}: Coding error"
|
|
112
|
+
) # mypy hint
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
def set_state(
|
|
116
|
+
self,
|
|
117
|
+
state_class: _ProtocolStateClassT,
|
|
118
|
+
expired: bool = False,
|
|
119
|
+
timed_out: bool = False,
|
|
120
|
+
exception: Exception | None = None,
|
|
121
|
+
result: Packet | None = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
async def expire_state_on_timeout() -> None:
|
|
124
|
+
# a separate coro, so can be spawned off with create_task()
|
|
125
|
+
|
|
126
|
+
assert self._cmd is not None # mypy
|
|
127
|
+
|
|
128
|
+
assert isinstance(self.is_sending, bool), (
|
|
129
|
+
f"{self}: Coding error"
|
|
130
|
+
) # TODO: remove
|
|
131
|
+
assert self._cmd_tx_count > 0, f"{self}: Coding error" # TODO: remove
|
|
132
|
+
|
|
133
|
+
if isinstance(self._state, WantEcho): # otherwise is WantRply
|
|
134
|
+
delay = self.echo_timeout * (2**self._multiplier)
|
|
135
|
+
# elif self._cmd.code == Code._0404:
|
|
136
|
+
# delay = self.reply_timeout * (2**self._multiplier) * 2
|
|
137
|
+
else: # isinstance(self._state, WantRply):
|
|
138
|
+
delay = self.reply_timeout * (2**self._multiplier)
|
|
139
|
+
|
|
140
|
+
# assuming success, multiplier can be decremented...
|
|
141
|
+
self._multiplier, old_val = max(0, self._multiplier - 1), self._multiplier
|
|
142
|
+
|
|
143
|
+
await asyncio.sleep(delay) # ideally, will be interrupted by wait_for()
|
|
144
|
+
|
|
145
|
+
# nope, was not successful, so multiplier should be incremented...
|
|
146
|
+
self._multiplier = min(3, old_val + 1)
|
|
147
|
+
|
|
148
|
+
if isinstance(self._state, WantEcho):
|
|
149
|
+
_LOGGER.warning("TOUT.. = %s: echo_timeout=%s", self, delay)
|
|
150
|
+
else: # isinstance(self._state, WantRply):
|
|
151
|
+
_LOGGER.warning("TOUT.. = %s: rply_timeout=%s", self, delay)
|
|
152
|
+
|
|
153
|
+
assert isinstance(self.is_sending, bool), (
|
|
154
|
+
f"{self}: Coding error"
|
|
155
|
+
) # TODO: remove
|
|
156
|
+
|
|
157
|
+
# Timer has expired, can we retry or are we done?
|
|
158
|
+
assert isinstance(self._cmd_tx_count, int)
|
|
159
|
+
|
|
160
|
+
if self._cmd_tx_count < self._cmd_tx_limit:
|
|
161
|
+
self.set_state(WantEcho, timed_out=True)
|
|
162
|
+
else:
|
|
163
|
+
self.set_state(IsInIdle, expired=True)
|
|
164
|
+
|
|
165
|
+
assert isinstance(self.is_sending, bool), (
|
|
166
|
+
f"{self}: Coding error"
|
|
167
|
+
) # TODO: remove
|
|
168
|
+
|
|
169
|
+
def effect_state(timed_out: bool) -> None:
|
|
170
|
+
"""Take any actions indicated by state, and optionally set expiry timer."""
|
|
171
|
+
# a separate function, so can be spawned off with call_soon()
|
|
172
|
+
|
|
173
|
+
assert isinstance(self.is_sending, bool), (
|
|
174
|
+
f"{self}: Coding error"
|
|
175
|
+
) # TODO: remove
|
|
176
|
+
|
|
177
|
+
if timed_out:
|
|
178
|
+
assert self._cmd is not None, f"{self}: Coding error" # mypy hint
|
|
179
|
+
self._send_cmd(self._cmd, is_retry=True)
|
|
180
|
+
|
|
181
|
+
if isinstance(self._state, IsInIdle):
|
|
182
|
+
self._loop.call_soon_threadsafe(self._check_buffer_for_cmd)
|
|
183
|
+
|
|
184
|
+
elif isinstance(self._state, WantRply) and not self._qos.wait_for_reply: # type: ignore[union-attr]
|
|
185
|
+
self.set_state(IsInIdle, result=self._state._echo_pkt)
|
|
186
|
+
|
|
187
|
+
elif isinstance(self._state, WantEcho | WantRply):
|
|
188
|
+
self._expiry_timer = self._loop.create_task(expire_state_on_timeout())
|
|
189
|
+
|
|
190
|
+
if self._expiry_timer is not None:
|
|
191
|
+
self._expiry_timer.cancel("Changing state")
|
|
192
|
+
self._expiry_timer = None
|
|
193
|
+
|
|
194
|
+
# when _fut.done(), three possibilities:
|
|
195
|
+
# _fut.set_result()
|
|
196
|
+
# _fut.set_exception()
|
|
197
|
+
# _fut.cancel() (incl. via a send_cmd(qos.timeout) -> wait_for(timeout))
|
|
198
|
+
|
|
199
|
+
# Changing the order of the following is fraught with danger
|
|
200
|
+
if self._fut is None: # logging only - IsInIdle, Inactive
|
|
201
|
+
_LOGGER.debug("BEFORE = %s", self)
|
|
202
|
+
assert self._cmd is None, f"{self}: Coding error" # mypy hint
|
|
203
|
+
assert isinstance(self._state, IsInIdle | Inactive | None), (
|
|
204
|
+
f"{self}: Coding error"
|
|
205
|
+
) # mypy hint
|
|
206
|
+
|
|
207
|
+
elif self._fut.cancelled() and not isinstance(self._state, IsInIdle):
|
|
208
|
+
# cancelled by wait_for(timeout), cancel("buffer overflow"), or other?
|
|
209
|
+
# was for previous send_cmd if currently IsInIdle (+/- Inactive?)
|
|
210
|
+
_LOGGER.debug("BEFORE = %s: expired=%s (global)", self, expired)
|
|
211
|
+
assert self._cmd is not None, f"{self}: Coding error" # mypy hint
|
|
212
|
+
assert isinstance(self._state, WantEcho | WantRply), (
|
|
213
|
+
f"{self}: Coding error"
|
|
214
|
+
) # mypy hint
|
|
215
|
+
|
|
216
|
+
elif exception:
|
|
217
|
+
_LOGGER.debug("BEFORE = %s: exception=%s", self, exception)
|
|
218
|
+
assert not self._fut.done(), (
|
|
219
|
+
f"{self}: Coding error ({self._fut})"
|
|
220
|
+
) # mypy hint
|
|
221
|
+
assert isinstance(self._state, WantEcho | WantRply), (
|
|
222
|
+
f"{self}: Coding error"
|
|
223
|
+
) # mypy hint
|
|
224
|
+
self._fut.set_exception(exception) # apologise to the sender
|
|
225
|
+
|
|
226
|
+
elif result:
|
|
227
|
+
_LOGGER.debug("BEFORE = %s: result=%s", self, result._hdr)
|
|
228
|
+
assert not self._fut.done(), (
|
|
229
|
+
f"{self}: Coding error ({self._fut})"
|
|
230
|
+
) # mypy hint
|
|
231
|
+
assert isinstance(self._state, WantEcho | WantRply), (
|
|
232
|
+
f"{self}: Coding error"
|
|
233
|
+
) # mypy hint
|
|
234
|
+
self._fut.set_result(result)
|
|
235
|
+
|
|
236
|
+
elif expired: # by expire_state_on_timeout(echo_timeout/reply_timeout)
|
|
237
|
+
_LOGGER.debug("BEFORE = %s: expired=%s", self, expired)
|
|
238
|
+
assert not self._fut.done(), (
|
|
239
|
+
f"{self}: Coding error ({self._fut})"
|
|
240
|
+
) # mypy hint
|
|
241
|
+
assert isinstance(self._state, WantEcho | WantRply), (
|
|
242
|
+
f"{self}: Coding error"
|
|
243
|
+
) # mypy hint
|
|
244
|
+
self._fut.set_exception(
|
|
245
|
+
exc.ProtocolSendFailed(f"{self}: Exceeded maximum retries")
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
else: # logging only - WantEcho, WantRply
|
|
249
|
+
_LOGGER.debug("BEFORE = %s", self)
|
|
250
|
+
assert self._fut is None or self._fut.cancelled() or not self._fut.done(), (
|
|
251
|
+
f"{self}: Coding error ({self._fut})"
|
|
252
|
+
) # mypy hint
|
|
253
|
+
# sert isinstance(self._state, WantEcho | WantRply), f"{self}: Coding error" # mypy hint
|
|
254
|
+
|
|
255
|
+
prev_state = self._state # for _DBG_MAINTAIN_STATE_CHAIN
|
|
256
|
+
|
|
257
|
+
self._state = state_class(self) # keep atomic with tx_count / tx_limit calcs
|
|
258
|
+
|
|
259
|
+
if _DBG_MAINTAIN_STATE_CHAIN: # for debugging
|
|
260
|
+
# tattr(prev_state, "_next_state", self._state)
|
|
261
|
+
setattr(self._state, "_prev_state", prev_state) # noqa: B010
|
|
262
|
+
|
|
263
|
+
if timed_out: # isinstance(self._state, WantEcho):
|
|
264
|
+
assert isinstance(self._cmd_tx_count, int), (
|
|
265
|
+
f"{self}: Coding error"
|
|
266
|
+
) # mypy hint
|
|
267
|
+
self._cmd_tx_count += 1
|
|
268
|
+
|
|
269
|
+
elif isinstance(self._state, WantEcho):
|
|
270
|
+
assert self._qos is not None, f"{self}: Coding error" # mypy hint
|
|
271
|
+
# self._cmd_tx_limit = min(self._qos.max_retries, self.max_retry_limit) + 1
|
|
272
|
+
self._cmd_tx_count = 1
|
|
273
|
+
|
|
274
|
+
elif not isinstance(self._state, WantRply): # IsInIdle, IsInactive
|
|
275
|
+
self._cmd = self._qos = None
|
|
276
|
+
self._cmd_tx_count = 0 # was: = None
|
|
277
|
+
|
|
278
|
+
assert isinstance(self.is_sending, bool) # TODO: remove
|
|
279
|
+
|
|
280
|
+
# remaining code spawned off with a call_soon(), so early return to caller
|
|
281
|
+
self._loop.call_soon_threadsafe(effect_state, timed_out) # calls expire_state
|
|
282
|
+
|
|
283
|
+
if not isinstance(self._state, WantRply):
|
|
284
|
+
_LOGGER.debug("AFTER. = %s", self)
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
assert self._qos is not None, f"{self}: Coding error" # mypy hint
|
|
288
|
+
_LOGGER.debug("AFTER. = %s: wait_for_reply=%s", self, self._qos.wait_for_reply)
|
|
289
|
+
|
|
290
|
+
def connection_made(self, transport: RamsesTransportT) -> None:
|
|
291
|
+
# may want to set some instance variables, according to type of transport
|
|
292
|
+
self._state.connection_made()
|
|
293
|
+
|
|
294
|
+
# TODO: Should we clear the buffer if connection is lost (and apoligise to senders?
|
|
295
|
+
def connection_lost(self, err: ExceptionT | None) -> None:
|
|
296
|
+
self._state.connection_lost()
|
|
297
|
+
|
|
298
|
+
def pkt_received(self, pkt: Packet) -> Any:
|
|
299
|
+
self._state.pkt_rcvd(pkt)
|
|
300
|
+
|
|
301
|
+
def pause_writing(self) -> None:
|
|
302
|
+
self._state.writing_paused()
|
|
303
|
+
|
|
304
|
+
def resume_writing(self) -> None:
|
|
305
|
+
self._state.writing_resumed()
|
|
306
|
+
|
|
307
|
+
async def send_cmd(
|
|
308
|
+
self,
|
|
309
|
+
send_fnc: Callable[[Command], Coroutine[Any, Any, None]], # TODO: remove
|
|
310
|
+
cmd: Command,
|
|
311
|
+
priority: Priority,
|
|
312
|
+
qos: QosParams,
|
|
313
|
+
) -> Packet:
|
|
314
|
+
self._send_fnc = send_fnc # TODO: REMOVE: make per Context, not per Command
|
|
315
|
+
|
|
316
|
+
if isinstance(self._state, Inactive):
|
|
317
|
+
raise exc.ProtocolSendFailed(f"{self}: Send failed (no active transport?)")
|
|
318
|
+
|
|
319
|
+
assert self._loop is asyncio.get_running_loop() # BUG is here
|
|
320
|
+
|
|
321
|
+
fut: _FutureT = self._loop.create_future()
|
|
322
|
+
try:
|
|
323
|
+
self._que.put_nowait((priority, dt.now(), cmd, qos, fut))
|
|
324
|
+
except Full as err:
|
|
325
|
+
fut.cancel("Send buffer overflow")
|
|
326
|
+
raise exc.ProtocolSendFailed(f"{self}: Send buffer overflow") from err
|
|
327
|
+
|
|
328
|
+
if isinstance(self._state, IsInIdle):
|
|
329
|
+
self._loop.call_soon_threadsafe(self._check_buffer_for_cmd)
|
|
330
|
+
|
|
331
|
+
timeout = min( # needs to be greater than worse-case via set_state engine
|
|
332
|
+
qos.timeout, self.SEND_TIMEOUT_LIMIT
|
|
333
|
+
) # incl. time queued in buffer
|
|
334
|
+
try:
|
|
335
|
+
await asyncio.wait_for(fut, timeout=timeout)
|
|
336
|
+
except TimeoutError as err: # incl. fut.cancel()
|
|
337
|
+
msg = f"{self}: Expired global timer after {timeout} sec"
|
|
338
|
+
_LOGGER.warning(
|
|
339
|
+
"TOUT.. = %s: send_timeout=%s (%s)", self, timeout, self._cmd is cmd
|
|
340
|
+
)
|
|
341
|
+
if self._cmd is cmd: # NOTE: # this cmd may not yet be self._cmd
|
|
342
|
+
self.set_state(
|
|
343
|
+
IsInIdle, expired=True
|
|
344
|
+
) # set_exception() will cause InvalidStateError
|
|
345
|
+
raise exc.ProtocolSendFailed(msg) from err # make msg *before* state reset
|
|
346
|
+
|
|
347
|
+
try:
|
|
348
|
+
return fut.result()
|
|
349
|
+
except exc.ProtocolSendFailed:
|
|
350
|
+
raise
|
|
351
|
+
except (exc.ProtocolError, exc.TransportError) as err: # incl. ProtocolFsmError
|
|
352
|
+
raise exc.ProtocolSendFailed(f"{self}: Send failed: {err}") from err
|
|
353
|
+
|
|
354
|
+
def _check_buffer_for_cmd(self) -> None:
|
|
355
|
+
self._lock.acquire()
|
|
356
|
+
assert isinstance(self.is_sending, bool), f"{self}: Coding error" # mypy hint
|
|
357
|
+
|
|
358
|
+
if self._fut is not None and not self._fut.done():
|
|
359
|
+
self._lock.release()
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
while True:
|
|
363
|
+
try:
|
|
364
|
+
*_, self._cmd, self._qos, self._fut = self._que.get_nowait()
|
|
365
|
+
except Empty:
|
|
366
|
+
self._cmd = self._qos = self._fut = None
|
|
367
|
+
self._lock.release()
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
self._cmd_tx_count = 0
|
|
371
|
+
self._cmd_tx_limit = min(self._qos.max_retries, self.max_retry_limit) + 1
|
|
372
|
+
|
|
373
|
+
assert isinstance(self._fut, asyncio.Future) # mypy hint
|
|
374
|
+
if self._fut.done(): # e.g. TimeoutError
|
|
375
|
+
self._que.task_done()
|
|
376
|
+
continue
|
|
377
|
+
|
|
378
|
+
break
|
|
379
|
+
|
|
380
|
+
self._lock.release()
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
assert self._cmd is not None, f"{self}: Coding error" # mypy hint
|
|
384
|
+
self._send_cmd(self._cmd)
|
|
385
|
+
finally:
|
|
386
|
+
self._que.task_done()
|
|
387
|
+
|
|
388
|
+
def _send_cmd(self, cmd: Command, is_retry: bool = False) -> None:
|
|
389
|
+
"""Wrapper to send a command with retries, until success or exception."""
|
|
390
|
+
|
|
391
|
+
async def send_fnc_wrapper(cmd: Command) -> None:
|
|
392
|
+
try: # the wrapped function (actual Tx.write)
|
|
393
|
+
await self._send_fnc(cmd)
|
|
394
|
+
except exc.TransportError as err:
|
|
395
|
+
self.set_state(IsInIdle, exception=err)
|
|
396
|
+
|
|
397
|
+
# TODO: check what happens when exception here - why does it hang?
|
|
398
|
+
assert cmd is not None, f"{self}: Coding error"
|
|
399
|
+
|
|
400
|
+
try: # the wrapped function (actual Tx.write)
|
|
401
|
+
self._state.cmd_sent(cmd, is_retry=is_retry)
|
|
402
|
+
except exc.ProtocolFsmError as err:
|
|
403
|
+
self.set_state(IsInIdle, exception=err)
|
|
404
|
+
else:
|
|
405
|
+
self._loop.create_task(send_fnc_wrapper(cmd))
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# With wait_for_reply=False
|
|
409
|
+
# AFTER. = <ProtocolContext state=IsInIdle>
|
|
410
|
+
# BEFORE = <ProtocolContext state=IsInIdle cmd_=2349|RQ|01:145038|08, tx_count=0/4>
|
|
411
|
+
# AFTER. = <ProtocolContext state=WantEcho cmd_=2349|RQ|01:145038|08, tx_count=1/4>
|
|
412
|
+
# BEFORE = <ProtocolContext state=WantEcho echo=2349|RQ|01:145038|08, tx_count=1/4>
|
|
413
|
+
# AFTER. = <ProtocolContext state=WantRply echo=2349|RQ|01:145038|08, tx_count=1/4>: wait_for_reply=False
|
|
414
|
+
#
|
|
415
|
+
# BEFORE = <ProtocolContext state=WantRply echo=2349|RQ|01:145038|08, tx_count=1/4>: result=2349|RQ|01:145038|08
|
|
416
|
+
# AFTER. = <ProtocolContext state=IsInIdle>
|
|
417
|
+
|
|
418
|
+
# With wait_for_reply=True
|
|
419
|
+
# AFTER. = <ProtocolContext state=IsInIdle>
|
|
420
|
+
# BEFORE = <ProtocolContext state=IsInIdle cmd_=0004|RQ|01:145038|05, tx_count=0/4>
|
|
421
|
+
# AFTER. = <ProtocolContext state=WantEcho cmd_=0004|RQ|01:145038|05, tx_count=1/4>
|
|
422
|
+
# BEFORE = <ProtocolContext state=WantEcho echo=0004|RQ|01:145038|05, tx_count=1/4>
|
|
423
|
+
# AFTER. = <ProtocolContext state=WantRply echo=0004|RQ|01:145038|05, tx_count=1/4>: wait_for_reply=True
|
|
424
|
+
#
|
|
425
|
+
# BEFORE = <ProtocolContext state=WantRply rply=0004|RP|01:145038|05, tx_count=1/4>: result=0004|RP|01:145038|05
|
|
426
|
+
# AFTER. = <ProtocolContext state=IsInIdle>
|
|
427
|
+
|
|
428
|
+
#######################################################################################
|
|
429
|
+
|
|
430
|
+
# NOTE: Because .dst / .src may switch from Address to Device from one pkt to the next:
|
|
431
|
+
# - use: pkt.dst.id == self._echo_pkt.src.id
|
|
432
|
+
# - not: pkt.dst is self._echo_pkt.src
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
class ProtocolStateBase:
|
|
436
|
+
def __init__(self, context: ProtocolContext) -> None:
|
|
437
|
+
self._context = context
|
|
438
|
+
|
|
439
|
+
self._sent_cmd: Command | None = None
|
|
440
|
+
self._echo_pkt: Packet | None = None
|
|
441
|
+
self._rply_pkt: Packet | None = None
|
|
442
|
+
|
|
443
|
+
def __repr__(self) -> str:
|
|
444
|
+
msg = f"<ProtocolState state={self.__class__.__name__}"
|
|
445
|
+
if self._rply_pkt:
|
|
446
|
+
return msg + f" rply={self._rply_pkt._hdr}>"
|
|
447
|
+
if self._echo_pkt:
|
|
448
|
+
return msg + f" echo={self._echo_pkt._hdr}>"
|
|
449
|
+
if self._sent_cmd:
|
|
450
|
+
return msg + f" cmd_={self._sent_cmd._hdr}>"
|
|
451
|
+
return msg + ">"
|
|
452
|
+
|
|
453
|
+
def connection_made(self) -> None: # For all states except Inactive
|
|
454
|
+
"""Do nothing, as (except for InActive) we're already connected."""
|
|
455
|
+
pass
|
|
456
|
+
|
|
457
|
+
def connection_lost(self) -> None: # Varies by states (not needed if Inactive)
|
|
458
|
+
"""Transition to Inactive, regardless of current state."""
|
|
459
|
+
|
|
460
|
+
if isinstance(self._context._state, Inactive):
|
|
461
|
+
return
|
|
462
|
+
|
|
463
|
+
if isinstance(self._context._state, IsInIdle):
|
|
464
|
+
self._context.set_state(Inactive)
|
|
465
|
+
return
|
|
466
|
+
|
|
467
|
+
self._context.set_state(
|
|
468
|
+
Inactive, exception=exc.TransportError("Connection lost")
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
def pkt_rcvd(self, pkt: Packet) -> None: # Different for each state
|
|
472
|
+
"""Raise a NotImplementedError."""
|
|
473
|
+
raise NotImplementedError("Invalid state to receive a packet")
|
|
474
|
+
|
|
475
|
+
def writing_paused(self) -> None: # Currently same for all states (TBD)
|
|
476
|
+
"""Do nothing."""
|
|
477
|
+
pass
|
|
478
|
+
|
|
479
|
+
def writing_resumed(self) -> None: # Currently same for all states (TBD)
|
|
480
|
+
"""Do nothing."""
|
|
481
|
+
pass
|
|
482
|
+
|
|
483
|
+
def cmd_sent( # For all except IsInIdle, WantEcho
|
|
484
|
+
self, cmd: Command, is_retry: bool | None = None
|
|
485
|
+
) -> None:
|
|
486
|
+
raise exc.ProtocolFsmError(f"Invalid state to send a command: {self._context}")
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
class Inactive(ProtocolStateBase):
|
|
490
|
+
"""The Protocol is not connected to the transport layer."""
|
|
491
|
+
|
|
492
|
+
def connection_made(self) -> None:
|
|
493
|
+
"""Transition to IsInIdle."""
|
|
494
|
+
self._context.set_state(IsInIdle)
|
|
495
|
+
|
|
496
|
+
def pkt_rcvd(self, pkt: Packet) -> None: # raise ProtocolFsmError
|
|
497
|
+
"""Raise an exception, as a packet is not expected in this state."""
|
|
498
|
+
|
|
499
|
+
assert self._sent_cmd is None, f"{self}: Coding error"
|
|
500
|
+
|
|
501
|
+
if pkt.code != Code._PUZZ:
|
|
502
|
+
_LOGGER.warning("%s: Invalid state to receive a packet", self._context)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
class IsInIdle(ProtocolStateBase):
|
|
506
|
+
"""The Protocol is not in the process of sending a Command."""
|
|
507
|
+
|
|
508
|
+
def pkt_rcvd(self, pkt: Packet) -> None: # Do nothing
|
|
509
|
+
"""Do nothing as we're not expecting an echo, nor a reply."""
|
|
510
|
+
|
|
511
|
+
assert self._sent_cmd is None, f"{self}: Coding error"
|
|
512
|
+
|
|
513
|
+
pass
|
|
514
|
+
|
|
515
|
+
def cmd_sent( # Will expect an Echo
|
|
516
|
+
self, cmd: Command, is_retry: bool | None = None
|
|
517
|
+
) -> None:
|
|
518
|
+
"""Transition to WantEcho."""
|
|
519
|
+
|
|
520
|
+
assert self._sent_cmd is None and is_retry is False, f"{self}: Coding error"
|
|
521
|
+
|
|
522
|
+
self._sent_cmd = cmd
|
|
523
|
+
|
|
524
|
+
# HACK for headers with sentinel values:
|
|
525
|
+
# I --- 18:000730 18:222222 --:------ 30C9 003 000333 # 30C9| I|18:000730, *but* will be: 30C9| I|18:222222
|
|
526
|
+
# I --- --:------ --:------ 18:000730 0008 002 00BB # 0008| I|18:000730|00, *and* will be unchanged
|
|
527
|
+
|
|
528
|
+
if HGI_DEVICE_ID in cmd.tx_header: # HACK: what do I do about this
|
|
529
|
+
cmd._hdr_ = cmd._hdr_.replace(HGI_DEVICE_ID, self._context._protocol.hgi_id)
|
|
530
|
+
self._context.set_state(WantEcho)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
class WantEcho(ProtocolStateBase):
|
|
534
|
+
"""The Protocol is waiting to receive an echo Packet."""
|
|
535
|
+
|
|
536
|
+
# NOTE: unfortunately, the cmd's src / echo's src can be different:
|
|
537
|
+
# RQ --- 18:000730 10:052644 --:------ 3220 005 0000050000 # RQ|10:048122|3220|05
|
|
538
|
+
# RQ --- 18:198151 10:052644 --:------ 3220 005 0000050000 # RQ|10:048122|3220|05
|
|
539
|
+
|
|
540
|
+
def __init__(self, context: ProtocolContext) -> None:
|
|
541
|
+
super().__init__(context)
|
|
542
|
+
|
|
543
|
+
self._sent_cmd = context._state._sent_cmd
|
|
544
|
+
# if isinstance(context._state, WantEcho | WantRply):
|
|
545
|
+
# self._echo_pkt = context._state._echo_pkt
|
|
546
|
+
# else:
|
|
547
|
+
# self._echo_pkt = None
|
|
548
|
+
|
|
549
|
+
def pkt_rcvd(self, pkt: Packet) -> None: # Check if pkt is expected Echo
|
|
550
|
+
"""If the pkt is the expected Echo, transition to IsInIdle, or WantRply."""
|
|
551
|
+
|
|
552
|
+
# RQ --- 18:002563 01:078710 --:------ 2349 002 0200 # 2349|RQ|01:078710|02
|
|
553
|
+
# RP --- 01:078710 18:002563 --:------ 2349 007 0201F400FFFFFF # 2349|RP|01:078710|02
|
|
554
|
+
# W --- 30:257306 01:096339 --:------ 313F 009 0060002916050B07E7 # 313F| W|01:096339
|
|
555
|
+
# I --- 01:096339 30:257306 --:------ 313F 009 00FC0029D6050B07E7 # 313F| I|01:096339
|
|
556
|
+
|
|
557
|
+
assert self._sent_cmd, f"{self}: Coding error" # mypy hint
|
|
558
|
+
|
|
559
|
+
# if self._sent_cmd.rx_header and pkt._hdr == self._sent_cmd.rx_header:
|
|
560
|
+
# _LOGGER.error("hdr=%s", self._sent_cmd.rx_header)
|
|
561
|
+
# _LOGGER.error("src=%s", self._sent_cmd.src.id)
|
|
562
|
+
# _LOGGER.error("dst=%s", pkt.dst.id)
|
|
563
|
+
|
|
564
|
+
if (
|
|
565
|
+
self._sent_cmd.rx_header
|
|
566
|
+
and pkt._hdr == self._sent_cmd.rx_header
|
|
567
|
+
and (
|
|
568
|
+
pkt.dst.id == self._sent_cmd.src.id
|
|
569
|
+
or ( # handle: 18:146440 == 18:000730
|
|
570
|
+
self._sent_cmd.src.id == HGI_DEVICE_ID
|
|
571
|
+
and pkt.dst.id == self._context._protocol.hgi_id
|
|
572
|
+
)
|
|
573
|
+
)
|
|
574
|
+
):
|
|
575
|
+
_LOGGER.warning(
|
|
576
|
+
"%s: Invalid state to receive a reply (expecting echo)", self._context
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
self._rply_pkt = pkt
|
|
580
|
+
self._context.set_state(IsInIdle, result=pkt)
|
|
581
|
+
return
|
|
582
|
+
|
|
583
|
+
# HACK for packets with addr sets like (issue is only with sentinel values?):
|
|
584
|
+
# I --- --:------ --:------ 18:000730 0008 002 00BB
|
|
585
|
+
|
|
586
|
+
if HGI_DEVICE_ID in pkt._hdr: # HACK: what do I do about this?
|
|
587
|
+
pkt__hdr = pkt._hdr_.replace(HGI_DEVICE_ID, self._context._protocol.hgi_id)
|
|
588
|
+
else:
|
|
589
|
+
pkt__hdr = pkt._hdr
|
|
590
|
+
|
|
591
|
+
if pkt__hdr != self._sent_cmd.tx_header:
|
|
592
|
+
return
|
|
593
|
+
|
|
594
|
+
# # HACK: for testing - drop some packets
|
|
595
|
+
# import random
|
|
596
|
+
# if random.random() < 0.2:
|
|
597
|
+
# return
|
|
598
|
+
|
|
599
|
+
self._echo_pkt = pkt
|
|
600
|
+
if self._sent_cmd.rx_header:
|
|
601
|
+
self._context.set_state(WantRply)
|
|
602
|
+
else:
|
|
603
|
+
self._context.set_state(IsInIdle, result=pkt)
|
|
604
|
+
|
|
605
|
+
def cmd_sent(self, cmd: Command, is_retry: bool | None = None) -> None:
|
|
606
|
+
"""Transition to WantEcho (i.e. a retransmit)."""
|
|
607
|
+
|
|
608
|
+
assert self._sent_cmd is not None and is_retry is True, f"{self}: Coding error"
|
|
609
|
+
|
|
610
|
+
# NOTE: don't self._context.set_state(WantEcho) here - may cause endless loop
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
class WantRply(ProtocolStateBase):
|
|
614
|
+
"""The Protocol is waiting to receive an reply Packet."""
|
|
615
|
+
|
|
616
|
+
# NOTE: is possible get a false rply (same rx_header), e.g.:
|
|
617
|
+
# RP --- 10:048122 18:198151 --:------ 3220 005 00C0050000 # 3220|RP|10:048122|05
|
|
618
|
+
# RP --- 10:048122 01:145038 --:------ 3220 005 00C0050000 # 3220|RP|10:048122|05
|
|
619
|
+
|
|
620
|
+
# NOTE: unfortunately, the cmd's src / rply's dst can still be different:
|
|
621
|
+
# RQ --- 18:000730 10:052644 --:------ 3220 005 0000050000 # 3220|RQ|10:048122|05
|
|
622
|
+
# RP --- 10:048122 18:198151 --:------ 3220 005 00C0050000 # 3220|RP|10:048122|05
|
|
623
|
+
|
|
624
|
+
def __init__(self, context: ProtocolContext) -> None:
|
|
625
|
+
super().__init__(context)
|
|
626
|
+
|
|
627
|
+
self._sent_cmd = context._state._sent_cmd
|
|
628
|
+
self._echo_pkt = context._state._echo_pkt
|
|
629
|
+
|
|
630
|
+
def pkt_rcvd(self, pkt: Packet) -> None: # Check if pkt is expected Reply
|
|
631
|
+
"""If the pkt is the expected reply, transition to IsInIdle."""
|
|
632
|
+
|
|
633
|
+
assert self._sent_cmd, f"{self}: Coding error" # mypy hint
|
|
634
|
+
assert self._echo_pkt, f"{self}: Coding error" # mypy hint
|
|
635
|
+
|
|
636
|
+
# NOTE: beware collisions: same header, but is not reply (must check RSSI or src)
|
|
637
|
+
# 2024-04-16 08:28:33.895 000 RQ --- 18:146440 10:048122 --:------ 3220 005 0000110000 # 3220|RQ|10:048122|11
|
|
638
|
+
# 2024-04-16 08:28:33.910 052 RQ --- 01:145038 10:048122 --:------ 3220 005 0000110000 # 3220|RQ|10:048122|11
|
|
639
|
+
|
|
640
|
+
if pkt._hdr == self._sent_cmd.tx_header and pkt.src == self._echo_pkt.src:
|
|
641
|
+
_LOGGER.warning(
|
|
642
|
+
"%s: Invalid state to receive an echo (expecting reply)", self._context
|
|
643
|
+
)
|
|
644
|
+
return # do not transition, wait until existing timer expires
|
|
645
|
+
|
|
646
|
+
# HACK: special case: if null log entry for log_idx=nn, then
|
|
647
|
+
# HACK: rx_hdr will be 0418|RP|01:145038|00, and not 0418|RP|01:145038|nn
|
|
648
|
+
# HACK: wait_for_reply must be true for RQ|0418 commands
|
|
649
|
+
if (
|
|
650
|
+
self._sent_cmd.rx_header[:8] == "0418|RP|" # type: ignore[index]
|
|
651
|
+
and self._sent_cmd.rx_header[:-2] == pkt._hdr[:-2] # type: ignore[index]
|
|
652
|
+
and pkt.payload == "000000B0000000000000000000007FFFFF7000000000"
|
|
653
|
+
):
|
|
654
|
+
self._rply_pkt = pkt
|
|
655
|
+
|
|
656
|
+
elif pkt._hdr != self._sent_cmd.rx_header:
|
|
657
|
+
return
|
|
658
|
+
|
|
659
|
+
else:
|
|
660
|
+
self._rply_pkt = pkt
|
|
661
|
+
|
|
662
|
+
self._context.set_state(IsInIdle, result=pkt)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
#######################################################################################
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
_ProtocolStateT: TypeAlias = Inactive | IsInIdle | WantEcho | WantRply
|
|
669
|
+
|
|
670
|
+
_ProtocolStateClassT: TypeAlias = (
|
|
671
|
+
type[Inactive] | type[IsInIdle] | type[WantEcho] | type[WantRply]
|
|
672
|
+
)
|
ramses_tx/py.typed
ADDED
|
File without changes
|