ramses-rf 0.51.8__py3-none-any.whl → 0.51.9__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_rf/database.py CHANGED
@@ -8,7 +8,7 @@ import logging
8
8
  import sqlite3
9
9
  from collections import OrderedDict
10
10
  from datetime import datetime as dt, timedelta as td
11
- from typing import NewType, TypedDict
11
+ from typing import Any, NewType, TypedDict
12
12
 
13
13
  from ramses_tx import Message
14
14
 
@@ -24,6 +24,7 @@ class Params(TypedDict):
24
24
  code: str | None
25
25
  ctx: str | None
26
26
  hdr: str | None
27
+ plk: str | None
27
28
 
28
29
 
29
30
  _LOGGER = logging.getLogger(__name__)
@@ -33,50 +34,87 @@ def _setup_db_adapters() -> None:
33
34
  """Set up the database adapters and converters."""
34
35
 
35
36
  def adapt_datetime_iso(val: dt) -> str:
36
- """Adapt datetime.datetime to timezone-naive ISO 8601 datetime."""
37
+ """Adapt datetime.datetime to timezone-naive ISO 8601 datetime to match _msgs_ dtm keys."""
37
38
  return val.isoformat(timespec="microseconds")
38
39
 
39
40
  sqlite3.register_adapter(dt, adapt_datetime_iso)
40
41
 
41
42
  def convert_datetime(val: bytes) -> dt:
42
- """Convert ISO 8601 datetime to datetime.datetime object."""
43
+ """Convert ISO 8601 datetime to datetime.datetime object to import dtm in msg_db."""
43
44
  return dt.fromisoformat(val.decode())
44
45
 
45
- sqlite3.register_converter("dtm", convert_datetime)
46
+ sqlite3.register_converter("DTM", convert_datetime)
47
+
48
+
49
+ def payload_keys(parsed_payload: list[dict] | dict) -> str: # type: ignore[type-arg]
50
+ """
51
+ Copy payload keys for fast query check.
52
+
53
+ :param parsed_payload: pre-parsed message payload dict
54
+ :return: string of payload keys, separated by the | char
55
+ """
56
+ _keys: str = "|"
57
+
58
+ def append_keys(ppl: dict) -> str: # type: ignore[type-arg]
59
+ _ks: str = ""
60
+ for k, v in ppl.items():
61
+ if (
62
+ k not in _ks and k not in _keys and v is not None
63
+ ): # ignore keys with None value
64
+ _ks += k + "|"
65
+ return _ks
66
+
67
+ if isinstance(parsed_payload, list):
68
+ for d in parsed_payload:
69
+ _keys += append_keys(d)
70
+ elif isinstance(parsed_payload, dict):
71
+ _keys += append_keys(parsed_payload)
72
+ return _keys
46
73
 
47
74
 
48
75
  class MessageIndex:
49
- """A simple in-memory SQLite3 database for indexing messages."""
76
+ """A simple in-memory SQLite3 database for indexing RF messages.
77
+ Index holds the latest message to & from all devices by header
78
+ (example of a hdr: 000C|RP|01:223036|0208)."""
50
79
 
51
- def __init__(self) -> None:
80
+ def __init__(self, maintain: bool = True) -> None:
52
81
  """Instantiate a message database/index."""
53
82
 
54
- self._msgs: MsgDdT = OrderedDict()
83
+ self.maintain = maintain
84
+ self._msgs: MsgDdT = (
85
+ OrderedDict()
86
+ ) # stores all messages for retrieval. Filled in housekeeping loop.
55
87
 
56
- self._cx = sqlite3.connect(":memory:") # Connect to a SQLite DB in memory
88
+ # Connect to a SQLite DB in memory
89
+ self._cx = sqlite3.connect(
90
+ ":memory:", detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES
91
+ )
92
+ # detect_types should retain dt type on store/retrieve
57
93
  self._cu = self._cx.cursor() # Create a cursor
58
94
 
59
- _setup_db_adapters() # dtm adapter/converter
95
+ _setup_db_adapters() # DTM adapter/converter
60
96
  self._setup_db_schema()
61
97
 
62
- self._lock = asyncio.Lock()
63
- self._last_housekeeping: dt = None # type: ignore[assignment]
64
- self._housekeeping_task: asyncio.Task[None] = None # type: ignore[assignment]
98
+ if self.maintain:
99
+ self._lock = asyncio.Lock()
100
+ self._last_housekeeping: dt = None # type: ignore[assignment]
101
+ self._housekeeping_task: asyncio.Task[None] = None # type: ignore[assignment]
65
102
 
66
103
  self.start()
67
104
 
68
105
  def __repr__(self) -> str:
69
- return f"MessageIndex({len(self._msgs)} messages)"
106
+ return f"MessageIndex({len(self._msgs)} messages)" # or msg_db.count()
70
107
 
71
108
  def start(self) -> None:
72
109
  """Start the housekeeper loop."""
73
110
 
74
- if self._housekeeping_task and not self._housekeeping_task.done():
75
- return
111
+ if self.maintain:
112
+ if self._housekeeping_task and not self._housekeeping_task.done():
113
+ return
76
114
 
77
- self._housekeeping_task = asyncio.create_task(
78
- self._housekeeping_loop(), name=f"{self.__class__.__name__}.housekeeper"
79
- )
115
+ self._housekeeping_task = asyncio.create_task(
116
+ self._housekeeping_loop(), name=f"{self.__class__.__name__}.housekeeper"
117
+ )
80
118
 
81
119
  def stop(self) -> None:
82
120
  """Stop the housekeeper loop."""
@@ -95,27 +133,29 @@ class MessageIndex:
95
133
  def _setup_db_schema(self) -> None:
96
134
  """Set up the message database schema.
97
135
 
98
- Fields:
136
+ messages TABLE Fields:
99
137
 
100
138
  - dtm message timestamp
101
- - verb _I, RQ etc.
139
+ - verb " I", "RQ" etc.
102
140
  - src message origin address
103
141
  - dst message destination address
104
142
  - code packet code aka command class e.g. _0005, _31DA
105
143
  - ctx message context, created from payload as index + extra markers (Heat)
106
144
  - hdr packet header e.g. 000C|RP|01:223036|0208 (see: src/ramses_tx/frame.py)
145
+ - plk the keys stored in the parsed payload, separated by the | char
107
146
  """
108
147
 
109
148
  self._cu.execute(
110
149
  """
111
150
  CREATE TABLE messages (
112
- dtm TEXT(26) NOT NULL PRIMARY KEY,
151
+ dtm DTM NOT NULL PRIMARY KEY,
113
152
  verb TEXT(2) NOT NULL,
114
153
  src TEXT(9) NOT NULL,
115
154
  dst TEXT(9) NOT NULL,
116
155
  code TEXT(4) NOT NULL,
117
- ctx TEXT NOT NULL,
118
- hdr TEXT NOT NULL UNIQUE
156
+ ctx TEXT,
157
+ hdr TEXT NOT NULL UNIQUE,
158
+ plk TEXT NOT NULL
119
159
  )
120
160
  """
121
161
  )
@@ -130,10 +170,16 @@ class MessageIndex:
130
170
  self._cx.commit()
131
171
 
132
172
  async def _housekeeping_loop(self) -> None:
133
- """Periodically remove stale messages from the index."""
173
+ """Periodically remove stale messages from the index,
174
+ unless self.maintain is False."""
134
175
 
135
176
  async def housekeeping(dt_now: dt, _cutoff: td = td(days=1)) -> None:
136
- dtm = (dt_now - _cutoff).isoformat(timespec="microseconds")
177
+ """
178
+ Delete all messages from the using the MessageIndex older than a given delta.
179
+ :param dt_now: current timestamp
180
+ :param _cutoff: the oldest timestamp to retain, default is 24 hours ago
181
+ """
182
+ dtm = dt_now - _cutoff # .isoformat(timespec="microseconds") < needed?
137
183
 
138
184
  self._cu.execute("SELECT dtm FROM messages WHERE dtm => ?", (dtm,))
139
185
  rows = self._cu.fetchall()
@@ -154,30 +200,34 @@ class MessageIndex:
154
200
  while True:
155
201
  self._last_housekeeping = dt.now()
156
202
  await asyncio.sleep(3600)
203
+ _LOGGER.info("Starting next MessageIndex housekeeping")
157
204
  await housekeeping(self._last_housekeeping)
158
205
 
159
206
  def add(self, msg: Message) -> Message | None:
160
- """Add a single message to the index.
161
-
162
- Returns any message that was removed because it had the same header.
163
-
164
- Throws a warning if there is a duplicate dtm.
165
- """ # TODO: eventually, may be better to use SqlAlchemy
207
+ """
208
+ Add a single message to the MessageIndex.
209
+ Logs a warning if there is a duplicate dtm.
210
+ :returns: any message that was removed because it had the same header
211
+ """
212
+ # TODO: eventually, may be better to use SqlAlchemy
166
213
 
167
214
  dup: tuple[Message, ...] = tuple() # avoid UnboundLocalError
168
215
  old: Message | None = None # avoid UnboundLocalError
169
216
 
170
- try: # TODO: remove, or use only when source is a packet log?
217
+ try: # TODO: remove this, or apply only when source is a real packet log?
171
218
  # await self._lock.acquire()
172
219
  dup = self._delete_from( # HACK: because of contrived pkt logs
173
- dtm=msg.dtm.isoformat(timespec="microseconds")
220
+ dtm=msg.dtm # stored as such with DTM formatter
174
221
  )
175
- old = self._insert_into(msg) # will delete old msg by hdr
222
+ old = self._insert_into(msg) # will delete old msg by hdr (not dtm!)
176
223
 
177
- except sqlite3.Error: # UNIQUE constraint failed: ? messages.dtm (so: HACK)
224
+ except (
225
+ sqlite3.Error
226
+ ): # UNIQUE constraint failed: ? messages.dtm or .hdr (so: HACK)
178
227
  self._cx.rollback()
179
228
 
180
229
  else:
230
+ # _msgs dict requires a timestamp reformat
181
231
  dtm: DtmStrT = msg.dtm.isoformat(timespec="microseconds") # type: ignore[assignment]
182
232
  self._msgs[dtm] = msg
183
233
 
@@ -186,48 +236,106 @@ class MessageIndex:
186
236
 
187
237
  if dup:
188
238
  _LOGGER.warning(
189
- "Overwrote dtm for %s: %s (contrived log?)", msg._pkt._hdr, dup[0]._pkt
239
+ "Overwrote dtm (%s) for %s: %s (contrived log?)",
240
+ msg.dtm,
241
+ msg._pkt._hdr,
242
+ dup[0]._pkt,
190
243
  )
244
+ if old is not None:
245
+ _LOGGER.info("Old msg replaced: %s", old)
191
246
 
192
247
  return old
193
248
 
249
+ def add_record(self, src: str, code: str = "", verb: str = "") -> None:
250
+ """
251
+ Add a single record to the MessageIndex with timestamp now() and no Message contents.
252
+ """
253
+ # Used by OtbGateway init, via entity_base.py
254
+ dtm: DtmStrT = DtmStrT(dt.strftime(dt.now(), "%Y-%m-%dT%H:%M:%S"))
255
+ hdr = f"{code}|{verb}|{src}|00" # dummy record has no contents
256
+
257
+ dup = self._delete_from(hdr=hdr)
258
+
259
+ sql = """
260
+ INSERT INTO messages (dtm, verb, src, dst, code, ctx, hdr, plk)
261
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
262
+ """
263
+ try:
264
+ self._cu.execute(
265
+ sql,
266
+ (
267
+ dtm,
268
+ verb,
269
+ src,
270
+ src,
271
+ code,
272
+ None,
273
+ hdr,
274
+ "|",
275
+ ),
276
+ )
277
+ except sqlite3.Error:
278
+ self._cx.rollback()
279
+
280
+ if dup: # expected when more than one heat system in schema
281
+ _LOGGER.debug("Replaced record with same hdr: %s", hdr)
282
+
194
283
  def _insert_into(self, msg: Message) -> Message | None:
195
- """Insert a message into the index (and return any message replaced by hdr)."""
284
+ """
285
+ Insert a message into the index.
286
+ :returns: any message replaced (by same hdr)
287
+ """
288
+ assert msg._pkt._hdr is not None, "Skipping: Packet has no hdr: {msg._pkt}"
289
+
290
+ if msg._pkt._ctx is True:
291
+ msg_pkt_ctx = "True"
292
+ elif msg._pkt._ctx is False:
293
+ msg_pkt_ctx = "False"
294
+ else:
295
+ msg_pkt_ctx = msg._pkt._ctx # can be None
196
296
 
197
- msgs = self._delete_from(hdr=msg._pkt._hdr)
297
+ _old_msgs = self._delete_from(hdr=msg._pkt._hdr)
198
298
 
199
299
  sql = """
200
- INSERT INTO messages (dtm, verb, src, dst, code, ctx, hdr)
201
- VALUES (?, ?, ?, ?, ?, ?, ?)
300
+ INSERT INTO messages (dtm, verb, src, dst, code, ctx, hdr, plk)
301
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
202
302
  """
203
303
 
204
304
  self._cu.execute(
205
305
  sql,
206
306
  (
207
307
  msg.dtm,
208
- msg.verb,
308
+ str(msg.verb),
209
309
  msg.src.id,
210
310
  msg.dst.id,
211
- msg.code,
212
- msg._pkt._ctx,
311
+ str(msg.code),
312
+ msg_pkt_ctx,
213
313
  msg._pkt._hdr,
314
+ payload_keys(msg.payload),
214
315
  ),
215
316
  )
317
+ _LOGGER.info(f"Added {msg} to gwy.msg_db")
216
318
 
217
- return msgs[0] if msgs else None
319
+ return _old_msgs[0] if _old_msgs else None
218
320
 
219
321
  def rem(
220
- self, msg: Message | None = None, **kwargs: str
322
+ self, msg: Message | None = None, **kwargs: str | dt
221
323
  ) -> tuple[Message, ...] | None:
222
324
  """Remove a set of message(s) from the index.
223
325
 
224
- Returns any messages that were removed.
326
+ :returns: any messages that were removed.
225
327
  """
226
-
227
- if bool(msg) ^ bool(kwargs):
328
+ # _LOGGER.debug(f"SQL REM msg={msg} bool{bool(msg)} kwargs={kwargs} bool(kwargs)")
329
+ # SQL REM
330
+ # msg=|| 02:044328 | | I | heat_demand | FC || {'domain_id': 'FC', 'heat_demand': 0.74}
331
+ # boolTrue
332
+ # kwargs={}
333
+ # bool(kwargs)
334
+
335
+ if not bool(msg) ^ bool(kwargs):
228
336
  raise ValueError("Either a Message or kwargs should be provided, not both")
229
337
  if msg:
230
- kwargs["dtm"] = msg.dtm.isoformat(timespec="microseconds")
338
+ kwargs["dtm"] = msg.dtm # .isoformat(timespec="microseconds")
231
339
 
232
340
  msgs = None
233
341
  try: # make this operation atomic, i.e. update self._msgs only on success
@@ -247,8 +355,9 @@ class MessageIndex:
247
355
 
