ramses-rf 0.51.9__py3-none-any.whl → 0.52.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ramses_cli/client.py CHANGED
@@ -438,17 +438,29 @@ def print_summary(gwy: Gateway, **kwargs: Any) -> None:
438
438
 
439
439
  if kwargs.get("show_crazys"):
440
440
  for device in [d for d in gwy.devices if d.type == DEV_TYPE_MAP.CTL]:
441
- for code, verbs in device._msgz.items():
442
- if code in (Code._0005, Code._000C):
443
- for verb in verbs.values():
444
- for pkt in verb.values():
445
- print(f"{pkt}")
441
+ if gwy.msg_db:
442
+ for msg in gwy.msg_db.get(device=device.id, code=Code._0005):
443
+ print(f"{msg._pkt}")
444
+ for msg in gwy.msg_db.get(device=device.id, code=Code._000C):
445
+ print(f"{msg._pkt}")
446
+ else: # TODO(eb): replace next block by
447
+ # raise NotImplementedError
448
+ for code, verbs in device._msgz.items():
449
+ if code in (Code._0005, Code._000C):
450
+ for verb in verbs.values():
451
+ for pkt in verb.values():
452
+ print(f"{pkt}")
446
453
  print()
447
454
  for device in [d for d in gwy.devices if d.type == DEV_TYPE_MAP.UFC]:
448
- for code in device._msgz.values():
449
- for verb in code.values():
450
- for pkt in verb.values():
451
- print(f"{pkt}")
455
+ if gwy.msg_db:
456
+ for msg in gwy.msg_db.get(device=device.id):
457
+ print(f"{msg._pkt}")
458
+ else: # TODO(eb): replace next block by
459
+ # raise NotImplementedError
460
+ for code in device._msgz.values():
461
+ for verb in code.values():
462
+ for pkt in verb.values():
463
+ print(f"{pkt}")
452
464
  print()
453
465
 
454
466
 
ramses_cli/discovery.py CHANGED
@@ -394,7 +394,7 @@ async def script_scan_otb_ramses(
394
394
  Code._3223,
395
395
  Code._3EF0, # rel. modulation level / RelativeModulationLevel (also, below)
396
396
  Code._3EF1, # rel. modulation level / RelativeModulationLevel
397
- ) # excl. 3220
397
+ ) # excl. 3150, 3220
398
398
 
399
399
  for c in _CODES:
400
400
  gwy.send_cmd(Command.from_attrs(RQ, dev_id, c, "00"), priority=Priority.LOW)
ramses_rf/database.py CHANGED
@@ -8,24 +8,13 @@ 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 Any, NewType, TypedDict
11
+ from typing import TYPE_CHECKING, Any, NewType
12
12
 
13
- from ramses_tx import Message
14
-
15
- DtmStrT = NewType("DtmStrT", str)
16
- MsgDdT = OrderedDict[DtmStrT, Message]
17
-
18
-
19
- class Params(TypedDict):
20
- dtm: dt | str | None
21
- verb: str | None
22
- src: str | None
23
- dst: str | None
24
- code: str | None
25
- ctx: str | None
26
- hdr: str | None
27
- plk: str | None
13
+ from ramses_tx import CODES_SCHEMA, Code, Message
28
14
 
15
+ if TYPE_CHECKING:
16
+ DtmStrT = NewType("DtmStrT", str)
17
+ MsgDdT = OrderedDict[DtmStrT, Message]
29
18
 
30
19
  _LOGGER = logging.getLogger(__name__)
31
20
 
@@ -34,7 +23,7 @@ def _setup_db_adapters() -> None:
34
23
  """Set up the database adapters and converters."""
35
24
 
36
25
  def adapt_datetime_iso(val: dt) -> str:
37
- """Adapt datetime.datetime to timezone-naive ISO 8601 datetime to match _msgs_ dtm keys."""
26
+ """Adapt datetime.datetime to timezone-naive ISO 8601 datetime to match _msgs dtm keys."""
38
27
  return val.isoformat(timespec="microseconds")
