ramses-rf 0.53.3__py3-none-any.whl → 0.53.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_cli/client.py CHANGED
@@ -471,13 +471,8 @@ def print_results(gwy: Gateway, **kwargs: Any) -> None:
471
471
  system_id, _ = kwargs[GET_SCHED]
472
472
 
473
473
 
474
- def _save_state(gwy: Gateway) -> None:
475
- """Save the gateway state to files.
476
-
477
- :param gwy: The gateway instance.
478
- """
479
- schema, msgs = gwy.get_state()
480
-
474
+ def _write_state(schema: dict[str, Any], msgs: dict[str, str]) -> None:
475
+ """Write the state to the file system (blocking)."""
481
476
  with open("state_msgs.log", "w") as f:
482
477
  [f.write(f"{dtm} {pkt}\r\n") for dtm, pkt in msgs.items()] # if not m._expired
483
478
 
@@ -485,13 +480,22 @@ def _save_state(gwy: Gateway) -> None:
485
480
  f.write(json.dumps(schema, indent=4))
486
481
 
487
482
 
488
- def _print_engine_state(gwy: Gateway, **kwargs: Any) -> None:
483
+ async def _save_state(gwy: Gateway) -> None:
484
+ """Save the gateway state to files.
485
+
486
+ :param gwy: The gateway instance.
487
+ """
488
+ schema, msgs = await gwy.get_state()
489
+ await asyncio.to_thread(_write_state, schema, msgs)
490
+
491
+
492
+ async def _print_engine_state(gwy: Gateway, **kwargs: Any) -> None:
489
493
  """Print the current engine state (schema and packets).
490
494
 
491
495
  :param gwy: The gateway instance.
492
496
  :param kwargs: Command arguments to determine verbosity.
493
497
  """
494
- (schema, packets) = gwy.get_state(include_expired=True)
498
+ (schema, packets) = await gwy.get_state(include_expired=True)
495
499
 
496
500
  if kwargs["print_state"] > 0:
497
501
  print(f"schema: {json.dumps(schema, indent=4)}\r\n")
@@ -671,10 +675,10 @@ async def async_main(command: str, lib_kwargs: dict[str, Any], **kwargs: Any) ->
671
675
  print(f"\r\nclient.py: Engine stopped: {msg}")
672
676
 
673
677
  # if kwargs["save_state"]:
674
- # _save_state(gwy)
678
+ # await _save_state(gwy)
675
679
 
676
680
  if kwargs["print_state"]:
677
- _print_engine_state(gwy, **kwargs)
681
+ await _print_engine_state(gwy, **kwargs)
678
682
 
679
683
  elif command == EXECUTE:
680
684
  print_results(gwy, **kwargs)
ramses_rf/database.py CHANGED
@@ -35,7 +35,7 @@ from typing import TYPE_CHECKING, Any, NewType
35
35
 
36
36
  from ramses_tx import CODES_SCHEMA, RQ, Code, Message, Packet
37
37
 
38
- from .storage import StorageWorker
38
+ from .storage import PacketLogEntry, StorageWorker
39
39
 
40
40
  if TYPE_CHECKING:
41
41
  DtmStrT = NewType("DtmStrT", str)
@@ -241,28 +241,30 @@ class MessageIndex:
241
241
  :param dt_now: current timestamp
242
242
  :param _cutoff: the oldest timestamp to retain, default is 24 hours ago
243
243
  """
244
- msgs = None
245
244
  dtm = dt_now - _cutoff
246
245
 
247
- self._cu.execute("SELECT dtm FROM messages WHERE dtm >= ?", (dtm,))
248
- rows = self._cu.fetchall() # fetch dtm of current messages to retain
246
+ # Submit prune request to worker (Non-blocking I/O)
247
+ self._worker.submit_prune(dtm)
248
+
249
+ # Prune in-memory cache synchronously (Fast CPU-bound op)
250
+ dtm_iso = dtm.isoformat(timespec="microseconds")
249
251
 
250
252
  try: # make this operation atomic, i.e. update self._msgs only on success
251
253
  await self._lock.acquire()
252
- self._cu.execute("DELETE FROM messages WHERE dtm < ?", (dtm,))
253
- msgs = OrderedDict({row[0]: self._msgs[row[0]] for row in rows})
254
- self._cx.commit()
254
+ # Rebuild dict keeping only newer items
255
+ self._msgs = OrderedDict(
256
+ (k, v) for k, v in self._msgs.items() if k >= dtm_iso
257
+ )
255
258
 
256
- except sqlite3.Error: # need to tighten?
257
- self._cx.rollback()
259
+ except Exception as err:
260
+ _LOGGER.warning("MessageIndex housekeeping error: %s", err)
258
261
  else:
259
- self._msgs = msgs
262
+ _LOGGER.debug(
263
+ "MessageIndex housekeeping completed, retained messages >= %s",
264
+ dtm_iso,
265
+ )
260
266
  finally:
261
267
  self._lock.release()
262
- if msgs:
263
- _LOGGER.debug(
264
- "MessageIndex size was: %d, now: %d", len(rows), len(msgs)
265
- )
266
268
 
267
269
  while True:
268
270
  self._last_housekeeping = dt.now()
@@ -345,15 +347,15 @@ class MessageIndex:
345
347
  # Avoid blocking read; worker handles REPLACE on unique constraint collision
346
348
 
347
349
  # Prepare data tuple for worker
348
- data = (
349
- _now,
350
- verb,
351
- src,
352
- src,
353
- code,
354
- None,
355
- hdr,
356
- "|",
350
+ data = PacketLogEntry(
351
+ dtm=_now,
352
+ verb=verb,
353
+ src=src,
354
+ dst=src,
355
+ code=code,
356
+ ctx=None,
357
+ hdr=hdr,
358
+ plk="|",
357
359
  )
358
360
 
359
361
  self._worker.submit_packet(data)
@@ -390,15 +392,15 @@ class MessageIndex:
390
392
  # _old_msgs = self._delete_from(hdr=msg._pkt._hdr)
391
393
  # Refactor: Worker uses INSERT OR REPLACE to handle collision
392
394
 
393
- data = (
394
- msg.dtm,
395
- str(msg.verb),
396
- msg.src.id,
397
- msg.dst.id,
398
- str(msg.code),
399
- msg_pkt_ctx,
400
- msg._pkt._hdr,
401
- payload_keys(msg.payload),
395
+ data = PacketLogEntry(
396
+ dtm=msg.dtm,
397
+ verb=str(msg.verb),
398
+ src=msg.src.id,
399
+ dst=msg.dst.id,
400
+ code=str(msg.code),
401
+ ctx=msg_pkt_ctx,
402
+ hdr=msg._pkt._hdr,
403
+ plk=payload_keys(msg.payload),
402
404
  )
403
405
 
404
406
  self._worker.submit_packet(data)
ramses_rf/entity_base.py CHANGED
@@ -913,7 +913,9 @@ class _Discovery(_MessageDB):
913
913
  sql, (self.id[:_ID_SLICE], self.id[:_ID_SLICE])
914
914
  ):
915
915
  _LOGGER.debug("Fetched OT ctx from index: %s", rec[0])
916
- res.append(rec[0])
916
+ # SQLite can return int, expected str (hex)
917
+ val = f"{rec[0]:02X}" if isinstance(rec[0], int) else rec[0]
918
+ res.append(val)
917
919
  else: # TODO(eb): remove next Q1 2026
918
920
  res_dict: dict[bool | str | None, Message] | list[Any] = self._msgz[
919
921
  Code._3220
ramses_rf/gateway.py CHANGED
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
  import asyncio
13
13
  import logging
14
14
  from collections.abc import Awaitable, Callable
15
+ from logging.handlers import QueueListener
15
16
  from types import SimpleNamespace
16
17
  from typing import TYPE_CHECKING, Any
17
18
 
@@ -101,6 +102,7 @@ class Gateway(Engine):
101
102
  known_list: DeviceListT | None = None,
102
103
  loop: asyncio.AbstractEventLoop | None = None,
103
104
  transport_constructor: Callable[..., Awaitable[RamsesTransportT]] | None = None,
105
+ hgi_id: str | None = None,
104
106
  **kwargs: Any,
105
107
  ) -> None:
106
108
  """Initialize the Gateway instance.