248
356
  return msgs
249
357
 
250
- def _delete_from(self, **kwargs: str) -> tuple[Message, ...]:
251
- """Remove message(s) from the index (and return any messages removed)."""
358
+ def _delete_from(self, **kwargs: bool | dt | str) -> tuple[Message, ...]:
359
+ """Remove message(s) from the index.
360
+ :returns: any messages that were removed"""
252
361
 
253
362
  msgs = self._select_from(**kwargs)
254
363
 
@@ -259,48 +368,117 @@ class MessageIndex:
259
368
 
260
369
  return msgs
261
370
 
262
- def get(self, msg: Message | None = None, **kwargs: str) -> tuple[Message, ...]:
263
- """Return a set of message(s) from the index."""
371
+ def get(
372
+ self, msg: Message | None = None, **kwargs: bool | dt | str
373
+ ) -> tuple[Message, ...]:
374
+ """Get a set of message(s) from the index."""
264
375
 
265
376
  if not (bool(msg) ^ bool(kwargs)):
266
377
  raise ValueError("Either a Message or kwargs should be provided, not both")
378
+
267
379
  if msg:
268
- kwargs["dtm"] = msg.dtm.isoformat(timespec="microseconds")
380
+ kwargs["dtm"] = msg.dtm # .isoformat(timespec="microseconds")
269
381
 
270
382
  return self._select_from(**kwargs)
271
383
 
272
- def _select_from(self, **kwargs: str) -> tuple[Message, ...]:
273
- """Select message(s) from the index (and return any such messages)."""
384
+ def qry_dtms(self, **kwargs: bool | dt | str) -> list[Any]:
385
+ # tweak kwargs as stored in SQLite, inverse from _insert_into():
386
+ kw = {key: value for key, value in kwargs.items() if key != "ctx"}
387
+ if "ctx" in kwargs:
388
+ if isinstance(kwargs["ctx"], str):
389
+ kw["ctx"] = kwargs["ctx"]
390
+ elif kwargs["ctx"]:
391
+ kw["ctx"] = "True"
392
+ else:
393
+ kw["ctx"] = "False"
274
394
 
275
395
  sql = "SELECT dtm FROM messages WHERE "
276
- sql += " AND ".join(f"{k} = ?" for k in kwargs)
396
+ sql += " AND ".join(f"{k} = ?" for k in kw)
277
397
 
278
- self._cu.execute(sql, tuple(kwargs.values()))
398
+ self._cu.execute(sql, tuple(kw.values()))
399
+ return self._cu.fetchall()
400
+
401
+ def contains(self, **kwargs: bool | dt | str) -> bool:
402
+ """
403
+ Check if the MessageIndex contains at least 1 record that matches the provided fields.
404
+ :param kwargs: (exact) SQLite table field_name: required_value pairs
405
+ :return: True if at least one message fitting the given conditions is present, False when qry returned empty
406
+ """
407
+ # adapted from _select_from()
408
+
409
+ return len(self.qry_dtms(**kwargs)) > 0
279
410
 
280
- return tuple(self._msgs[row[0]] for row in self._cu.fetchall())
411
+ def _select_from(self, **kwargs: bool | dt | str) -> tuple[Message, ...]:
412
+ """Select message(s) using the MessageIndex.
413
+ :param kwargs: (exact) SQLite table field_name: required_value pairs
414
+ :returns: a tuple of qualifying messages"""
415
+
416
+ return tuple(
417
+ self._msgs[row[0].isoformat(timespec="microseconds")]
418
+ for row in self.qry_dtms(**kwargs)
419
+ )
281
420
 
282
421
  def qry(self, sql: str, parameters: tuple[str, ...]) -> tuple[Message, ...]:
283
- """Return a set of message(s) from the index, given sql and parameters."""
422
+ """Get a tuple of messages from the index, given sql and parameters."""
284
423
 
285
424
  if "SELECT" not in sql:
286
425
  raise ValueError(f"{self}: Only SELECT queries are allowed")
287
426
 
288
427
  self._cu.execute(sql, parameters)
289
428
 
290
- return tuple(self._msgs[row[0]] for row in self._cu.fetchall())
429
+ lst: list[Message] = []
430
+ # stamp = list(self._msgs)[0] if len(self._msgs) > 0 else "N/A" # for debug
431
+ for row in self._cu.fetchall():
432
+ ts: DtmStrT = row[0].isoformat(timespec="microseconds")
433
+ # _LOGGER.debug(
434
+ # f"QRY Msg key raw: {row[0]} Reformatted: {ts} _msgs stamp format: {stamp}"
435
+ # )
436
+ # QRY Msg key raw: 2022-09-08 13:43:31.536862 Reformatted: 2022-09-08T13:43:31.536862
437
+ # _msgs stamp format: 2022-09-08T13:40:52.447364
438
+ if ts in self._msgs:
439
+ lst.append(self._msgs[ts])
440
+ else: # happens in tests with artificial msg from heat
441
+ _LOGGER.warning("MessageIndex ts %s not in device messages", ts)
442
+ return tuple(lst)
443
+
444
+ def qry_field(
445
+ self, sql: str, parameters: tuple[str, ...]
446
+ ) -> list[tuple[dt | str, str]]:
447
+ """
448
+ Get a list of message field values from the index, given sql and parameters.
449
+ """
291
450
 
292
- def all(self, include_expired: bool = False) -> tuple[Message, ...]:
293
- """Return all messages from the index."""
451
+ if "SELECT" not in sql:
452
+ raise ValueError(f"{self}: Only SELECT queries are allowed")
294
453
 
295
- # self._cu.execute("SELECT * FROM messages")
296
- # return tuple(self._msgs[row[0]] for row in self._cu.fetchall())
454
+ self._cu.execute(sql, parameters)
297
455
 
298
- return tuple(
299
- m for m in self._msgs.values() if include_expired or not m._expired
300
- )
456
+ return self._cu.fetchall()
457
+
458
+ def all(self, include_expired: bool = False) -> tuple[Message, ...]:
459
+ """Get all messages from the index."""
460
+
461
+ self._cu.execute("SELECT * FROM messages")
462
+
463
+ lst: list[Message] = []
464
+ # stamp = list(self._msgs)[0] if len(self._msgs) > 0 else "N/A"
465
+ for row in self._cu.fetchall():
466
+ ts: DtmStrT = row[0].isoformat(timespec="microseconds")
467
+ # _LOGGER.debug(
468
+ # f"ALL Msg key raw: {row[0]} Reformatted: {ts} _msgs stamp format: {stamp}"
469
+ # )
470
+ # ALL Msg key raw: 2022-05-02 10:02:02.744905
471
+ # Reformatted: 2022-05-02T10:02:02.744905
472
+ # _msgs stamp format: 2022-05-02T10:02:02.744905
473
+ if ts in self._msgs:
474
+ # if include_expired or not self._msgs[ts].HAS_EXPIRED: # not working
475
+ lst.append(self._msgs[ts])
476
+ else: # happens in tests with dummy msg from heat init
477
+ _LOGGER.warning("MessageIndex ts %s not in device messages", ts)
478
+ return tuple(lst)
301
479
 
302
480
  def clr(self) -> None:
303
- """Clear the message index (remove all messages)."""
481
+ """Clear the message index (remove indexes of all messages)."""
304
482
 
305
483
  self._cu.execute("DELETE FROM messages")
306
484
  self._cx.commit()
