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
ramses_tx/protocol.py ADDED
@@ -0,0 +1,801 @@
1
+ #!/usr/bin/env python3
2
+ """RAMSES RF - RAMSES-II compatible packet protocol."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import logging
8
+ from collections.abc import Awaitable, Callable
9
+ from datetime import datetime as dt
10
+ from typing import TYPE_CHECKING, Any, Final, TypeAlias
11
+
12
+ from . import exceptions as exc
13
+ from .address import ALL_DEV_ADDR, HGI_DEV_ADDR, NON_DEV_ADDR
14
+ from .command import Command
15
+ from .const import (
16
+ DEFAULT_DISABLE_QOS,
17
+ DEFAULT_GAP_DURATION,
18
+ DEFAULT_NUM_REPEATS,
19
+ DEV_TYPE_MAP,
20
+ SZ_ACTIVE_HGI,
21
+ SZ_IS_EVOFW3,
22
+ DevType,
23
+ Priority,
24
+ )
25
+ from .logger import set_logger_timesource
26
+ from .message import Message
27
+ from .packet import Packet
28
+ from .protocol_fsm import ProtocolContext
29
+ from .schemas import SZ_BLOCK_LIST, SZ_CLASS, SZ_KNOWN_LIST, SZ_PORT_NAME
30
+ from .transport import transport_factory
31
+ from .typing import ExceptionT, MsgFilterT, MsgHandlerT, QosParams
32
+
33
+ from .const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
34
+ I_,
35
+ RP,
36
+ RQ,
37
+ W_,
38
+ Code,
39
+ )
40
+
41
+ if TYPE_CHECKING:
42
+ from .schemas import DeviceIdT, DeviceListT
43
+ from .transport import RamsesTransportT
44
+
45
+
46
+ TIP = f", configure the {SZ_KNOWN_LIST}/{SZ_BLOCK_LIST} as required"
47
+
48
+ #
49
+ # NOTE: All debug flags should be False for deployment to end-users
50
+ _DBG_DISABLE_IMPERSONATION_ALERTS: Final[bool] = False
51
+ _DBG_DISABLE_QOS: Final[bool] = False
52
+ _DBG_FORCE_LOG_PACKETS: Final[bool] = False
53
+
54
+ _LOGGER = logging.getLogger(__name__)
55
+
56
+
57
+ DEFAULT_QOS = QosParams()
58
+
59
+
60
+ class _BaseProtocol(asyncio.Protocol):
61
+ """Base class for RAMSES II protocols."""
62
+
63
+ WRITER_TASK = "writer_task"
64
+
65
+ def __init__(self, msg_handler: MsgHandlerT) -> None:
66
+ self._msg_handler = msg_handler
67
+ self._msg_handlers: list[MsgHandlerT] = []
68
+
69
+ self._transport: RamsesTransportT = None # type: ignore[assignment]
70
+ self._loop = asyncio.get_running_loop()
71
+
72
+ self._pause_writing = False # FIXME: Start in R/O mode as no connection yet?
73
+ self._wait_connection_lost: asyncio.Future[None] | None = None
74
+ self._wait_connection_made: asyncio.Future[RamsesTransportT] = (
75
+ self._loop.create_future()
76
+ )
77
+
78
+ self._this_msg: Message | None = None
79
+ self._prev_msg: Message | None = None
80
+
81
+ self._is_evofw3: bool | None = None
82
+
83
+ @property
84
+ def hgi_id(self) -> DeviceIdT:
85
+ return HGI_DEV_ADDR.id
86
+
87
+ def add_handler(
88
+ self,
89
+ msg_handler: MsgHandlerT,
90
+ /,
91
+ *,
92
+ msg_filter: MsgFilterT | None = None,
93
+ ) -> Callable[[], None]:
94
+ """Add a Message handler to the list of such callbacks.
95
+
96
+ Returns a callback that can be used to subsequently remove the Message handler.
97
+ """
98
+
99
+ def del_handler() -> None:
100
+ if msg_handler in self._msg_handlers:
101
+ self._msg_handlers.remove(msg_handler)
102
+
103
+ if msg_handler not in self._msg_handlers:
104
+ self._msg_handlers.append(msg_handler)
105
+
106
+ return del_handler
107
+
108
+ def connection_made(self, transport: RamsesTransportT) -> None: # type: ignore[override]
109
+ """Called when the connection to the Transport is established.
110
+
111
+ The argument is the transport representing the pipe connection. To receive data,
112
+ wait for pkt_received() calls. When the connection is closed, connection_lost()
113
+ is called.
114
+ """
115
+
116
+ if self._wait_connection_made.done():
117
+ return
118
+
119
+ self._wait_connection_lost = self._loop.create_future()
120
+ self._wait_connection_made.set_result(transport)
121
+ self._transport = transport
122
+
123
+ async def wait_for_connection_made(self, timeout: float = 1) -> RamsesTransportT:
124
+ """A courtesy function to wait until connection_made() has been invoked.
125
+
126
+ Will raise TransportError if isn't connected within timeout seconds.
127
+ """
128
+
129
+ try:
130
+ return await asyncio.wait_for(self._wait_connection_made, timeout)
131
+ except TimeoutError as err:
132
+ raise exc.TransportError(
133
+ f"Transport did not bind to Protocol within {timeout} secs"
134
+ ) from err
135
+
136
+ def connection_lost(self, err: ExceptionT | None) -> None: # type: ignore[override]
137
+ """Called when the connection to the Transport is lost or closed.
138
+
139
+ The argument is an exception object or None (the latter meaning a regular EOF is
140
+ received or the connection was aborted or closed).
141
+ """
142
+
143
+ assert self._wait_connection_lost # mypy
144
+
145
+ if self._wait_connection_lost.done(): # BUG: why is callback invoked twice?
146
+ return
147
+
148
+ self._wait_connection_made = self._loop.create_future()
149
+ if err:
150
+ self._wait_connection_lost.set_exception(err)
151
+ else:
152
+ self._wait_connection_lost.set_result(None)
153
+
154
+ async def wait_for_connection_lost(self, timeout: float = 1) -> ExceptionT | None:
155
+ """A courtesy function to wait until connection_lost() has been invoked.
156
+
157
+ Includes scenarios where neither connection_made() nor connection_lost() were
158
+ invoked.
159
+
160
+ Will raise TransportError if isn't disconnect within timeout seconds.
161
+ """
162
+
163
+ if not self._wait_connection_lost:
164
+ return None
165
+
166
+ try:
167
+ return await asyncio.wait_for(self._wait_connection_lost, timeout)
168
+ except TimeoutError as err:
169
+ raise exc.TransportError(
170
+ f"Transport did not unbind from Protocol within {timeout} secs"
171
+ ) from err
172
+
173
+ def pause_writing(self) -> None:
174
+ """Called when the transport's buffer goes over the high-water mark.
175
+
176
+ Pause and resume calls are paired -- pause_writing() is called once when the
177
+ buffer goes strictly over the high-water mark (even if subsequent writes
178
+ increases the buffer size even more), and eventually resume_writing() is called
179
+ once when the buffer size reaches the low-water mark.
180
+
181
+ Note that if the buffer size equals the high-water mark, pause_writing() is not
182
+ called -- it must go strictly over. Conversely, resume_writing() is called when
183
+ the buffer size is equal or lower than the low-water mark. These end conditions
184
+ are important to ensure that things go as expected when either mark is zero.
185
+
186
+ NOTE: This is the only Protocol callback that is not called through
187
+ EventLoop.call_soon() -- if it were, it would have no effect when it's most
188
+ needed (when the app keeps writing without yielding until pause_writing() is
189
+ called).
190
+ """
191
+
192
+ self._pause_writing = True
193
+
194
+ def resume_writing(self) -> None:
195
+ """Called when the transport's buffer drains below the low-water mark.
196
+
197
+ See pause_writing() for details.
198
+ """
199
+
200
+ self._pause_writing = False
201
+
202
+ async def send_cmd(
203
+ self,
204
+ cmd: Command,
205
+ /,
206
+ *,
207
+ gap_duration: float = DEFAULT_GAP_DURATION,
208
+ num_repeats: int = DEFAULT_NUM_REPEATS,
209
+ priority: Priority = Priority.DEFAULT,
210
+ qos: QosParams = DEFAULT_QOS,
211
+ ) -> Packet:
212
+ """This is the wrapper for self._send_cmd(cmd)."""
213
+
214
+ # if not self._transport:
215
+ # raise exc.ProtocolSendFailed("There is no connected Transport")
216
+
217
+ if _DBG_FORCE_LOG_PACKETS:
218
+ _LOGGER.warning(f"QUEUED: {cmd}")
219
+ else:
220
+ _LOGGER.debug(f"QUEUED: {cmd}")
221
+
222
+ if self._pause_writing:
223
+ raise exc.ProtocolError("The Protocol is currently read-only/paused")
224
+
225
+ return await self._send_cmd(
226
+ cmd,
227
+ gap_duration=gap_duration,
228
+ num_repeats=num_repeats,
229
+ priority=priority,
230
+ qos=qos,
231
+ )
232
+
233
+ async def _send_cmd(
234
+ self,
235
+ cmd: Command,
236
+ /,
237
+ *,
238
+ gap_duration: float = DEFAULT_GAP_DURATION,
239
+ num_repeats: int = DEFAULT_NUM_REPEATS,
240
+ priority: Priority = Priority.DEFAULT,
241
+ qos: QosParams = DEFAULT_QOS,
242
+ ) -> Packet: # only cmd, no args, kwargs
243
+ # await self._send_frame(
244
+ # str(cmd), num_repeats=num_repeats, gap_duration=gap_duration
245
+ # )
246
+ raise NotImplementedError(f"{self}: Unexpected error")
247
+
248
+ async def _send_frame(
249
+ self, frame: str, num_repeats: int = 0, gap_duration: float = 0.0
250
+ ) -> None: # _send_frame() -> transport
251
+ """Write to the transport."""
252
+ await self._transport.write_frame(frame)
253
+ for _ in range(num_repeats - 1):
254
+ await asyncio.sleep(gap_duration)
255
+ await self._transport.write_frame(frame)
256
+
257
+ def pkt_received(self, pkt: Packet) -> None:
258
+ """A wrapper for self._pkt_received(pkt)."""
259
+ if _DBG_FORCE_LOG_PACKETS:
260
+ _LOGGER.warning(f"Recv'd: {pkt._rssi} {pkt}")
261
+ elif _LOGGER.getEffectiveLevel() > logging.DEBUG:
262
+ _LOGGER.info(f"Recv'd: {pkt._rssi} {pkt}")
263
+ else:
264
+ _LOGGER.debug(f"Recv'd: {pkt._rssi} {pkt}")
265
+
266
+ self._pkt_received(pkt)
267
+
268
+ def _pkt_received(self, pkt: Packet) -> None:
269
+ """Called by the Transport when a Packet is received."""
270
+ try:
271
+ msg = Message(pkt) # should log all invalid msgs appropriately
272
+ except exc.PacketInvalid: # TODO: InvalidMessageError (packet is valid)
273
+ return
274
+
275
+ self._this_msg, self._prev_msg = msg, self._this_msg
276
+ self._msg_received(msg)
277
+
278
+ def _msg_received(self, msg: Message) -> None:
279
+ """Pass any valid/wanted Messages to the client's callback.
280
+
281
+ Also maintain _prev_msg, _this_msg attrs.
282
+ """
283
+
284
+ if self._msg_handler: # type: ignore[truthy-function]
285
+ self._loop.call_soon_threadsafe(self._msg_handler, msg)
286
+ for callback in self._msg_handlers:
287
+ # TODO: if handler's filter returns True:
288
+ self._loop.call_soon_threadsafe(callback, msg)
289
+
290
+
291
+ class _DeviceIdFilterMixin(_BaseProtocol):
292
+ """Filter out any unwanted (but otherwise valid) packets via device ids."""
293
+
294
+ def __init__(
295
+ self,
296
+ msg_handler: MsgHandlerT,
297
+ enforce_include_list: bool = False,
298
+ exclude_list: DeviceListT | None = None,
299
+ include_list: DeviceListT | None = None,
300
+ ) -> None:
301
+ super().__init__(msg_handler)
302
+
303
+ exclude_list = exclude_list or {}
304
+ include_list = include_list or {}
305
+
306
+ self.enforce_include = enforce_include_list
307
+ self._exclude = list(exclude_list.keys())
308
+ self._include = list(include_list.keys())
309
+ self._include += [ALL_DEV_ADDR.id, NON_DEV_ADDR.id]
310
+
311
+ self._active_hgi: DeviceIdT | None = None
312
+ # HACK: to disable_warnings if pkt source is static (e.g. a file/dict)
313
+ # HACK: but a dynamic source (e.g. a port/MQTT) should warn if needed
314
+ self._known_hgi = self._extract_known_hgi_id(
315
+ include_list, disable_warnings=isinstance(self, ReadProtocol)
316
+ )
317
+
318
+ self._foreign_gwys_lst: list[DeviceIdT] = []
319
+ self._foreign_last_run = dt.now().date()
320
+
321
+ @property
322
+ def hgi_id(self) -> DeviceIdT:
323
+ if not self._transport:
324
+ return self._known_hgi or HGI_DEV_ADDR.id
325
+ return self._transport.get_extra_info( # type: ignore[no-any-return]
326
+ SZ_ACTIVE_HGI, self._known_hgi or HGI_DEV_ADDR.id
327
+ )
328
+
329
+ @staticmethod
330
+ def _extract_known_hgi_id(
331
+ include_list: DeviceListT,
332
+ /,
333
+ *,
334
+ disable_warnings: bool = False,
335
+ strick_checking: bool = False,
336
+ ) -> DeviceIdT | None:
337
+ """Return the device_id of the gateway specified in the include_list, if any.
338
+
339
+ The 'Known' gateway is the predicted Active gateway, given the known_list.
340
+ The 'Active' gateway is the USB device that is actually Tx/Rx-ing frames.
341
+
342
+ The Known gateway ID should be the Active gateway ID, but does not have to
343
+ match.
344
+
345
+ Will send a warning if the include_list is configured incorrectly.
346
+ """
347
+
348
+ logger = _LOGGER.warning if not disable_warnings else _LOGGER.debug
349
+
350
+ explicit_hgis = [
351
+ k
352
+ for k, v in include_list.items()
353
+ if v.get(SZ_CLASS) in (DevType.HGI, DEV_TYPE_MAP[DevType.HGI])
354
+ ]
355
+ implicit_hgis = [
356
+ k
357
+ for k, v in include_list.items()
358
+ if not v.get(SZ_CLASS) and k[:2] == DEV_TYPE_MAP._hex(DevType.HGI)
359
+ ]
360
+
361
+ if not explicit_hgis and not implicit_hgis:
362
+ logger(
363
+ f"The {SZ_KNOWN_LIST} SHOULD include exactly one gateway (HGI), "
364
+ f"but does not (it should specify 'class: HGI')"
365
+ )
366
+ return None
367
+
368
+ known_hgi = (explicit_hgis if explicit_hgis else implicit_hgis)[0]
369
+
370
+ if include_list[known_hgi].get(SZ_CLASS) != DevType.HGI:
371
+ logger(
372
+ f"The {SZ_KNOWN_LIST} SHOULD include exactly one gateway (HGI): "
373
+ f"{known_hgi} should specify 'class: HGI', as 18: is also used for HVAC"
374
+ )
375
+
376
+ elif len(explicit_hgis) > 1:
377
+ logger(
378
+ f"The {SZ_KNOWN_LIST} SHOULD include exactly one gateway (HGI): "
379
+ f"{known_hgi} is the chosen device id (why is there >1 HGI?)"
380
+ )
381
+
382
+ else:
383
+ _LOGGER.debug(
384
+ f"The {SZ_KNOWN_LIST} includes exactly one gateway (HGI): {known_hgi}"
385
+ )
386
+
387
+ if strick_checking:
388
+ return known_hgi if [known_hgi] == explicit_hgis else None
389
+ return known_hgi
390
+
391
+ def _set_active_hgi(self, dev_id: DeviceIdT, by_signature: bool = False) -> None:
392
+ """Set the Active Gateway (HGI) device_id.
393
+
394
+ Send a warning if the include list is configured incorrectly.
395
+ """
396
+
397
+ assert self._active_hgi is None # should only be called once
398
+
399
+ msg = f"The active gateway '{dev_id}: {{ class: HGI }}' "
400
+ msg += "(by signature)" if by_signature else "(by filter)"
401
+
402
+ if dev_id not in self._exclude:
403
+ self._active_hgi = dev_id
404
+ # else: setting self._active_hgi will not help
405
+
406
+ if dev_id in self._exclude:
407
+ _LOGGER.error(f"{msg} MUST NOT be in the {SZ_BLOCK_LIST}{TIP}")
408
+
409
+ elif dev_id not in self._include:
410
+ _LOGGER.warning(f"{msg} SHOULD be in the (enforced) {SZ_KNOWN_LIST}")
411
+ # self._include.append(dev_id) # a good idea?
412
+
413
+ elif not self.enforce_include:
414
+ _LOGGER.info(f"{msg} is in the {SZ_KNOWN_LIST}, which SHOULD be enforced")
415
+
416
+ else:
417
+ _LOGGER.debug(f"{msg} is in the {SZ_KNOWN_LIST}")
418
+
419
+ def _is_wanted_addrs(
420
+ self, src_id: DeviceIdT, dst_id: DeviceIdT, sending: bool = False
421
+ ) -> bool:
422
+ """Return True if the packet is not to be filtered out.
423
+
424
+ In any one packet, an excluded device_id 'trumps' an included device_id.
425
+
426
+ There are two ways to set the Active Gateway (HGI80/evofw3):
427
+ - by signature (evofw3 only), when frame -> packet
428
+ - by known_list (HGI80/evofw3), when filtering packets
429
+ """
430
+
431
+ def warn_foreign_hgi(dev_id: DeviceIdT) -> None:
432
+ current_date = dt.now().date()
433
+
434
+ if self._foreign_last_run != current_date:
435
+ self._foreign_last_run = current_date
436
+ self._foreign_gwys_lst = [] # reset the list every 24h
437
+
438
+ if dev_id in self._foreign_gwys_lst:
439
+ return
440
+
441
+ _LOGGER.warning(
442
+ f"Device {dev_id} is potentially a Foreign gateway, "
443
+ f"the Active gateway is {self._active_hgi}, "
444
+ f"alternatively, is it a HVAC device?{TIP}"
445
+ )
446
+ self._foreign_gwys_lst.append(dev_id)
447
+
448
+ for dev_id in dict.fromkeys((src_id, dst_id)): # removes duplicates
449
+ if dev_id in self._exclude: # problems if incl. active gateway
450
+ return False
451
+
452
+ if dev_id == self._active_hgi: # is active gwy
453
+ continue # consider: return True (but what if corrupted dst.id?)
454
+
455
+ if dev_id in self._include: # incl. 63:262142 & --:------
456
+ continue
457
+
458
+ if sending and dev_id == HGI_DEV_ADDR.id:
459
+ continue
460
+
461
+ if self.enforce_include:
462
+ return False
463
+
464
+ if dev_id[:2] != DEV_TYPE_MAP.HGI:
465
+ continue
466
+
467
+ if self._active_hgi: # this 18: is not in known_list
468
+ warn_foreign_hgi(dev_id)
469
+
470
+ return True
471
+
472
+ def pkt_received(self, pkt: Packet) -> None:
473
+ if not self._is_wanted_addrs(pkt.src.id, pkt.dst.id):
474
+ _LOGGER.debug("%s < Packet excluded by device_id filter", pkt)
475
+ return
476
+ super().pkt_received(pkt)
477
+
478
+ async def send_cmd(self, cmd: Command, *args: Any, **kwargs: Any) -> Packet:
479
+ if not self._is_wanted_addrs(cmd.src.id, cmd.dst.id, sending=True):
480
+ raise exc.ProtocolError(f"Command excluded by device_id filter: {cmd}")
481
+ return await super().send_cmd(cmd, *args, **kwargs)
482
+
483
+
484
+ class ReadProtocol(_DeviceIdFilterMixin, _BaseProtocol):
485
+ """A protocol that can only receive Packets."""
486
+
487
+ def __init__(self, msg_handler: MsgHandlerT, **kwargs: Any) -> None:
488
+ super().__init__(msg_handler, **kwargs)
489
+
490
+ self._pause_writing = True
491
+
492
+ def connection_made( # type: ignore[override]
493
+ self, transport: RamsesTransportT, /, *, ramses: bool = False
494
+ ) -> None:
495
+ """Consume the callback if invoked by SerialTransport rather than PortTransport.
496
+
497
+ Our PortTransport wraps SerialTransport and will wait for the signature echo
498
+ to be received (c.f. FileTransport) before calling connection_made(ramses=True).
499
+ """
500
+ super().connection_made(transport)
501
+
502
+ def resume_writing(self) -> None:
503
+ raise NotImplementedError(f"{self}: The chosen Protocol is Read-Only")
504
+
505
+ async def send_cmd(
506
+ self,
507
+ cmd: Command,
508
+ /,
509
+ *,
510
+ gap_duration: float = DEFAULT_GAP_DURATION,
511
+ num_repeats: int = DEFAULT_NUM_REPEATS,
512
+ priority: Priority = Priority.DEFAULT,
513
+ qos: QosParams | None = None,
514
+ ) -> Packet:
515
+ """Raise an exception as the Protocol cannot send Commands."""
516
+ raise NotImplementedError(f"{cmd._hdr}: < this Protocol is Read-Only")
517
+
518
+
519
+ class PortProtocol(_DeviceIdFilterMixin, _BaseProtocol):
520
+ """A protocol that can receive Packets and send Commands +/- QoS (using a FSM)."""
521
+
522
+ def __init__(
523
+ self,
524
+ msg_handler: MsgHandlerT,
525
+ disable_qos: bool | None = DEFAULT_DISABLE_QOS,
526
+ **kwargs: Any,
527
+ ) -> None:
528
+ """Add a FSM to the Protocol, to provide QoS."""
529
+ super().__init__(msg_handler, **kwargs)
530
+
531
+ self._context = ProtocolContext(self)
532
+ self._disable_qos = disable_qos # no wait_for_reply
533
+
534
+ def __repr__(self) -> str:
535
+ if not self._context:
536
+ return super().__repr__()
537
+ cls = self._context.state.__class__.__name__
538
+ return f"QosProtocol({cls}, len(queue)={self._context._que.qsize()})"
539
+
540
+ def connection_made( # type: ignore[override]
541
+ self, transport: RamsesTransportT, /, *, ramses: bool = False
542
+ ) -> None:
543
+ """Consume the callback if invoked by SerialTransport rather than PortTransport.
544
+
545
+ Our PortTransport wraps SerialTransport and will wait for the signature echo
546
+ to be received (c.f. FileTransport) before calling connection_made(ramses=True).
547
+ """
548
+
549
+ if not ramses:
550
+ return None
551
+
552
+ # if isinstance(transport, MqttTransport): # HACK
553
+ # self._context.echo_timeout = 0.5 # HACK: need to move FSM to transport?
554
+
555
+ super().connection_made(transport)
556
+ # TODO: needed? self.resume_writing()
557
+
558
+ self._set_active_hgi(self._transport.get_extra_info(SZ_ACTIVE_HGI))
559
+ self._is_evofw3 = self._transport.get_extra_info(SZ_IS_EVOFW3)
560
+
561
+ if not self._context:
562
+ return
563
+
564
+ self._context.connection_made(transport)
565
+
566
+ if self._pause_writing:
567
+ self._context.pause_writing()
568
+ else:
569
+ self._context.resume_writing()
570
+
571
+ def connection_lost(self, err: ExceptionT | None) -> None: # type: ignore[override]
572
+ """Inform the FSM that the connection with the Transport has been lost."""
573
+
574
+ super().connection_lost(err)
575
+ if self._context:
576
+ self._context.connection_lost(err) # is this safe, when KeyboardInterrupt?
577
+
578
+ def pause_writing(self) -> None:
579
+ """Inform the FSM that the Protocol has been paused."""
580
+
581
+ super().pause_writing()
582
+ if self._context:
583
+ self._context.pause_writing()
584
+
585
+ def resume_writing(self) -> None:
586
+ """Inform the FSM that the Protocol has been paused."""
587
+
588
+ super().resume_writing()
589
+ if self._context:
590
+ self._context.resume_writing()
591
+
592
+ def pkt_received(self, pkt: Packet) -> None:
593
+ """Pass any valid/wanted packets to the callback."""
594
+
595
+ super().pkt_received(pkt)
596
+ if self._context:
597
+ self._context.pkt_received(pkt)
598
+
599
+ async def _send_impersonation_alert(self, cmd: Command) -> None:
600
+ """Send an puzzle packet warning that impersonation is occurring."""
601
+
602
+ if _DBG_DISABLE_IMPERSONATION_ALERTS:
603
+ return
604
+
605
+ msg = f"{self}: Impersonating device: {cmd.src}, for pkt: {cmd.tx_header}"
606
+ if self._is_evofw3 is False:
607
+ _LOGGER.error(f"{msg}, NB: non-evofw3 gateways can't impersonate!")
608
+ else:
609
+ _LOGGER.info(msg)
610
+
611
+ await self._send_cmd(Command._puzzle(msg_type="11", message=cmd.tx_header))
612
+
613
+ async def _send_cmd( # NOTE: QoS wrapped here...
614
+ self,
615
+ cmd: Command,
616
+ /,
617
+ *,
618
+ gap_duration: float = DEFAULT_GAP_DURATION,
619
+ num_repeats: int = DEFAULT_NUM_REPEATS,
620
+ priority: Priority = Priority.DEFAULT,
621
+ qos: QosParams = DEFAULT_QOS,
622
+ ) -> Packet:
623
+ """Wrapper to send a Command with QoS (retries, until success or exception)."""
624
+
625
+ # TODO: use a sync function, so we don't have a stack of awaits before the write
626
+ async def send_cmd(kmd: Command) -> None:
627
+ """Wrapper to for self._send_frame(cmd)."""
628
+
629
+ await self._send_frame(
630
+ str(kmd), gap_duration=gap_duration, num_repeats=num_repeats
631
+ )
632
+
633
+ qos = qos or DEFAULT_QOS
634
+
635
+ if _DBG_DISABLE_QOS: # TODO: should allow echo Packet?
636
+ await send_cmd(cmd)
637
+ return None # type: ignore[return-value] # used for test/dev
638
+
639
+ # if cmd.code == Code._PUZZ: # NOTE: not as simple as this
640
+ # priority = Priority.HIGHEST # FIXME: hack for _7FFF
641
+
642
+ _CODES = (Code._0006, Code._0404, Code._0418, Code._1FC9) # must have QoS
643
+ # 0006|RQ must have wait_for_reply: (TODO: explain why)
644
+ # 0404|RQ must have wait_for_reply: (TODO: explain why)
645
+ # 0418|RQ must have wait_for_reply: if null log entry, reply has no idx
646
+ # 1FC9|xx must have wait_for_reply and priority (timing critical)
647
+
648
+ if self._disable_qos is True or _DBG_DISABLE_QOS:
649
+ qos._wait_for_reply = False
650
+ elif self._disable_qos is None and cmd.code not in _CODES:
651
+ qos._wait_for_reply = False
652
+
653
+ # Should do this check before, or after previous block (of non-QoS sends)?
654
+ # if not self._transport._is_wanted_addrs(cmd.src.id, cmd.dst.id, sending=True):
655
+ # raise exc.ProtocolError(
656
+ # f"{self}: Failed to send {cmd._hdr}: excluded by list"
657
+ # )
658
+
659
+ try:
660
+ return await self._context.send_cmd(send_cmd, cmd, priority, qos)
661
+ # except InvalidStateError as err: # TODO: handle InvalidStateError separately
662
+ # # reset protocol stack
663
+ except exc.ProtocolError as err:
664
+ _LOGGER.info(f"{self}: Failed to send {cmd._hdr}: {err}")
665
+ raise
666
+
667
+ async def send_cmd(
668
+ self,
669
+ cmd: Command,
670
+ /,
671
+ *,
672
+ gap_duration: float = DEFAULT_GAP_DURATION,
673
+ num_repeats: int = DEFAULT_NUM_REPEATS,
674
+ priority: Priority = Priority.DEFAULT,
675
+ qos: QosParams = DEFAULT_QOS, # max_retries, timeout, wait_for_reply
676
+ ) -> Packet:
677
+ """Send a Command with Qos (with retries, until success or ProtocolError).
678
+
679
+ Returns the Command's response Packet or the Command echo if a response is not
680
+ expected (e.g. sending an RP).
681
+
682
+ If wait_for_reply is True, return the RQ's RP (or W's I), or raise an exception
683
+ if one doesn't arrive. If it is False, return the echo of the Command only. If
684
+ it is None (the default), act as True for RQs, and False for all other Commands.
685
+
686
+ num_repeats is # of times to send the Command, in addition to the fist transmit,
687
+ with gap_duration seconds between each transmission. If wait_for_reply is True,
688
+ then num_repeats is ignored.
689
+
690
+ Commands are queued and sent FIFO, except higher-priority Commands are always
691
+ sent first.
692
+
693
+ Will raise:
694
+ ProtocolSendFailed: tried to Tx Command, but didn't get echo/reply
695
+ ProtocolError: didn't attempt to Tx Command for some reason
696
+ """
697
+
698
+ assert gap_duration == DEFAULT_GAP_DURATION
699
+ assert 0 <= num_repeats <= 3 # if QoS, only Tx x1, with no repeats
700
+
701
+ if qos and not self._context:
702
+ _LOGGER.warning(f"{cmd} < QoS is currently disabled by this Protocol")
703
+
704
+ if cmd.src.id != HGI_DEV_ADDR.id: # or actual HGI addr
705
+ await self._send_impersonation_alert(cmd)
706
+
707
+ if qos.wait_for_reply and num_repeats:
708
+ _LOGGER.warning(f"{cmd} < num_repeats set to 0, as wait_for_reply is True")
709
+ num_repeats = 0 # the lesser crime over wait_for_reply=False
710
+
711
+ pkt = await super().send_cmd( # may: raise ProtocolError/ProtocolSendFailed
712
+ cmd,
713
+ gap_duration=gap_duration,
714
+ num_repeats=num_repeats,
715
+ priority=priority,
716
+ qos=qos,
717
+ )
718
+
719
+ if not pkt: # HACK: temporary workaround for returning None
720
+ raise exc.ProtocolSendFailed(f"Failed to send command: {cmd} (REPORT THIS)")
721
+
722
+ return pkt
723
+
724
+
725
+ RamsesProtocolT: TypeAlias = PortProtocol | ReadProtocol
726
+
727
+
728
+ def protocol_factory(
729
+ msg_handler: MsgHandlerT,
730
+ /,
731
+ *,
732
+ disable_qos: bool | None = DEFAULT_DISABLE_QOS,
733
+ disable_sending: bool | None = False,
734
+ enforce_include_list: bool = False, # True, None, False
735
+ exclude_list: DeviceListT | None = None,
736
+ include_list: DeviceListT | None = None,
737
+ ) -> RamsesProtocolT:
738
+ """Create and return a Ramses-specific async packet Protocol."""
739
+
740
+ if disable_sending:
741
+ _LOGGER.debug("ReadProtocol: Sending has been disabled")
742
+ return ReadProtocol(
743
+ msg_handler,
744
+ enforce_include_list=enforce_include_list,
745
+ exclude_list=exclude_list,
746
+ include_list=include_list,
747
+ )
748
+
749
+ if disable_qos:
750
+ _LOGGER.debug("PortProtocol: QoS has been disabled (will wait_for echos)")
751
+
752
+ return PortProtocol(
753
+ msg_handler,
754
+ disable_qos=disable_qos,
755
+ enforce_include_list=enforce_include_list,
756
+ exclude_list=exclude_list,
757
+ include_list=include_list,
758
+ )
759
+
760
+
761
+ async def create_stack(
762
+ msg_handler: MsgHandlerT,
763
+ /,
764
+ *,
765
+ protocol_factory_: Callable[..., RamsesProtocolT] | None = None,
766
+ transport_factory_: Awaitable[RamsesTransportT] | None = None,
767
+ disable_qos: bool | None = DEFAULT_DISABLE_QOS, # True, None, False
768
+ disable_sending: bool | None = False,
769
+ enforce_include_list: bool = False,
770
+ exclude_list: DeviceListT | None = None,
771
+ include_list: DeviceListT | None = None,
772
+ **kwargs: Any, # TODO: these are for the transport_factory
773
+ ) -> tuple[RamsesProtocolT, RamsesTransportT]:
774
+ """Utility function to provide a Protocol / Transport pair.
775
+
776
+ Architecture: gwy (client) -> msg (Protocol) -> pkt (Transport) -> HGI/log (or dict)
777
+ - send Commands via awaitable Protocol.send_cmd(cmd)
778
+ - receive Messages via Gateway._handle_msg(msg) callback
779
+ """
780
+
781
+ read_only = kwargs.get("packet_dict") or kwargs.get("packet_log")
782
+ disable_sending = disable_sending or read_only
783
+
784
+ protocol: RamsesProtocolT = (protocol_factory_ or protocol_factory)(
785
+ msg_handler,
786
+ disable_qos=disable_qos,
787
+ disable_sending=disable_sending,
788
+ enforce_include_list=enforce_include_list,
789
+ exclude_list=exclude_list,
790
+ include_list=include_list,
791
+ )
792
+
793
+ transport: RamsesTransportT = await (transport_factory_ or transport_factory)( # type: ignore[operator]
794
+ protocol, disable_sending=disable_sending, **kwargs
795
+ )
796
+
797
+ if not kwargs.get(SZ_PORT_NAME):
798
+ set_logger_timesource(transport._dt_now)
799
+ _LOGGER.warning("Logger datetimes maintained as most recent packet timestamp")
800
+
801
+ return protocol, transport