@@ -121,6 +123,8 @@ class Gateway(Engine):
121
123
  :type loop: asyncio.AbstractEventLoop | None, optional
122
124
  :param transport_constructor: A factory for creating the transport layer, defaults to None.
123
125
  :type transport_constructor: Callable[..., Awaitable[RamsesTransportT]] | None, optional
126
+ :param hgi_id: The Device ID to use for the HGI (gateway), overriding defaults.
127
+ :type hgi_id: str | None, optional
124
128
  :param kwargs: Additional configuration parameters passed to the engine and schema.
125
129
  :type kwargs: Any
126
130
  """
@@ -138,6 +142,7 @@ class Gateway(Engine):
138
142
  block_list=block_list,
139
143
  known_list=known_list,
140
144
  loop=loop,
145
+ hgi_id=hgi_id,
141
146
  transport_constructor=transport_constructor,
142
147
  **SCH_ENGINE_CONFIG(config),
143
148
  )
@@ -159,6 +164,7 @@ class Gateway(Engine):
159
164
  self.device_by_id: dict[DeviceIdT, Device] = {}
160
165
 
161
166
  self.msg_db: MessageIndex | None = None
167
+ self._pkt_log_listener: QueueListener | None = None
162
168
 
163
169
  def __repr__(self) -> str:
164
170
  """Return a string representation of the Gateway.
@@ -218,10 +224,12 @@ class Gateway(Engine):
218
224
  if system.dhw:
219
225
  system.dhw._start_discovery_poller()
220
226
 
221
- await set_pkt_logging_config( # type: ignore[arg-type]
227
+ _, self._pkt_log_listener = await set_pkt_logging_config( # type: ignore[arg-type]
222
228
  cc_console=self.config.reduce_processing >= DONT_CREATE_MESSAGES,
223
229
  **self._packet_log,
224
230
  )
231
+ if self._pkt_log_listener:
232
+ self._pkt_log_listener.start()
225
233
 
226
234
  # initialize SQLite index, set in _tx/Engine
227
235
  if self._sqlite_index: # TODO(eb): default to True in Q1 2026
@@ -267,12 +275,21 @@ class Gateway(Engine):
267
275
  :returns: None
268
276
  :rtype: None
269
277
  """
278
+ # Stop the Engine first to ensure no tasks/callbacks try to write
279
+ # to the DB while we are closing it.
280
+ await super().stop()
281
+
282
+ if self._pkt_log_listener:
283
+ self._pkt_log_listener.stop()
284
+ # Close handlers to ensure files are flushed/closed
285
+ for handler in self._pkt_log_listener.handlers:
286
+ handler.close()
287
+ self._pkt_log_listener = None
270
288
 
271
289
  if self.msg_db:
272
290
  self.msg_db.stop()
273
- await super().stop()
274
291
 
275
- def _pause(self, *args: Any) -> None:
292
+ async def _pause(self, *args: Any) -> None:
276
293
  """Pause the (unpaused) gateway (disables sending/discovery).
277
294
 
278
295
  There is the option to save other objects, as `args`.
@@ -288,12 +305,12 @@ class Gateway(Engine):
288
305
  self.config.disable_discovery, disc_flag = True, self.config.disable_discovery
289
306
 
290
307
  try:
291
- super()._pause(disc_flag, *args)
308
+ await super()._pause(disc_flag, *args)
292
309
  except RuntimeError:
293
310
  self.config.disable_discovery = disc_flag
294
311
  raise
295
312
 
296
- def _resume(self) -> tuple[Any]:
313
+ async def _resume(self) -> tuple[Any]:
297
314
  """Resume the (paused) gateway (enables sending/discovery, if applicable).
298
315
 
299
316
  Will restore other objects, as `args`.
@@ -305,11 +322,13 @@ class Gateway(Engine):
305
322
 
306
323
  _LOGGER.debug("Gateway: Resuming engine...")
307
324
 
308
- self.config.disable_discovery, *args = super()._resume() # type: ignore[assignment]
325
+ # args_tuple = await super()._resume()
326
+ # self.config.disable_discovery, *args = args_tuple # type: ignore[assignment]
327
+ self.config.disable_discovery, *args = await super()._resume() # type: ignore[assignment]
309
328
 
310
329
  return args
311
330
 
312
- def get_state(
331
+ async def get_state(
313
332
  self, include_expired: bool = False
314
333
  ) -> tuple[dict[str, Any], dict[str, str]]:
315
334
  """Return the current schema & state (may include expired packets).
@@ -320,7 +339,7 @@ class Gateway(Engine):
320
339
  :rtype: tuple[dict[str, Any], dict[str, str]]
321
340
  """
322
341
 
323
- self._pause()
342
+ await self._pause()
324
343
 
325
344
  def wanted_msg(msg: Message, include_expired: bool = False) -> bool:
326
345
  if msg.code == Code._313F:
@@ -357,7 +376,7 @@ class Gateway(Engine):
357
376
  }
358
377
  # _LOGGER.warning("Missing MessageIndex")
359
378
 
360
- self._resume()
379
+ await self._resume()
361
380
 
362
381
  return self.schema, dict(sorted(pkts.items()))
363
382
 
@@ -392,7 +411,7 @@ class Gateway(Engine):
392
411
  tmp_transport: RamsesTransportT # mypy hint
393
412
 
394
413
  _LOGGER.debug("Gateway: Restoring a cached packet log...")
395
- self._pause()
414
+ await self._pause()
396
415
 
397
416
  if _clear_state: # only intended for test suite use
398
417
  clear_state()
@@ -428,7 +447,7 @@ class Gateway(Engine):
428
447
  await tmp_transport.get_extra_info(SZ_READER_TASK)
429
448
 
430
449
  _LOGGER.debug("Gateway: Restored, resuming")
431
- self._resume()
450
+ await self._resume()
432
451
 
433
452
  def _add_device(self, dev: Device) -> None: # TODO: also: _add_system()
434
453
  """Add a device to the gateway (called by devices during instantiation).
ramses_rf/storage.py CHANGED
@@ -7,24 +7,46 @@ import logging
7
7
  import queue
8
8
  import sqlite3
9
9
  import threading
10
- from typing import Any
10
+ from typing import Any, NamedTuple
11
11
 
12
12
  _LOGGER = logging.getLogger(__name__)
13
13
 
14
14
 
15
+ class PacketLogEntry(NamedTuple):
16
+ """Represents a packet to be written to the database."""
17
+
18
+ dtm: Any
19
+ verb: str
20
+ src: str
21
+ dst: str
22
+ code: str
23
+ ctx: str | None
24
+ hdr: str
25
+ plk: str
26
+
27
+
28
+ class PruneRequest(NamedTuple):
29
+ """Represents a request to prune old records."""
30
+
31
+ dtm_limit: Any
32
+
33
+
34
+ QueueItem = PacketLogEntry | PruneRequest | tuple[str, Any] | None
35
+
36
+
15
37
  class StorageWorker:
16
38
  """A background worker thread to handle blocking storage I/O asynchronously."""
17
39
 
18
- def __init__(self, db_path: str = ":memory:"):
40
+ def __init__(self, db_path: str = ":memory:") -> None:
19
41
  """Initialize the storage worker thread."""
20
42
  self._db_path = db_path
21
- self._queue: queue.SimpleQueue[tuple[str, Any] | None] = queue.SimpleQueue()
43
+ self._queue: queue.SimpleQueue[QueueItem] = queue.SimpleQueue()
22
44
  self._ready_event = threading.Event()
23
45
 
24
46
  self._thread = threading.Thread(
25
47
  target=self._run,
26
48
  name="RamsesStorage",
27
- daemon=True, # FIX: Set to True so the process can exit even if stop() is missed
49
+ daemon=True, # Allows process exit even if stop() is missed
28
50
  )
29
51
  self._thread.start()
30
52
 
@@ -32,16 +54,16 @@ class StorageWorker:
32
54
  """Wait until the database is initialized and ready."""
33
55
  return self._ready_event.wait(timeout)
34
56
 
35
- def submit_packet(self, packet_data: tuple[Any, ...]) -> None:
57
+ def submit_packet(self, packet: PacketLogEntry) -> None:
36
58
  """Submit a packet tuple for SQL insertion (Non-blocking)."""
37
- self._queue.put(("SQL", packet_data))
59
+ self._queue.put(packet)
60
+
61
+ def submit_prune(self, dtm_limit: Any) -> None:
62
+ """Submit a prune request for SQL deletion (Non-blocking)."""
63
+ self._queue.put(PruneRequest(dtm_limit))
38
64
 
39
65
  def flush(self, timeout: float = 10.0) -> None:
40
66
  """Block until all currently pending tasks are processed."""
41
- # REMOVED: if self._queue.empty(): return
42
- # This check caused a race condition where flush() returned before
43
- # the worker finished committing the last item it just popped.
44
-
45
67
  # We inject a special marker into the queue
46
68
  sentinel = threading.Event()
47
69
  self._queue.put(("MARKER", sentinel))
@@ -89,7 +111,7 @@ class StorageWorker:
89
111
  detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES,
90
112
  check_same_thread=False,
91
113
  uri=True,
92
- timeout=10.0, # Increased timeout for locking
114
+ timeout=10.0,
93
115
  )