ramses_rf/device/hvac.py CHANGED
@@ -446,6 +446,7 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A], 2411
446
446
  """
447
447
  super().__init__(*args, **kwargs)
448
448
  self._supports_2411 = False # Flag for 2411 parameter support
449
+ self._params_2411: dict[str, float] = {} # Store 2411 parameters here
449
450
  self._initialized_callback = None # Called when device is fully initialized
450
451
  self._param_update_callback = None # Called when 2411 parameters are updated
451
452
  self._hgi: Any | None = None # Will be set when HGI is available
@@ -517,7 +518,7 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A], 2411
517
518
  :param param_id: The ID of the parameter that was updated
518
519
  :type param_id: str
519
520
  :param value: The new value of the parameter
520
- :type value: Any
521
+ :type value: float
521
522
  """
522
523
  if callable(self._param_update_callback):
523
524
  try:
@@ -541,12 +542,62 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A], 2411
541
542
  The HGI device provides additional functionality for certain operations.
542
543
 
543
544
  :return: The HGI device instance, or None if not available
544
- :rtype: Any | None
545
+ :rtype: float | None
545
546
  """
546
547
  if self._hgi is None and self._gwy and hasattr(self._gwy, "hgi"):
547
548
  self._hgi = self._gwy.hgi
548
549
  return self._hgi
549
550
 
551
+ def get_2411_param(self, param_id: str) -> float | None:
552
+ """Get a 2411 parameter value.
553
+
554
+ :param param_id: The parameter ID to retrieve.
555
+ :type param_id: str
556
+ :return: The parameter value if found, None otherwise
557
+ :rtype: float | None
558
+ """
559
+ return self._params_2411.get(param_id)
560
+
561
+ def set_2411_param(self, param_id: str, value: float) -> bool:
562
+ """Set a 2411 parameter value.
563
+
564
+ :param param_id: The parameter ID to retrieve.
565
+ :type param_id: str
566
+ :param value: The parameter value to set.
567
+ :type value: float
568
+ :return: True if the parameter was set, False otherwise
569
+ :rtype: bool
570
+ """
571
+ if not self._supports_2411:
572
+ _LOGGER.warning("Device %s doesn't support 2411 parameters", self.id)
573
+ return False
574
+
575
+ self._params_2411[param_id] = value
576
+ return True
577
+
578
+ def get_fan_param(self, param_id: str) -> Any | None:
579
+ """Retrieve a fan parameter value from the device's message store.
580
+
581
+ This wrapper method gets a specific parameter value for a FAN device stored in
582
+ _params_2411 dict. It first makes sure we use the proper param_id format
583
+
584
+ :param param_id: The parameter ID to retrieve.
585
+ :type param_id: str
586
+ :return: The parameter value if found, None otherwise
587
+ :rtype: float | None
588
+ """
589
+ # Ensure param_id is uppercase and strip leading zeros for consistency
590
+ param_id = (
591
+ str(param_id).upper().lstrip("0") or "0"
592
+ ) # Handle case where param_id is "0"
593
+
594
+ param_value = self.get_2411_param(param_id)
595
+ if param_value is not None:
596
+ return param_value
597
+ else:
598
+ _LOGGER.debug("Parameter %s not found for %s", param_id, self.id)
599
+ return None
600
+
550
601
  def _handle_2411_message(self, msg: Message) -> None:
551
602
  """Handle incoming 2411 parameter messages.
552
603
 
@@ -568,33 +619,30 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A], 2411
568
619
  _LOGGER.debug("Missing parameter ID or value in 2411 message: %s", msg)
569
620
  return
570
621
 
571
- # Create a composite key for this parameter using the normalized ID
572
- key = f"{Code._2411}_{param_id}"
622
+ # Mark that we support 2411 parameters
623
+ if not self._supports_2411:
624
+ self._supports_2411 = True
625
+ _LOGGER.debug("Device %s supports 2411 parameters", self.id)
573
626
 
574
- # Store the message in the device's message store
575
- old_value = self._msgs.get(Code._2411)
576
- # Use direct assignment for Code._2411 key
577
- self._msgs[Code._2411] = msg
578
- # For the composite key, we need to bypass type checking
579
- self._msgs[key] = msg # type: ignore[index]
627
+ # Normalize the value if needed
628
+ if param_id == "75" and isinstance(param_value, (int, float)):
629
+ param_value = round(float(param_value), 1)
630
+ elif param_id in ("52", "95"): # Percentage parameters
631
+ param_value = round(float(param_value), 3) # Keep precision for percentages
632
+
633
+ # Store in params
634
+ old_value = self.get_2411_param(param_id)
635
+ self.set_2411_param(param_id, param_value)
580
636
 
637
+ # Log the update
581
638
  _LOGGER.debug(
582
- "Updated 2411 parameter %s = %s (was: %s) for %s",
639
+ "Updated 2411 parameter %s: %s (was: %s) for %s",
583
640
  param_id,
584
641
  param_value,
585
- old_value.payload if old_value else None,
642
+ old_value,
586
643
  self.id,
587
644
  )
588
645
 
589
- # Mark that we support 2411 parameters
590
- if not self._supports_2411:
591
- self._supports_2411 = True
592
- _LOGGER.debug("Device %s supports 2411 parameters", self.id)
593
-
594
- # Round parameter 75 values to 1 decimal place
595
- if param_id == "75" and isinstance(param_value, int | float):
596
- param_value = round(float(param_value), 1)
597
-
598
646
  # call the 2411 parameter update callback
599
647
  self._handle_param_update(param_id, param_value)
600
648
 
@@ -749,72 +797,6 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A], 2411
749
797
  _LOGGER.debug("No bound REM or DIS devices found for FAN %s", self.id)
750
798
  return None
751
799
 
752
- def get_fan_param(self, param_id: str) -> Any | None:
753
- """Retrieve a fan parameter value from the device's message store.
754
-
755
- This method attempts to fetch a specific parameter value for a FAN device from the
756
- stored messages. It first looks for the parameter using a composite key (e.g., '2411_3F')
757
- and falls back to checking the general 2411 message if needed.
758
-
759
- :param param_id: The parameter ID to retrieve.
760
- :type param_id: str
761
- :return: The parameter value if found, None otherwise
762
- :rtype: Any | None
763
- """
764
- # Ensure param_id is uppercase and strip leading zeros for consistency
765
- param_id = (
766
- str(param_id).upper().lstrip("0") or "0"
767
- ) # Handle case where param_id is "0"
768
- # we need some extra workarounds to please mypy
769
- # Create a composite key for this parameter using the normalized ID
770
- key = f"{Code._2411}_{param_id}"
771
-
772
- # Get the message using the composite key first, fall back to just the code
773
- msg = None
774
-
775
- # First try to get the specific parameter message
776
- try:
777
- # Try to access the message directly using the key
778
- msg = self._msgs[key] # type: ignore[index]
779
- except (KeyError, TypeError):
780
- # If that fails, try to find the message by iterating through the dictionary
781
- msg = next((v for k, v in self._msgs.items() if str(k) == key), None)
782
-
783
- # If not found, try to get the general 2411 message
784
- if msg is None:
785
- msg = self._msgs.get(Code._2411)
786
-
787
- if not msg or not hasattr(msg, "payload"):
788
- if not self.supports_2411:
789
- _LOGGER.debug(
790
- "Cannot get parameter %s from %s: 2411 parameters not supported",
791
- param_id,
792
- self.id,
793
- )
794
- else:
795
- _LOGGER.debug(
796
- "No payload found for parameter %s on %s", param_id, self.id
797
- )
798
- return None
799
-
800
- # If we have a message but not the specific parameter, try to get it from the payload
801
- if param_id and hasattr(msg.payload, "get"):
802
- value = msg.payload.get("value")
803
- if value is not None:
804
- return value
805
-
806
- # If we get here, the parameter wasn't found in the message
807
- if not self.supports_2411:
808
- _LOGGER.debug(
809
- "Parameter %s not found for %s: 2411 parameters not supported",
810
- param_id,
811
- self.id,
812
- )
813
- else:
814
- _LOGGER.debug("Parameter %s not found in payload for %s", param_id, self.id)
815
-
816
- return None
817
-
818
800
  @property