39
28
 
40
29
  sqlite3.register_adapter(dt, adapt_datetime_iso)
@@ -77,13 +66,13 @@ class MessageIndex:
77
66
  Index holds the latest message to & from all devices by header
78
67
  (example of a hdr: 000C|RP|01:223036|0208)."""
79
68
 
69
+ _housekeeping_task: asyncio.Task[None]
70
+
80
71
  def __init__(self, maintain: bool = True) -> None:
81
72
  """Instantiate a message database/index."""
82
73
 
83
74
  self.maintain = maintain
84
- self._msgs: MsgDdT = (
85
- OrderedDict()
86
- ) # stores all messages for retrieval. Filled in housekeeping loop.
75
+ self._msgs: MsgDdT = OrderedDict() # stores all messages for retrieval. Filled & cleaned up in housekeeping_loop.
87
76
 
88
77
  # Connect to a SQLite DB in memory
89
78
  self._cx = sqlite3.connect(
@@ -98,7 +87,7 @@ class MessageIndex:
98
87
  if self.maintain:
99
88
  self._lock = asyncio.Lock()
100
89
  self._last_housekeeping: dt = None # type: ignore[assignment]
101
- self._housekeeping_task: asyncio.Task[None] = None # type: ignore[assignment]
90
+ self._housekeeping_task = None # type: ignore[assignment]
102
91
 
103
92
  self.start()
104
93
 
@@ -109,7 +98,7 @@ class MessageIndex:
109
98
  """Start the housekeeper loop."""
110
99
 
111
100
  if self.maintain:
112
- if self._housekeeping_task and not self._housekeeping_task.done():
101
+ if self._housekeeping_task and (not self._housekeeping_task.done()):
113
102
  return
114
103
 
115
104
  self._housekeeping_task = asyncio.create_task(
@@ -119,7 +108,7 @@ class MessageIndex:
119
108
  def stop(self) -> None:
120
109
  """Stop the housekeeper loop."""
121
110
 
122
- if self._housekeeping_task and not self._housekeeping_task.done():
111
+ if self._housekeeping_task and (not self._housekeeping_task.done()):
123
112
  self._housekeeping_task.cancel() # stop the housekeeper
124
113
 
125
114
  self._cx.commit() # just in case
@@ -150,8 +139,8 @@ class MessageIndex:
150
139
  CREATE TABLE messages (
151
140
  dtm DTM NOT NULL PRIMARY KEY,
152
141
  verb TEXT(2) NOT NULL,
153
- src TEXT(9) NOT NULL,
154
- dst TEXT(9) NOT NULL,
142
+ src TEXT(12) NOT NULL,
143
+ dst TEXT(12) NOT NULL,
155
144
  code TEXT(4) NOT NULL,
156
145
  ctx TEXT,
157
146
  hdr TEXT NOT NULL UNIQUE,
@@ -175,14 +164,14 @@ class MessageIndex:
175
164
 
176
165
  async def housekeeping(dt_now: dt, _cutoff: td = td(days=1)) -> None:
177
166
  """
178
- Delete all messages from the using the MessageIndex older than a given delta.
167
+ Deletes all messages older than a given delta from the dict using the MessageIndex.
179
168
  :param dt_now: current timestamp
180
169
  :param _cutoff: the oldest timestamp to retain, default is 24 hours ago