94
116
 
95
117
  # Enable Write-Ahead Logging for concurrency
@@ -116,16 +138,9 @@ class StorageWorker:
116
138
  if item is None: # Shutdown signal
117
139
  break
118
140
 
119
- task_type, data = item
120
-
121
- if task_type == "MARKER":
122
- # Flush requested
123
- data.set()
124
- continue
125
-
126
- if task_type == "SQL":
141
+ if isinstance(item, PacketLogEntry):
127
142
  # Optimization: Batch processing
128
- batch = [data]
143
+ batch = [item]
129
144
  # Drain queue of pending SQL tasks to bulk insert
130
145
  while not self._queue.empty():
131
146
  try:
@@ -135,22 +150,19 @@ class StorageWorker:
135
150
  self._queue.put(None) # Re-queue poison pill
136
151
  break
137
152
 
138
- next_type, next_data = next_item
139
- if next_type == "SQL":
140
- batch.append(next_data)
141
- elif next_type == "MARKER":
142
- # Handle marker after this batch
143
- self._queue.put(next_item) # Re-queue marker
144
- break
153
+ if isinstance(next_item, PacketLogEntry):
154
+ batch.append(next_item)
145
155
  else:
146
- pass
156
+ # Handle other types after this batch
157
+ self._queue.put(next_item) # Re-queue
158
+ break
147
159
  except queue.Empty:
148
160
  break
149
161
 
150
162
  try:
