ramses-rf 0.52.4__py3-none-any.whl → 0.52.5__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_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 = None # type: ignore[assignment]
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
- assert self._wait_connection_lost # mypy
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,64 @@ 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
- """This is the wrapper for self._send_cmd(cmd)."""
228
+ """Send a Command with Qos (with retries, until success or ProtocolError).
213
229
 
214
- # if not self._transport:
215
- # raise exc.ProtocolSendFailed("There is no connected Transport")
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
- if _DBG_FORCE_LOG_PACKETS:
218
- _LOGGER.warning(f"QUEUED: {cmd}")
219
- else:
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
- if self._pause_writing:
223
- raise exc.ProtocolError("The Protocol is currently read-only/paused")
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
+ """
224
248
 
225
- return await self._send_cmd(
249
+ assert gap_duration == DEFAULT_GAP_DURATION
250
+ assert 0 <= num_repeats <= 3 # if QoS, only Tx x1, with no repeats
251
+
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
+ if (
255
+ self._active_hgi
256
+ and self._is_evofw3 # Only patch if using evofw3 (not HGI80)
257
+ and cmd._addrs[0].id == HGI_DEV_ADDR.id
258
+ and self._active_hgi != HGI_DEV_ADDR.id
259
+ ):
260
+ # The command uses the default 18:000730, but we know the real ID.
261
+ # Reconstruct the command string with the correct address.
262
+
263
+ # Get current addresses as strings
264
+ new_addrs = [a.id for a in cmd._addrs]
265
+
266
+ # ONLY patch the Source Address (Index 0).
267
+ # Leave Dest (Index 1/2) alone to avoid breaking tests that expect 18:000730 there.
268
+ new_addrs[0] = self._active_hgi
269
+
270
+ new_frame = f"{cmd.verb} {cmd.seqn} {new_addrs[0]} {new_addrs[1]} {new_addrs[2]} {cmd.code} {int(cmd.len_):03d} {cmd.payload}"
271
+ cmd = Command(new_frame)
272
+
273
+ if qos and not self._context:
274
+ _LOGGER.warning(f"{cmd} < QoS is currently disabled by this Protocol")
275
+
276
+ if cmd.src.id != self.hgi_id: # Was HGI_DEV_ADDR.id
277
+ await self._send_impersonation_alert(cmd)
278
+
279
+ if qos.wait_for_reply and num_repeats:
280
+ _LOGGER.warning(f"{cmd} < num_repeats set to 0, as wait_for_reply is True")
281
+ num_repeats = 0 # the lesser crime over wait_for_reply=False
282
+
283
+ pkt = await self._send_cmd( # may: raise ProtocolError/ProtocolSendFailed
226
284
  cmd,
227
285
  gap_duration=gap_duration,
228
286
  num_repeats=num_repeats,
@@ -230,6 +288,11 @@ class _BaseProtocol(asyncio.Protocol):
230
288
  qos=qos,
231
289
  )
232
290
 
291
+ if not pkt: # HACK: temporary workaround for returning None
292
+ raise exc.ProtocolSendFailed(f"Failed to send command: {cmd} (REPORT THIS)")
293
+
294
+ return pkt
295
+
233
296
  async def _send_cmd(
234
297
  self,
235
298
  cmd: Command,
@@ -249,6 +312,10 @@ class _BaseProtocol(asyncio.Protocol):
249
312
  self, frame: str, num_repeats: int = 0, gap_duration: float = 0.0
250
313
  ) -> None: # _send_frame() -> transport
251
314
  """Write to the transport."""
315
+
316
+ if self._transport is None:
317
+ raise exc.ProtocolSendFailed("Transport is not connected")
318
+
252
319
  await self._transport.write_frame(frame)
253
320
  for _ in range(num_repeats - 1):
254
321
  await asyncio.sleep(gap_duration)
@@ -555,8 +622,17 @@ class PortProtocol(_DeviceIdFilterMixin, _BaseProtocol):
555
622
  super().connection_made(transport)
556
623
  # TODO: needed? self.resume_writing()
557
624
 
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)
625
+ # ROBUSTNESS FIX: Ensure self._transport is set even if the wait future was cancelled
626
+ if self._transport is None:
627
+ _LOGGER.warning(
628
+ f"{self}: Transport bound after wait cancelled (late connection)"
629
+ )
630
+ self._transport = transport
631
+
632
+ # Safe access with check (optional but recommended)
633
+ if self._transport:
634
+ self._set_active_hgi(self._transport.get_extra_info(SZ_ACTIVE_HGI))
635
+ self._is_evofw3 = self._transport.get_extra_info(SZ_IS_EVOFW3)
560
636
 
