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_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,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
- """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
+ """
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
- return await self._send_cmd(
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
- return self._transport.get_extra_info( # type: ignore[no-any-return]
326
- SZ_ACTIVE_HGI, self._known_hgi or HGI_DEV_ADDR.id
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) != DevType.HGI:
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._set_active_hgi(self._transport.get_extra_info(SZ_ACTIVE_HGI))
559
- self._is_evofw3 = self._transport.get_extra_info(SZ_IS_EVOFW3)
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("TOUT.. = %s: echo_timeout=%s", self, delay)
149
+ _LOGGER.warning(
150
+ f"Timeout expired waiting for echo: {self} (delay={delay})"
151
+ )
150
152
  else: # isinstance(self._state, WantRply):
151
- _LOGGER.warning("TOUT.. = %s: rply_timeout=%s", self, delay)
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("BEFORE = %s", self)
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("BEFORE = %s: expired=%s (global)", self, expired)
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("BEFORE = %s: exception=%s", self, exception)
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("BEFORE = %s: result=%s", self, result._hdr)
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("BEFORE = %s: expired=%s", self, expired)
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("BEFORE = %s", self)
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 if heat_traits else vol.REMOVE_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 if hvac_traits else vol.REMOVE_EXTRA,
317
+ extra=vol.PREVENT_EXTRA, # Always prevent extra keys
318
318
  )
319
319
 
320
320
  SCH_TRAITS = vol.Any(