151
163
  conn.executemany(
152
164
  """
153
- INSERT OR REPLACE INTO messages
165
+ INSERT OR REPLACE INTO messages
154
166
  (dtm, verb, src, dst, code, ctx, hdr, plk)
155
167
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
156
168
  """,
@@ -160,6 +172,20 @@ class StorageWorker:
160
172
  except sqlite3.Error as err:
161
173
  _LOGGER.error("SQL Write Failed: %s", err)
162
174
 
175
+ elif isinstance(item, PruneRequest):
176
+ try:
177
+ conn.execute(
178
+ "DELETE FROM messages WHERE dtm < ?", (item.dtm_limit,)
179
+ )
180
+ conn.commit()
181
+ _LOGGER.debug("Pruned records older than %s", item.dtm_limit)
182
+ except sqlite3.Error as err:
183
+ _LOGGER.error("SQL Prune Failed: %s", err)
184
+
185
+ elif isinstance(item, tuple) and item[0] == "MARKER":
186
+ # Flush requested
187
+ item[1].set()
188
+
163
189
  except Exception as err:
164
190
  _LOGGER.exception("StorageWorker encountered an error: %s", err)
165
191
 
ramses_rf/system/heat.py CHANGED
@@ -137,7 +137,7 @@ class SystemBase(Parent, Entity): # 3B00 (multi-relay)
137
137
  self._child_id = FF # NOTE: domain_id
138
138
 
139
139
  self._app_cntrl: BdrSwitch | OtbGateway | None = None
140
- self._heat_demand = None
140
+ self._heat_demand: dict[str, Any] | None = None
141
141
 
142
142
  def __repr__(self) -> str:
143
143
  return f"{self.ctl.id} ({self._SLUG})"
@@ -217,17 +217,33 @@ class SystemBase(Parent, Entity): # 3B00 (multi-relay)
217
217
  super()._handle_msg(msg)
218
218
 
219
219
  if msg.code == Code._000C:
220
- if msg.payload[SZ_ZONE_TYPE] == DEV_ROLE_MAP.APP and msg.payload.get(
221
- SZ_DEVICES
222
- ):
223
- self._gwy.get_device(
224
- msg.payload[SZ_DEVICES][0], parent=self, child_id=FC
225
- ) # sets self._app_cntrl
220
+ if isinstance(msg.payload, dict):
221
+ if msg.payload[SZ_ZONE_TYPE] == DEV_ROLE_MAP.APP and msg.payload.get(
222
+ SZ_DEVICES
223
+ ):
224
+ self._gwy.get_device(
225
+ msg.payload[SZ_DEVICES][0], parent=self, child_id=FC
226
+ ) # sets self._app_cntrl
227
+ else:
228
+ _LOGGER.warning(
229
+ f"{msg!r} < Unexpected payload type for {msg.code}: {type(msg.payload)} (expected dict)"
230
+ )
226
231
  return
227
232
 
228
- if msg.code == Code._3150:
229
- if msg.payload.get(SZ_DOMAIN_ID) == FC and msg.verb in (I_, RP):
230
- self._heat_demand = msg.payload
233
+ if msg.code == Code._3150 and msg.verb in (I_, RP):
234
+ # 3150 payload can be a dict (old) or list (new, multi-zone)
235
+ if isinstance(msg.payload, list):
236
+ if payload := next(
237
+ (d for d in msg.payload if d.get(SZ_DOMAIN_ID) == FC), None
238
+ ):
239
+ self._heat_demand = payload
240
+ elif isinstance(msg.payload, dict):
241
+ if msg.payload.get(SZ_DOMAIN_ID) == FC:
242
+ self._heat_demand = msg.payload
243
+ else:
244
+ _LOGGER.warning(
245
+ f"{msg!r} < Unexpected payload type for {msg.code}: {type(msg.payload)} (expected list/dict)"
246
+ )
231
247
 
232
248
  if self._gwy.config.enable_eavesdrop and not self.appliance_control:
233
249
  eavesdrop_appliance_control(msg)
@@ -588,7 +604,12 @@ class ScheduleSync(SystemBase): # 0006 (+/- 0404?)
588
604
  super()._handle_msg(msg)
589
605
 
590
606
  if msg.code == Code._0006:
591
- self._msg_0006 = msg
607
+ if isinstance(msg.payload, dict):
608
+ self._msg_0006 = msg
609
+ else:
610
+ _LOGGER.warning(
611
+ f"{msg!r} < Unexpected payload type for {msg.code}: {type(msg.payload)} (expected dict)"
612
+ )
592
613
 
593
614
  async def _schedule_version(self, *, force_io: bool = False) -> tuple[int, bool]:
594
615
  """Return the global schedule version number, and an indication if I/O was done.
@@ -706,7 +727,12 @@ class Logbook(SystemBase): # 0418
706
727
  super()._handle_msg(msg)
707
728
 
708
729
  if msg.code == Code._0418: # and msg.verb in (I_, RP):
709
- self._faultlog.handle_msg(msg)
730
+ if isinstance(msg.payload, dict):
731
+ self._faultlog.handle_msg(msg)
732
+ else:
733
+ _LOGGER.warning(
734
+ f"{msg!r} < Unexpected payload type for {msg.code}: {type(msg.payload)} (expected dict)"
735
+ )
710
736
 
711
737
  async def get_faultlog(
712
738
  self,
ramses_rf/version.py CHANGED
@@ -1,4 +1,4 @@
1
1
  """RAMSES RF - a RAMSES-II protocol decoder & analyser (application layer)."""
2
2
 
3
- __version__ = "0.53.3"
3
+ __version__ = "0.53.5"
4
4
  VERSION = __version__
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ramses_rf
3
- Version: 0.53.3
3
+ Version: 0.53.5
4
4
  Summary: A stateful RAMSES-II protocol decoder & analyser.
5
5
  Project-URL: Homepage, https://github.com/ramses-rf/ramses_rf
6
6
  Project-URL: Bug Tracker, https://github.com/ramses-rf/ramses_rf/issues
@@ -1,5 +1,5 @@
1
1
  ramses_cli/__init__.py,sha256=d_3uIFkK8JnWOxknrBloKCe6-vI9Ouo_KGqR4kfBQW8,417
2
- ramses_cli/client.py,sha256=Fd6hXGBvI-i89Xu8QbBU9NW-dWlQjSer-_LYAtvJPW4,25055
2
+ ramses_cli/client.py,sha256=w95Xv2_kVlYelI5XnGt6D2QVLG3guiSMqo_MO1Ni-dc,25277
3
3
  ramses_cli/debug.py,sha256=PLcz-3PjUiMVqtD_p6VqTA92eHUM58lOBFXh_qgQ_wA,576
4
4
  ramses_cli/discovery.py,sha256=WTcoFH5hNhQ1AeOZtpdZIVYwdUfmUKlq2iBpa-KcgoI,12512
5
5
  ramses_cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -8,50 +8,50 @@ ramses_cli/utils/convert.py,sha256=N3LxGe3_0pclijtmYW-ChqCuPTzbkoJA4XNAnoSnBk0,1
8
8
  ramses_rf/__init__.py,sha256=AXsCK1Eh9FWeAI9D_zY_2KB0dqrTb9a5TNY1NvyQaDM,1271
9
9
  ramses_rf/binding_fsm.py,sha256=fuqvcc9YW-wr8SPH8zadpPqrHAvzl_eeWF-IBtlLppY,26632
10
10
  ramses_rf/const.py,sha256=L3z31CZ-xqno6oZp_h-67CB_5tDDqTwSWXsqRtsjMcs,5460
11
- ramses_rf/database.py,sha256=Fv3Xv6S_7qOf6-biHHKZvntkB8ps_SJvjPlKX0pzGfg,23919
11
+ ramses_rf/database.py,sha256=eARZ8F5lcITK6d_MfvozmMxSGNkiy1kbtAh0NOIHMoc,24066
12
12
  ramses_rf/dispatcher.py,sha256=YjEU-QrBLo9IfoEhJo2ikg_FxOaMYoWvzelr9Vi-JZ8,11398
13
- ramses_rf/entity_base.py,sha256=L47P_6CRz3tLDzOzII9AgmueKDb-Bp7Ot3vVsr8jo10,59121
13
+ ramses_rf/entity_base.py,sha256=Lv4N3dyIRfsz_5Ztgcu4bc49UE-N4c1VuN732_HQp-g,59255
14
14
  ramses_rf/exceptions.py,sha256=mt_T7irqHSDKir6KLaf6oDglUIdrw0S40JbOrWJk5jc,3657
15
- ramses_rf/gateway.py,sha256=hHsXbpeZ2Fetpu3jzv8UeUwlNf4rsxOD7eOHuLY83Xk,30101
15
+ ramses_rf/gateway.py,sha256=BsS3gyFcSOCLuzQ_OxgyqHTcn2wAVsEJaV6B5PbYre0,31087
16
16
  ramses_rf/helpers.py,sha256=TNk_QkpIOB3alOp1sqnA9LOzi4fuDCeapNlW3zTzNas,4250
17
17
  ramses_rf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
18
  ramses_rf/schemas.py,sha256=X1GAK3kttuLMiSCUDY2s-85fgBxPeU8xiDa6gJ1I5mY,13543
19
- ramses_rf/storage.py,sha256=lGKUgQzXBUwksmEeHMLoVoKPCMPAWWuzwCXM8G2CKmg,6452
20
- ramses_rf/version.py,sha256=2TZdwRJV5RSMyoyplQPOveNyPUW-VUGwr0W9X-R6arE,125
19
+ ramses_rf/storage.py,sha256=ZFUhvgsWRCVS1_r6LL032XFEJwZVp1RAK8Nfba8nf7o,7052
20
+ ramses_rf/version.py,sha256=HfkMfk1rLOZbqMvqEgE1o6j48RTa0l-dHJ-_7nrquYM,125
21
21
  ramses_rf/device/__init__.py,sha256=sUbH5dhbYFXSoM_TPFRutpRutBRpup7_cQ9smPtDTy8,4858
22
22
  ramses_rf/device/base.py,sha256=Tu5I8Lj7KplfRsIBQAYjilS6YPgTyjpU8qgKugMR2Jk,18281
23
23
  ramses_rf/device/heat.py,sha256=CU6GlIgjuYD21braJ_RJlS56zP47TGXNxXnZeavfEMY,54654
24
24
  ramses_rf/device/hvac.py,sha256=vdgiPiLtCAGr7CVsGhQl6XuAFkyYdQSE_2AEdCmRl2I,48502
25
25
  ramses_rf/system/__init__.py,sha256=uZLKio3gLlBzePa2aDQ1nxkcp1YXOGrn6iHTG8LiNIw,711
26
26
  ramses_rf/system/faultlog.py,sha256=GdGmVGT3137KsTlV_nhccgIFEmYu6DFsLTn4S-8JSok,12799
27
- ramses_rf/system/heat.py,sha256=qQmzgmyHy2x87gHAstn0ee7ZVVOq-GJIfDxCrC-6gFU,39254
27
+ ramses_rf/system/heat.py,sha256=31vCAgazc3x27XdMbz6UWH_nt2y-W483Ud0kA7-qpEI,40522
28
28
  ramses_rf/system/schedule.py,sha256=Ts6tdZPTQLV5NkgwA73tPa5QUsnZNIIuYoKC-8VsXDk,18808
29
29
  ramses_rf/system/zones.py,sha256=6VbPsOuNbGwBUuiRu8w9D1Q18SHKkuZa2YtKTE5nqlo,37110
30
- ramses_tx/__init__.py,sha256=sqnjM7pUGJDmec6igTtKViSB8FLX49B5gwhAmcY9ERY,3596
30
+ ramses_tx/__init__.py,sha256=2Ouc5CQJ3O0W4P8BAm5ThST6NbErhrTCp_jxVn816AM,3714
31
31
  ramses_tx/address.py,sha256=IuwUwZxykn3fP1UCRcv4D-zbTICBe2FJjDAFX5X6VoI,9108
32
32
  ramses_tx/command.py,sha256=drxmpdM4YgyPg4h0QIr1ouxK9QjfeLVgnFpDRox0CCY,125652
33
- ramses_tx/const.py,sha256=jiE2UaGBJ5agr68EMrcEHWtVz2KMidU7c7rRYCIiaoM,33010
33
+ ramses_tx/const.py,sha256=bkIP8NNGKY2dH37LRFYBdOKM23UZ35kgcsqmS28Kbf0,33158
34
34
  ramses_tx/exceptions.py,sha256=FJSU9YkvpKjs3yeTqUJX1o3TPFSe_B01gRGIh9b3PNc,2632
35
35
  ramses_tx/fingerprints.py,sha256=nfftA1E62HQnb-eLt2EqjEi_la0DAoT0wt-PtTMie0s,11974
36
36
  ramses_tx/frame.py,sha256=GzNsXr15YLeidJYGtk_xPqsZQh4ehDDlUCtT6rTDhT8,22046
37
- ramses_tx/gateway.py,sha256=H4JtIkp7JFMyZZ76sij67rTbjc1i3iWaIf6tuxIpHAg,11529
37
+ ramses_tx/gateway.py,sha256=tJVyuKjftkcAVVCJTxQ4vZZKiI0KwYwIXA1t8QYclh4,11725
38
38
  ramses_tx/helpers.py,sha256=96OvSOWYuMcr89_c-3dRnqHZaMOctCO94uo1hETh3bc,33613
39
- ramses_tx/logger.py,sha256=1iKRHKUaqHqGd76CkE_6mCVR0sYODtxshRRwfY61fTk,10426
39
+ ramses_tx/logger.py,sha256=EizcFiuDPMf0eVbkfyo_ka2DHz1MsrbzdrYSZoQY5KU,10981
40
40
  ramses_tx/message.py,sha256=zsyDQztSUYeqj3-P598LSmy9ODQY2BUCzWxSoZds6bM,13953
41
41
  ramses_tx/opentherm.py,sha256=58PXz9l5x8Ou6Fm3y-R_UnGHCYahoi2RKIDdYStUMzk,42378
42
42
  ramses_tx/packet.py,sha256=_nzuInS_WhdOI26SYvgsdDqIaDvVNguc2YDwdPOVCbU,7661
43
- ramses_tx/parsers.py,sha256=CJKdLF1F5KR7MwYxwkwSvrusTONh5FGecj7_eeBWu7A,148609
44
- ramses_tx/protocol.py,sha256=nBPKCD1tcGp_FiX0qhsY0XoGO_h87w5cYywBjSpum4w,33048
43
+ ramses_tx/parsers.py,sha256=ALUoi21ewd_GZHvxq4051AcVwETOTgVr5feWaY7zdls,148659
44
+ ramses_tx/protocol.py,sha256=E62vWb8qY7_SB5tb_NcywAED4d9NJJJ-1NgMaK3HG5s,33198
45
45
  ramses_tx/protocol_fsm.py,sha256=o9vLvlXor3LkPgsY1zii5P1R01GzYLf_PECDdoxtC24,27520
46
46
  ramses_tx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
47
- ramses_tx/ramses.py,sha256=V4LqD6IaohU7TTZp-_f1K2SOCJwzRY0v8_-INESh2cU,53986
47
+ ramses_tx/ramses.py,sha256=89EFL91zwnArefVcEVw3KoyqF92d3r3aBoJapMNAT0I,54389
48
48
  ramses_tx/schemas.py,sha256=Hrmf_q9bAZtkKJzGu6GtUO0QV_-K9i4L99EzGWR13eE,13408
49
- ramses_tx/transport.py,sha256=U24yITMh8M2yZKxnSyBe4yoIZfsFt4v9PhFvGDR_CuM,75926
49
+ ramses_tx/transport.py,sha256=RIrcNrJwiKB_xmJLgG4Z--V2d83PLsJnLXZK-WFFgsA,76568
50
50
  ramses_tx/typed_dicts.py,sha256=w-0V5t2Q3GiNUOrRAWiW9GtSwbta_7luME6GfIb1zhI,10869
51
51
  ramses_tx/typing.py,sha256=eF2SlPWhNhEFQj6WX2AhTXiyRQVXYnFutiepllYl2rI,5042
52
- ramses_tx/version.py,sha256=ps1zXjSlXEAn8-2HGlN4BfRIbiT1QSZo11KlMSwFdDY,123
53
- ramses_rf-0.53.3.dist-info/METADATA,sha256=EcLR1rL-xgkSjD0N3idYeMyGF1ptxyKjjNU1wsYFjSI,4179
54
- ramses_rf-0.53.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
55
- ramses_rf-0.53.3.dist-info/entry_points.txt,sha256=NnyK29baOCNg8DinPYiZ368h7MTH7bgTW26z2A1NeIE,50
56
- ramses_rf-0.53.3.dist-info/licenses/LICENSE,sha256=ptVutrtSMr7X-ek6LduiD8Cce4JsNn_8sR8MYlm-fvo,1086
57
- ramses_rf-0.53.3.dist-info/RECORD,,
52
+ ramses_tx/version.py,sha256=j6vK_nd2r1udUd4SmgheUwwD64f35BfwQoHsuHcFaYs,123
53
+ ramses_rf-0.53.5.dist-info/METADATA,sha256=m53OVOic5uCyW6ScGRSjARqKekXiTbJ4T2KLIMDe3LE,4179
54
+ ramses_rf-0.53.5.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
55
+ ramses_rf-0.53.5.dist-info/entry_points.txt,sha256=NnyK29baOCNg8DinPYiZ368h7MTH7bgTW26z2A1NeIE,50
56
+ ramses_rf-0.53.5.dist-info/licenses/LICENSE,sha256=ptVutrtSMr7X-ek6LduiD8Cce4JsNn_8sR8MYlm-fvo,1086
57
+ ramses_rf-0.53.5.dist-info/RECORD,,
ramses_tx/__init__.py CHANGED
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  import asyncio
9
9
  from functools import partial
10
+ from logging.handlers import QueueListener
10
11
  from typing import TYPE_CHECKING, Any
11
12
 
12
13
  from .address import (
@@ -156,17 +157,19 @@ if TYPE_CHECKING:
156
157
  from logging import Logger
157
158
 
158
159
 
159
- async def set_pkt_logging_config(**config: Any) -> Logger:
160
+ async def set_pkt_logging_config(**config: Any) -> tuple[Logger, QueueListener | None]:
160
161
  """
161
162
  Set up ramses packet logging to a file or port.
162
- Must run async in executor to prevent HA blocking call opening packet log file (issue #200)
163
+ Must run async in executor to prevent HA blocking call opening packet log file.
163
164
 
164
165
  :param config: if file_name is included, opens packet_log file
165
- :return: a logging.Logger
166
+ :return: a tuple (logging.Logger, QueueListener)
166
167
  """
167
168
  loop = asyncio.get_running_loop()
168
- await loop.run_in_executor(None, partial(set_pkt_logging, PKT_LOGGER, **config))
169
- return PKT_LOGGER
169
+ listener = await loop.run_in_executor(
170
+ None, partial(set_pkt_logging, PKT_LOGGER, **config)
171
+ )
172
+ return PKT_LOGGER, listener
170
173
 
171
174
 
172
175
  def extract_known_hgi_id(
ramses_tx/const.py CHANGED
@@ -20,10 +20,12 @@ DEFAULT_DISABLE_QOS: Final[bool | None] = None
20
20
  DEFAULT_WAIT_FOR_REPLY: Final[bool | None] = None
21
21
 
22
22
  #: Waiting for echo pkt after cmd sent (seconds)
23
- DEFAULT_ECHO_TIMEOUT: Final[float] = 0.50
23
+ # NOTE: Increased to 3.0s to support high-latency transports (e.g., MQTT)
24
+ DEFAULT_ECHO_TIMEOUT: Final[float] = 3.00
24
25
 
25
26
  #: Waiting for reply pkt after echo pkt rcvd (seconds)
26
- DEFAULT_RPLY_TIMEOUT: Final[float] = 0.50
27
+ # NOTE: Increased to 3.0s to support high-latency transports (e.g., MQTT)
28
+ DEFAULT_RPLY_TIMEOUT: Final[float] = 3.00
27
29
  DEFAULT_BUFFER_SIZE: Final[int] = 32
28
30
 
29
31
  #: Total waiting for successful send (seconds)
ramses_tx/gateway.py CHANGED
@@ -12,7 +12,6 @@ import asyncio
12
12
  import logging
13
13
  from collections.abc import Callable
14
14
  from datetime import datetime as dt
15
- from threading import Lock
16
15
  from typing import TYPE_CHECKING, Any, Never
17
16
 
18
17
  from .address import ALL_DEV_ADDR, HGI_DEV_ADDR, NON_DEV_ADDR
@@ -80,6 +79,7 @@ class Engine:
80
79
  packet_log: PktLogConfigT | None = None,
81
80
  block_list: DeviceListT | None = None,
82
81
  known_list: DeviceListT | None = None,
82
+ hgi_id: str | None = None,
83
83
  loop: asyncio.AbstractEventLoop | None = None,
84
84
  **kwargs: Any,
85
85
  ) -> None:
@@ -120,7 +120,11 @@ class Engine:
120
120
  self._log_all_mqtt = kwargs.pop(SZ_LOG_ALL_MQTT, False)
121
121
  self._kwargs: dict[str, Any] = kwargs # HACK
122
122
 
123
- self._engine_lock = Lock() # FIXME: threading lock, or asyncio lock?
123
+ self._hgi_id = hgi_id
124
+ if self._hgi_id:
125
+ self._kwargs[SZ_ACTIVE_HGI] = self._hgi_id
126
+
127
+ self._engine_lock = asyncio.Lock()
124
128
  self._engine_state: (
125
129
  tuple[_MsgHandlerT | None, bool | None, *tuple[Any, ...]] | None
126
130
  ) = None
@@ -136,6 +140,9 @@ class Engine:
136
140
  self._set_msg_handler(self._msg_handler) # sets self._protocol
137
141
 
138
142
  def __str__(self) -> str:
143
+ if self._hgi_id:
144
+ return f"{self._hgi_id} ({self.ser_name})"
145
+
139
146
  if not self._transport:
140
147
  return f"{HGI_DEV_ADDR.id} ({self.ser_name})"
141
148
 
@@ -217,15 +224,13 @@ class Engine:
217
224
  async def stop(self) -> None:
218
225
  """Close the transport (will stop the protocol)."""
219
226
 
220
- async def cancel_all_tasks() -> None: # TODO: needs a lock?
221
- _ = [t.cancel() for t in self._tasks if not t.done()]
222
- try: # FIXME: this is broken
223
- if tasks := (t for t in self._tasks if not t.done()):
224
- await asyncio.gather(*tasks)
225
- except asyncio.CancelledError:
226
- pass
227
+ # Shutdown Safety - wait for tasks to clean up
228
+ tasks = [t for t in self._tasks if not t.done()]
229
+ for t in tasks:
230
+ t.cancel()
227
231
 
228
- await cancel_all_tasks()
232
+ if tasks:
233
+ await asyncio.wait(tasks)
229
234
 
230
235
  if self._transport:
231
236
  self._transport.close()
@@ -233,12 +238,14 @@ class Engine:
233
238
 
234
239
  return None
235
240
 
236
- def _pause(self, *args: Any) -> None:
241
+ async def _pause(self, *args: Any) -> None:
237
242
  """Pause the (active) engine or raise a RuntimeError."""
238
-
239
- if not self._engine_lock.acquire(blocking=False):
243
+ # Async lock handling
244
+ if self._engine_lock.locked():
240
245
  raise RuntimeError("Unable to pause engine, failed to acquire lock")
241
246
 
247
+ await self._engine_lock.acquire()
248
+
242
249
  if self._engine_state is not None:
243
250
  self._engine_lock.release()
244
251
  raise RuntimeError("Unable to pause engine, it is already paused")
@@ -255,13 +262,18 @@ class Engine:
255
262
 
256
263
  self._engine_state = (handler, read_only, *args)
257
264
 
258
- def _resume(self) -> tuple[Any]: # FIXME: not atomic
265
+ async def _resume(self) -> tuple[Any]: # FIXME: not atomic
259
266
  """Resume the (paused) engine or raise a RuntimeError."""
260
267
 
261
268
  args: tuple[Any] # mypy
262
269
 
263
- if not self._engine_lock.acquire(timeout=0.1):
264
- raise RuntimeError("Unable to resume engine, failed to acquire lock")
270
+ # Async lock with timeout
271
+ try:
272
+ await asyncio.wait_for(self._engine_lock.acquire(), timeout=0.1)
273
+ except TimeoutError as err:
274
+ raise RuntimeError(
275
+ "Unable to resume engine, failed to acquire lock"
276
+ ) from err
265
277
 
266
278
  if self._engine_state is None:
267
279
  self._engine_lock.release()
ramses_tx/logger.py CHANGED
@@ -12,7 +12,12 @@ import shutil
12
12
  import sys
13
13
  from collections.abc import Callable, Mapping
14
14
  from datetime import datetime as dt
15
- from logging.handlers import TimedRotatingFileHandler as _TimedRotatingFileHandler
15
+ from logging.handlers import (
16
+ QueueHandler,
17
+ QueueListener,
18
+ TimedRotatingFileHandler as _TimedRotatingFileHandler,
19
+ )
20
+ from queue import Queue
16
21
  from typing import Any
17
22
 
18
23
  from .version import VERSION
@@ -239,7 +244,7 @@ def set_pkt_logging(
239
244
  file_name: str | None = None,
240
245
  rotate_backups: int = 0,
241
246
  rotate_bytes: int | None = None,
242
- ) -> None:
247
+ ) -> QueueListener | None:
243
248
  """Create/configure handlers, formatters, etc.
244
249
 
245
250
  Parameters:
@@ -255,6 +260,8 @@ def set_pkt_logging(
255
260
  for handler in logger.handlers: # dont use logger.hasHandlers() as not propagating
256
261
  logger.removeHandler(handler)
257
262
 
263
+ handlers: list[logging.Handler] = []
264
+
258
265
  if file_name: # note: this opens the packet_log file IO and may block
259
266
  if rotate_bytes:
260
267
  rotate_backups = rotate_backups or 2
@@ -273,14 +280,15 @@ def set_pkt_logging(
273
280
  handler.setFormatter(logfile_fmt)
274
281
  handler.setLevel(logging.INFO) # .INFO (usually), or .DEBUG
275
282
  handler.addFilter(PktLogFilter()) # record.levelno in (.INFO, .WARNING)
276
- logger.addHandler(handler)
283
+ handlers.append(handler)
277
284
 
278
285
  elif cc_console:
279
- logger.addHandler(logging.NullHandler())
286
+ # logger.addHandler(logging.NullHandler()) # Not needed with QueueHandler
287
+ pass
280
288
 
281
- else:
289
+ elif not cc_console:
282
290
  logger.setLevel(logging.CRITICAL)
283
- return
291
+ return None
284
292
 
285
293
  if cc_console: # CC: output to stdout/stderr
286
294
  console_fmt: ColoredFormatter | Formatter
@@ -297,13 +305,22 @@ def set_pkt_logging(
297
305
  handler.setFormatter(console_fmt)
298
306
  handler.setLevel(logging.WARNING) # musr be .WARNING or less
299
307
  handler.addFilter(StdErrFilter()) # record.levelno >= .WARNING
300
- logger.addHandler(handler)
308
+ handlers.append(handler)
301
309
 
302
310
  handler = logging.StreamHandler(stream=sys.stdout)
303
311
  handler.setFormatter(console_fmt)
304
312
  handler.setLevel(logging.DEBUG) # must be .INFO or less
305
313
  handler.addFilter(StdOutFilter()) # record.levelno < .WARNING
306
- logger.addHandler(handler)
314
+ handlers.append(handler)
315
+
316
+ # Use QueueHandler to decouple logging I/O from the main loop (see Issue #397)
317
+ if handlers:
318
+ log_queue: Queue[Any] = Queue(-1)
319
+ listener = QueueListener(log_queue, *handlers, respect_handler_level=True)
320
+ queue_handler = QueueHandler(log_queue)
321
+ logger.addHandler(queue_handler)
322
+ else:
323
+ return None
307
324
 
308
325
  extras = {
309
326
  "_frame": "",
@@ -311,3 +328,5 @@ def set_pkt_logging(
311
328
  "comment": f"ramses_tx {VERSION}",
312
329
  }
313
330
  logger.warning("", extra=extras) # initial log line
331
+
332
+ return listener
ramses_tx/parsers.py CHANGED
@@ -1898,6 +1898,7 @@ def parser_2210(payload: str, msg: Message) -> dict[str, Any]:
1898
1898
  assert payload[80:82] in (
1899
1899
  "01",
1900
1900
  "08",
1901
+ "0C", # seen on Orcon HCR-400 EcoMax
1901
1902
  ), f"expected byte 40 (01|08), not {payload[80:82]}"
1902
1903
  assert payload[82:] in (
1903
1904
  "00",
ramses_tx/protocol.py CHANGED
@@ -215,6 +215,41 @@ class _BaseProtocol(asyncio.Protocol):
215
215
  """Allow the Protocol to send an impersonation alert (stub)."""
216
216
  return
217
217
 
218
+ def _patch_cmd_if_needed(self, cmd: Command) -> Command:
219
+ """Patch the command with the actual HGI ID if it uses the default placeholder.
220
+
221
+ Legacy HGI80s (TI 3410) require the default ID (18:000730), or they will
222
+ silent-fail. However, evofw3 devices prefer the real ID.
223
+ """
224
+ # NOTE: accessing private member cmd._addrs to safely patch the source address
225
+
226
+ if (
227
+ self.hgi_id
228
+ and self._is_evofw3 # Only patch if using evofw3 (not HGI80)
229
+ and cmd._addrs[0].id == HGI_DEV_ADDR.id
230
+ and self.hgi_id != HGI_DEV_ADDR.id
231
+ ):
232
+ _LOGGER.debug(
233
+ f"Patching command with active HGI ID: swapped {HGI_DEV_ADDR.id} "
234
+ f"-> {self.hgi_id} for {cmd._hdr}"
235
+ )
236
+
237
+ # Get current addresses as strings
238
+ new_addrs = [a.id for a in cmd._addrs]
239
+
240
+ # ONLY patch the Source Address (Index 0).
241
+ # Leave Dest (Index 1/2) alone to avoid breaking tests that expect 18:000730.
242
+ new_addrs[0] = self.hgi_id
243
+
244
+ # Reconstruct the command string with the correct address
245
+ new_frame = (
246
+ f"{cmd.verb} {cmd.seqn} {new_addrs[0]} {new_addrs[1]} {new_addrs[2]} "
247
+ f"{cmd.code} {int(cmd.len_):03d} {cmd.payload}"
248
+ )
249
+ return Command(new_frame)
250
+
251
+ return cmd
252
+
218
253
  async def send_cmd(
219
254
  self,
220
255
  cmd: Command,
@@ -249,35 +284,8 @@ class _BaseProtocol(asyncio.Protocol):
249
284
  assert gap_duration == DEFAULT_GAP_DURATION
250
285
  assert 0 <= num_repeats <= 3 # if QoS, only Tx x1, with no repeats
251
286
 
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)
287
+ # Patch command with actual HGI ID if it uses the default placeholder
288
+ cmd = self._patch_cmd_if_needed(cmd)
281
289
 
282
290
  if qos and not self._context:
283
291
  _LOGGER.warning(f"{cmd} < QoS is currently disabled by this Protocol")
ramses_tx/ramses.py CHANGED
@@ -1,5 +1,14 @@
1
1
  #!/usr/bin/env python3
2
- """RAMSES RF - a RAMSES-II protocol decoder & analyser."""
2
+ """RAMSES RF - a RAMSES-II protocol decoder & analyser.
3
+
4
+ Contains e.g.:
5
+ :term:`CODES_SCHEMA` the master list of all known Ramses-II Code signatures
6
+ for both HEAT and HVAC.
7
+ :term:`_DEV_KLASSES_HEAT` defining Codes expected for each HEAT device class (SLUG).
8
+ :term:`_DEV_KLASSES_HVAC` defining Codes expected for each HVAC device class (SLUG).
9
+ :term:`_22F1_MODE_XXX` dicts defining valid fan commands.
10
+ :term:`_2411_PARAMS_SCHEMA` defining HVAC fan parameters.
11
+ """
3
12
 
4
13
  # TODO: code a lifespan for most packets
5
14
 
ramses_tx/transport.py CHANGED
@@ -49,7 +49,6 @@ import logging
49
49
  import os
50
50
  import re
51
51
  import sys
52
- import time
53
52
  from collections import deque
54
53
  from collections.abc import Awaitable, Callable, Iterable
55
54
  from datetime import datetime as dt, timedelta as td
@@ -979,18 +978,21 @@ class FileTransport(_ReadTransport, _FileTransportAbstractor):
979
978
  if bool(disable_sending) is False:
980
979
  raise exc.TransportSourceInvalid("This Transport cannot send packets")
981
980
 
981
+ self._evt_reading = asyncio.Event()
982
+
982
983
  self._extra[SZ_READER_TASK] = self._reader_task = self._loop.create_task(
983
984
  self._start_reader(), name="FileTransport._start_reader()"
984
985
  )
985
986
 
986
987
  self._make_connection(None)
987
988
 
988
- async def _start_reader(self) -> None: # TODO
989
+ async def _start_reader(self) -> None:
989
990
  """Start the reader task."""
990
991
  self._reading = True
992
+ self._evt_reading.set() # Start in reading state
993
+
991
994
  try:
992
- # await self._reader()
993
- await self.loop.run_in_executor(None, self._blocking_reader)
995
+ await self._producer_loop()
994
996
  except Exception as err:
995
997
  self.loop.call_soon_threadsafe(
996
998
  functools.partial(self._protocol.connection_lost, err) # type: ignore[arg-type]
@@ -1000,47 +1002,59 @@ class FileTransport(_ReadTransport, _FileTransportAbstractor):
1000
1002
  functools.partial(self._protocol.connection_lost, None)
1001
1003
  )
1002
1004
 
1003
- def _blocking_reader(self) -> None:
1005
+ def pause_reading(self) -> None:
1006
+ """Pause the receiving end (no data to protocol.pkt_received())."""
1007
+ self._reading = False
1008
+ self._evt_reading.clear() # Puts the loop to sleep efficiently
1009
+
1010
+ def resume_reading(self) -> None:
1011
+ """Resume the receiving end."""
1012
+ self._reading = True
1013
+ self._evt_reading.set() # Wakes the loop immediately
1014
+
1015
+ async def _producer_loop(self) -> None:
1004
1016
  """Loop through the packet source for Frames and process them."""
1017
+ # NOTE: fileinput interaction remains synchronous-blocking for simplicity,
1018
+ # but the PAUSE mechanism is now async-non-blocking.
1005
1019
 
1006
1020
  if isinstance(self._pkt_source, dict):
1007
1021
  for dtm_str, pkt_line in self._pkt_source.items(): # assume dtm_str is OK
1008
- self._process_line(dtm_str, pkt_line)
1022
+ await self._process_line(dtm_str, pkt_line)
1009
1023
 
1010
1024
  elif isinstance(self._pkt_source, str): # file_name, used in client parse
1011
1025
  # open file file_name before reading
1012
1026
  try:
1013
1027
  with fileinput.input(files=self._pkt_source, encoding="utf-8") as file:
1014
1028
  for dtm_pkt_line in file: # self._pkt_source:
1015
- self._process_line_from_raw(dtm_pkt_line)
1029
+ await self._process_line_from_raw(dtm_pkt_line)
1016
1030
  except FileNotFoundError as err:
1017
1031
  _LOGGER.warning(f"Correct the packet file name; {err}")
1018
1032
 
1019
1033
  elif isinstance(self._pkt_source, TextIOWrapper): # used by client monitor
1020
1034
  for dtm_pkt_line in self._pkt_source: # should check dtm_str is OK
1021
- self._process_line_from_raw(dtm_pkt_line)
1035
+ await self._process_line_from_raw(dtm_pkt_line)
1022
1036
 
1023
1037
  else:
1024
1038
  raise exc.TransportSourceInvalid(
1025
1039
  f"Packet source is not dict, TextIOWrapper or str: {self._pkt_source:!r}"
1026
1040
  )
1027
1041
 
1028
- def _process_line_from_raw(self, line: str) -> None:
1042
+ async def _process_line_from_raw(self, line: str) -> None:
1029
1043
  """Helper to process raw lines."""
1030
1044
  # there may be blank lines in annotated log files
1031
1045
  if (line := line.strip()) and line[:1] != "#":
1032
- self._process_line(line[:26], line[27:])
1046
+ await self._process_line(line[:26], line[27:])
1033
1047
  # this is where the parsing magic happens!
1034
1048
 
1035
- def _process_line(self, dtm_str: str, frame: str) -> None:
1049
+ async def _process_line(self, dtm_str: str, frame: str) -> None:
1036
1050
  """Push frame to protocol in a thread-safe way."""
1037
- while not self._reading:
1038
- time.sleep(0.001)
1051
+ # Efficient wait - 0% CPU usage while paused
1052
+ await self._evt_reading.wait()
1039
1053
 
1040
1054
  self._frame_read(dtm_str, frame)
1041
1055
 
1042
- # NOTE: instable without, big performance penalty if delay >0
1043
- time.sleep(0)
1056
+ # Yield control to the event loop to prevent starvation during large file reads
1057
+ await asyncio.sleep(0)
1044
1058
 
1045
1059
  def _close(self, exc: exc.RamsesException | None = None) -> None:
1046
1060
  """Close the transport (cancel any outstanding tasks).
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.53.3"
3
+ __version__ = "0.53.5"
4
4
  VERSION = __version__