ramses-rf 0.51.8__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,23 +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 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
13
+ from ramses_tx import CODES_SCHEMA, Code, Message
27
14
 
15
+ if TYPE_CHECKING:
16
+ DtmStrT = NewType("DtmStrT", str)
17
+ MsgDdT = OrderedDict[DtmStrT, Message]
28
18
 
29
19
  _LOGGER = logging.getLogger(__name__)
30
20
 
@@ -33,55 +23,92 @@ def _setup_db_adapters() -> None:
33
23
  """Set up the database adapters and converters."""
34
24
 
35
25
  def adapt_datetime_iso(val: dt) -> str:
36
- """Adapt datetime.datetime to timezone-naive ISO 8601 datetime."""
26
+ """Adapt datetime.datetime to timezone-naive ISO 8601 datetime to match _msgs dtm keys."""
37
27
  return val.isoformat(timespec="microseconds")
38
28
 
39
29
  sqlite3.register_adapter(dt, adapt_datetime_iso)
40
30
 
41
31
  def convert_datetime(val: bytes) -> dt:
42
- """Convert ISO 8601 datetime to datetime.datetime object."""
32
+ """Convert ISO 8601 datetime to datetime.datetime object to import dtm in msg_db."""
43
33
  return dt.fromisoformat(val.decode())
44
34
 
45
- sqlite3.register_converter("dtm", convert_datetime)
35
+ sqlite3.register_converter("DTM", convert_datetime)
36
+
37
+
38
+ def payload_keys(parsed_payload: list[dict] | dict) -> str: # type: ignore[type-arg]
39
+ """
40
+ Copy payload keys for fast query check.
41
+
42
+ :param parsed_payload: pre-parsed message payload dict
43
+ :return: string of payload keys, separated by the | char
44
+ """
45
+ _keys: str = "|"
46
+
47
+ def append_keys(ppl: dict) -> str: # type: ignore[type-arg]
48
+ _ks: str = ""
49
+ for k, v in ppl.items():
50
+ if (
51
+ k not in _ks and k not in _keys and v is not None
52
+ ): # ignore keys with None value
53
+ _ks += k + "|"
54
+ return _ks
55
+
56
+ if isinstance(parsed_payload, list):
57
+ for d in parsed_payload:
58
+ _keys += append_keys(d)
59
+ elif isinstance(parsed_payload, dict):
60
+ _keys += append_keys(parsed_payload)
61
+ return _keys
46
62
 
47
63
 
48
64
  class MessageIndex:
49
- """A simple in-memory SQLite3 database for indexing messages."""
65
+ """A simple in-memory SQLite3 database for indexing RF messages.
66
+ Index holds the latest message to & from all devices by header
67
+ (example of a hdr: 000C|RP|01:223036|0208)."""
50
68
 
51
- def __init__(self) -> None:
69
+ _housekeeping_task: asyncio.Task[None]
70
+
71
+ def __init__(self, maintain: bool = True) -> None:
52
72
  """Instantiate a message database/index."""
53
73
 
54
- self._msgs: MsgDdT = OrderedDict()
74
+ self.maintain = maintain
75
+ self._msgs: MsgDdT = OrderedDict() # stores all messages for retrieval. Filled & cleaned up in housekeeping_loop.
55
76
 
56
- self._cx = sqlite3.connect(":memory:") # Connect to a SQLite DB in memory
77
+ # Connect to a SQLite DB in memory
78
+ self._cx = sqlite3.connect(
79
+ ":memory:", detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES
80
+ )
81
+ # detect_types should retain dt type on store/retrieve
57
82
  self._cu = self._cx.cursor() # Create a cursor
58
83
 
59
- _setup_db_adapters() # dtm adapter/converter
84
+ _setup_db_adapters() # DTM adapter/converter
60
85
  self._setup_db_schema()
61
86
 
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]
87
+ if self.maintain:
88
+ self._lock = asyncio.Lock()
89
+ self._last_housekeeping: dt = None # type: ignore[assignment]
90
+ self._housekeeping_task = None # type: ignore[assignment]
65
91
 
66
92
  self.start()
67
93
 
68
94
  def __repr__(self) -> str:
69
- return f"MessageIndex({len(self._msgs)} messages)"
95
+ return f"MessageIndex({len(self._msgs)} messages)" # or msg_db.count()
70
96
 
71
97
  def start(self) -> None:
72
98
  """Start the housekeeper loop."""
73
99
 
