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/gateway.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
# TODO:
|
|
4
|
+
# - self._tasks is not ThreadSafe
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
"""RAMSES RF - The serial to RF gateway (HGI80, not RFG100)."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from datetime import datetime as dt
|
|
15
|
+
from threading import Lock
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Never
|
|
17
|
+
|
|
18
|
+
from .address import ALL_DEV_ADDR, HGI_DEV_ADDR, NON_DEV_ADDR
|
|
19
|
+
from .command import Command
|
|
20
|
+
from .const import (
|
|
21
|
+
DEFAULT_DISABLE_QOS,
|
|
22
|
+
DEFAULT_GAP_DURATION,
|
|
23
|
+
DEFAULT_MAX_RETRIES,
|
|
24
|
+
DEFAULT_NUM_REPEATS,
|
|
25
|
+
DEFAULT_SEND_TIMEOUT,
|
|
26
|
+
DEFAULT_WAIT_FOR_REPLY,
|
|
27
|
+
SZ_ACTIVE_HGI,
|
|
28
|
+
Priority,
|
|
29
|
+
)
|
|
30
|
+
from .message import Message
|
|
31
|
+
from .packet import Packet
|
|
32
|
+
from .protocol import protocol_factory
|
|
33
|
+
from .schemas import (
|
|
34
|
+
SZ_DISABLE_QOS,
|
|
35
|
+
SZ_DISABLE_SENDING,
|
|
36
|
+
SZ_ENFORCE_KNOWN_LIST,
|
|
37
|
+
SZ_PACKET_LOG,
|
|
38
|
+
SZ_PORT_CONFIG,
|
|
39
|
+
SZ_PORT_NAME,
|
|
40
|
+
PktLogConfigT,
|
|
41
|
+
PortConfigT,
|
|
42
|
+
select_device_filter_mode,
|
|
43
|
+
)
|
|
44
|
+
from .transport import transport_factory
|
|
45
|
+
from .typing import QosParams
|
|
46
|
+
|
|
47
|
+
from .const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
|
|
48
|
+
I_,
|
|
49
|
+
RP,
|
|
50
|
+
RQ,
|
|
51
|
+
W_,
|
|
52
|
+
Code,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if TYPE_CHECKING:
|
|
56
|
+
from .const import VerbT
|
|
57
|
+
from .frame import PayloadT
|
|
58
|
+
from .protocol import RamsesProtocolT
|
|
59
|
+
from .schemas import DeviceIdT, DeviceListT
|
|
60
|
+
from .transport import RamsesTransportT
|
|
61
|
+
|
|
62
|
+
_MsgHandlerT = Callable[[Message], None]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
DEV_MODE = False
|
|
66
|
+
|
|
67
|
+
_LOGGER = logging.getLogger(__name__)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Engine:
|
|
71
|
+
"""The engine class."""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
port_name: str | None,
|
|
76
|
+
input_file: str | None = None,
|
|
77
|
+
port_config: PortConfigT | None = None,
|
|
78
|
+
packet_log: PktLogConfigT | None = None,
|
|
79
|
+
block_list: DeviceListT | None = None,
|
|
80
|
+
known_list: DeviceListT | None = None,
|
|
81
|
+
loop: asyncio.AbstractEventLoop | None = None,
|
|
82
|
+
**kwargs: Any,
|
|
83
|
+
) -> None:
|
|
84
|
+
if port_name and input_file:
|
|
85
|
+
_LOGGER.warning(
|
|
86
|
+
"Port (%s) specified, so file (%s) ignored", port_name, input_file
|
|
87
|
+
)
|
|
88
|
+
input_file = None
|
|
89
|
+
|
|
90
|
+
self._disable_sending = kwargs.pop(SZ_DISABLE_SENDING, None)
|
|
91
|
+
if input_file:
|
|
92
|
+
self._disable_sending = True
|
|
93
|
+
elif not port_name:
|
|
94
|
+
raise TypeError("Either a port_name or a input_file must be specified")
|
|
95
|
+
|
|
96
|
+
self.ser_name = port_name
|
|
97
|
+
self._input_file = input_file
|
|
98
|
+
|
|
99
|
+
self._port_config: PortConfigT | dict[Never, Never] = port_config or {}
|
|
100
|
+
self._packet_log: PktLogConfigT | dict[Never, Never] = packet_log or {}
|
|
101
|
+
self._loop = loop or asyncio.get_running_loop()
|
|
102
|
+
|
|
103
|
+
self._exclude: DeviceListT = block_list or {}
|
|
104
|
+
self._include: DeviceListT = known_list or {}
|
|
105
|
+
self._unwanted: list[DeviceIdT] = [
|
|
106
|
+
NON_DEV_ADDR.id,
|
|
107
|
+
ALL_DEV_ADDR.id,
|
|
108
|
+
"01:000001", # type: ignore[list-item] # why this one?
|
|
109
|
+
]
|
|
110
|
+
self._enforce_known_list = select_device_filter_mode(
|
|
111
|
+
kwargs.pop(SZ_ENFORCE_KNOWN_LIST, None),
|
|
112
|
+
self._include,
|
|
113
|
+
self._exclude,
|
|
114
|
+
)
|
|
115
|
+
self._kwargs: dict[str, Any] = kwargs # HACK
|
|
116
|
+
|
|
117
|
+
self._engine_lock = Lock() # FIXME: threading lock, or asyncio lock?
|
|
118
|
+
self._engine_state: (
|
|
119
|
+
tuple[_MsgHandlerT | None, bool | None, *tuple[Any, ...]] | None
|
|
120
|
+
) = None
|
|
121
|
+
|
|
122
|
+
self._protocol: RamsesProtocolT = None # type: ignore[assignment]
|
|
123
|
+
self._transport: RamsesTransportT | None = None # None until self.start()
|
|
124
|
+
|
|
125
|
+
self._prev_msg: Message | None = None
|
|
126
|
+
self._this_msg: Message | None = None
|
|
127
|
+
|
|
128
|
+
self._tasks: list[asyncio.Task] = [] # type: ignore[type-arg]
|
|
129
|
+
|
|
130
|
+
self._set_msg_handler(self._msg_handler) # sets self._protocol
|
|
131
|
+
|
|
132
|
+
def __str__(self) -> str:
|
|
133
|
+
if not self._transport:
|
|
134
|
+
return f"{HGI_DEV_ADDR.id} ({self.ser_name})"
|
|
135
|
+
|
|
136
|
+
device_id = self._transport.get_extra_info(
|
|
137
|
+
SZ_ACTIVE_HGI, default=HGI_DEV_ADDR.id
|
|
138
|
+
)
|
|
139
|
+
return f"{device_id} ({self.ser_name})"
|
|
140
|
+
|
|
141
|
+
def _dt_now(self) -> dt:
|
|
142
|
+
return self._transport._dt_now() if self._transport else dt.now()
|
|
143
|
+
|
|
144
|
+
def _set_msg_handler(self, msg_handler: _MsgHandlerT) -> None:
|
|
145
|
+
"""Create an appropriate protocol for the packet source (transport).
|
|
146
|
+
|
|
147
|
+
The corresponding transport will be created later.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
self._protocol = protocol_factory(
|
|
151
|
+
msg_handler,
|
|
152
|
+
disable_sending=self._disable_sending,
|
|
153
|
+
disable_qos=self._kwargs.pop(SZ_DISABLE_QOS, DEFAULT_DISABLE_QOS),
|
|
154
|
+
enforce_include_list=self._enforce_known_list,
|
|
155
|
+
exclude_list=self._exclude,
|
|
156
|
+
include_list=self._include,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def add_msg_handler(
|
|
160
|
+
self,
|
|
161
|
+
msg_handler: Callable[[Message], None],
|
|
162
|
+
/,
|
|
163
|
+
msg_filter: Callable[[Message], bool] | None = None,
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Create a client protocol for the RAMSES-II message transport.
|
|
166
|
+
|
|
167
|
+
The optional filter will return True if the message is to be handled.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
# if msg_filter is not None and not is_callback(msg_filter):
|
|
171
|
+
# raise TypeError(f"Msg filter {msg_filter} is not a callback")
|
|
172
|
+
|
|
173
|
+
if not msg_filter:
|
|
174
|
+
msg_filter = lambda _: True # noqa: E731
|
|
175
|
+
else:
|
|
176
|
+
raise NotImplementedError
|
|
177
|
+
|
|
178
|
+
self._protocol.add_handler(msg_handler, msg_filter=msg_filter)
|
|
179
|
+
|
|
180
|
+
async def start(self) -> None:
|
|
181
|
+
"""Create a suitable transport for the specified packet source.
|
|
182
|
+
|
|
183
|
+
Initiate receiving (Messages) and sending (Commands).
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
pkt_source: dict[str, Any] = {} # [str, dict | str | TextIO]
|
|
187
|
+
if self.ser_name:
|
|
188
|
+
pkt_source[SZ_PORT_NAME] = self.ser_name
|
|
189
|
+
pkt_source[SZ_PORT_CONFIG] = self._port_config
|
|
190
|
+
else: # if self._input_file:
|
|
191
|
+
pkt_source[SZ_PACKET_LOG] = self._input_file # filename as string
|
|
192
|
+
|
|
193
|
+
# incl. await protocol.wait_for_connection_made(timeout=5)
|
|
194
|
+
self._transport = await transport_factory(
|
|
195
|
+
self._protocol,
|
|
196
|
+
disable_sending=self._disable_sending,
|
|
197
|
+
loop=self._loop,
|
|
198
|
+
**pkt_source,
|
|
199
|
+
**self._kwargs, # HACK: odd/misc params, e.g. comms_params
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
self._kwargs = {} # HACK
|
|
203
|
+
|
|
204
|
+
await self._protocol.wait_for_connection_made()
|
|
205
|
+
|
|
206
|
+
# TODO: should this be removed (if so, pytest all before committing)
|
|
207
|
+
if self._input_file:
|
|
208
|
+
await self._protocol.wait_for_connection_lost()
|
|
209
|
+
|
|
210
|
+
async def stop(self) -> None:
|
|
211
|
+
"""Close the transport (will stop the protocol)."""
|
|
212
|
+
|
|
213
|
+
async def cancel_all_tasks() -> None: # TODO: needs a lock?
|
|
214
|
+
_ = [t.cancel() for t in self._tasks if not t.done()]
|
|
215
|
+
try: # FIXME: this is broken
|
|
216
|
+
if tasks := (t for t in self._tasks if not t.done()):
|
|
217
|
+
await asyncio.gather(*tasks)
|
|
218
|
+
except asyncio.CancelledError:
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
await cancel_all_tasks()
|
|
222
|
+
|
|
223
|
+
if self._transport:
|
|
224
|
+
self._transport.close()
|
|
225
|
+
await self._protocol.wait_for_connection_lost()
|
|
226
|
+
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
def _pause(self, *args: Any) -> None:
|
|
230
|
+
"""Pause the (active) engine or raise a RuntimeError."""
|
|
231
|
+
|
|
232
|
+
if not self._engine_lock.acquire(blocking=False):
|
|
233
|
+
raise RuntimeError("Unable to pause engine, failed to acquire lock")
|
|
234
|
+
|
|
235
|
+
if self._engine_state is not None:
|
|
236
|
+
self._engine_lock.release()
|
|
237
|
+
raise RuntimeError("Unable to pause engine, it is already paused")
|
|
238
|
+
|
|
239
|
+
self._engine_state = (None, None, tuple()) # aka not None
|
|
240
|
+
self._engine_lock.release() # is ok to release now
|
|
241
|
+
|
|
242
|
+
self._protocol.pause_writing() # TODO: call_soon()?
|
|
243
|
+
if self._transport:
|
|
244
|
+
self._transport.pause_reading() # TODO: call_soon()?
|
|
245
|
+
|
|
246
|
+
self._protocol._msg_handler, handler = None, self._protocol._msg_handler # type: ignore[assignment]
|
|
247
|
+
self._disable_sending, read_only = True, self._disable_sending
|
|
248
|
+
|
|
249
|
+
self._engine_state = (handler, read_only, *args)
|
|
250
|
+
|
|
251
|
+
def _resume(self) -> tuple[Any]: # FIXME: not atomic
|
|
252
|
+
"""Resume the (paused) engine or raise a RuntimeError."""
|
|
253
|
+
|
|
254
|
+
args: tuple[Any] # mypy
|
|
255
|
+
|
|
256
|
+
if not self._engine_lock.acquire(timeout=0.1):
|
|
257
|
+
raise RuntimeError("Unable to resume engine, failed to acquire lock")
|
|
258
|
+
|
|
259
|
+
if self._engine_state is None:
|
|
260
|
+
self._engine_lock.release()
|
|
261
|
+
raise RuntimeError("Unable to resume engine, it was not paused")
|
|
262
|
+
|
|
263
|
+
self._protocol._msg_handler, self._disable_sending, *args = self._engine_state # type: ignore[assignment]
|
|
264
|
+
self._engine_lock.release()
|
|
265
|
+
|
|
266
|
+
if self._transport:
|
|
267
|
+
self._transport.resume_reading()
|
|
268
|
+
if not self._disable_sending:
|
|
269
|
+
self._protocol.resume_writing()
|
|
270
|
+
|
|
271
|
+
self._engine_state = None
|
|
272
|
+
|
|
273
|
+
return args
|
|
274
|
+
|
|
275
|
+
def add_task(self, task: asyncio.Task[Any]) -> None: # TODO: needs a lock?
|
|
276
|
+
# keep a track of tasks, so we can tidy-up
|
|
277
|
+
self._tasks = [t for t in self._tasks if not t.done()]
|
|
278
|
+
self._tasks.append(task)
|
|
279
|
+
|
|
280
|
+
@staticmethod
|
|
281
|
+
def create_cmd(
|
|
282
|
+
verb: VerbT, device_id: DeviceIdT, code: Code, payload: PayloadT, **kwargs: Any
|
|
283
|
+
) -> Command:
|
|
284
|
+
"""Make a command addressed to device_id."""
|
|
285
|
+
|
|
286
|
+
if [
|
|
287
|
+
k for k in kwargs if k not in ("from_id", "seqn")
|
|
288
|
+
]: # FIXME: deprecate QoS in kwargs
|
|
289
|
+
raise RuntimeError("Deprecated kwargs: %s", kwargs)
|
|
290
|
+
|
|
291
|
+
return Command.from_attrs(verb, device_id, code, payload, **kwargs)
|
|
292
|
+
|
|
293
|
+
async def async_send_cmd(
|
|
294
|
+
self,
|
|
295
|
+
cmd: Command,
|
|
296
|
+
/,
|
|
297
|
+
*,
|
|
298
|
+
gap_duration: float = DEFAULT_GAP_DURATION,
|
|
299
|
+
num_repeats: int = DEFAULT_NUM_REPEATS,
|
|
300
|
+
priority: Priority = Priority.DEFAULT,
|
|
301
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
302
|
+
timeout: float = DEFAULT_SEND_TIMEOUT,
|
|
303
|
+
wait_for_reply: bool | None = DEFAULT_WAIT_FOR_REPLY,
|
|
304
|
+
) -> Packet:
|
|
305
|
+
"""Send a Command and return the corresponding Packet.
|
|
306
|
+
|
|
307
|
+
If wait_for_reply is True (*and* the Command has a rx_header), return the
|
|
308
|
+
reply Packet. Otherwise, simply return the echo Packet.
|
|
309
|
+
|
|
310
|
+
If the expected Packet can't be returned, raise:
|
|
311
|
+
ProtocolSendFailed: tried to Tx Command, but didn't get echo/reply
|
|
312
|
+
ProtocolError: didn't attempt to Tx Command for some reason
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
qos = QosParams(
|
|
316
|
+
max_retries=max_retries,
|
|
317
|
+
timeout=timeout,
|
|
318
|
+
wait_for_reply=wait_for_reply,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# adjust priority, WFR here?
|
|
322
|
+
# if cmd.code in (Code._0005, Code._000C) and qos.wait_for_reply is None:
|
|
323
|
+
# qos.wait_for_reply = True
|
|
324
|
+
|
|
325
|
+
return await self._protocol.send_cmd(
|
|
326
|
+
cmd,
|
|
327
|
+
gap_duration=gap_duration,
|
|
328
|
+
num_repeats=num_repeats,
|
|
329
|
+
priority=priority,
|
|
330
|
+
qos=qos,
|
|
331
|
+
) # may: raise ProtocolError/ProtocolSendFailed
|
|
332
|
+
|
|
333
|
+
def _msg_handler(self, msg: Message) -> None:
|
|
334
|
+
# HACK: This is one consequence of an unpleaseant anachronism
|
|
335
|
+
msg.__class__ = Message # HACK (next line too)
|
|
336
|
+
msg._gwy = self # type: ignore[assignment]
|
|
337
|
+
|
|
338
|
+
self._this_msg, self._prev_msg = msg, self._this_msg
|