181
170
  """
182
171
  dtm = dt_now - _cutoff # .isoformat(timespec="microseconds") < needed?
183
172
 
184
173
  self._cu.execute("SELECT dtm FROM messages WHERE dtm => ?", (dtm,))
185
- rows = self._cu.fetchall()
174
+ rows = self._cu.fetchall() # fetch dtm of current messages to retain
186
175
 
187
176
  try: # make this operation atomic, i.e. update self._msgs only on success
188
177
  await self._lock.acquire()
@@ -234,7 +223,9 @@ class MessageIndex:
234
223
  finally:
235
224
  pass # self._lock.release()
236
225
 
237
- if dup:
226
+ if (
227
+ dup and msg.src is not msg.src
228
+ ): # when src==dst, expect to add duplicate, don't check
238
229
  _LOGGER.warning(
239
230
  "Overwrote dtm (%s) for %s: %s (contrived log?)",
240
231
  msg.dtm,
@@ -251,7 +242,7 @@ class MessageIndex:
251
242
  Add a single record to the MessageIndex with timestamp now() and no Message contents.
252
243
  """
253
244
  # Used by OtbGateway init, via entity_base.py
254
- dtm: DtmStrT = DtmStrT(dt.strftime(dt.now(), "%Y-%m-%dT%H:%M:%S"))
245
+ dtm: DtmStrT = dt.strftime(dt.now(), "%Y-%m-%dT%H:%M:%S") # type: ignore[assignment]
255
246
  hdr = f"{code}|{verb}|{src}|00" # dummy record has no contents
256
247
 
257
248
  dup = self._delete_from(hdr=hdr)
@@ -368,10 +359,36 @@ class MessageIndex:
368
359
 
369
360
  return msgs
370
361
 
362
+ # MessageIndex msg_db query methods > copy to docs/source/ramses_rf.rst
363
+ # (ex = entity_base.py query methods
364
+ #
365
+ # +----+--------------+-------------+------------+------+--------------------------+
366
+ # | ix |method name | args | returns | uses | used by |
367
+ # +====+==============+=============+============+======+==========================+
368
+ # | i1 | get | Msg/kwargs | tuple[Msg] | i3 | |
369
+ # +----+--------------+-------------+------------+------+--------------------------+
370
+ # | i2 | contains | kwargs | bool | i4 | |
371
+ # +----+--------------+-------------+------------+------+--------------------------+
372
+ # | i3 | _select_from | kwargs | tuple[Msg] | i4 | |
373
+ # +----+--------------+-------------+------------+------+--------------------------+
374
+ # | i4 | qry_dtms | kwargs | list(dtm) | | |
375
+ # +----+--------------+-------------+------------+------+--------------------------+
376
+ # | i5 | qry | sql, kwargs | tuple[Msg] | | _msgs() |
377
+ # +----+--------------+-------------+------------+------+--------------------------+
378
+ # | i6 | qry_field | sql, kwargs | tuple[fld] | | e4, e5 |
379
+ # +----+--------------+-------------+------------+------+--------------------------+
380
+ # | i7 | get_rp_codes | src, dst | list[Code] | | Discovery#supported_cmds |
381
+ # +----+--------------+-------------+------------+------+--------------------------+
382
+
371
383
  def get(
372
384
  self, msg: Message | None = None, **kwargs: bool | dt | str
373
385
  ) -> tuple[Message, ...]:
374
- """Get a set of message(s) from the index."""
386
+ """
387
+ Public method to get a set of message(s) from the index.
388
+ :param msg: Message to return, by dtm (expect a single result as dtm is unique key)
389
+ :param kwargs: data table field names and criteria, e.g. (hdr=...)
390
+ :return: tuple of matching Messages
391
+ """
375
392
 
376
393
  if not (bool(msg) ^ bool(kwargs)):
377
394
  raise ValueError("Either a Message or kwargs should be provided, not both")
@@ -381,7 +398,33 @@ class MessageIndex:
381
398
 
382
399
  return self._select_from(**kwargs)
