ramses-rf 0.22.2__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.
Files changed (72) hide show
  1. ramses_cli/__init__.py +18 -0
  2. ramses_cli/client.py +597 -0
  3. ramses_cli/debug.py +20 -0
  4. ramses_cli/discovery.py +405 -0
  5. ramses_cli/utils/cat_slow.py +17 -0
  6. ramses_cli/utils/convert.py +60 -0
  7. ramses_rf/__init__.py +31 -10
  8. ramses_rf/binding_fsm.py +787 -0
  9. ramses_rf/const.py +124 -105
  10. ramses_rf/database.py +297 -0
  11. ramses_rf/device/__init__.py +69 -39
  12. ramses_rf/device/base.py +187 -376
  13. ramses_rf/device/heat.py +540 -552
  14. ramses_rf/device/hvac.py +286 -171
  15. ramses_rf/dispatcher.py +153 -177
  16. ramses_rf/entity_base.py +478 -361
  17. ramses_rf/exceptions.py +82 -0
  18. ramses_rf/gateway.py +378 -514
  19. ramses_rf/helpers.py +57 -19
  20. ramses_rf/py.typed +0 -0
  21. ramses_rf/schemas.py +148 -194
  22. ramses_rf/system/__init__.py +16 -23
  23. ramses_rf/system/faultlog.py +363 -0
  24. ramses_rf/system/heat.py +295 -302
  25. ramses_rf/system/schedule.py +312 -198
  26. ramses_rf/system/zones.py +318 -238
  27. ramses_rf/version.py +2 -8
  28. ramses_rf-0.51.1.dist-info/METADATA +72 -0
  29. ramses_rf-0.51.1.dist-info/RECORD +55 -0
  30. {ramses_rf-0.22.2.dist-info → ramses_rf-0.51.1.dist-info}/WHEEL +1 -2
  31. ramses_rf-0.51.1.dist-info/entry_points.txt +2 -0
  32. {ramses_rf-0.22.2.dist-info → ramses_rf-0.51.1.dist-info/licenses}/LICENSE +1 -1
  33. ramses_tx/__init__.py +160 -0
  34. {ramses_rf/protocol → ramses_tx}/address.py +65 -59
  35. ramses_tx/command.py +1454 -0
  36. ramses_tx/const.py +903 -0
  37. ramses_tx/exceptions.py +92 -0
  38. {ramses_rf/protocol → ramses_tx}/fingerprints.py +56 -15
  39. {ramses_rf/protocol → ramses_tx}/frame.py +132 -131
  40. ramses_tx/gateway.py +338 -0
  41. ramses_tx/helpers.py +883 -0
  42. {ramses_rf/protocol → ramses_tx}/logger.py +67 -53
  43. {ramses_rf/protocol → ramses_tx}/message.py +155 -191
  44. ramses_tx/opentherm.py +1260 -0
  45. ramses_tx/packet.py +210 -0
  46. ramses_tx/parsers.py +2957 -0
  47. ramses_tx/protocol.py +801 -0
  48. ramses_tx/protocol_fsm.py +672 -0
  49. ramses_tx/py.typed +0 -0
  50. {ramses_rf/protocol → ramses_tx}/ramses.py +262 -185
  51. {ramses_rf/protocol → ramses_tx}/schemas.py +150 -133
  52. ramses_tx/transport.py +1471 -0
  53. ramses_tx/typed_dicts.py +492 -0
  54. ramses_tx/typing.py +181 -0
  55. ramses_tx/version.py +4 -0
  56. ramses_rf/discovery.py +0 -398
  57. ramses_rf/protocol/__init__.py +0 -59
  58. ramses_rf/protocol/backports.py +0 -42
  59. ramses_rf/protocol/command.py +0 -1561
  60. ramses_rf/protocol/const.py +0 -697
  61. ramses_rf/protocol/exceptions.py +0 -111
  62. ramses_rf/protocol/helpers.py +0 -390
  63. ramses_rf/protocol/opentherm.py +0 -1170
  64. ramses_rf/protocol/packet.py +0 -235
  65. ramses_rf/protocol/parsers.py +0 -2673
  66. ramses_rf/protocol/protocol.py +0 -613
  67. ramses_rf/protocol/transport.py +0 -1011
  68. ramses_rf/protocol/version.py +0 -10
  69. ramses_rf/system/hvac.py +0 -82
  70. ramses_rf-0.22.2.dist-info/METADATA +0 -64
  71. ramses_rf-0.22.2.dist-info/RECORD +0 -42
  72. ramses_rf-0.22.2.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