ramses-rf 0.22.40__py3-none-any.whl → 0.51.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. ramses_cli/__init__.py +18 -0
  2. ramses_cli/client.py +597 -0
  3. ramses_cli/debug.py +20 -0
  4. ramses_cli/discovery.py +405 -0
  5. ramses_cli/utils/cat_slow.py +17 -0
  6. ramses_cli/utils/convert.py +60 -0
  7. ramses_rf/__init__.py +31 -10
  8. ramses_rf/binding_fsm.py +787 -0
  9. ramses_rf/const.py +124 -105
  10. ramses_rf/database.py +297 -0
  11. ramses_rf/device/__init__.py +69 -39
  12. ramses_rf/device/base.py +187 -376
  13. ramses_rf/device/heat.py +540 -552
  14. ramses_rf/device/hvac.py +279 -171
  15. ramses_rf/dispatcher.py +153 -177
  16. ramses_rf/entity_base.py +478 -361
  17. ramses_rf/exceptions.py +82 -0
  18. ramses_rf/gateway.py +377 -513
  19. ramses_rf/helpers.py +57 -19
  20. ramses_rf/py.typed +0 -0
  21. ramses_rf/schemas.py +148 -194
  22. ramses_rf/system/__init__.py +16 -23
  23. ramses_rf/system/faultlog.py +363 -0
  24. ramses_rf/system/heat.py +295 -302
  25. ramses_rf/system/schedule.py +312 -198
  26. ramses_rf/system/zones.py +318 -238
  27. ramses_rf/version.py +2 -8
  28. ramses_rf-0.51.2.dist-info/METADATA +72 -0
  29. ramses_rf-0.51.2.dist-info/RECORD +55 -0
  30. {ramses_rf-0.22.40.dist-info → ramses_rf-0.51.2.dist-info}/WHEEL +1 -2
  31. ramses_rf-0.51.2.dist-info/entry_points.txt +2 -0
  32. {ramses_rf-0.22.40.dist-info → ramses_rf-0.51.2.dist-info/licenses}/LICENSE +1 -1
  33. ramses_tx/__init__.py +160 -0
  34. {ramses_rf/protocol → ramses_tx}/address.py +65 -59
  35. ramses_tx/command.py +1454 -0
  36. ramses_tx/const.py +903 -0
  37. ramses_tx/exceptions.py +92 -0
  38. {ramses_rf/protocol → ramses_tx}/fingerprints.py +56 -15
  39. {ramses_rf/protocol → ramses_tx}/frame.py +132 -131
  40. ramses_tx/gateway.py +338 -0
  41. ramses_tx/helpers.py +883 -0
  42. {ramses_rf/protocol → ramses_tx}/logger.py +67 -53
  43. {ramses_rf/protocol → ramses_tx}/message.py +155 -191
  44. ramses_tx/opentherm.py +1260 -0
  45. ramses_tx/packet.py +210 -0
  46. {ramses_rf/protocol → ramses_tx}/parsers.py +1266 -1003
  47. ramses_tx/protocol.py +801 -0
  48. ramses_tx/protocol_fsm.py +672 -0
  49. ramses_tx/py.typed +0 -0
  50. {ramses_rf/protocol → ramses_tx}/ramses.py +262 -185
  51. {ramses_rf/protocol → ramses_tx}/schemas.py +150 -133
  52. ramses_tx/transport.py +1471 -0
  53. ramses_tx/typed_dicts.py +492 -0
  54. ramses_tx/typing.py +181 -0
  55. ramses_tx/version.py +4 -0
  56. ramses_rf/discovery.py +0 -398
  57. ramses_rf/protocol/__init__.py +0 -59
  58. ramses_rf/protocol/backports.py +0 -42
  59. ramses_rf/protocol/command.py +0 -1576
  60. ramses_rf/protocol/const.py +0 -697
  61. ramses_rf/protocol/exceptions.py +0 -111
  62. ramses_rf/protocol/helpers.py +0 -390
  63. ramses_rf/protocol/opentherm.py +0 -1170
  64. ramses_rf/protocol/packet.py +0 -235
  65. ramses_rf/protocol/protocol.py +0 -613
  66. ramses_rf/protocol/transport.py +0 -1011
  67. ramses_rf/protocol/version.py +0 -10
  68. ramses_rf/system/hvac.py +0 -82
  69. ramses_rf-0.22.40.dist-info/METADATA +0 -64
  70. ramses_rf-0.22.40.dist-info/RECORD +0 -42
  71. ramses_rf-0.22.40.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