383
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
+
408
+ return len(self.qry_dtms(**kwargs)) > 0
409
+
410
+ def _select_from(self, **kwargs: bool | dt | str) -> tuple[Message, ...]:
411
+ """
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
+
417
+ return tuple(
418
+ self._msgs[row[0].isoformat(timespec="microseconds")]
419
+ for row in self.qry_dtms(**kwargs)
420
+ )
421
+
384
422
  def qry_dtms(self, **kwargs: bool | dt | str) -> list[Any]:
423
+ """
424
+ Select from the ImageIndex a list of dtms that match the provided arguments.
425
+ :param kwargs: data table field names and criteria
426
+ :return: list of unformatted dtms that match, useful for msg lookup, or an empty list if 0 matches
427
+ """
385
428
  # tweak kwargs as stored in SQLite, inverse from _insert_into():
386
429
  kw = {key: value for key, value in kwargs.items() if key != "ctx"}
387
430
  if "ctx" in kwargs:
@@ -398,28 +441,13 @@ class MessageIndex:
398
441
  self._cu.execute(sql, tuple(kw.values()))
399
442
  return self._cu.fetchall()
400
443
 
401
- def contains(self, **kwargs: bool | dt | str) -> bool:
444
+ def qry(self, sql: str, parameters: tuple[str, ...]) -> tuple[Message, ...]:
402
445
  """
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
446
+ Get a tuple of messages from _msgs using the index, given sql and parameters.
447
+ :param sql: a bespoke SQL query SELECT string that should return dtm as first field
448
+ :param parameters: tuple of kwargs with the selection filter
449
+ :return: a tuple of qualifying messages
406
450
  """
407
- # adapted from _select_from()
408
-
409
- return len(self.qry_dtms(**kwargs)) > 0
410
-
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
- )
420
-
421
- def qry(self, sql: str, parameters: tuple[str, ...]) -> tuple[Message, ...]:
422
- """Get a tuple of messages from the index, given sql and parameters."""
423
451
 
424
452
  if "SELECT" not in sql:
425
453
  raise ValueError(f"{self}: Only SELECT queries are allowed")
@@ -429,7 +457,9 @@ class MessageIndex:
429
457
  lst: list[Message] = []
430
458
  # stamp = list(self._msgs)[0] if len(self._msgs) > 0 else "N/A" # for debug
431
459
  for row in self._cu.fetchall():
432
- ts: DtmStrT = row[0].isoformat(timespec="microseconds")
460
+ ts: DtmStrT = row[0].isoformat(
461
+ timespec="microseconds"
462
+ ) # must reformat from DTM
433
463
  # _LOGGER.debug(
434
464
  # f"QRY Msg key raw: {row[0]} Reformatted: {ts} _msgs stamp format: {stamp}"
435
465
  # )
@@ -438,21 +468,46 @@ class MessageIndex:
438
468
  if ts in self._msgs:
439
469
  lst.append(self._msgs[ts])
440
470
  else: # happens in tests with artificial msg from heat
441
- _LOGGER.warning("MessageIndex ts %s not in device messages", ts)
471
+ _LOGGER.warning("MessageIndex timestamp %s not in device messages", ts)
442
472
  return tuple(lst)
443
473
 
474
+ def get_rp_codes(self, parameters: tuple[str, ...]) -> list[Code]:
475
+ """
476
+ Get a list of Codes from the index, given parameters.
477
+ :param parameters: tuple of additional kwargs
478
+ :return: list of Code: value pairs
479
+ """
480
+
481
+ def get_code(code: str) -> Code:
482
+ for Cd in CODES_SCHEMA:
483
+ if code == Cd:
484
+ return Cd
485
+ raise LookupError(f"Failed to find matching code for {code}")
486
+
487
+ sql = """
488
+ SELECT code from messages WHERE verb is 'RP' AND (src = ? OR dst = ?)
489
+ """
490
+ if "SELECT" not in sql:
491
+ raise ValueError(f"{self}: Only SELECT queries are allowed")
492
+
493
+ self._cu.execute(sql, parameters)
494
+ res = self._cu.fetchall()
495
+ return [get_code(res[0]) for res[0] in self._cu.fetchall()]
496
+
444
497
  def qry_field(
445
498
  self, sql: str, parameters: tuple[str, ...]
446
499
  ) -> list[tuple[dt | str, str]]:
447
500
  """
