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_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
|