74
- if self._housekeeping_task and not self._housekeeping_task.done():
75
- return
100
+ if self.maintain:
101
+ if self._housekeeping_task and (not self._housekeeping_task.done()):
102
+ return
76
103
 
77
- self._housekeeping_task = asyncio.create_task(
78
- self._housekeeping_loop(), name=f"{self.__class__.__name__}.housekeeper"
79
- )
104
+ self._housekeeping_task = asyncio.create_task(
105
+ self._housekeeping_loop(), name=f"{self.__class__.__name__}.housekeeper"
106
+ )
80
107
 
81
108
  def stop(self) -> None:
82
109
  """Stop the housekeeper loop."""
83
110
 
84
- if self._housekeeping_task and not self._housekeeping_task.done():
111
+ if self._housekeeping_task and (not self._housekeeping_task.done()):
85
112
  self._housekeeping_task.cancel() # stop the housekeeper
86
113
 
87
114
  self._cx.commit() # just in case
@@ -95,27 +122,29 @@ class MessageIndex:
95
122
  def _setup_db_schema(self) -> None:
96
123
  """Set up the message database schema.
97
124
 
98
- Fields:
125
+ messages TABLE Fields:
99
126
 
100
127
  - dtm message timestamp
101
- - verb _I, RQ etc.
128
+ - verb " I", "RQ" etc.
102
129
  - src message origin address
103
130
  - dst message destination address
104
131
  - code packet code aka command class e.g. _0005, _31DA
105
132
  - ctx message context, created from payload as index + extra markers (Heat)
106
133
  - hdr packet header e.g. 000C|RP|01:223036|0208 (see: src/ramses_tx/frame.py)
134
+ - plk the keys stored in the parsed payload, separated by the | char
107
135
  """
108
136
 
109
137
  self._cu.execute(
110
138
  """
111
139
  CREATE TABLE messages (
112
- dtm TEXT(26) NOT NULL PRIMARY KEY,
140
+ dtm DTM NOT NULL PRIMARY KEY,
113
141
  verb TEXT(2) NOT NULL,
114
- src TEXT(9) NOT NULL,
115
- dst TEXT(9) NOT NULL,
142
+ src TEXT(12) NOT NULL,
143
+ dst TEXT(12) NOT NULL,
116
144
  code TEXT(4) NOT NULL,
117
- ctx TEXT NOT NULL,
118
- hdr TEXT NOT NULL UNIQUE
145
+ ctx TEXT,
146
+ hdr TEXT NOT NULL UNIQUE,
147
+ plk TEXT NOT NULL
119
148
  )
120
149
  """
121
150
  )
@@ -130,13 +159,19 @@ class MessageIndex:
130
159
  self._cx.commit()
131
160
 
132
161
  async def _housekeeping_loop(self) -> None:
133
- """Periodically remove stale messages from the index."""
162
+ """Periodically remove stale messages from the index,
163
+ unless self.maintain is False."""
134
164
 
135
165
  async def housekeeping(dt_now: dt, _cutoff: td = td(days=1)) -> None:
136
- dtm = (dt_now - _cutoff).isoformat(timespec="microseconds")
166
+ """
167
+ Deletes all messages older than a given delta from the dict using the MessageIndex.
168
+ :param dt_now: current timestamp
169
+ :param _cutoff: the oldest timestamp to retain, default is 24 hours ago
170
+ """
171
+ dtm = dt_now - _cutoff # .isoformat(timespec="microseconds") < needed?
137
172
 
138
173
  self._cu.execute("SELECT dtm FROM messages WHERE dtm => ?", (dtm,))
139
- rows = self._cu.fetchall()
174
+ rows = self._cu.fetchall() # fetch dtm of current messages to retain
140
175
 
141
176
  try: # make this operation atomic, i.e. update self._msgs only on success
142
177
  await self._lock.acquire()
@@ -154,80 +189,144 @@ class MessageIndex:
154
189
  while True:
155
190
  self._last_housekeeping = dt.now()
156
191
  await asyncio.sleep(3600)
192
+ _LOGGER.info("Starting next MessageIndex housekeeping")
157
193
  await housekeeping(self._last_housekeeping)
158
194
 
159
195
  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
196
+ """
197
+ Add a single message to the MessageIndex.
198
+ Logs a warning if there is a duplicate dtm.
199
+ :returns: any message that was removed because it had the same header
200
+ """
201
+ # TODO: eventually, may be better to use SqlAlchemy
166
202
 
167
203
  dup: tuple[Message, ...] = tuple() # avoid UnboundLocalError
168
204
  old: Message | None = None # avoid UnboundLocalError
169
205
 