448
- Get a list of message field values from the index, given sql and parameters.
501
+ Get a list of fields from the index, given select sql and parameters.
502
+ :param sql: a bespoke SQL query SELECT string
503
+ :param parameters: tuple of additional kwargs
504
+ :return: list of key: value pairs as defined in sql
449
505
  """
450
506
 
451
507
  if "SELECT" not in sql:
452
508
  raise ValueError(f"{self}: Only SELECT queries are allowed")
453
509
 
454
510
  self._cu.execute(sql, parameters)
455
-
456
511
  return self._cu.fetchall()
457
512
 
458
513
  def all(self, include_expired: bool = False) -> tuple[Message, ...]:
ramses_rf/device/base.py CHANGED
@@ -60,7 +60,7 @@ class DeviceBase(Entity):
60
60
  def __init__(self, gwy: Gateway, dev_addr: Address, **kwargs: Any) -> None:
61
61
  super().__init__(gwy)
62
62
 
63
- # FIXME: ZZZ entities must know their parent device ID and their own idx
63
+ # FIXME: gwy.msg_db entities must know their parent device ID and their own idx
64
64
  self._z_id = dev_addr.id # the responsible device is itself
65
65
  self._z_idx = None # depends upon its location in the schema
66
66
 
@@ -76,8 +76,9 @@ class DeviceBase(Entity):
76
76
  self._scheme: Vendor = None
77
77
 
78
78
  def __str__(self) -> str:
79
- if self._STATE_ATTR:
80
- return f"{self.id} ({self._SLUG}): {getattr(self, self._STATE_ATTR)}"
79
+ if self._STATE_ATTR and hasattr(self, self._STATE_ATTR):
80
+ state: float | None = getattr(self, self._STATE_ATTR)
81
+ return f"{self.id} ({self._SLUG}): {state}"
81
82
  return f"{self.id} ({self._SLUG})"
82
83
 
83
84
  def __lt__(self, other: object) -> bool:
@@ -165,7 +166,11 @@ class DeviceBase(Entity):
165
166
  @property
166
167
  def has_battery(self) -> None | bool: # 1060
167
168
  """Return True if the device is battery powered (excludes battery-backup)."""
168
-
169
+ if self._gwy.msg_db:
170
+ code_list = self._msg_dev_qry()
171
+ return isinstance(self, BatteryState) or (
172
+ code_list is not None and Code._1060 in code_list
173
+ ) # TODO(eb): clean up next line Q1 2026
169
174
  return isinstance(self, BatteryState) or Code._1060 in self._msgz
170
175
 
171
176
  @property
@@ -204,7 +209,7 @@ class DeviceBase(Entity):
204
209
 
205
210
  @property
206
211
  def traits(self) -> dict[str, Any]:
207
- """Return the traits of the device."""
212
+ """Get the traits of the device."""
208
213
 
209
214
  result = super().traits
210
215
 
ramses_rf/device/heat.py CHANGED
@@ -144,7 +144,7 @@ class Actuator(DeviceHeat): # 3EF0, 3EF1 (for 10:/13:)
144
144
  if self._gwy.config.disable_discovery:
145
145
  return
146
146
 
147
- # TODO: why are we doing this here? Should simply use dscovery poller!
147
+ # TODO: why are we doing this here? Should simply use discovery poller!
148
148
  if msg.code == Code._3EF0 and msg.verb == I_ and not self.is_faked:
149
149
  # lf._send_cmd(Command.get_relay_demand(self.id), qos=QOS_LOW)
150
150
  self._send_cmd(
@@ -429,7 +429,7 @@ class UfhController(Parent, DeviceHeat): # UFC (02):
429
429
  def _handle_msg(self, msg: Message) -> None:
430
430
  super()._handle_msg(msg)
431
431
 
432
- # Several assumptions ar emade, regarding 000C pkts:
432
+ # Several assumptions are made, regarding 000C pkts:
433
433
  # - UFC bound only to CTL (not, e.g. SEN)
434
434
  # - all circuits bound to the same controller
435
435
 
@@ -450,7 +450,7 @@ class UfhController(Parent, DeviceHeat): # UFC (02):
450
450
  # )
451
451
  # self._send_cmd(cmd)
452
452
 
453
- elif msg.code == Code._0008: # relay_demand, TODO: use msg DB?
453
+ elif msg.code == Code._0008: # relay_demand, TODO: use msgIndex DB?
454
454
  if msg.payload.get(SZ_DOMAIN_ID) == FC:
455
455
  self._relay_demand = msg
456
456
  else: # FA
@@ -491,7 +491,6 @@ class UfhController(Parent, DeviceHeat): # UFC (02):
491
491
 
492
492
  # elif msg.code not in (Code._10E0, Code._22D0):
493
493
  # print("xxx")
494
-
495
494
  # "0008|FA/FC", "22C9|array", "22D0|none", "3150|ZZ/array(/FC?)"
496
495
 
497
496
  # TODO: should be a private method
@@ -635,7 +634,7 @@ def _to_msg_id(data_id: OtDataId) -> MsgId:
635
634
  return f"{data_id:02X}"
636
635
 
637
636
 
638
- # NOTE: config.use_native_ot should enforces sends, but not reads from _msgz DB
637
+ # NOTE: config.use_native_ot should enforce sends, but not reads from _msgz DB
639
638
  class OtbGateway(Actuator, HeatDemand): # OTB (10): 3220 (22D9, others)
640
639
  """The OTB class, specifically an OpenTherm Bridge (R8810A Bridge)."""
641
640
 
@@ -668,7 +667,16 @@ class OtbGateway(Actuator, HeatDemand): # OTB (10): 3220 (22D9, others)
668
667
 
669
668
  self._child_id = FC # NOTE: domain_id
670
669
 
671
- self._msgz[Code._3220] = {RP: {}} # _msgz[Code._3220][RP][msg_id]
670
+ # TODO(eb): cleanup
671
+ # should fix src/ramses_rf/database.py _add_record try/except when activating next line
672
+ if self._gwy.msg_db:
673
+ self._add_record(
674
+ address=self.addr, code=Code._3220, verb="RP"
675
+ ) # << essential?
676
+ # adds a "sim" RP opentherm_msg to the SQLite MessageIndex with code _3220
677
+ # causes exc when fetching ALL, when no "real" msg was added to _msgs_. We skip those.
678
+ else:
679
+ self._msgz[Code._3220] = {RP: {}} # No ctx! (not None)
672
680
 
673
681
  # lf._use_ot = self._gwy.config.use_native_ot
674
682
  self._msgs_ot: dict[MsgId, Message] = {}
@@ -758,7 +766,7 @@ class OtbGateway(Actuator, HeatDemand): # OTB (10): 3220 (22D9, others)
758
766
  if msg.payload.get(SZ_VALUE) is None:
759
767
  return
760
768
 
761
- # msg_id is int in msg payload/opentherm.py, but MsgId (str) is in this module
769
+ # msg_id is int in msg payload/opentherm.py, but MsgId (str) in this module
762
770
  msg_id = _to_msg_id(msg.payload[SZ_MSG_ID])
763
771
  self._msgs_ot[msg_id] = msg
764
772
 
ramses_rf/device/hvac.py CHANGED
@@ -426,7 +426,7 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A], 2411
426
426
  Also handles 2411 parameter messages for configuration.
427
427
  Since 2411 is not supported by all vendors, discovery is used to determine if it is supported.
428
428
  Since more than 1 different parameters can be sent on 2411 messages,
429
- we will process these in the dedicated _handle_2411_message method.
429
+ we process these in the dedicated _handle_2411_message method.
430
430
  """