819
801
  def air_quality(self) -> float | None:
820
802
  """Return the current air quality measurement.
ramses_rf/dispatcher.py CHANGED
@@ -70,12 +70,12 @@ def _create_devices_from_addrs(gwy: Gateway, this: Message) -> None:
70
70
  # Devices need to know their controller, ?and their location ('parent' domain)
71
71
  # NB: only addrs processed here, packet metadata is processed elsewhere
72
72
 
73
- # Determinging bindings to a controller:
73
+ # Determining bindings to a controller:
74
74
  # - configury; As per any schema # codespell:ignore configury
75
75
  # - discovery: If in 000C pkt, or pkt *to* device where src is a controller
76
76
  # - eavesdrop: If pkt *from* device where dst is a controller
77
77
 
78
- # Determinging location in a schema (domain/DHW/zone):
78
+ # Determining location in a schema (domain/DHW/zone):
79
79
  # - configury; As per any schema # codespell:ignore configury
80
80
  # - discovery: If in 000C pkt - unable for 10: & 00: (TRVs)
81
81
  # - discovery: from packet fingerprint, excl. payloads (only for 10:)
@@ -99,7 +99,7 @@ def _create_devices_from_addrs(gwy: Gateway, this: Message) -> None:
99
99
  def _check_msg_addrs(msg: Message) -> None: # TODO
100
100
  """Validate the packet's address set.
101
101
 