170
- try: # TODO: remove, or use only when source is a packet log?
206
+ try: # TODO: remove this, or apply only when source is a real packet log?
171
207
  # await self._lock.acquire()
172
208
  dup = self._delete_from( # HACK: because of contrived pkt logs
173
- dtm=msg.dtm.isoformat(timespec="microseconds")
209
+ dtm=msg.dtm # stored as such with DTM formatter
174
210
  )
175
- old = self._insert_into(msg) # will delete old msg by hdr
211
+ old = self._insert_into(msg) # will delete old msg by hdr (not dtm!)
176
212
 
177
- except sqlite3.Error: # UNIQUE constraint failed: ? messages.dtm (so: HACK)
213
+ except (
214
+ sqlite3.Error
215
+ ): # UNIQUE constraint failed: ? messages.dtm or .hdr (so: HACK)
178
216
  self._cx.rollback()
179
217
 
180
218
  else:
219
+ # _msgs dict requires a timestamp reformat
181
220
  dtm: DtmStrT = msg.dtm.isoformat(timespec="microseconds") # type: ignore[assignment]
182
221
  self._msgs[dtm] = msg
183
222
 
184
223
  finally:
185
224
  pass # self._lock.release()
186
225
 
187
- if dup:
226
+ if (
227
+ dup and msg.src is not msg.src
228
+ ): # when src==dst, expect to add duplicate, don't check
188
229
  _LOGGER.warning(
189
- "Overwrote dtm for %s: %s (contrived log?)", msg._pkt._hdr, dup[0]._pkt
230
+ "Overwrote dtm (%s) for %s: %s (contrived log?)",
231
+ msg.dtm,
232
+ msg._pkt._hdr,
233
+ dup[0]._pkt,
190
234
  )
235
+ if old is not None:
236
+ _LOGGER.info("Old msg replaced: %s", old)
191
237
 
192
238
  return old
193
239
 
240
+ def add_record(self, src: str, code: str = "", verb: str = "") -> None:
241
+ """
242
+ Add a single record to the MessageIndex with timestamp now() and no Message contents.
243
+ """
244
+ # Used by OtbGateway init, via entity_base.py
245
+ dtm: DtmStrT = dt.strftime(dt.now(), "%Y-%m-%dT%H:%M:%S") # type: ignore[assignment]
246
+ hdr = f"{code}|{verb}|{src}|00" # dummy record has no contents
247
+
248
+ dup = self._delete_from(hdr=hdr)
249
+
250
+ sql = """
251
+ INSERT INTO messages (dtm, verb, src, dst, code, ctx, hdr, plk)
252
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
253
+ """
254
+ try:
255
+ self._cu.execute(
256
+ sql,
257
+ (
258
+ dtm,
259
+ verb,
260
+ src,
261
+ src,
262
+ code,
263
+ None,
264
+ hdr,
265
+ "|",
266
+ ),
267
+ )
268
+ except sqlite3.Error:
269
+ self._cx.rollback()
270
+
271
+ if dup: # expected when more than one heat system in schema
272
+ _LOGGER.debug("Replaced record with same hdr: %s", hdr)
273
+
194
274
  def _insert_into(self, msg: Message) -> Message | None:
195
- """Insert a message into the index (and return any message replaced by hdr)."""
275
+ """
276
+ Insert a message into the index.
277
+ :returns: any message replaced (by same hdr)
278
+ """
279
+ assert msg._pkt._hdr is not None, "Skipping: Packet has no hdr: {msg._pkt}"
280
+
281
+ if msg._pkt._ctx is True:
282
+ msg_pkt_ctx = "True"
283
+ elif msg._pkt._ctx is False:
284
+ msg_pkt_ctx = "False"
285
+ else:
286
+ msg_pkt_ctx = msg._pkt._ctx # can be None
196
287
 
197
- msgs = self._delete_from(hdr=msg._pkt._hdr)
288
+ _old_msgs = self._delete_from(hdr=msg._pkt._hdr)
198
289
 
199
290
  sql = """
200
- INSERT INTO messages (dtm, verb, src, dst, code, ctx, hdr)
201
- VALUES (?, ?, ?, ?, ?, ?, ?)
291
+ INSERT INTO messages (dtm, verb, src, dst, code, ctx, hdr, plk)
292
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
202
293
  """
203
294
 