431
431
 
432
432
  # Itho Daalderop (NL)
@@ -606,7 +606,7 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A], 2411
606
606
  It handles parameter value normalization and validation.
607
607
 
608
608
  :param msg: The incoming 2411 message
609
- :type msg: Message
609
+ :type msg: Message to process
610
610
  """
611
611
  if not hasattr(msg, "payload") or not isinstance(msg.payload, dict):
612
612
  _LOGGER.debug("Invalid 2411 message format: %s", msg)
@@ -653,7 +653,7 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A], 2411
653
653
  handling for 2411 parameter messages. It updates the device state and
654
654
  triggers any necessary callbacks.
655
655
 
656
- After handling the messages, it calls the initialized callback if set to notify that
656
+ After handling the messages, it calls the initialized callback - if set - to notify that
657
657
  the device was fully initialized.
658
658
 
659
659
  :param msg: The incoming message to process
@@ -911,14 +911,38 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A], 2411
911
911
  @property
912
912
  def fan_info(self) -> str | None:
913
913
  """
914
- Extract fan info description from _31D9 or _31DA message payload, e.g. "speed 2, medium".
914
+ Extract fan info description from MessageIndex _31D9 or _31DA payload,
915
+ e.g. "speed 2, medium".
915
916
  By its name, the result is picked up by a sensor in HA Climate UI.
