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 +21 -9
- ramses_cli/discovery.py +1 -1
- ramses_rf/database.py +322 -89
- ramses_rf/device/base.py +10 -5
- ramses_rf/device/heat.py +15 -7
- ramses_rf/device/hvac.py +100 -94
- ramses_rf/dispatcher.py +8 -6
- ramses_rf/entity_base.py +477 -116
- ramses_rf/gateway.py +16 -6
- ramses_rf/version.py +1 -1
- {ramses_rf-0.51.8.dist-info → ramses_rf-0.52.0.dist-info}/METADATA +1 -1
- {ramses_rf-0.51.8.dist-info → ramses_rf-0.52.0.dist-info}/RECORD +26 -26
- ramses_tx/address.py +1 -1
- ramses_tx/const.py +1 -1
- ramses_tx/frame.py +4 -4
- ramses_tx/gateway.py +3 -1
- ramses_tx/message.py +22 -14
- ramses_tx/packet.py +1 -1
- ramses_tx/parsers.py +48 -27
- ramses_tx/ramses.py +11 -3
- ramses_tx/schemas.py +8 -2
- ramses_tx/transport.py +9 -7
- ramses_tx/version.py +1 -1
- {ramses_rf-0.51.8.dist-info → ramses_rf-0.52.0.dist-info}/WHEEL +0 -0
- {ramses_rf-0.51.8.dist-info → ramses_rf-0.52.0.dist-info}/entry_points.txt +0 -0
- {ramses_rf-0.51.8.dist-info → ramses_rf-0.52.0.dist-info}/licenses/LICENSE +0 -0
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
449
|
-
for
|
|
450
|
-
|
|
451
|
-
|
|
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
|
|
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("
|
|
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
|
-
|
|
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.
|
|
74
|
+
self.maintain = maintain
|
|
75
|
+
self._msgs: MsgDdT = OrderedDict() # stores all messages for retrieval. Filled & cleaned up in housekeeping_loop.
|
|
55
76
|
|
|
56
|
-
|
|
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() #
|
|
84
|
+
_setup_db_adapters() # DTM adapter/converter
|
|
60
85
|
self._setup_db_schema()
|
|
61
86
|
|
|
62
|
-
self.
|
|
63
|
-
|
|
64
|
-
|
|
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.
|
|
75
|
-
|
|
100
|
+
if self.maintain:
|
|
101
|
+
if self._housekeeping_task and (not self._housekeeping_task.done()):
|
|
102
|
+
return
|
|
76
103
|
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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
|
|
140
|
+
dtm DTM NOT NULL PRIMARY KEY,
|
|
113
141
|
verb TEXT(2) NOT NULL,
|
|
114
|
-
src TEXT(
|
|
115
|
-
dst TEXT(
|
|
142
|
+
src TEXT(12) NOT NULL,
|
|
143
|
+
dst TEXT(12) NOT NULL,
|
|
116
144
|
code TEXT(4) NOT NULL,
|
|
117
|
-
ctx TEXT
|
|
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
|
-
|
|
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
|
-
"""
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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?)",
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
317
|
+
:returns: any messages that were removed.
|
|
225
318
|
"""
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
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
|
-
|
|
263
|
-
|
|
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
|
|
273
|
-
"""
|
|
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
|
-
|
|
276
|
-
sql += " AND ".join(f"{k} = ?" for k in kwargs)
|
|
408
|
+
return len(self.qry_dtms(**kwargs)) > 0
|
|
277
409
|
|
|
278
|
-
|
|
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(
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
507
|
+
if "SELECT" not in sql:
|
|
508
|
+
raise ValueError(f"{self}: Only SELECT queries are allowed")
|
|
294
509
|
|
|
295
|
-
|
|
296
|
-
|
|
510
|
+
self._cu.execute(sql, parameters)
|
|
511
|
+
return self._cu.fetchall()
|
|
297
512
|
|
|
298
|
-
|
|
299
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
"""
|
|
212
|
+
"""Get the traits of the device."""
|
|
208
213
|
|
|
209
214
|
result = super().traits
|
|
210
215
|
|