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