916
917
  Some manufacturers (Orcon, Vasco) include the fan mode (auto, manual), others don't (Itho).
917
918
 
918
- :return: a string describing fan mode, speed
919
- """
919
+ :return: string describing fan mode, speed
920
+ """
921
+ if self._gwy.msg_db:
922
+ # Use SQLite query on MessageIndex. res_rate/res_mode not exposed yet
923
+ sql = f"""
924
+ SELECT code from messages WHERE verb in (' I', 'RP')
925
+ AND (src = ? OR dst = ?)
926
+ AND (plk LIKE '%{SZ_FAN_MODE}%')
927
+ """
928
+ res_mode: list = self._msg_qry(sql)
929
+ # SQLite query on MessageIndex
930
+ _LOGGER.info(f"{res_mode} # FAN_MODE FETCHED from MessageIndex")
931
+
932
+ sql = f"""
933
+ SELECT code from messages WHERE verb in (' I', 'RP')
934
+ AND (src = ? OR dst = ?)
935
+ AND (plk LIKE '%{SZ_FAN_RATE}%')
936
+ """
937
+ res_rate: list = self._msg_qry(sql)
938
+ # SQLite query on MessageIndex
939
+ _LOGGER.info(
940
+ f"{res_rate} # FAN_RATE FETCHED from MessageIndex"
941
+ ) # DEBUG always empty?
942
+
920
943
  if Code._31D9 in self._msgs:
921
- # Itho, Vasco D60 and ClimaRad (MiniBox fan) send mode/speed in _31D9
944
+ # was a dict by Code
945
+ # Itho, Vasco D60 and ClimaRad MiniBox fan send mode/speed in _31D9
922
946
  v: str
923
947
  for k, v in self._msgs[Code._31D9].payload.items():
924
948
  if k == SZ_FAN_MODE and len(v) > 2: # prevent non-lookups to pass
ramses_rf/dispatcher.py CHANGED
@@ -268,10 +268,10 @@ def process_msg(gwy: Gateway, msg: Message) -> None:
268
268
 
269
269
  else:
270
270
  logger_xxxx(msg)
271
- if gwy.msg_db:
272
- gwy.msg_db.add(
273
- msg
274
- ) # why add it anyway? will fail in testst comparing to _msgs
271
+ # if gwy.msg_db:
272
+ # gwy.msg_db.add(
273
+ # msg
274
+ # ) # why add it? passes all tests without
275
275
 
276
276
 
277
277
  # TODO: this needs cleaning up (e.g. handle intervening packet)