102
- Raise InvalidAddrSetError if the meta data is invalid, otherwise simply return.
102
+ Raise InvalidAddrSetError if the metadata is invalid, otherwise simply return.
103
103
  """
104
104
 
105
105
  # TODO: needs work: doesn't take into account device's (non-HVAC) class
@@ -218,7 +218,7 @@ def process_msg(gwy: Gateway, msg: Message) -> None:
218
218
  and msg.dst != msg.src
219
219
  ):
220
220
  # HGI80 can do what it likes
221
- # receiving an I isn't currently in the schema & so can't yet be tested
221
+ # receiving an I_ isn't currently in the schema & so can't yet be tested
222
222
  _check_dst_slug(msg) # ? raise exc.PacketInvalid
223
223
 
224
224
  if gwy.config.reduce_processing >= DONT_UPDATE_ENTITIES:
@@ -269,7 +269,9 @@ def process_msg(gwy: Gateway, msg: Message) -> None:
269
269
  else:
270
270
  logger_xxxx(msg)
271
271
  if gwy.msg_db:
272
- gwy.msg_db.add(msg)
272
+ gwy.msg_db.add(
273
+ msg
274
+ ) # why add it anyway? will fail in testst comparing to _msgs
273
275
 
274
276
 
275
277
  # TODO: this needs cleaning up (e.g. handle intervening packet)
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.51.8"
3
+ __version__ = "0.51.9"
4
4
  VERSION = __version__
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ramses_rf
3
- Version: 0.51.8
3
+ Version: 0.51.9
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
@@ -7,49 +7,49 @@ ramses_cli/utils/convert.py,sha256=D_YiCyX5na9pgC-_NhBlW9N1dgRKUK-uLtLBfofjzZM,1
7
7
  ramses_rf/__init__.py,sha256=VG3E9GHbtC6lx6E1DMQJeFitHnydMKJyPxQBethdrzg,1193
8
8
  ramses_rf/binding_fsm.py,sha256=uZAOl3i19KCXqqlaLJWkEqMMP7NJBhVPW3xTikQD1fY,25996
9
9
  ramses_rf/const.py,sha256=L3z31CZ-xqno6oZp_h-67CB_5tDDqTwSWXsqRtsjMcs,5460
10
- ramses_rf/database.py,sha256=ZZZgucyuU1IHsSewGukZfDg2gu8KeNaEFriWKM0TUHs,10287
11
- ramses_rf/dispatcher.py,sha256=JGkqSi1o-YhQ2rj8tNkXwYLLeJIC7F061xpHoH8sSsM,11201
10
+ ramses_rf/database.py,sha256=eaD34qyYmjg1AZ5mU78CcjwSbEbjLSVzRXlSoZLtmbI,17264
11
+ ramses_rf/dispatcher.py,sha256=7DNJ5nLpMnaJTCXhm_BLEAnMnYWlvh1XPdkMP_ucBGg,11290
12
12
  ramses_rf/entity_base.py,sha256=Byt8mFRUKETNiKHaL0cNaMywjLcopDG3Sldiy1Q7lAo,39213
13
13
  ramses_rf/exceptions.py,sha256=mt_T7irqHSDKir6KLaf6oDglUIdrw0S40JbOrWJk5jc,3657
14
14
  ramses_rf/gateway.py,sha256=s2bhkUzR42mzL4lZ1crTBsEWMOMGI8tpuHN1UZdAB74,20564
15
15
  ramses_rf/helpers.py,sha256=TNk_QkpIOB3alOp1sqnA9LOzi4fuDCeapNlW3zTzNas,4250
16
16
  ramses_rf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
17
  ramses_rf/schemas.py,sha256=UhvRhV4nZ3kvrLLM3wwIguQUIjIgd_AKvp2wkTSpNEA,13468
18
- ramses_rf/version.py,sha256=PUTZH-D81qyH8onRDdeZzxGwuCVDzMYTbItYAp9NKKI,125
18
+ ramses_rf/version.py,sha256=NXm_d5LEm9kUTuOqMEH3o0RLs7W6Ahdv2527DIwmnJc,125
19
19
  ramses_rf/device/__init__.py,sha256=sUbH5dhbYFXSoM_TPFRutpRutBRpup7_cQ9smPtDTy8,4858
20
20
  ramses_rf/device/base.py,sha256=WGkBTUNjRUEe-phxdtdiXVCZnTi6-i1i_YT6g689UTM,17450
21
21
  ramses_rf/device/heat.py,sha256=2sCsggySVcuTzyXDmgWy76QbhlU5MQWSejy3zgI5BDE,54242
22
- ramses_rf/device/hvac.py,sha256=uKljqweushvpHwRTiYfR26YST6--KAIp8X9zyiRv9HI,45607
22
+ ramses_rf/device/hvac.py,sha256=SY2FpO54k4y5E7MYWyjMMRbBDqRLWgB8Cvk9lAOFkyY,44653
23
23
  ramses_rf/system/__init__.py,sha256=uZLKio3gLlBzePa2aDQ1nxkcp1YXOGrn6iHTG8LiNIw,711
24
24
  ramses_rf/system/faultlog.py,sha256=GdGmVGT3137KsTlV_nhccgIFEmYu6DFsLTn4S-8JSok,12799
25
25
  ramses_rf/system/heat.py,sha256=3jaFEChU-HlWCRMY1y7u09s7AH4hT0pC63hnqwdmZOc,39223
26
26
  ramses_rf/system/schedule.py,sha256=Ts6tdZPTQLV5NkgwA73tPa5QUsnZNIIuYoKC-8VsXDk,18808
27
27
  ramses_rf/system/zones.py,sha256=9AH7ooN5QfiqvWuor2P1Dn8aILjQb2RWL9rWqDH1IjA,36075
28
28
  ramses_tx/__init__.py,sha256=qNMTe8hBkIuecvtCiekUB0pKdD8atb0SjWxVNVe3yhE,3538
29
- ramses_tx/address.py,sha256=5swDr_SvOs1CxBmT-iJpldf8R00mOb7gKPMiEnxLz84,8452
29
+ ramses_tx/address.py,sha256=F5ZE-EbPNNom1fW9XXUILvD7DYSMBxNJvsHVliT5gjw,8452
30
30
  ramses_tx/command.py,sha256=r9dNaofjjOQXZSUrZjsNpvEukNn4rSGy0OLr2Dyd2TI,125129
31
- ramses_tx/const.py,sha256=QmwSS4BIN3ZFrLUiiFScP1RCUHuJ782V3ycRPQTtB_c,30297
31
+ ramses_tx/const.py,sha256=pnxq5upXvLUizv9Ye_I1llD9rAa3wddHgsSkc91AIUc,30300
32
32
  ramses_tx/exceptions.py,sha256=FJSU9YkvpKjs3yeTqUJX1o3TPFSe_B01gRGIh9b3PNc,2632
33
33
  ramses_tx/fingerprints.py,sha256=nfftA1E62HQnb-eLt2EqjEi_la0DAoT0wt-PtTMie0s,11974
34
- ramses_tx/frame.py,sha256=9lUVh8gAMXNRAolfFw2WuWANjn24AWkmscuM9Tm5imE,22036
35
- ramses_tx/gateway.py,sha256=TXLYwT6tFpmSokD29Qyj1ze7UGCxKidooeyP557Jfoo,11266
34
+ ramses_tx/frame.py,sha256=GzNsXr15YLeidJYGtk_xPqsZQh4ehDDlUCtT6rTDhT8,22046
35
+ ramses_tx/gateway.py,sha256=Z2TLysmTsi6wc2LEvbF-mL141aesdcWEFRCm0zheR0I,11267
36
36
  ramses_tx/helpers.py,sha256=J4OCRckp3JshGQTvvqEskFjB1hPS7uA_opVsuIqmZds,32915
37
37
  ramses_tx/logger.py,sha256=qYbUoNPnPaFWKVsYvLG6uTVuPTdZ8HsMzBbGx0DpBqc,10177
38
- ramses_tx/message.py,sha256=hl_gLfwrF79ftUNnsgNt3XGsIhM2Pts0MtZZuGjfaxk,13169
38
+ ramses_tx/message.py,sha256=LnzLVMmdV1oNHbdoldCAFW3lESgOqzPWWDsHed5K7iI,13391
39
39
  ramses_tx/opentherm.py,sha256=58PXz9l5x8Ou6Fm3y-R_UnGHCYahoi2RKIDdYStUMzk,42378
40
- ramses_tx/packet.py,sha256=NGunaGCkEjhTp9t4mARK5e7kbqT-Z_JKCH7ibMYMJXU,7357
41
- ramses_tx/parsers.py,sha256=c6D3NB79vZfOGpVmrHc-EhSh4_N4tKljBp1J3HFfkXw,109950
40
+ ramses_tx/packet.py,sha256=_qHiPFWpQpKueZOgf1jJ93Y09iZjo3LZWStLglVkXg4,7370
41
+ ramses_tx/parsers.py,sha256=g0vKu1vDXjjp_kV_iipeGL5M8XYi67AE3c97IZX00Qk,110888
42
42
  ramses_tx/protocol.py,sha256=9R3aCzuiWEyXmugmB5tyR_RhBlgfnpUXj7AsMP9BzzU,28867
43
43
  ramses_tx/protocol_fsm.py,sha256=ZKtehCr_4TaDdfdlfidFLJaOVTYtaEq5h4tLqNIhb9s,26827
44
44
  ramses_tx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
45
45
  ramses_tx/ramses.py,sha256=DujcZe4WelHvPNKYfoz9YWmXNoCayV5UsAkiu8Y6vms,53432
46
46
  ramses_tx/schemas.py,sha256=18IPRdoCWXpcRg4v8Z1ehTnRronQPYGrf4AvRL-1OD0,12932
47
- ramses_tx/transport.py,sha256=MwPnkQ0L-2qJt4mIJy3-C9XmHwBDjT7Kg-1LthPByVw,58331
47
+ ramses_tx/transport.py,sha256=bGprlfuuwBgQ1bmBRSrcicuk7s-jVqyuKpZCfQ-sSpw,58469
48
48
  ramses_tx/typed_dicts.py,sha256=w-0V5t2Q3GiNUOrRAWiW9GtSwbta_7luME6GfIb1zhI,10869
49
49
  ramses_tx/typing.py,sha256=eF2SlPWhNhEFQj6WX2AhTXiyRQVXYnFutiepllYl2rI,5042
50
- ramses_tx/version.py,sha256=kLXw7G-2GHtLEZnlOm_5jj8TGeibQYmH_NZ9F7FVHAU,123
51
- ramses_rf-0.51.8.dist-info/METADATA,sha256=JmxHDvvXI3gRkt2zHELxS9G3ZaMyOn8bOvaraq5_0Ro,4000
52
- ramses_rf-0.51.8.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
53
- ramses_rf-0.51.8.dist-info/entry_points.txt,sha256=NnyK29baOCNg8DinPYiZ368h7MTH7bgTW26z2A1NeIE,50
54
- ramses_rf-0.51.8.dist-info/licenses/LICENSE,sha256=-Kc35W7l1UkdiQ4314_yVWv7vDDrg7IrJfMLUiq6Nfs,1074
55
- ramses_rf-0.51.8.dist-info/RECORD,,
50
+ ramses_tx/version.py,sha256=F7K6xGXIUnJDASKlhAoABr7EZ8WnHdqeUfJqZuvdczY,123
51
+ ramses_rf-0.51.9.dist-info/METADATA,sha256=ITVv4OBf_zE3S2iCQ9Eqci8EuceqtCIfr9oyK5lE_Sw,4000
52
+ ramses_rf-0.51.9.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
53
+ ramses_rf-0.51.9.dist-info/entry_points.txt,sha256=NnyK29baOCNg8DinPYiZ368h7MTH7bgTW26z2A1NeIE,50
54
+ ramses_rf-0.51.9.dist-info/licenses/LICENSE,sha256=-Kc35W7l1UkdiQ4314_yVWv7vDDrg7IrJfMLUiq6Nfs,1074
55
+ ramses_rf-0.51.9.dist-info/RECORD,,
ramses_tx/address.py CHANGED
@@ -195,7 +195,7 @@ def pkt_addrs(addr_fragment: str) -> tuple[Address, Address, Address, Address, A
195
195
 
196
196
  returns: src_addr, dst_addr, addr_0, addr_1, addr_2
197
197
 
198
- Will raise an InvalidAddrSetError is the address fields are not valid.
198
+ Will raise an InvalidAddrSetError if the address fields are not valid.
199
199
  """
200
200
  # for debug: print(pkt_addrs.cache_info())
201
201
 
ramses_tx/const.py CHANGED
@@ -788,7 +788,7 @@ class MsgId(StrEnum):
788
788
  _7F = "7F"
789
789
 
790
790
 
791
- # StrEnum is intended include all known codes, see: test suite, code schema in ramses.py
791
+ # StrEnum is intended to include all known codes, see: test suite, code schema in ramses.py
792
792
  @verify(EnumCheck.UNIQUE)
793
793
  class Code(StrEnum):
794
794
  _0001 = "0001"
ramses_tx/frame.py CHANGED
@@ -65,7 +65,7 @@ class Frame:
65
65
  def __init__(self, frame: str) -> None:
66
66
  """Create a frame from a string.
67
67
 
68
- Will raise InvalidPacketError if it is invalid.
68
+ :raises InvalidPacketError: if provided string is invalid.
69
69
  """
70
70
 
71
71
  self._frame: str = frame