561
637
  if not self._context:
562
638
  return
@@ -656,6 +732,8 @@ class PortProtocol(_DeviceIdFilterMixin, _BaseProtocol):
656
732
  # f"{self}: Failed to send {cmd._hdr}: excluded by list"
657
733
  # )
658
734
 
735
+ assert self._context
736
+
659
737
  try:
660
738
  return await self._context.send_cmd(send_cmd, cmd, priority, qos)
661
739
  # except InvalidStateError as err: # TODO: handle InvalidStateError separately
@@ -701,9 +779,6 @@ class PortProtocol(_DeviceIdFilterMixin, _BaseProtocol):
701
779
  if qos and not self._context:
702
780
  _LOGGER.warning(f"{cmd} < QoS is currently disabled by this Protocol")
703
781
 
704
- if cmd.src.id != HGI_DEV_ADDR.id: # or actual HGI addr
705
- await self._send_impersonation_alert(cmd)
706
-
707
782
  if qos.wait_for_reply and num_repeats:
708
783
  _LOGGER.warning(f"{cmd} < num_repeats set to 0, as wait_for_reply is True")
709
784
  num_repeats = 0 # the lesser crime over wait_for_reply=False
ramses_tx/transport.py CHANGED
@@ -103,7 +103,7 @@ if TYPE_CHECKING:
103
103
 
104
104
 
105
105
  _DEFAULT_TIMEOUT_PORT: Final[float] = 3
106
- _DEFAULT_TIMEOUT_MQTT: Final[float] = 9
106
+ _DEFAULT_TIMEOUT_MQTT: Final[float] = 60 # Updated from 9s to 60s for robustness
107
107
 
108
108
  _SIGNATURE_GAP_SECS = 0.05
109
109
  _SIGNATURE_MAX_TRYS = 40 # was: 24
@@ -1368,7 +1368,13 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1368
1368
  )
1369
1369
  # FIXME: convert all dt early, and convert to aware, i.e. dt.now().astimezone()
1370
1370
 
1371
- self._frame_read(dtm.isoformat(), _normalise(payload["msg"]))
1371
+ try:
1372
+ self._frame_read(dtm.isoformat(), _normalise(payload["msg"]))
1373
+ except exc.TransportError:
1374
+ # If the transport is closing, we expect this error and can safely ignore it
1375
+ # prevents "Uncaught thread exception" in paho.mqtt client
1376
+ if not self._closing:
1377
+ raise
1372
1378
 
1373
1379
  async def write_frame(self, frame: str, disable_tx_limits: bool = False) -> None:
1374
1380
  """Transmit a frame via the underlying handler (e.g. serial port, MQTT).
@@ -1572,13 +1578,31 @@ async def transport_factory(
1572
1578
  assert port_config is not None # mypy check
1573
1579
 
1574
1580
  # MQTT
1575
- if port_name[:4] == "mqtt": # TODO: handle disable_sending
1581
+ if port_name[:4] == "mqtt":
1582
+ # Check for custom timeout in kwargs, fallback to constant
1583
+ mqtt_timeout = kwargs.get("timeout", _DEFAULT_TIMEOUT_MQTT)
1584
+
1576
1585
  transport = MqttTransport(
1577
- port_name, protocol, extra=extra, loop=loop, log_all=log_all, **kwargs
1586
+ port_name,
1587
+ protocol,
1588
+ disable_sending=bool(
1589
+ disable_sending
1590
+ ), # Feature Added: handled disable_sending
1591
+ extra=extra,
1592
+ loop=loop,
1593
+ log_all=log_all,
1594
+ **kwargs,
1578
1595
  )
1579
1596
 
1580
- # TODO: remove this? better to invoke timeout after factory returns?
1581
- await protocol.wait_for_connection_made(timeout=_DEFAULT_TIMEOUT_MQTT)
1597
+ try:
1598
+ # Robustness Fix: Wait with timeout, handle failure gracefully
1599
+ await protocol.wait_for_connection_made(timeout=mqtt_timeout)
1600
+ except Exception:
1601
+ # CRITICAL FIX: Close the transport if setup fails to prevent "Zombie" callbacks
1602
+ # This prevents the "AttributeError: 'NoneType'..." crash later on
1603
+ transport.close()
1604
+ raise
1605
+
1582
1606
  return transport
1583
1607
 
1584
1608
  # Serial
ramses_tx/version.py CHANGED
@@ -1,4 +1,4 @@
1
1
  """RAMSES RF - a RAMSES-II protocol decoder & analyser (transport layer)."""
2
2
 
3
- __version__ = "0.52.4"
3
+ __version__ = "0.52.5"
4
4
  VERSION = __version__