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 +247 -69
- ramses_rf/device/hvac.py +69 -87
- ramses_rf/dispatcher.py +7 -5
- ramses_rf/version.py +1 -1
- {ramses_rf-0.51.8.dist-info → ramses_rf-0.51.9.dist-info}/METADATA +1 -1
- {ramses_rf-0.51.8.dist-info → ramses_rf-0.51.9.dist-info}/RECORD +18 -18
- ramses_tx/address.py +1 -1
- ramses_tx/const.py +1 -1
- ramses_tx/frame.py +4 -4
- ramses_tx/gateway.py +1 -1
- ramses_tx/message.py +20 -14
- ramses_tx/packet.py +1 -1
- ramses_tx/parsers.py +45 -26
- ramses_tx/transport.py +9 -7
- ramses_tx/version.py +1 -1
- {ramses_rf-0.51.8.dist-info → ramses_rf-0.51.9.dist-info}/WHEEL +0 -0
- {ramses_rf-0.51.8.dist-info → ramses_rf-0.51.9.dist-info}/entry_points.txt +0 -0
- {ramses_rf-0.51.8.dist-info → ramses_rf-0.51.9.dist-info}/licenses/LICENSE +0 -0
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("
|
|
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.
|
|
83
|
+
self.maintain = maintain
|
|
84
|
+
self._msgs: MsgDdT = (
|
|
85
|
+
OrderedDict()
|
|
86
|
+
) # stores all messages for retrieval. Filled in housekeeping loop.
|
|
55
87
|
|
|
56
|
-
|
|
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() #
|
|
95
|
+
_setup_db_adapters() # DTM adapter/converter
|
|
60
96
|
self._setup_db_schema()
|
|
61
97
|
|
|
62
|
-
self.
|
|
63
|
-
|
|
64
|
-
|
|
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.
|
|
75
|
-
|
|
111
|
+
if self.maintain:
|
|
112
|
+
if self._housekeeping_task and not self._housekeeping_task.done():
|
|
113
|
+
return
|
|
76
114
|
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
"""
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
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
|
|
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
|
|
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?)",
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
326
|
+
:returns: any messages that were removed.
|
|
225
327
|
"""
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
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(
|
|
263
|
-
|
|
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
|
|
273
|
-
|
|
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
|
|
396
|
+
sql += " AND ".join(f"{k} = ?" for k in kw)
|
|
277
397
|
|
|
278
|
-
self._cu.execute(sql, tuple(
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
451
|
+
if "SELECT" not in sql:
|
|
452
|
+
raise ValueError(f"{self}: Only SELECT queries are allowed")
|
|
294
453
|
|
|
295
|
-
|
|
296
|
-
# return tuple(self._msgs[row[0]] for row in self._cu.fetchall())
|
|
454
|
+
self._cu.execute(sql, parameters)
|
|
297
455
|
|
|
298
|
-
return
|
|
299
|
-
|
|
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:
|
|
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:
|
|
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
|
-
#
|
|
572
|
-
|
|
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
|
-
#
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
|
639
|
+
"Updated 2411 parameter %s: %s (was: %s) for %s",
|
|
583
640
|
param_id,
|
|
584
641
|
param_value,
|
|
585
|
-
old_value
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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(
|
|
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,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ramses_rf
|
|
3
|
-
Version: 0.51.
|
|
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=
|
|
11
|
-
ramses_rf/dispatcher.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
29
|
+
ramses_tx/address.py,sha256=F5ZE-EbPNNom1fW9XXUILvD7DYSMBxNJvsHVliT5gjw,8452
|
|
30
30
|
ramses_tx/command.py,sha256=r9dNaofjjOQXZSUrZjsNpvEukNn4rSGy0OLr2Dyd2TI,125129
|
|
31
|
-
ramses_tx/const.py,sha256=
|
|
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=
|
|
35
|
-
ramses_tx/gateway.py,sha256=
|
|
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=
|
|
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=
|
|
41
|
-
ramses_tx/parsers.py,sha256=
|
|
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=
|
|
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=
|
|
51
|
-
ramses_rf-0.51.
|
|
52
|
-
ramses_rf-0.51.
|
|
53
|
-
ramses_rf-0.51.
|
|
54
|
-
ramses_rf-0.51.
|
|
55
|
-
ramses_rf-0.51.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
|
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
|
|
246
|
+
"""Validate a message packet payload, and parse it if valid.
|
|
241
247
|
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
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":
|
|
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
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
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
|
-
|
|
1934
|
-
|
|
1935
|
-
"_value_06": payload[6:10],
|
|
1936
|
-
}
|
|
1948
|
+
if msg.len == 9:
|
|
1949
|
+
return result
|
|
1937
1950
|
|
|
1938
|
-
|
|
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
|
|
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
|
-
|
|
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
|
File without changes
|
|
File without changes
|
|
File without changes
|