@@ -123,7 +123,7 @@ class Frame:
123
123
  if not strict_checking:
124
124
  return
125
125
 
126
- try: # Strict checking: helps users avoid to constructing bad commands
126
+ try: # Strict checking: helps users avoid constructing bad commands
127
127
  if addrs[0] == NON_DEV_ADDR:
128
128
  assert self.verb == I_, "wrong verb or dst addr should be present"
129
129
  elif addrs[2] == NON_DEV_ADDR:
@@ -138,7 +138,7 @@ class Frame:
138
138
  raise exc.PacketInvalid(f"Bad frame: Invalid address set: {err}") from err
139
139
 
140
140
  def __repr__(self) -> str:
141
- """Return a unambiguous string representation of this object."""
141
+ """Return an unambiguous string representation of this object."""
142
142
 
143
143
  if self._repr is None:
144
144
  self._repr = " ".join( # type: ignore[unreachable]
@@ -387,7 +387,7 @@ class Frame:
387
387
 
388
388
  @property
389
389
  def _hdr(self) -> HeaderT: # incl. self._ctx
390
- """Return the QoS header (fingerprint) of this packet (i.e. device_id/code/hdr).
390
+ """Return the QoS header (fingerprint) of this packet (i.e. device_id|code|verb).
391
391
 
392
392
  Used for QoS (timeouts, retries), callbacks, etc.
393
393
  """
ramses_tx/gateway.py CHANGED
@@ -91,7 +91,7 @@ class Engine:
91
91
  if input_file:
92
92
  self._disable_sending = True
93
93
  elif not port_name:
94
- raise TypeError("Either a port_name or a input_file must be specified")
94
+ raise TypeError("Either a port_name or an input_file must be specified")
95
95
 
96
96
  self.ser_name = port_name
97
97
  self._input_file = input_file
ramses_tx/message.py CHANGED
@@ -54,7 +54,7 @@ class MessageBase:
54
54
  def __init__(self, pkt: Packet) -> None:
55
55
  """Create a message from a valid packet.
56
56
 
57
- Will raise InvalidPacketError if it is invalid.
57
+ :raises InvalidPacketError if message payload is invalid.
58
58
  """
59
59
 
60
60
  self._pkt = pkt
@@ -66,11 +66,15 @@ class MessageBase:
66
66
  self.dtm: dt = pkt.dtm
67
67
 
68
68
  self.verb: VerbT = pkt.verb
69
- self.seqn: str = pkt.seqn
69
+ self.seqn: str = (
70
+ pkt.seqn
71
+ ) # the msg is part of a set for 1 Code, received in order
70
72
  self.code: Code = pkt.code
71
73
  self.len: int = pkt._len
72
74
 
73
- self._payload = self._validate(self._pkt.payload) # ? raise InvalidPacketError
75
+ self._payload = self._validate(
76
+ self._pkt.payload
77
+ ) # ? may raise InvalidPacketError
74
78
 
75
79
  self._str: str = None # type: ignore[assignment]
76
80
 
@@ -92,7 +96,7 @@ class MessageBase:
92
96
 
93
97
  if self.src.id == self._addrs[0].id: # type: ignore[unreachable]
94
98
  name_0 = self._name(self.src)
95
- name_1 = "" if self.dst is self.src else self._name(self.dst)
99
+ name_1 = "" if self.dst == self.src else self._name(self.dst)
96
100
  else:
97
101
  name_0 = ""
98
102
  name_1 = self._name(self.src)
@@ -139,16 +143,18 @@ class MessageBase:
139
143
 
140
144
  @property
141
145
  def _has_array(self) -> bool:
142
- """Return True if the message's raw payload is an array."""
146
+ """
147
+ :return: True if the message's raw payload is an array.
148
+ """
143
149
 
144
150
  return bool(self._pkt._has_array)
145
151
 
146
152
  @property
147
153
  def _idx(self) -> dict[str, str]:
148
- """Return the domain_id/zone_idx/other_idx of a message payload, if any.
154
+ """Get the domain_id/zone_idx/other_idx of a message payload, if any.
155
+ Used to identify the zone/domain that a message applies to.
149
156
 
150
- Used to identify the zone/domain that a message applies to. Returns an empty
151
- dict if there is none such, or None if undetermined.
157
+ :return: an empty dict if there is none such, or None if undetermined.
152
158
  """
153
159
 
154
160
  # .I --- 01:145038 --:------ 01:145038 3B00 002 FCC8
@@ -229,7 +235,7 @@ class MessageBase:
229
235
  assert isinstance(self._pkt._idx, str) # mypy hint
230
236
  return {IDX_NAMES[Code._22C9]: self._pkt._idx}
231
237
 
232
- assert isinstance(self._pkt._idx, str) # mypy check
238
+ assert isinstance(self._pkt._idx, str) # mypy hint
233
239
  idx_name = SZ_DOMAIN_ID if self._pkt._idx[:1] == "F" else SZ_ZONE_IDX
234
240
  index_name = IDX_NAMES.get(self.code, idx_name)
235
241
 
@@ -237,9 +243,10 @@ class MessageBase:
237
243
 
238
244
  # TODO: needs work...
239
245
  def _validate(self, raw_payload: str) -> dict | list[dict]: # type: ignore[type-arg]
240
- """Validate the message, and parse the payload if so.
246
+ """Validate a message packet payload, and parse it if valid.
241
247
 
242
- Raise an exception (InvalidPacketError) if it is not valid.
248
+ :return: a dict containing key: value pairs, or a list of those created from the payload
249
+ :raises an InvalidPacketError exception if it is not valid.
243
250
  """
244
251
 
245
252
  try: # parse the payload
@@ -249,10 +256,9 @@ class MessageBase:
249
256
  if not self._has_payload and (
250
257
  self.verb == RQ and self.code not in RQ_IDX_COMPLEX
251
258
  ):
252
- # _LOGGER.error("%s", msg)
253
259
  return {}
254
260
 
255
- result = parse_payload(self)
261
+ result = parse_payload(self) # invoke the code parsers
256
262
 
257
263
  if isinstance(result, list):
258
264
  return result
@@ -353,7 +359,7 @@ def re_compile_re_match(regex: str, string: str) -> bool: # Optional[Match[Any]
353
359
  def _check_msg_payload(msg: MessageBase, payload: str) -> None:
354
360
  """Validate the packet's payload against its verb/code pair.
355
361
 
356
- Raise an InvalidPayloadError if the payload is seen as invalid. Such payloads may
362
+ :raises InvalidPayloadError if the payload is seen as invalid. Such payloads may
357
363
  actually be valid, in which case the rules (likely the regex) will need updating.
358
364
  """
359
365
 
ramses_tx/packet.py CHANGED
@@ -104,7 +104,7 @@ class Packet(Frame):
104
104
  return f"{dtm} ... {self}{hdr}"
105
105
 
106
106
  def __str__(self) -> str:
107
- """Return a brief readable string representation of this object."""
107
+ """Return a brief readable string representation of this object aka 'header'."""
108
108
  # e.g.: 000A|RQ|01:145038|08
109
109
  return super().__repr__() # TODO: self._hdr
110
110
 
ramses_tx/parsers.py CHANGED
@@ -1912,42 +1912,57 @@ def parser_2411(payload: str, msg: Message) -> dict[str, Any]:
1912
1912
  "92": (4, hex_to_temp), # 75 (0-30) (C)
1913
1913
  } # TODO: _2411_TYPES.get(payload[8:10], (8, no_op))
1914
1914
 