204
295
  self._cu.execute(
205
296
  sql,
206
297
  (
207
298
  msg.dtm,
208
- msg.verb,
299
+ str(msg.verb),
209
300
  msg.src.id,
210
301
  msg.dst.id,
211
- msg.code,
212
- msg._pkt._ctx,
302
+ str(msg.code),
303
+ msg_pkt_ctx,
213
304
  msg._pkt._hdr,
305
+ payload_keys(msg.payload),
214
306
  ),
215
307
  )
308
+ _LOGGER.info(f"Added {msg} to gwy.msg_db")
216
309
 
217
- return msgs[0] if msgs else None
310
+ return _old_msgs[0] if _old_msgs else None
218
311
 
219
312
  def rem(
220
- self, msg: Message | None = None, **kwargs: str
313
+ self, msg: Message | None = None, **kwargs: str | dt
221
314
  ) -> tuple[Message, ...] | None:
222
315
  """Remove a set of message(s) from the index.
223
316
 
224
- Returns any messages that were removed.
317
+ :returns: any messages that were removed.
225
318
  """
226
-
227
- if bool(msg) ^ bool(kwargs):
319
+ # _LOGGER.debug(f"SQL REM msg={msg} bool{bool(msg)} kwargs={kwargs} bool(kwargs)")
320
+ # SQL REM
321
+ # msg=|| 02:044328 | | I | heat_demand | FC || {'domain_id': 'FC', 'heat_demand': 0.74}
322
+ # boolTrue
323
+ # kwargs={}
324
+ # bool(kwargs)
325
+
326
+ if not bool(msg) ^ bool(kwargs):
228
327
  raise ValueError("Either a Message or kwargs should be provided, not both")
229
328
  if msg:
230
- kwargs["dtm"] = msg.dtm.isoformat(timespec="microseconds")
329
+ kwargs["dtm"] = msg.dtm # .isoformat(timespec="microseconds")
231
330
 
232
331
  msgs = None
233
332
  try: # make this operation atomic, i.e. update self._msgs only on success
@@ -247,8 +346,9 @@ class MessageIndex:
247
346
 
248
347
  return msgs
249
348
 
250
- def _delete_from(self, **kwargs: str) -> tuple[Message, ...]:
251
- """Remove message(s) from the index (and return any messages removed)."""
349
+ def _delete_from(self, **kwargs: bool | dt | str) -> tuple[Message, ...]:
350
+ """Remove message(s) from the index.
351
+ :returns: any messages that were removed"""
252
352
 
253
353
  msgs = self._select_from(**kwargs)
254
354
 
@@ -259,48 +359,181 @@ class MessageIndex:
259
359
 
260
360
  return msgs
261
361
 
262
- def get(self, msg: Message | None = None, **kwargs: str) -> tuple[Message, ...]:
263
- """Return a set of message(s) from the index."""
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
+
383
+ def get(
384
+ self, msg: Message | None = None, **kwargs: bool | dt | str
385
+ ) -> tuple[Message, ...]:
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
+ """
264
392
 
265
393
  if not (bool(msg) ^ bool(kwargs)):
266
394
  raise ValueError("Either a Message or kwargs should be provided, not both")
395
+
267
396
  if msg:
268
- kwargs["dtm"] = msg.dtm.isoformat(timespec="microseconds")
397
+ kwargs["dtm"] = msg.dtm # .isoformat(timespec="microseconds")
269
398
 
270
399
  return self._select_from(**kwargs)
271
400
 
272
- def _select_from(self, **kwargs: str) -> tuple[Message, ...]:
273
- """Select message(s) from the index (and return any such messages)."""
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
+ """
274
407
 
275
- sql = "SELECT dtm FROM messages WHERE "
276
- sql += " AND ".join(f"{k} = ?" for k in kwargs)
408
+ return len(self.qry_dtms(**kwargs)) > 0
277
409
 
278
- self._cu.execute(sql, tuple(kwargs.values()))
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
+ """
279
416
 
280
- return tuple(self._msgs[row[0]] for row in self._cu.fetchall())
417
+ return tuple(
418
+ self._msgs[row[0].isoformat(timespec="microseconds")]
419
+ for row in self.qry_dtms(**kwargs)
420
+ )
421
+
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
+ """
428
+ # tweak kwargs as stored in SQLite, inverse from _insert_into():
429
+ kw = {key: value for key, value in kwargs.items() if key != "ctx"}
430
+ if "ctx" in kwargs:
431
+ if isinstance(kwargs["ctx"], str):
432
+ kw["ctx"] = kwargs["ctx"]
433
+ elif kwargs["ctx"]:
434
+ kw["ctx"] = "True"
435
+ else:
436
+ kw["ctx"] = "False"
437
+
438
+ sql = "SELECT dtm FROM messages WHERE "
439
+ sql += " AND ".join(f"{k} = ?" for k in kw)
440
+
441
+ self._cu.execute(sql, tuple(kw.values()))
442
+ return self._cu.fetchall()
281
443
 
