ramses-rf 0.52.4__py3-none-any.whl → 0.53.0__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/client.py +168 -54
- ramses_cli/debug.py +1 -1
- ramses_cli/py.typed +0 -0
- ramses_cli/utils/convert.py +2 -2
- ramses_rf/__init__.py +2 -0
- ramses_rf/database.py +40 -17
- ramses_rf/device/base.py +14 -3
- ramses_rf/device/heat.py +1 -1
- ramses_rf/device/hvac.py +24 -21
- ramses_rf/entity_base.py +9 -7
- ramses_rf/gateway.py +214 -27
- ramses_rf/schemas.py +2 -1
- ramses_rf/system/zones.py +22 -2
- ramses_rf/version.py +1 -1
- {ramses_rf-0.52.4.dist-info → ramses_rf-0.53.0.dist-info}/METADATA +1 -1
- ramses_rf-0.53.0.dist-info/RECORD +56 -0
- {ramses_rf-0.52.4.dist-info → ramses_rf-0.53.0.dist-info}/WHEEL +1 -1
- {ramses_rf-0.52.4.dist-info → ramses_rf-0.53.0.dist-info}/licenses/LICENSE +1 -1
- ramses_tx/address.py +21 -6
- ramses_tx/command.py +19 -3
- ramses_tx/const.py +110 -23
- ramses_tx/helpers.py +30 -10
- ramses_tx/message.py +11 -5
- ramses_tx/packet.py +13 -5
- ramses_tx/parsers.py +1039 -16
- ramses_tx/protocol.py +112 -23
- ramses_tx/protocol_fsm.py +28 -10
- ramses_tx/schemas.py +2 -2
- ramses_tx/transport.py +529 -47
- ramses_tx/version.py +1 -1
- ramses_rf-0.52.4.dist-info/RECORD +0 -55
- {ramses_rf-0.52.4.dist-info → ramses_rf-0.53.0.dist-info}/entry_points.txt +0 -0
ramses_tx/protocol.py
CHANGED
|
@@ -66,7 +66,7 @@ class _BaseProtocol(asyncio.Protocol):
|
|
|
66
66
|
self._msg_handler = msg_handler
|
|
67
67
|
self._msg_handlers: list[MsgHandlerT] = []
|
|
68
68
|
|
|
69
|
-
self._transport: RamsesTransportT
|
|
69
|
+
self._transport: RamsesTransportT | None = None
|
|
70
70
|
self._loop = asyncio.get_running_loop()
|
|
71
71
|
|
|
72
72
|
self._pause_writing = False # FIXME: Start in R/O mode as no connection yet?
|
|
@@ -80,6 +80,9 @@ class _BaseProtocol(asyncio.Protocol):
|
|
|
80
80
|
|
|
81
81
|
self._is_evofw3: bool | None = None
|
|
82
82
|
|
|
83
|
+
self._active_hgi: DeviceIdT | None = None
|
|
84
|
+
self._context: ProtocolContext | None = None
|
|
85
|
+
|
|
83
86
|
@property
|
|
84
87
|
def hgi_id(self) -> DeviceIdT:
|
|
85
88
|
return HGI_DEV_ADDR.id
|
|
@@ -140,7 +143,16 @@ class _BaseProtocol(asyncio.Protocol):
|
|
|
140
143
|
received or the connection was aborted or closed).
|
|
141
144
|
"""
|
|
142
145
|
|
|
143
|
-
|
|
146
|
+
# FIX: Check if _wait_connection_lost exists before asserting
|
|
147
|
+
# This handles cases where connection was never fully established (e.g. timeout)
|
|
148
|
+
if not self._wait_connection_lost:
|
|
149
|
+
_LOGGER.debug(
|
|
150
|
+
"connection_lost called but no connection was established (ignoring)"
|
|
151
|
+
)
|
|
152
|
+
# Reset the connection made future for next attempt
|
|
153
|
+
if self._wait_connection_made.done():
|
|
154
|
+
self._wait_connection_made = self._loop.create_future()
|
|
155
|
+
return
|
|
144
156
|
|
|
145
157
|
if self._wait_connection_lost.done(): # BUG: why is callback invoked twice?
|
|
146
158
|
return
|
|
@@ -199,6 +211,10 @@ class _BaseProtocol(asyncio.Protocol):
|
|
|
199
211
|
|
|
200
212
|
self._pause_writing = False
|
|
201
213
|
|
|
214
|
+
async def _send_impersonation_alert(self, cmd: Command) -> None:
|
|
215
|
+
"""Allow the Protocol to send an impersonation alert (stub)."""
|
|
216
|
+
return
|
|
217
|
+
|
|
202
218
|
async def send_cmd(
|
|
203
219
|
self,
|
|
204
220
|
cmd: Command,
|
|
@@ -207,22 +223,73 @@ class _BaseProtocol(asyncio.Protocol):
|
|
|
207
223
|
gap_duration: float = DEFAULT_GAP_DURATION,
|
|
208
224
|
num_repeats: int = DEFAULT_NUM_REPEATS,
|
|
209
225
|
priority: Priority = Priority.DEFAULT,
|
|
210
|
-
qos: QosParams = DEFAULT_QOS,
|
|
226
|
+
qos: QosParams = DEFAULT_QOS, # max_retries, timeout, wait_for_reply
|
|
211
227
|
) -> Packet:
|
|
212
|
-
"""
|
|
228
|
+
"""Send a Command with Qos (with retries, until success or ProtocolError).
|
|
213
229
|
|
|
214
|
-
|
|
215
|
-
|
|
230
|
+
Returns the Command's response Packet or the Command echo if a response is not
|
|
231
|
+
expected (e.g. sending an RP).
|
|
216
232
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
_LOGGER.debug(f"QUEUED: {cmd}")
|
|
233
|
+
If wait_for_reply is True, return the RQ's RP (or W's I), or raise an exception
|
|
234
|
+
if one doesn't arrive. If it is False, return the echo of the Command only. If
|
|
235
|
+
it is None (the default), act as True for RQs, and False for all other Commands.
|
|
221
236
|
|
|
222
|
-
|
|
223
|
-
|
|
237
|
+
num_repeats is # of times to send the Command, in addition to the fist transmit,
|
|
238
|
+
with gap_duration seconds between each transmission. If wait_for_reply is True,
|
|
239
|
+
then num_repeats is ignored.
|
|
240
|
+
|
|
241
|
+
Commands are queued and sent FIFO, except higher-priority Commands are always
|
|
242
|
+
sent first.
|
|
243
|
+
|
|
244
|
+
Will raise:
|
|
245
|
+
ProtocolSendFailed: tried to Tx Command, but didn't get echo/reply
|
|
246
|
+
ProtocolError: didn't attempt to Tx Command for some reason
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
assert gap_duration == DEFAULT_GAP_DURATION
|
|
250
|
+
assert 0 <= num_repeats <= 3 # if QoS, only Tx x1, with no repeats
|
|
224
251
|
|
|
225
|
-
|
|
252
|
+
# FIX: Patch command with actual HGI ID if it uses the default placeholder
|
|
253
|
+
# NOTE: HGI80s (TI 3410) require the default ID (18:000730), or they will silent-fail
|
|
254
|
+
|
|
255
|
+
if (
|
|
256
|
+
self.hgi_id
|
|
257
|
+
and self._is_evofw3 # Only patch if using evofw3 (not HGI80)
|
|
258
|
+
and cmd._addrs[0].id == HGI_DEV_ADDR.id
|
|
259
|
+
and self.hgi_id != HGI_DEV_ADDR.id
|
|
260
|
+
):
|
|
261
|
+
# The command uses the default 18:000730, but we know the real ID.
|
|
262
|
+
# Reconstruct the command string with the correct address.
|
|
263
|
+
|
|
264
|
+
_LOGGER.debug(
|
|
265
|
+
f"Patching command with active HGI ID: swapped {HGI_DEV_ADDR.id} -> {self.hgi_id} for {cmd._hdr}"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# Get current addresses as strings
|
|
269
|
+
# The command uses the default 18:000730, but we know the real ID.
|
|
270
|
+
# Reconstruct the command string with the correct address.
|
|
271
|
+
|
|
272
|
+
# Get current addresses as strings
|
|
273
|
+
new_addrs = [a.id for a in cmd._addrs]
|
|
274
|
+
|
|
275
|
+
# ONLY patch the Source Address (Index 0).
|
|
276
|
+
# Leave Dest (Index 1/2) alone to avoid breaking tests that expect 18:000730 there.
|
|
277
|
+
new_addrs[0] = self.hgi_id
|
|
278
|
+
|
|
279
|
+
new_frame = f"{cmd.verb} {cmd.seqn} {new_addrs[0]} {new_addrs[1]} {new_addrs[2]} {cmd.code} {int(cmd.len_):03d} {cmd.payload}"
|
|
280
|
+
cmd = Command(new_frame)
|
|
281
|
+
|
|
282
|
+
if qos and not self._context:
|
|
283
|
+
_LOGGER.warning(f"{cmd} < QoS is currently disabled by this Protocol")
|
|
284
|
+
|
|
285
|
+
if cmd.src.id != self.hgi_id: # Was HGI_DEV_ADDR.id
|
|
286
|
+
await self._send_impersonation_alert(cmd)
|
|
287
|
+
|
|
288
|
+
if qos.wait_for_reply and num_repeats:
|
|
289
|
+
_LOGGER.warning(f"{cmd} < num_repeats set to 0, as wait_for_reply is True")
|
|
290
|
+
num_repeats = 0 # the lesser crime over wait_for_reply=False
|
|
291
|
+
|
|
292
|
+
pkt = await self._send_cmd( # may: raise ProtocolError/ProtocolSendFailed
|
|
226
293
|
cmd,
|
|
227
294
|
gap_duration=gap_duration,
|
|
228
295
|
num_repeats=num_repeats,
|
|
@@ -230,6 +297,11 @@ class _BaseProtocol(asyncio.Protocol):
|
|
|
230
297
|
qos=qos,
|
|
231
298
|
)
|
|
232
299
|
|
|
300
|
+
if not pkt: # HACK: temporary workaround for returning None
|
|
301
|
+
raise exc.ProtocolSendFailed(f"Failed to send command: {cmd} (REPORT THIS)")
|
|
302
|
+
|
|
303
|
+
return pkt
|
|
304
|
+
|
|
233
305
|
async def _send_cmd(
|
|
234
306
|
self,
|
|
235
307
|
cmd: Command,
|
|
@@ -249,6 +321,10 @@ class _BaseProtocol(asyncio.Protocol):
|
|
|
249
321
|
self, frame: str, num_repeats: int = 0, gap_duration: float = 0.0
|
|
250
322
|
) -> None: # _send_frame() -> transport
|
|
251
323
|
"""Write to the transport."""
|
|
324
|
+
|
|
325
|
+
if self._transport is None:
|
|
326
|
+
raise exc.ProtocolSendFailed("Transport is not connected")
|
|
327
|
+
|
|
252
328
|
await self._transport.write_frame(frame)
|
|
253
329
|
for _ in range(num_repeats - 1):
|
|
254
330
|
await asyncio.sleep(gap_duration)
|
|
@@ -282,6 +358,7 @@ class _BaseProtocol(asyncio.Protocol):
|
|
|
282
358
|
"""
|
|
283
359
|
|
|
284
360
|
if self._msg_handler: # type: ignore[truthy-function]
|
|
361
|
+
_LOGGER.debug(f"Dispatching valid message to handler: {msg}")
|
|
285
362
|
self._loop.call_soon_threadsafe(self._msg_handler, msg)
|
|
286
363
|
for callback in self._msg_handlers:
|
|
287
364
|
# TODO: if handler's filter returns True:
|
|
@@ -322,9 +399,10 @@ class _DeviceIdFilterMixin(_BaseProtocol):
|
|
|
322
399
|
def hgi_id(self) -> DeviceIdT:
|
|
323
400
|
if not self._transport:
|
|
324
401
|
return self._known_hgi or HGI_DEV_ADDR.id
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
)
|
|
402
|
+
# CRITICAL FIX: get_extra_info returns None if key exists but val is None
|
|
403
|
+
# We must ensure we fallback to the known HGI or default if it returns None
|
|
404
|
+
hgi = self._transport.get_extra_info(SZ_ACTIVE_HGI)
|
|
405
|
+
return hgi or self._known_hgi or HGI_DEV_ADDR.id
|
|
328
406
|
|
|
329
407
|
@staticmethod
|
|
330
408
|
def _extract_known_hgi_id(
|
|
@@ -367,7 +445,10 @@ class _DeviceIdFilterMixin(_BaseProtocol):
|
|
|
367
445
|
|
|
368
446
|
known_hgi = (explicit_hgis if explicit_hgis else implicit_hgis)[0]
|
|
369
447
|
|
|
370
|
-
if include_list[known_hgi].get(SZ_CLASS)
|
|
448
|
+
if include_list[known_hgi].get(SZ_CLASS) not in (
|
|
449
|
+
DevType.HGI,
|
|
450
|
+
DEV_TYPE_MAP[DevType.HGI],
|
|
451
|
+
):
|
|
371
452
|
logger(
|
|
372
453
|
f"The {SZ_KNOWN_LIST} SHOULD include exactly one gateway (HGI): "
|
|
373
454
|
f"{known_hgi} should specify 'class: HGI', as 18: is also used for HVAC"
|
|
@@ -555,8 +636,17 @@ class PortProtocol(_DeviceIdFilterMixin, _BaseProtocol):
|
|
|
555
636
|
super().connection_made(transport)
|
|
556
637
|
# TODO: needed? self.resume_writing()
|
|
557
638
|
|
|
558
|
-
self.
|
|
559
|
-
self.
|
|
639
|
+
# ROBUSTNESS FIX: Ensure self._transport is set even if the wait future was cancelled
|
|
640
|
+
if self._transport is None:
|
|
641
|
+
_LOGGER.warning(
|
|
642
|
+
f"{self}: Transport bound after wait cancelled (late connection)"
|
|
643
|
+
)
|
|
644
|
+
self._transport = transport
|
|
645
|
+
|
|
646
|
+
# Safe access with check (optional but recommended)
|
|
647
|
+
if self._transport:
|
|
648
|
+
self._set_active_hgi(self._transport.get_extra_info(SZ_ACTIVE_HGI))
|
|
649
|
+
self._is_evofw3 = self._transport.get_extra_info(SZ_IS_EVOFW3)
|
|
560
650
|
|
|
561
651
|
if not self._context:
|
|
562
652
|
return
|
|
@@ -656,6 +746,8 @@ class PortProtocol(_DeviceIdFilterMixin, _BaseProtocol):
|
|
|
656
746
|
# f"{self}: Failed to send {cmd._hdr}: excluded by list"
|
|
657
747
|
# )
|
|
658
748
|
|
|
749
|
+
assert self._context
|
|
750
|
+
|
|
659
751
|
try:
|
|
660
752
|
return await self._context.send_cmd(send_cmd, cmd, priority, qos)
|
|
661
753
|
# except InvalidStateError as err: # TODO: handle InvalidStateError separately
|
|
@@ -701,9 +793,6 @@ class PortProtocol(_DeviceIdFilterMixin, _BaseProtocol):
|
|
|
701
793
|
if qos and not self._context:
|
|
702
794
|
_LOGGER.warning(f"{cmd} < QoS is currently disabled by this Protocol")
|
|
703
795
|
|
|
704
|
-
if cmd.src.id != HGI_DEV_ADDR.id: # or actual HGI addr
|
|
705
|
-
await self._send_impersonation_alert(cmd)
|
|
706
|
-
|
|
707
796
|
if qos.wait_for_reply and num_repeats:
|
|
708
797
|
_LOGGER.warning(f"{cmd} < num_repeats set to 0, as wait_for_reply is True")
|
|
709
798
|
num_repeats = 0 # the lesser crime over wait_for_reply=False
|
|
@@ -791,7 +880,7 @@ async def create_stack(
|
|
|
791
880
|
)
|
|
792
881
|
|
|
793
882
|
transport: RamsesTransportT = await (transport_factory_ or transport_factory)( # type: ignore[operator]
|
|
794
|
-
protocol, disable_sending=disable_sending, **kwargs
|
|
883
|
+
protocol, disable_sending=bool(disable_sending), **kwargs
|
|
795
884
|
)
|
|
796
885
|
|
|
797
886
|
if not kwargs.get(SZ_PORT_NAME):
|
ramses_tx/protocol_fsm.py
CHANGED
|
@@ -146,9 +146,13 @@ class ProtocolContext:
|
|
|
146
146
|
self._multiplier = min(3, old_val + 1)
|
|
147
147
|
|
|
148
148
|
if isinstance(self._state, WantEcho):
|
|
149
|
-
_LOGGER.warning(
|
|
149
|
+
_LOGGER.warning(
|
|
150
|
+
f"Timeout expired waiting for echo: {self} (delay={delay})"
|
|
151
|
+
)
|
|
150
152
|
else: # isinstance(self._state, WantRply):
|
|
151
|
-
_LOGGER.warning(
|
|
153
|
+
_LOGGER.warning(
|
|
154
|
+
f"Timeout expired waiting for reply: {self} (delay={delay})"
|
|
155
|
+
)
|
|
152
156
|
|
|
153
157
|
assert isinstance(self.is_sending, bool), (
|
|
154
158
|
f"{self}: Coding error"
|
|
@@ -197,8 +201,16 @@ class ProtocolContext:
|
|
|
197
201
|
# _fut.cancel() (incl. via a send_cmd(qos.timeout) -> wait_for(timeout))
|
|
198
202
|
|
|
199
203
|
# Changing the order of the following is fraught with danger
|
|
204
|
+
# Formatitng: [TRACE_FSM] [State: Old->New] [Reason] | ...
|
|
205
|
+
|
|
206
|
+
current_state_name = self._state.__class__.__name__
|
|
207
|
+
new_state_name = state_class.__name__
|
|
208
|
+
transition = f"{current_state_name}->{new_state_name}"
|
|
209
|
+
|
|
200
210
|
if self._fut is None: # logging only - IsInIdle, Inactive
|
|
201
|
-
_LOGGER.debug(
|
|
211
|
+
_LOGGER.debug(
|
|
212
|
+
f"FSM state changed {transition}: no active future (ctx={self})"
|
|
213
|
+
)
|
|
202
214
|
assert self._cmd is None, f"{self}: Coding error" # mypy hint
|
|
203
215
|
assert isinstance(self._state, IsInIdle | Inactive | None), (
|
|
204
216
|
f"{self}: Coding error"
|
|
@@ -207,14 +219,18 @@ class ProtocolContext:
|
|
|
207
219
|
elif self._fut.cancelled() and not isinstance(self._state, IsInIdle):
|
|
208
220
|
# cancelled by wait_for(timeout), cancel("buffer overflow"), or other?
|
|
209
221
|
# was for previous send_cmd if currently IsInIdle (+/- Inactive?)
|
|
210
|
-
_LOGGER.debug(
|
|
222
|
+
_LOGGER.debug(
|
|
223
|
+
f"FSM state changed {transition}: future cancelled (expired={expired}, ctx={self})"
|
|
224
|
+
)
|
|
211
225
|
assert self._cmd is not None, f"{self}: Coding error" # mypy hint
|
|
212
226
|
assert isinstance(self._state, WantEcho | WantRply), (
|
|
213
227
|
f"{self}: Coding error"
|
|
214
228
|
) # mypy hint
|
|
215
229
|
|
|
216
230
|
elif exception:
|
|
217
|
-
_LOGGER.debug(
|
|
231
|
+
_LOGGER.debug(
|
|
232
|
+
f"FSM state changed {transition}: exception occurred (error={exception}, ctx={self})"
|
|
233
|
+
)
|
|
218
234
|
assert not self._fut.done(), (
|
|
219
235
|
f"{self}: Coding error ({self._fut})"
|
|
220
236
|
) # mypy hint
|
|
@@ -224,7 +240,9 @@ class ProtocolContext:
|
|
|
224
240
|
self._fut.set_exception(exception) # apologise to the sender
|
|
225
241
|
|
|
226
242
|
elif result:
|
|
227
|
-
_LOGGER.debug(
|
|
243
|
+
_LOGGER.debug(
|
|
244
|
+
f"FSM state changed {transition}: result received (result={result._hdr}, ctx={self})"
|
|
245
|
+
)
|
|
228
246
|
assert not self._fut.done(), (
|
|
229
247
|
f"{self}: Coding error ({self._fut})"
|
|
230
248
|
) # mypy hint
|
|
@@ -234,7 +252,7 @@ class ProtocolContext:
|
|
|
234
252
|
self._fut.set_result(result)
|
|
235
253
|
|
|
236
254
|
elif expired: # by expire_state_on_timeout(echo_timeout/reply_timeout)
|
|
237
|
-
_LOGGER.debug("
|
|
255
|
+
_LOGGER.debug(f"FSM state changed {transition}: timer expired (ctx={self})")
|
|
238
256
|
assert not self._fut.done(), (
|
|
239
257
|
f"{self}: Coding error ({self._fut})"
|
|
240
258
|
) # mypy hint
|
|
@@ -246,7 +264,7 @@ class ProtocolContext:
|
|
|
246
264
|
)
|
|
247
265
|
|
|
248
266
|
else: # logging only - WantEcho, WantRply
|
|
249
|
-
_LOGGER.debug("
|
|
267
|
+
_LOGGER.debug(f"FSM state changed {transition}: successful (ctx={self})")
|
|
250
268
|
assert self._fut is None or self._fut.cancelled() or not self._fut.done(), (
|
|
251
269
|
f"{self}: Coding error ({self._fut})"
|
|
252
270
|
) # mypy hint
|
|
@@ -281,11 +299,11 @@ class ProtocolContext:
|
|
|
281
299
|
self._loop.call_soon_threadsafe(effect_state, timed_out) # calls expire_state
|
|
282
300
|
|
|
283
301
|
if not isinstance(self._state, WantRply):
|
|
284
|
-
_LOGGER.debug("AFTER. = %s", self)
|
|
302
|
+
# _LOGGER.debug("AFTER. = %s", self)
|
|
285
303
|
return
|
|
286
304
|
|
|
287
305
|
assert self._qos is not None, f"{self}: Coding error" # mypy hint
|
|
288
|
-
_LOGGER.debug("AFTER. = %s: wait_for_reply=%s", self, self._qos.wait_for_reply)
|
|
306
|
+
# _LOGGER.debug("AFTER. = %s: wait_for_reply=%s", self, self._qos.wait_for_reply)
|
|
289
307
|
|
|
290
308
|
def connection_made(self, transport: RamsesTransportT) -> None:
|
|
291
309
|
# may want to set some instance variables, according to type of transport
|
ramses_tx/schemas.py
CHANGED
|
@@ -294,7 +294,7 @@ def sch_global_traits_dict_factory(
|
|
|
294
294
|
)
|
|
295
295
|
SCH_TRAITS_HEAT = SCH_TRAITS_HEAT.extend(
|
|
296
296
|
heat_traits,
|
|
297
|
-
extra=vol.PREVENT_EXTRA
|
|
297
|
+
extra=vol.PREVENT_EXTRA, # Always prevent extra keys
|
|
298
298
|
)
|
|
299
299
|
|
|
300
300
|
# NOTE: voluptuous doesn't like StrEnums, hence str(s)
|
|
@@ -314,7 +314,7 @@ def sch_global_traits_dict_factory(
|
|
|
314
314
|
)
|
|
315
315
|
SCH_TRAITS_HVAC = SCH_TRAITS_HVAC.extend(
|
|
316
316
|
hvac_traits,
|
|
317
|
-
extra=vol.PREVENT_EXTRA
|
|
317
|
+
extra=vol.PREVENT_EXTRA, # Always prevent extra keys
|
|
318
318
|
)
|
|
319
319
|
|
|
320
320
|
SCH_TRAITS = vol.Any(
|