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
@@ -1,1011 +0,0 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- #
4
- """RAMSES RF - RAMSES-II compatible Packet processor.
5
-
6
- Operates at the pkt layer of: app - msg - pkt - h/w
7
-
8
- For ser2net, use the following YAML file with: ser2net -c hgi80.yaml
9
-
10
- connection: &con00
11
- accepter: telnet(rfc2217),tcp,5001
12
- timeout: 0
13
- connector: serialdev,/dev/ttyUSB0,115200n81,local
14
- options:
15
- max-connections: 3
16
-
17
- For socat, see:
18
- socat -dd pty,raw,echo=0 pty,raw,echo=0
19
- python client.py monitor /dev/pts/0
20
- cat packet.log | cut -d ' ' -f 2- | unix2dos > /dev/pts/1
21
- """
22
- from __future__ import annotations
23
-
24
- import asyncio
25
- import logging
26
- import os
27
- import re
28
- from collections import deque
29
- from datetime import datetime as dt
30
- from datetime import timedelta as td
31
- from functools import wraps
32
- from io import TextIOWrapper
33
- from queue import Queue
34
- from string import printable # ascii_letters, digits
35
- from time import perf_counter
36
- from types import SimpleNamespace
37
- from typing import Any, Awaitable, Callable, Iterable, TextIO, TypeVar
38
-
39
- from serial import SerialBase, SerialException, serial_for_url # type: ignore[import]
40
- from serial_asyncio import SerialTransport as SerTransportAsync # type: ignore[import]
41
-
42
- from .address import HGI_DEV_ADDR, NON_DEV_ADDR, NUL_DEV_ADDR
43
- from .command import Command, Qos
44
-
45
- # skipcq: PY-W2000
46
- from .const import DEV_TYPE, DEV_TYPE_MAP, SZ_DEVICE_ID, __dev_mode__
47
- from .exceptions import ForeignGatewayError, InvalidPacketError
48
- from .helpers import dt_now
49
- from .packet import Packet
50
- from .protocol import create_protocol_factory
51
- from .schemas import (
52
- SCH_SERIAL_PORT_CONFIG,
53
- SZ_BLOCK_LIST,
54
- SZ_CLASS,
55
- SZ_INBOUND,
56
- SZ_KNOWN_LIST,
57
- SZ_OUTBOUND,
58
- SZ_USE_REGEX,
59
- )
60
- from .version import VERSION
61
-
62
- # TODO: switch dtm from naive to aware
63
- # TODO: https://evohome-hackers.slack.com/archives/C02SYCLATSL/p1646997554178989
64
- # TODO: https://evohome-hackers.slack.com/archives/C02SYCLATSL/p1646998253052939
65
-
66
-
67
- # skipcq: PY-W2000
68
- from .const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
69
- I_,
70
- RP,
71
- RQ,
72
- W_,
73
- Code,
74
- )
75
-
76
- _PacketProtocolT = TypeVar("_PacketProtocolT", bound="PacketProtocolBase")
77
- _PacketTransportT = TypeVar("_PacketTransportT", bound=asyncio.BaseTransport)
78
-
79
-
80
- DEV_MODE = __dev_mode__ and False # debug is_wanted, or qos_fx
81
- DEV_HACK_REGEX = False
82
-
83
-
84
- _LOGGER = logging.getLogger(__name__)
85
- if DEV_MODE: # or True:
86
- _LOGGER.setLevel(logging.DEBUG) # should be INFO
87
-
88
-
89
- _DEFAULT_USE_REGEX = {
90
- SZ_INBOUND: {"( 03:.* 03:.* (1060|2389|30C9) 003) ..": "\\1 00"},
91
- SZ_OUTBOUND: {},
92
- }
93
-
94
- TIP = f", configure the {SZ_KNOWN_LIST}/{SZ_BLOCK_LIST} as required"
95
-
96
- SZ_FINGERPRINT = "fingerprint"
97
- SZ_IS_EVOFW3 = "is_evofw3"
98
- SZ_KNOWN_HGI = "known_hgi"
99
-
100
- ERR_MSG_REGEX = re.compile(r"^([0-9A-F]{2}\.)+$")
101
-
102
- SZ_POLLER_TASK = "poller_task"
103
-
104
- _MIN_GAP_BETWEEN_WRITES = 0.2 # seconds
105
- _MIN_GAP_BETWEEN_RETRYS = td(seconds=2.0) # seconds
106
-
107
- Pause = SimpleNamespace(
108
- NONE=td(seconds=0),
109
- MINIMUM=td(seconds=0.01),
110
- SHORT=td(seconds=0.05),
111
- DEFAULT=td(seconds=0.15),
112
- LONG=td(seconds=0.5),
113
- )
114
-
115
- VALID_CHARACTERS = printable # "".join((ascii_letters, digits, ":-<*# "))
116
-
117
- # evofw3 commands (as of 0.7.0) include (from cmd.c):
118
- # case 'V': validCmd = cmd_version( cmd ); break;
119
- # case 'T': validCmd = cmd_trace( cmd ); break;
120
- # case 'B': validCmd = cmd_boot( cmd ); break;
121
- # case 'C': validCmd = cmd_cc1101( cmd ); break;
122
- # case 'F': validCmd = cmd_cc_tune( cmd ); break;
123
- # case 'E': validCmd = cmd_eeprom( cmd ); break;
124
- # !F - indicate autotune status
125
- # !FT - start autotune
126
- # !FS - save autotune
127
-
128
-
129
- def _str(value: bytes) -> str:
130
- try:
131
- result = "".join(
132
- c
133
- for c in value.decode("ascii", errors="strict").strip()
134
- if c in VALID_CHARACTERS
135
- )
136
- except UnicodeDecodeError:
137
- _LOGGER.warning("%s < Cant decode bytestream (ignoring)", value)
138
- return ""
139
- return result
140
-
141
-
142
- def _normalise(pkt_line: str) -> str:
143
- """Perform any (transparent) frame-level hacks, as required at (near-)RF layer.
144
-
145
- Goals:
146
- - ensure an evofw3 provides the exact same output as a HGI80
147
- - handle 'strange' packets (e.g. I/08:/0008)
148
- """
149
-
150
- # psuedo-RAMSES-II packets...
151
- if pkt_line[10:14] in (" 08:", " 31:") and pkt_line[-16:] == "* Checksum error":
152
- pkt_line = pkt_line[:-17] + " # Checksum error (ignored)"
153
- _LOGGER.warning("Packet line has been normalised (0x01)")
154
-
155
- return pkt_line.strip()
156
-
157
-
158
- def _regex_hack(pkt_line: str, regex_filters: dict) -> str:
159
- """Perform any packet hacks, as configured."""
160
-
161
- if not regex_filters:
162
- return pkt_line
163
-
164
- result = pkt_line
165
-
166
- for k, v in regex_filters.items():
167
- try:
168
- result = re.sub(k, v, result)
169
- except re.error as exc:
170
- _LOGGER.warning(f"{pkt_line} < issue with regex ({k}, {v}): {exc}")
171
-
172
- if result != pkt_line and not DEV_HACK_REGEX:
173
- (_LOGGER.debug if DEV_MODE else _LOGGER.warning)(
174
- f"{pkt_line} < Changed by use_regex to: {result}"
175
- )
176
-
177
- return result
178
-
179
-
180
- sync_cycles: deque = deque() # used by @avoid_system_syncs / @track_system_syncs
181
-
182
-
183
- def avoid_system_syncs(fnc: Callable[..., Awaitable]):
184
- """Take measures to avoid Tx when any controller is doing a sync cycle."""
185
-
186
- DURATION_PKT_GAP = 0.020 # 0.0200 for evohome, or 0.0127 for DTS92
187
- DURATION_LONG_PKT = 0.022 # time to tx I|2309|048 (or 30C9, or 000A)
188
- DURATION_SYNC_PKT = 0.010 # time to tx I|1F09|003
189
-
190
- SYNC_WAIT_LONG = (DURATION_PKT_GAP + DURATION_LONG_PKT) * 2
191
- SYNC_WAIT_SHORT = DURATION_SYNC_PKT
192
- SYNC_WINDOW_LOWER = td(seconds=SYNC_WAIT_SHORT * 0.8) # could be * 0
193
- SYNC_WINDOW_UPPER = SYNC_WINDOW_LOWER + td(seconds=SYNC_WAIT_LONG * 1.2) #
194
-
195
- times_0 = [] # FIXME: remove
196
-
197
- async def wrapper(*args, **kwargs):
198
- global sync_cycles # skipcq: PYL-W0602
199
-
200
- def is_imminent(p):
201
- """Return True if a sync cycle is imminent."""
202
- return (
203
- SYNC_WINDOW_LOWER
204
- < (p.dtm + td(seconds=int(p.payload[2:6], 16) / 10) - dt_now())
205
- < SYNC_WINDOW_UPPER
206
- )
207
-
208
- start = perf_counter() # TODO: remove
209
-
210
- # wait for the start of the sync cycle (I|1F09|003, Tx time ~0.009)
211
- while any(is_imminent(p) for p in sync_cycles):
212
- await asyncio.sleep(SYNC_WAIT_SHORT)
213
-
214
- # wait for the remainder of sync cycle (I|2309/30C9) to complete
215
- if (x := perf_counter() - start) > SYNC_WAIT_SHORT:
216
- await asyncio.sleep(SYNC_WAIT_LONG)
217
- # FIXME: remove this block, and merge both ifs
218
- times_0.append(x)
219
- _LOGGER.warning(
220
- f"*** sync cycle stats: {x:.3f}, "
221
- f"avg: {sum(times_0) / len(times_0):.3f}, "
222
- f"lower: {min(times_0):.3f}, "
223
- f"upper: {max(times_0):.3f}, "
224
- f"times: {[f'{t:.3f}' for t in times_0]}"
225
- )
226
-
227
- await fnc(*args, **kwargs)
228
-
229
- return wrapper
230
-
231
-
232
- def track_system_syncs(fnc: Callable):
233
- """Track/remember the any new/outstanding TCS sync cycle."""
234
-
235
- MAX_SYNCS_TRACKED = 3
236
-
237
- def wrapper(self, pkt: Packet, *args, **kwargs) -> None:
238
- global sync_cycles
239
-
240
- def is_pending(p):
241
- """Return True if a sync cycle is still pending (ignores drift)."""
242
- return p.dtm + td(seconds=int(p.payload[2:6], 16) / 10) > dt_now()
243
-
244
- if pkt.code != Code._1F09 or pkt.verb != I_ or pkt._len != 3:
245
- return fnc(self, pkt, *args, **kwargs)
246
-
247
- sync_cycles = deque(
248
- p for p in sync_cycles if p.src != pkt.src and is_pending(p)
249
- )
250
- sync_cycles.append(pkt) # TODO: sort
251
-
252
- if len(sync_cycles) > MAX_SYNCS_TRACKED: # safety net for corrupted payloads
253
- sync_cycles.popleft()
254
-
255
- fnc(self, pkt, *args, **kwargs)
256
-
257
- return wrapper
258
-
259
-
260
- def limit_duty_cycle(max_duty_cycle: float, time_window: int = 60):
261
- """Limit the Tx rate to the RF duty cycle regulations (e.g. 1% per hour).
262
-
263
- max_duty_cycle: bandwidth available per observation window (%)
264
- time_window: duration of the sliding observation window (default 60 seconds)
265
- """
266
-
267
- TX_RATE_AVAIL: int = 38400 # bits per second (deemed)
268
- FILL_RATE: float = TX_RATE_AVAIL * max_duty_cycle # bits per second
269
- BUCKET_CAPACITY: float = FILL_RATE * time_window
270
-
271
- def decorator(fnc: Callable[..., Awaitable]):
272
- # start with a full bit bucket
273
- bits_in_bucket: float = BUCKET_CAPACITY
274
- last_time_bit_added = perf_counter()
275
-
276
- @wraps(fnc)
277
- async def wrapper(self, packet: str, *args, **kwargs):
278
- nonlocal bits_in_bucket
279
- nonlocal last_time_bit_added
280
-
281
- rf_frame_size = 330 + len(packet[46:]) * 10
282
-
283
- # top-up the bit bucket
284
- elapsed_time = perf_counter() - last_time_bit_added
285
- bits_in_bucket = min(
286
- bits_in_bucket + elapsed_time * FILL_RATE, BUCKET_CAPACITY
287
- )
288
- last_time_bit_added = perf_counter()
289
-
290
- # if required, wait for the bit bucket to refill (not for SETs/PUTs)
291
- if bits_in_bucket < rf_frame_size:
292
- await asyncio.sleep((rf_frame_size - bits_in_bucket) / FILL_RATE)
293
-
294
- # consume the bits from the bit bucket
295
- try:
296
- await fnc(self, packet, *args, **kwargs) # was return ...
297
- finally:
298
- bits_in_bucket -= rf_frame_size
299
-
300
- return wrapper
301
-
302
- return decorator
303
-
304
-
305
- def limit_transmit_rate(max_tokens: float, time_window: int = 60):
306
- """Limit the Tx rate as # packets per period of time.
307
-
308
- Rate-limits the decorated function locally, for one process (Token Bucket).
309
-
310
- max_tokens: maximum number of calls of function in time_window
311
- time_window: duration of the sliding observation window (default 60 seconds)
312
- """
313
- # thanks, kudos to: Thomas Meschede, license: MIT
314
- # see: https://gist.github.com/yeus/dff02dce88c6da9073425b5309f524dd
315
-
316
- token_fill_rate: float = max_tokens / time_window
317
-
318
- def decorator(fnc: Callable):
319
- token_bucket: float = max_tokens # initialize with max tokens
320
- last_time_token_added = perf_counter()
321
-
322
- @wraps(fnc)
323
- async def wrapper(*args, **kwargs):
324
- nonlocal token_bucket
325
- nonlocal last_time_token_added
326
-
327
- # top-up the bit bucket
328
- elapsed = perf_counter() - last_time_token_added
329
- token_bucket = min(token_bucket + elapsed * token_fill_rate, max_tokens)
330
- last_time_token_added = perf_counter()
331
-
332
- # if required, wait for a token (not for SETs/PUTs)
333
- if token_bucket < 1.0:
334
- await asyncio.sleep((1 - token_bucket) / token_fill_rate)
335
-
336
- # consume one token for every call
337
- try:
338
- await fnc(*args, **kwargs)
339
- finally:
340
- token_bucket -= 1.0
341
-
342
- return wrapper
343
-
344
- return decorator
345
-
346
-
347
- class SerTransportBase(asyncio.ReadTransport):
348
- """Interface for a packet transport."""
349
-
350
- _extra: dict
351
-
352
- def __init__(self, loop: asyncio.AbstractEventLoop, extra=None):
353
- super().__init__(extra=extra)
354
-
355
- self._loop = loop
356
-
357
- task = self._loop.create_task(self._polling_loop())
358
- task.add_done_callback(self._handle_polling_loop_result)
359
- self._extra[SZ_POLLER_TASK] = task
360
-
361
- # for sig in (signal.SIGINT, signal.SIGTERM):
362
- # self._loop.add_signal_handler(sig, self.abort)
363
-
364
- self._is_closing = False
365
-
366
- async def _polling_loop(self) -> None: # TODO: make into a thread, as doing I/O
367
- """Poll the packet source for packets, also calls connection_made/lost()."""
368
- # self._protocol.connection_made(self)
369
- raise NotImplementedError
370
- # self._protocol.connection_lost(None)
371
-
372
- def _handle_polling_loop_result(self, task: asyncio.Task) -> None:
373
- """Call connection_lost(exc) if there was an Exception from the polling_loop.
374
-
375
- The polling_loop should call connection_lost(None) if there is no exception.
376
- """
377
- try:
378
- task.result()
379
- # except (KeyboardInterrupt, SystemExit):
380
- # pass # no value in raising
381
- except asyncio.CancelledError:
382
- pass # self._protocol.connection_lost(None)
383
- except Exception as exc: # pylint: disable=broad-except
384
- self._protocol.connection_lost(exc)
385
-
386
- def close(self):
387
- """Close the transport."""
388
-
389
- if self._is_closing:
390
- return
391
- self._is_closing = True
392
-
393
- if task := self._extra.get(SZ_POLLER_TASK):
394
- task.cancel()
395
-
396
- self._loop.call_soon(self._protocol.connection_lost, None)
397
- # cause: self._protocol.pause_writing()
398
-
399
- def is_closing(self) -> bool:
400
- """Return True if the transport is closing or closed."""
401
- return self._is_closing
402
-
403
-
404
- class SerTransportRead(SerTransportBase):
405
- """Interface for a packet transport via a dict (saved state) or a file (pkt log)."""
406
-
407
- def __init__(
408
- self,
409
- loop: asyncio.AbstractEventLoop,
410
- protocol: _PacketProtocolT,
411
- packet_source,
412
- extra=None,
413
- ):
414
- super().__init__(loop, extra=extra)
415
-
416
- self._protocol = protocol
417
- self._packets = packet_source
418
-
419
- self._protocol.pause_writing()
420
-
421
- async def _polling_loop(self): # TODO: harden: wrap d_r() with try
422
- """Poll the packet source for packets, also calls connection_made/lost()."""
423
- self._protocol.connection_made(self)
424
-
425
- if isinstance(self._packets, dict): # can assume dtm_str is OK
426
- for dtm_str, pkt_line in self._packets.items():
427
- self._protocol.data_received(f"{dtm_str} {pkt_line}")
428
- await asyncio.sleep(0)
429
-
430
- elif isinstance(self._packets, TextIOWrapper):
431
- for dtm_pkt_line in self._packets: # should check dtm_str is OK
432
- self._protocol.data_received(dtm_pkt_line) # .rstrip())
433
- await asyncio.sleep(0)
434
-
435
- else:
436
- raise TypeError(f"Wrong type of packet source: {type(self._packets)}")
437
-
438
- self._protocol.connection_lost(None)
439
-
440
- def write(self, *args, **kwargs) -> None:
441
- raise NotImplementedError
442
-
443
-
444
- class SerTransportPoll(SerTransportBase, asyncio.WriteTransport):
445
- """Interface for a packet transport using polling."""
446
-
447
- MAX_BUFFER_SIZE = 500
448
-
449
- def __init__(
450
- self,
451
- loop: asyncio.AbstractEventLoop,
452
- protocol: _PacketProtocolT,
453
- ser_instance: SerialBase,
454
- extra=None,
455
- ):
456
- super().__init__(loop, extra=extra)
457
-
458
- self._protocol = protocol
459
- self.serial = ser_instance
460
-
461
- self._is_closing: bool = None # type: ignore[assignment]
462
- self._write_queue: Queue = Queue(maxsize=self.MAX_BUFFER_SIZE)
463
-
464
- async def _polling_loop(self) -> None:
465
- """Poll the packet source for packets, also calls connection_made/lost()."""
466
- self._protocol.connection_made(self)
467
-
468
- while self.serial.is_open:
469
- await asyncio.sleep(0.001)
470
-
471
- if self.serial.in_waiting:
472
- # NOTE: cant use readline(), as it blocks until a newline is received
473
- self._protocol.data_received(self.serial.read_all())
474
- continue
475
-
476
- if getattr(self.serial, "out_waiting", 0):
477
- # NOTE: rfc2217 ports have no out_waiting attr!
478
- continue
479
-
480
- if not self._write_queue.empty():
481
- self.serial.write(self._write_queue.get())
482
- self._write_queue.task_done()
483
-
484
- self._protocol.connection_lost(None)
485
-
486
- def write(self, cmd):
487
- """Write some data bytes to the transport.
488
-
489
- This does not block; it buffers the data and arranges for it to be sent out
490
- asynchronously.
491
- """
492
-
493
- self._write_queue.put_nowait(cmd)
494
-
495
-
496
- class PacketProtocolBase(asyncio.Protocol):
497
- """Interface for a packet protocol (no Qos).
498
-
499
- ex transport: self.data_received(bytes) -> self._callback(pkt)
500
- to transport: self.send_data(cmd) -> self._transport.write(bytes)
501
- """
502
-
503
- def __init__(self, gwy, pkt_handler: Callable) -> None:
504
-
505
- _LOGGER.info(f"RAMSES_RF protocol library v{VERSION}, using {self}")
506
-
507
- self._gwy = gwy
508
- self._loop = gwy._loop
509
- self._callback: None | Callable = pkt_handler
510
-
511
- self._transport: asyncio.Transport = None # type: ignore[assignment]
512
- self._pause_writing = True
513
- self._recv_buffer = bytes()
514
-
515
- self._prev_pkt: Packet = None # type: ignore[assignment]
516
- self._this_pkt: Packet = None # type: ignore[assignment]
517
-
518
- self._disable_sending = gwy.config.disable_sending
519
- self._evofw_flag = getattr(gwy.config, "evofw_flag", None)
520
-
521
- self.enforce_include = gwy.config.enforce_known_list
522
- self._exclude = list(gwy._exclude.keys())
523
- self._include = list(gwy._include.keys()) + [NON_DEV_ADDR.id, NUL_DEV_ADDR.id]
524
- self._unwanted: list = [] # not: [NON_DEV_ADDR.id, NUL_DEV_ADDR.id]
525
-
526
- self._hgi80: dict[str, Any] = {
527
- SZ_DEVICE_ID: None,
528
- SZ_FINGERPRINT: None,
529
- SZ_IS_EVOFW3: None,
530
- SZ_KNOWN_HGI: None,
531
- } # also: "evofw3_ver"
532
-
533
- if known_hgis := [
534
- k for k, v in gwy._include.items() if v.get(SZ_CLASS) == DEV_TYPE.HGI
535
- ]:
536
- self._hgi80[SZ_KNOWN_HGI] = known_hgis[0]
537
- else:
538
- _LOGGER.info(f"The {SZ_KNOWN_LIST} should specify the gateway (HGI) device")
539
-
540
- self._use_regex = getattr(self._gwy.config, SZ_USE_REGEX, {})
541
-
542
- def __repr__(self) -> str:
543
- return f"{self.__class__.__name__}(enforce_include={self.enforce_include})"
544
-
545
- def __str__(self) -> str:
546
- return self.__class__.__name__
547
-
548
- def _dt_now(self) -> dt:
549
- """Return a precise datetime, using the curent dtm."""
550
- return dt_now()
551
-
552
- def connection_made(self, transport: asyncio.Transport) -> None: # type: ignore[override]
553
- """Called when a connection is made."""
554
- _LOGGER.debug(f"{self}.connection_made({transport})")
555
-
556
- self._transport = transport
557
-
558
- # self.resume_writing() # executed in selected sub-classes
559
-
560
- if self.enforce_include: # TODO: here, or in init?
561
- _LOGGER.info(
562
- f"Enforcing the {SZ_KNOWN_LIST} (as a whitelist): %s", self._include
563
- )
564
- elif self._exclude:
565
- _LOGGER.info(
566
- f"Enforcing the {SZ_BLOCK_LIST} (as a blacklist): %s", self._exclude
567
- )
568
- else:
569
- _LOGGER.warning(
570
- f"Not using any device filter: using a {SZ_KNOWN_LIST} (as a whitelist) "
571
- "is strongly recommended)"
572
- )
573
-
574
- def connection_lost(self, exc: None | Exception) -> None:
575
- """Called when the connection is lost or closed."""
576
- # serial.serialutil.SerialException: device reports error (poll)
577
- if exc:
578
- _LOGGER.error("Exception raised by transport: %s", exc)
579
-
580
- self.pause_writing()
581
-
582
- if exc:
583
- raise exc
584
-
585
- def pause_writing(self) -> None:
586
- """Called when the transport's buffer goes over the high-water mark."""
587
- _LOGGER.debug(f"{self}.pause_writing()")
588
-
589
- self._pause_writing = True
590
-
591
- def resume_writing(self) -> None:
592
- """Called when the transport's buffer drains below the low-water mark."""
593
- _LOGGER.debug(f"{self}.resume_writing()")
594
-
595
- self._pause_writing = False
596
-
597
- def data_received(self, data: bytes) -> None:
598
- """Called by the transport when some data (packet fragments) is received."""
599
-
600
- def bytes_received(data: bytes) -> Iterable[tuple[dt, bytes]]:
601
- self._recv_buffer += data
602
- if b"\r\n" in self._recv_buffer:
603
- lines = self._recv_buffer.split(b"\r\n")
604
- self._recv_buffer = lines[-1]
605
- for line in lines[:-1]:
606
- yield self._dt_now(), line
607
-
608
- for dtm, raw_line in bytes_received(data):
609
- self._line_received(dtm, _normalise(_str(raw_line)), raw_line)
610
-
611
- def _line_received(self, dtm: dt, line: str, raw_line: bytes) -> None:
612
-
613
- if _LOGGER.getEffectiveLevel() == logging.INFO: # i.e. don't log for DEBUG
614
- _LOGGER.info("RF Rx: %s", raw_line)
615
-
616
- try:
617
- pkt = Packet.from_port(
618
- self._gwy,
619
- dtm,
620
- _regex_hack(line, self._use_regex.get(SZ_INBOUND, {})),
621
- raw_line=raw_line,
622
- ) # should log all? invalid pkts appropriately
623
-
624
- except InvalidPacketError as exc:
625
- if "# evofw" in line and self._hgi80[SZ_IS_EVOFW3] is None:
626
- self._hgi80[SZ_IS_EVOFW3] = True
627
- self._hgi80["evofw3_ver"] = line
628
- if self._evofw_flag not in (None, "!V"):
629
- self._transport.write(
630
- bytes(f"{self._evofw_flag}\r\n".encode("ascii"))
631
- )
632
- _LOGGER.debug("%s < Cant create packet (ignoring): %s", line, exc)
633
- return
634
-
635
- self._pkt_received(pkt)
636
-
637
- def _pkt_received(self, pkt: Packet) -> None:
638
- """Pass any valid/wanted packets to the callback.
639
-
640
- Called by data_received(bytes) -> line_received(frame) -> pkt_received(pkt).
641
- """
642
-
643
- def is_wanted_addrs(src_id: str, dst_id: str) -> bool:
644
- """Return True if the packet is not to be filtered out.
645
-
646
- In any one packet, an excluded device_id 'trumps' an included device_id.
647
- """
648
-
649
- for dev_id in dict.fromkeys((src_id, dst_id)): # removes duplicates
650
- # _unwanted exists because (in future) stale entries need to be removed
651
- if dev_id in self._exclude or dev_id in self._unwanted:
652
- return False
653
- if dev_id == self._hgi80[SZ_DEVICE_ID]: # even if not in include list
654
- continue
655
- if dev_id not in self._include and self.enforce_include:
656
- return False
657
- if dev_id[:2] != DEV_TYPE_MAP.HGI:
658
- continue
659
- if dev_id not in self._include and self._hgi80[SZ_DEVICE_ID]:
660
- self._unwanted.append(dev_id)
661
- raise ForeignGatewayError(
662
- f"Blacklisting a Foreign gateway (or is it HVAC?): {dev_id}"
663
- f" (Active gateway: {self._hgi80[SZ_DEVICE_ID]}){TIP}"
664
- )
665
- if dev_id == self._hgi80[SZ_KNOWN_HGI] or (
666
- dev_id == pkt.src.id and pkt.payload == self._hgi80[SZ_FINGERPRINT]
667
- ):
668
- self._hgi80[SZ_DEVICE_ID] = dev_id
669
- if dev_id not in self._include:
670
- _LOGGER.warning(f"Active gateway set to: {dev_id}{TIP}")
671
- return True
672
-
673
- self._this_pkt, self._prev_pkt = pkt, self._this_pkt
674
-
675
- try: # TODO: why not call soon?
676
- if self._callback and is_wanted_addrs(pkt.src.id, pkt.dst.id):
677
- self._callback(pkt) # only wanted PKTs to the MSG transport's handler
678
- except AssertionError as exc: # protect from upper-layer callbacks
679
- _LOGGER.exception("%s < exception from msg layer: %s", pkt, exc)
680
- except ForeignGatewayError as exc:
681
- _LOGGER.error("%s < exception from pkt layer: %s", pkt, exc)
682
-
683
- async def send_data(self, cmd: Command) -> None:
684
- raise NotImplementedError
685
-
686
-
687
- class PacketProtocolFile(PacketProtocolBase):
688
- """Interface for a packet protocol (for packet log)."""
689
-
690
- def __init__(self, gwy, pkt_handler: Callable) -> None:
691
- super().__init__(gwy, pkt_handler)
692
-
693
- self._dt_str_: str = None # type: ignore[assignment]
694
-
695
- def _dt_now(self) -> dt:
696
- """Return a precise datetime, using a packet's dtm field."""
697
-
698
- try:
699
- return dt.fromisoformat(self._dt_str_) # always current pkt's dtm
700
- except (TypeError, ValueError):
701
- pass
702
-
703
- try:
704
- return self._this_pkt.dtm # if above fails, will be previous pkt's dtm
705
- except AttributeError:
706
- return dt(1970, 1, 1, 1, 0)
707
-
708
- def data_received(self, data: str) -> None: # type: ignore[override]
709
- """Called when a packet line is received (from a log file)."""
710
-
711
- self._dt_str_ = data[:26] # used for self._dt_now
712
-
713
- self._line_received(data[:26], data[27:].strip(), data)
714
-
715
- def _line_received(self, dtm: str, line: str, raw_line: str) -> None: # type: ignore[override]
716
-
717
- try:
718
- pkt = Packet.from_file(
719
- self._gwy,
720
- dtm,
721
- _regex_hack(line, self._use_regex.get(SZ_INBOUND, {})),
722
- ) # should log all invalid pkts appropriately
723
-
724
- except (InvalidPacketError, ValueError): # VE from dt.fromisoformat()
725
- return
726
-
727
- self._pkt_received(pkt)
728
-
729
-
730
- class PacketProtocolPort(PacketProtocolBase):
731
- """Interface for a packet protocol (without QoS)."""
732
-
733
- def __init__(self, gwy, pkt_handler: Callable) -> None:
734
- super().__init__(gwy, pkt_handler)
735
-
736
- self._sem = asyncio.BoundedSemaphore()
737
- self._leaker = None
738
-
739
- async def _leak_sem(self):
740
- """Used to enforce a minimum time between calls to `self._transport.write()`."""
741
- while True:
742
- await asyncio.sleep(_MIN_GAP_BETWEEN_WRITES)
743
- try:
744
- self._sem.release()
745
- except ValueError:
746
- pass
747
-
748
- def connection_lost(self, exc: None | Exception) -> None:
749
- """Called when the connection is lost or closed."""
750
-
751
- if self._leaker:
752
- self._leaker.cancel()
753
-
754
- super().connection_lost(exc)
755
-
756
- def connection_made(self, transport: asyncio.Transport) -> None: # type: ignore[override]
757
- """Called when a connection is made."""
758
-
759
- if not self._leaker:
760
- self._leaker = self._loop.create_task(self._leak_sem())
761
-
762
- super().connection_made(transport) # self._transport = transport
763
- # self._transport.serial.rts = False
764
-
765
- self._transport.write(bytes("!V\r\n".encode("ascii"))) # is evofw3 or HGI80?
766
-
767
- # add this to start of the pkt log, if any
768
- if not self._disable_sending:
769
- cmd = Command._puzzle()
770
- self._hgi80[SZ_FINGERPRINT] = cmd.payload
771
- self._transport.write(bytes(str(cmd), "ascii") + b"\r\n")
772
-
773
- self.resume_writing()
774
-
775
- @track_system_syncs
776
- def _pkt_received(self, pkt: Packet) -> None:
777
- """Pass any valid/wanted packets to the callback.
778
-
779
- Called by data_received(bytes) -> line_received(frame) -> pkt_received(pkt).
780
- """
781
- super()._pkt_received(pkt)
782
-
783
- async def send_data(self, cmd: Command) -> None:
784
- """Called when some data is to be sent (not a callback)."""
785
-
786
- if self._disable_sending:
787
- raise RuntimeError("Sending is disabled")
788
-
789
- if cmd.src.id != HGI_DEV_ADDR.id:
790
- await self._alert_is_impersonating(cmd)
791
-
792
- await self._send_data(str(cmd))
793
-
794
- async def _alert_is_impersonating(self, cmd: Command) -> None:
795
- msg = f"Impersonating device: {cmd.src}, for pkt: {cmd.tx_header}"
796
- if self._hgi80[SZ_IS_EVOFW3]:
797
- _LOGGER.info(msg)
798
- else:
799
- _LOGGER.warning(
800
- "%s, NB: HGI80s dont support impersonation, it requires evofw3!", msg
801
- )
802
- await self.send_data(Command._puzzle(msg_type="11", message=cmd.tx_header))
803
-
804
- @avoid_system_syncs
805
- @limit_duty_cycle(0.01) # @limit_transmit_rate(45)
806
- async def _send_data(self, data: str) -> None: # NOTE: is also throttled internally
807
- """Send a bytearray to the transport (serial) interface."""
808
-
809
- while self._pause_writing:
810
- await asyncio.sleep(0.005)
811
-
812
- # while (
813
- # self._transport is None
814
- # # or self._transport.serial is None # Shouldn't be required, but is!
815
- # or getattr(self._transport.serial, "out_waiting", False)
816
- # ):
817
- # await asyncio.sleep(0.005)
818
-
819
- data_bytes = bytes(
820
- _regex_hack(
821
- data,
822
- self._use_regex.get(SZ_OUTBOUND, {}),
823
- ).encode("ascii")
824
- )
825
-
826
- await self._sem.acquire() # minimum time between Tx
827
-
828
- if _LOGGER.getEffectiveLevel() == logging.INFO: # i.e. don't log for DEBUG
829
- _LOGGER.info("RF Tx: %s", data_bytes)
830
- self._transport.write(data_bytes + b"\r\n")
831
-
832
-
833
- class PacketProtocolQos(PacketProtocolPort):
834
- """Interface for a packet protocol (includes QoS)."""
835
-
836
- def __init__(self, gwy, pkt_handler: Callable) -> None:
837
- super().__init__(gwy, pkt_handler)
838
-
839
- # self._qos_lock = Lock()
840
- self._qos_cmd: None | Command = None
841
- self._tx_rcvd: None | Packet = None
842
- self._rx_rcvd: None | Packet = None
843
-
844
- def _pkt_received(self, pkt: Packet) -> None:
845
- """Called when packets are received (a callback).
846
-
847
- Perform any QoS functions on packets received from the transport.
848
- """
849
-
850
- if self._qos_cmd:
851
- if pkt._hdr == self._qos_cmd.tx_header:
852
- if self._tx_rcvd:
853
- err = f"have seen tx_rcvd({self._tx_rcvd}), rx_rcvd={self._rx_rcvd}"
854
- (_LOGGER.error if DEV_MODE else _LOGGER.debug)(err)
855
- self._tx_rcvd = pkt
856
- elif pkt._hdr == self._qos_cmd.rx_header:
857
- if self._rx_rcvd:
858
- err = f"have seen rx_rcvd({self._rx_rcvd}), tx_rcvd={self._tx_rcvd}"
859
- (_LOGGER.error if DEV_MODE else _LOGGER.debug)(err)
860
- self._rx_rcvd = pkt
861
-
862
- super()._pkt_received(pkt)
863
-
864
- async def send_data(self, cmd: Command) -> None | Packet: # type: ignore[override]
865
- """Called when packets are to be sent (not a callback)."""
866
-
867
- if self._disable_sending:
868
- raise RuntimeError("Sending is disabled")
869
-
870
- if cmd.src.id != HGI_DEV_ADDR.id:
871
- await self._alert_is_impersonating(cmd)
872
-
873
- def get_expiry_time(timeout, disable_backoff, retry_count):
874
- """Return a dtm for expiring the Tx (or Rx), with an optional backoff."""
875
- if disable_backoff:
876
- return dt.now() + timeout
877
- return dt.now() + timeout * 2 ** min(retry_count, Qos.TX_BACKOFFS_MAX)
878
-
879
- # self._qos_lock.acquire()
880
- if self._qos_cmd:
881
- raise RuntimeError # TODO: remove me
882
- self._qos_cmd = cmd
883
- # self._qos_lock.release()
884
- self._tx_rcvd = None
885
-
886
- retry_count = 0
887
- while retry_count <= min(cmd._qos.retry_limit, Qos.TX_RETRIES_MAX):
888
-
889
- self._rx_rcvd = None
890
- await super()._send_data(str(cmd))
891
-
892
- tx_expires = get_expiry_time(
893
- cmd._qos.tx_timeout, cmd._qos.disable_backoff, retry_count
894
- )
895
- while tx_expires > dt.now(): # Step 1: wait for Tx to echo
896
- await asyncio.sleep(Qos.POLL_INTERVAL) # 0.002
897
- if self._tx_rcvd or self._rx_rcvd:
898
- break
899
- else:
900
- retry_count += 1
901
- continue
902
-
903
- if not cmd._qos.rx_timeout or self._rx_rcvd: # not expected an Rx
904
- break
905
-
906
- rx_expires = dt.now() + cmd._qos.rx_timeout
907
- while rx_expires > dt.now(): # Step 2: wait for Rx to arrive
908
- await asyncio.sleep(Qos.POLL_INTERVAL)
909
- if self._rx_rcvd:
910
- break
911
- else:
912
- retry_count += 1
913
- continue
914
-
915
- if self._rx_rcvd:
916
- break
917
-
918
- else:
919
- _LOGGER.debug(
920
- f"PacketProtocolQos.send_data({cmd}) timed out"
921
- f": tx_rcvd={bool(self._tx_rcvd)} (retry_count={retry_count - 1})"
922
- f", rx_rcvd={bool(self._rx_rcvd)} (timeout={cmd._qos.rx_timeout})"
923
- )
924
-
925
- self._qos_cmd = None
926
- return self._rx_rcvd
927
-
928
-
929
- def create_pkt_stack(
930
- gwy,
931
- pkt_callback: Callable[[Packet], None],
932
- /,
933
- *,
934
- protocol_factory: Callable[[], _PacketProtocolT] = None,
935
- port_name: str = None,
936
- port_config: dict = None,
937
- packet_log: TextIO = None,
938
- packet_dict: dict = None,
939
- ) -> tuple[_PacketProtocolT, _PacketTransportT]:
940
- """Utility function to provide a transport to the internal protocol.
941
-
942
- The architecture is: app (client) -> msg -> pkt -> ser (HW interface) / log (file).
943
-
944
- The msg/pkt interface is via:
945
- - PktProtocol.data_received to (pkt_callback) MsgTransport._pkt_receiver
946
- - MsgTransport.write (pkt_dispatcher) to (pkt_protocol) PktProtocol.send_data
947
- """
948
-
949
- def get_serial_instance(ser_name: str, ser_config: dict) -> SerialBase:
950
-
951
- # For example:
952
- # - python client.py monitor 'rfc2217://localhost:5001'
953
- # - python client.py monitor 'alt:///dev/ttyUSB0?class=PosixPollSerial'
954
-
955
- ser_config = SCH_SERIAL_PORT_CONFIG(ser_config or {})
956
-
957
- try:
958
- ser_obj = serial_for_url(ser_name, **ser_config)
959
- except SerialException as exc:
960
- _LOGGER.exception(
961
- "Failed to open %s (config: %s): %s", ser_name, ser_config, exc
962
- )
963
- raise
964
-
965
- try: # FTDI on Posix/Linux would be a common environment for this library...
966
- ser_obj.set_low_latency_mode(True)
967
- except (
968
- AttributeError,
969
- NotImplementedError,
970
- ValueError,
971
- ): # Wrong OS/Platform/not FTDI
972
- pass
973
-
974
- return ser_obj
975
-
976
- def issue_warning() -> None:
977
- _LOGGER.warning(
978
- f"{'Windows' if os.name == 'nt' else 'This type of serial interface'} "
979
- "is not fully supported by this library: "
980
- "please don't report any Transport/Protocol errors/warnings, "
981
- "unless they are reproducable with a standard configuration "
982
- "(e.g. linux with a local serial port)"
983
- )
984
-
985
- def protocol_factory_() -> _PacketProtocolT:
986
- if packet_log or packet_dict is not None:
987
- return create_protocol_factory(PacketProtocolFile, gwy, pkt_callback)()
988
- elif gwy.config.disable_sending: # NOTE: assumes we wont change our mind
989
- return create_protocol_factory(PacketProtocolPort, gwy, pkt_callback)()
990
- else:
991
- return create_protocol_factory(
992
- PacketProtocolQos, gwy, pkt_callback
993
- )() # NOTE: should be: PacketProtocolQos, not PacketProtocolPort
994
-
995
- if len([x for x in (packet_dict, packet_log, port_name) if x is not None]) != 1:
996
- raise TypeError("must have exactly one of: serial port, pkt log or pkt dict")
997
-
998
- pkt_protocol = (protocol_factory or protocol_factory_)()
999
-
1000
- if (pkt_source := packet_log or packet_dict) is not None: # {} is a processable log
1001
- return pkt_protocol, SerTransportRead(gwy._loop, pkt_protocol, pkt_source)
1002
-
1003
- assert port_name is not None # instead of: _type: ignore[arg-type]
1004
- assert port_config is not None # instead of: _type: ignore[arg-type]
1005
- ser_instance = get_serial_instance(port_name, port_config)
1006
-
1007
- if os.name == "nt" or ser_instance.portstr[:7] in ("rfc2217", "socket:"):
1008
- issue_warning()
1009
- return pkt_protocol, SerTransportPoll(gwy._loop, pkt_protocol, ser_instance)
1010
-
1011
- return pkt_protocol, SerTransportAsync(gwy._loop, pkt_protocol, ser_instance)