282
444
  def qry(self, sql: str, parameters: tuple[str, ...]) -> tuple[Message, ...]:
283
- """Return a set of message(s) from the index, given sql and parameters."""
445
+ """
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
450
+ """
451
+
452
+ if "SELECT" not in sql:
453
+ raise ValueError(f"{self}: Only SELECT queries are allowed")
454
+
455
+ self._cu.execute(sql, parameters)
456
+
457
+ lst: list[Message] = []
458
+ # stamp = list(self._msgs)[0] if len(self._msgs) > 0 else "N/A" # for debug
459
+ for row in self._cu.fetchall():
460
+ ts: DtmStrT = row[0].isoformat(
461
+ timespec="microseconds"
462
+ ) # must reformat from DTM
463
+ # _LOGGER.debug(
464
+ # f"QRY Msg key raw: {row[0]} Reformatted: {ts} _msgs stamp format: {stamp}"
465
+ # )
466
+ # QRY Msg key raw: 2022-09-08 13:43:31.536862 Reformatted: 2022-09-08T13:43:31.536862
467
+ # _msgs stamp format: 2022-09-08T13:40:52.447364
468
+ if ts in self._msgs:
469
+ lst.append(self._msgs[ts])
470
+ else: # happens in tests with artificial msg from heat
471
+ _LOGGER.warning("MessageIndex timestamp %s not in device messages", ts)
472
+ return tuple(lst)
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}")
284
486
 
487
+ sql = """
488
+ SELECT code from messages WHERE verb is 'RP' AND (src = ? OR dst = ?)
489
+ """
285
490
  if "SELECT" not in sql:
286
491
  raise ValueError(f"{self}: Only SELECT queries are allowed")
287
492
 
288
493
  self._cu.execute(sql, parameters)
494
+ res = self._cu.fetchall()
495
+ return [get_code(res[0]) for res[0] in self._cu.fetchall()]
289
496
 
290
- return tuple(self._msgs[row[0]] for row in self._cu.fetchall())
497
+ def qry_field(
498
+ self, sql: str, parameters: tuple[str, ...]
499
+ ) -> list[tuple[dt | str, str]]:
500
+ """
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
505
+ """
291
506
 
292
- def all(self, include_expired: bool = False) -> tuple[Message, ...]:
293
- """Return all messages from the index."""
507
+ if "SELECT" not in sql:
508
+ raise ValueError(f"{self}: Only SELECT queries are allowed")
294
509
 
295
- # self._cu.execute("SELECT * FROM messages")
296
- # return tuple(self._msgs[row[0]] for row in self._cu.fetchall())
510
+ self._cu.execute(sql, parameters)
511
+ return self._cu.fetchall()
297
512
 
298
- return tuple(
299
- m for m in self._msgs.values() if include_expired or not m._expired
300
- )
513
+ def all(self, include_expired: bool = False) -> tuple[Message, ...]:
514
+ """Get all messages from the index."""
515
+
516
+ self._cu.execute("SELECT * FROM messages")
517
+
518
+ lst: list[Message] = []
519
+ # stamp = list(self._msgs)[0] if len(self._msgs) > 0 else "N/A"
520
+ for row in self._cu.fetchall():
521
+ ts: DtmStrT = row[0].isoformat(timespec="microseconds")
522
+ # _LOGGER.debug(
523
+ # f"ALL Msg key raw: {row[0]} Reformatted: {ts} _msgs stamp format: {stamp}"
524
+ # )
525
+ # ALL Msg key raw: 2022-05-02 10:02:02.744905
526
+ # Reformatted: 2022-05-02T10:02:02.744905
527
+ # _msgs stamp format: 2022-05-02T10:02:02.744905
528
+ if ts in self._msgs:
529
+ # if include_expired or not self._msgs[ts].HAS_EXPIRED: # not working
530
+ lst.append(self._msgs[ts])
531
+ else: # happens in tests with dummy msg from heat init
532
+ _LOGGER.warning("MessageIndex ts %s not in device messages", ts)
533
+ return tuple(lst)
301
534
 
302
535
  def clr(self) -> None:
303
- """Clear the message index (remove all messages)."""
536
+ """Clear the message index (remove indexes of all messages)."""
304
537
 
305
538
  self._cu.execute("DELETE FROM messages")
306
539
  self._cx.commit()
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