1915
- assert payload[4:6] in _2411_TABLE, (
1916
- f"param {payload[4:6]} is unknown"
1917
- ) # _INFORM_DEV_MSG
1918
- description = _2411_TABLE.get(payload[4:6], "Unknown")
1915
+ # Handle unknown parameters gracefully instead of asserting
1916
+ param_id = payload[4:6]
1917
+ try:
1918
+ description = _2411_TABLE.get(param_id, "Unknown")
1919
+ if param_id not in _2411_TABLE:
1920
+ _LOGGER.warning(
1921
+ f"2411 message received with unknown parameter ID: {param_id}. "
1922
+ f"This parameter is not in the known parameter schema. "
1923
+ f"Message: {msg!r}"
1924
+ )
1925
+ except Exception as err:
1926
+ _LOGGER.warning(f"Error looking up 2411 parameter {param_id}: {err}")
1927
+ description = "Unknown"
1919
1928
 
1920
1929
  result = {
1921
- "parameter": payload[4:6],
1930
+ "parameter": param_id,
1922
1931
  "description": description,
1923
1932
  }
1924
1933
 
1925
1934
  if msg.verb == RQ:
1926
1935
  return result
1927
1936
 
1928
- assert payload[8:10] in _2411_DATA_TYPES, (
1929
- f"param {payload[4:6]} has unknown data_type: {payload[8:10]}"
1930
- ) # _INFORM_DEV_MSG
1931
- length, parser = _2411_DATA_TYPES.get(payload[8:10], (8, lambda x: x))
1937
+ try:
1938
+ assert payload[8:10] in _2411_DATA_TYPES, (
1939
+ f"param {param_id} has unknown data_type: {payload[8:10]}"
1940
+ ) # _INFORM_DEV_MSG
1941
+ length, parser = _2411_DATA_TYPES.get(payload[8:10], (8, lambda x: x))
1942
+
1943
+ result |= {
1944
+ "value": parser(payload[10:18][-length:]), # type: ignore[operator]
1945
+ "_value_06": payload[6:10],
1946
+ }
1932
1947
 
1933
- result |= {
1934
- "value": parser(payload[10:18][-length:]), # type: ignore[operator]
1935
- "_value_06": payload[6:10],
1936
- }
1948
+ if msg.len == 9:
1949
+ return result
1937
1950
 
1938
- if msg.len == 9:
1951
+ return (
1952
+ result
1953
+ | {
1954
+ "min_value": parser(payload[18:26][-length:]), # type: ignore[operator]
1955
+ "max_value": parser(payload[26:34][-length:]), # type: ignore[operator]
1956
+ "precision": parser(payload[34:42][-length:]), # type: ignore[operator]
1957
+ "_value_42": payload[42:],
1958
+ }
1959
+ )
1960
+ except AssertionError as err:
1961
+ _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({err})")
1962
+ # Return partial result for unknown parameters
1963
+ result["value"] = ""
1939
1964
  return result
1940
1965
 
1941
- return (
1942
- result
1943
- | {
1944
- "min_value": parser(payload[18:26][-length:]), # type: ignore[operator]
1945
- "max_value": parser(payload[26:34][-length:]), # type: ignore[operator]
1946
- "precision": parser(payload[34:42][-length:]), # type: ignore[operator]
1947
- "_value_42": payload[42:],
1948
- }
1949
- )
1950
-
1951
1966
 
1952
1967
  # unknown_2420, from OTB
1953
1968
  def parser_2420(payload: str, msg: Message) -> dict[str, Any]:
@@ -2347,7 +2362,7 @@ def parser_3210(payload: str, msg: Message) -> PayDictT._3210:
2347
2362
  return {SZ_TEMPERATURE: hex_to_temp(payload[2:])}
2348
2363
 
2349
2364
 
2350
- # opentherm_msg, from OTB (and some RND)
2365
+ # opentherm_msg, from OTB (and OT_RND)
2351
2366
  def parser_3220(payload: str, msg: Message) -> dict[str, Any]:
2352
2367
  try:
2353
2368
  ot_type, ot_id, ot_value, ot_schema = decode_frame(payload[2:10])
@@ -2971,8 +2986,12 @@ _PAYLOAD_PARSERS = {
2971
2986
 
2972
2987
 
2973
2988
  def parse_payload(msg: Message) -> dict | list[dict]:
2989
+ """
2990
+ Apply the appropriate parser defined in this module to the message.
2991
+ :param msg: a Message object containing packet data and extra attributes
2992
+ :return: a dict of key: value pairs or a list of such dicts, e.g. {'temperature': 21.5}
2993
+ """
2974
2994
  result: dict | list[dict]
2975
-
2976
2995
  result = _PAYLOAD_PARSERS.get(msg.code, parser_unknown)(msg._pkt.payload, msg)
2977
2996
  if isinstance(result, dict) and msg.seqn.isnumeric(): # e.g. 22F1/3
2978
2997
  result["seqx_num"] = msg.seqn
ramses_tx/transport.py CHANGED
@@ -44,7 +44,7 @@ import sys
44
44
  from collections import deque
45
45
  from collections.abc import Awaitable, Callable, Iterable
46
46
  from datetime import datetime as dt, timedelta as td
47
- from functools import wraps
47
+ from functools import partial, wraps
48
48
  from io import TextIOWrapper
49
49
  from string import printable
50
50
  from time import perf_counter
@@ -142,8 +142,7 @@ else: # is linux
142
142
 
143
143
  def list_links(devices: set[str]) -> list[str]:
144
144
  """Search for symlinks to ports already listed in devices."""
145
-
146
- links = []
145
+ links: list[str] = []
147
146
  for device in glob.glob("/dev/*") + glob.glob("/dev/serial/by-id/*"):
148
147
  if os.path.islink(device) and os.path.realpath(device) in devices:
149
148
  links.append(device)
@@ -174,7 +173,7 @@ else: # is linux
174
173
  return result
175
174
 
176
175
 
177
- def is_hgi80(serial_port: SerPortNameT) -> bool | None:
176
+ async def is_hgi80(serial_port: SerPortNameT) -> bool | None:
178
177
  """Return True/False if the device attached to the port has the attrs of an HGI80.
179
178
 
180
179
  Return None if it's not possible to tell (falsy should assume is evofw3).
@@ -209,7 +208,10 @@ def is_hgi80(serial_port: SerPortNameT) -> bool | None:
209
208
 
210
209
  # otherwise, we can look at device attrs via comports()...
211
210
  try:
212
- komports = comports(include_links=True)
211
+ loop = asyncio.get_running_loop()
212
+ komports = await loop.run_in_executor(
213
+ None, partial(comports, include_links=True)
214
+ )
213
215
  except ImportError as err:
214
216
  raise exc.TransportSerialError(f"Unable to find {serial_port}: {err}") from err
215
217
 
@@ -841,8 +843,6 @@ class PortTransport(_RegHackMixin, _FullTransport, _PortTransportAbstractor): #
841
843
  self._leak_sem(), name="PortTransport._leak_sem()"
842
844
  )
843
845
 
844
- self._is_hgi80 = is_hgi80(self.serial.name)
845
-
846
846
  self._loop.create_task(
847
847
  self._create_connection(), name="PortTransport._create_connection()"
848
848
  )
@@ -855,6 +855,8 @@ class PortTransport(_RegHackMixin, _FullTransport, _PortTransportAbstractor): #
855
855
 
856
856
  # signature also serves to discover the HGI's device_id (& for pkt log, if any)
857
857
 
858
+ self._is_hgi80 = await is_hgi80(self.serial.name)
859
+
858
860
  async def connect_sans_signature() -> None:
859
861
  """Call connection_made() without sending/waiting for a signature."""
860
862
 
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.51.8"
3
+ __version__ = "0.51.9"
4
4
  VERSION = __version__