ramses-rf 0.51.5__py3-none-any.whl → 0.51.6__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 +0 -1
- ramses_rf/database.py +39 -24
- ramses_rf/device/base.py +2 -2
- ramses_rf/device/hvac.py +5 -4
- ramses_rf/dispatcher.py +2 -2
- ramses_rf/entity_base.py +28 -37
- ramses_rf/gateway.py +8 -16
- ramses_rf/system/zones.py +4 -2
- ramses_rf/version.py +1 -1
- {ramses_rf-0.51.5.dist-info → ramses_rf-0.51.6.dist-info}/METADATA +2 -2
- {ramses_rf-0.51.5.dist-info → ramses_rf-0.51.6.dist-info}/RECORD +26 -26
- ramses_tx/__init__.py +1 -1
- ramses_tx/address.py +1 -1
- ramses_tx/command.py +111 -3
- ramses_tx/const.py +1 -1
- ramses_tx/gateway.py +1 -1
- ramses_tx/helpers.py +63 -24
- ramses_tx/logger.py +26 -23
- ramses_tx/message.py +1 -1
- ramses_tx/parsers.py +6 -2
- ramses_tx/ramses.py +3 -1
- ramses_tx/typed_dicts.py +3 -2
- ramses_tx/version.py +1 -1
- {ramses_rf-0.51.5.dist-info → ramses_rf-0.51.6.dist-info}/WHEEL +0 -0
- {ramses_rf-0.51.5.dist-info → ramses_rf-0.51.6.dist-info}/entry_points.txt +0 -0
- {ramses_rf-0.51.5.dist-info → ramses_rf-0.51.6.dist-info}/licenses/LICENSE +0 -0
ramses_cli/client.py
CHANGED
|
@@ -483,7 +483,6 @@ async def async_main(command: str, lib_kwargs: dict, **kwargs: Any) -> None:
|
|
|
483
483
|
print(f"{COLORS.get(msg.verb)}{dtm} {msg}"[:con_cols])
|
|
484
484
|
|
|
485
485
|
serial_port, lib_kwargs = normalise_config(lib_kwargs)
|
|
486
|
-
assert isinstance(lib_kwargs.get(SZ_INPUT_FILE), str)
|
|
487
486
|
|
|
488
487
|
if kwargs["restore_schema"]:
|
|
489
488
|
print(" - restoring client schema from a HA cache...")
|
ramses_rf/database.py
CHANGED
|
@@ -29,6 +29,22 @@ class Params(TypedDict):
|
|
|
29
29
|
_LOGGER = logging.getLogger(__name__)
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
def _setup_db_adapters() -> None:
|
|
33
|
+
"""Set up the database adapters and converters."""
|
|
34
|
+
|
|
35
|
+
def adapt_datetime_iso(val: dt) -> str:
|
|
36
|
+
"""Adapt datetime.datetime to timezone-naive ISO 8601 datetime."""
|
|
37
|
+
return val.isoformat(timespec="microseconds")
|
|
38
|
+
|
|
39
|
+
sqlite3.register_adapter(dt, adapt_datetime_iso)
|
|
40
|
+
|
|
41
|
+
def convert_datetime(val: bytes) -> dt:
|
|
42
|
+
"""Convert ISO 8601 datetime to datetime.datetime object."""
|
|
43
|
+
return dt.fromisoformat(val.decode())
|
|
44
|
+
|
|
45
|
+
sqlite3.register_converter("dtm", convert_datetime)
|
|
46
|
+
|
|
47
|
+
|
|
32
48
|
class MessageIndex:
|
|
33
49
|
"""A simple in-memory SQLite3 database for indexing messages."""
|
|
34
50
|
|
|
@@ -40,7 +56,7 @@ class MessageIndex:
|
|
|
40
56
|
self._cx = sqlite3.connect(":memory:") # Connect to a SQLite DB in memory
|
|
41
57
|
self._cu = self._cx.cursor() # Create a cursor
|
|
42
58
|
|
|
43
|
-
|
|
59
|
+
_setup_db_adapters() # dtm adapter/converter
|
|
44
60
|
self._setup_db_schema()
|
|
45
61
|
|
|
46
62
|
self._lock = asyncio.Lock()
|
|
@@ -76,23 +92,19 @@ class MessageIndex:
|
|
|
76
92
|
"""Return the messages in the index in a threadsafe way."""
|
|
77
93
|
return self._msgs
|
|
78
94
|
|
|
79
|
-
def
|
|
80
|
-
"""
|
|
81
|
-
|
|
82
|
-
def adapt_datetime_iso(val: dt) -> str:
|
|
83
|
-
"""Adapt datetime.datetime to timezone-naive ISO 8601 datetime."""
|
|
84
|
-
return val.isoformat(timespec="microseconds")
|
|
85
|
-
|
|
86
|
-
sqlite3.register_adapter(dt, adapt_datetime_iso)
|
|
87
|
-
|
|
88
|
-
def convert_datetime(val: bytes) -> dt:
|
|
89
|
-
"""Convert ISO 8601 datetime to datetime.datetime object."""
|
|
90
|
-
return dt.fromisoformat(val.decode())
|
|
95
|
+
def _setup_db_schema(self) -> None:
|
|
96
|
+
"""Set up the message database schema.
|
|
91
97
|
|
|
92
|
-
|
|
98
|
+
Fields:
|
|
93
99
|
|
|
94
|
-
|
|
95
|
-
|
|
100
|
+
- dtm message timestamp
|
|
101
|
+
- verb _I, RQ etc.
|
|
102
|
+
- src message origin address
|
|
103
|
+
- dst message destination address
|
|
104
|
+
- code packet code aka command class e.g. _0005, _31DA
|
|
105
|
+
- ctx message context, created from payload as index + extra markers (Heat)
|
|
106
|
+
- hdr packet header e.g. 000C|RP|01:223036|0208 (see: src/ramses_tx/frame.py)
|
|
107
|
+
"""
|
|
96
108
|
|
|
97
109
|
self._cu.execute(
|
|
98
110
|
"""
|
|
@@ -120,14 +132,14 @@ class MessageIndex:
|
|
|
120
132
|
async def _housekeeping_loop(self) -> None:
|
|
121
133
|
"""Periodically remove stale messages from the index."""
|
|
122
134
|
|
|
123
|
-
def housekeeping(dt_now: dt, _cutoff: td = td(days=1)) -> None:
|
|
135
|
+
async def housekeeping(dt_now: dt, _cutoff: td = td(days=1)) -> None:
|
|
124
136
|
dtm = (dt_now - _cutoff).isoformat(timespec="microseconds")
|
|
125
137
|
|
|
126
138
|
self._cu.execute("SELECT dtm FROM messages WHERE dtm => ?", (dtm,))
|
|
127
139
|
rows = self._cu.fetchall()
|
|
128
140
|
|
|
129
141
|
try: # make this operation atomic, i.e. update self._msgs only on success
|
|
130
|
-
|
|
142
|
+
await self._lock.acquire()
|
|
131
143
|
self._cu.execute("DELETE FROM messages WHERE dtm < ?", (dtm,))
|
|
132
144
|
msgs = OrderedDict({row[0]: self._msgs[row[0]] for row in rows})
|
|
133
145
|
self._cx.commit()
|
|
@@ -137,19 +149,19 @@ class MessageIndex:
|
|
|
137
149
|
else:
|
|
138
150
|
self._msgs = msgs
|
|
139
151
|
finally:
|
|
140
|
-
|
|
152
|
+
self._lock.release()
|
|
141
153
|
|
|
142
154
|
while True:
|
|
143
155
|
self._last_housekeeping = dt.now()
|
|
144
156
|
await asyncio.sleep(3600)
|
|
145
|
-
housekeeping(self._last_housekeeping)
|
|
157
|
+
await housekeeping(self._last_housekeeping)
|
|
146
158
|
|
|
147
159
|
def add(self, msg: Message) -> Message | None:
|
|
148
160
|
"""Add a single message to the index.
|
|
149
161
|
|
|
150
162
|
Returns any message that was removed because it had the same header.
|
|
151
163
|
|
|
152
|
-
Throws a warning
|
|
164
|
+
Throws a warning if there is a duplicate dtm.
|
|
153
165
|
""" # TODO: eventually, may be better to use SqlAlchemy
|
|
154
166
|
|
|
155
167
|
dup: tuple[Message, ...] = tuple() # avoid UnboundLocalError
|
|
@@ -204,7 +216,9 @@ class MessageIndex:
|
|
|
204
216
|
|
|
205
217
|
return msgs[0] if msgs else None
|
|
206
218
|
|
|
207
|
-
def rem(
|
|
219
|
+
def rem(
|
|
220
|
+
self, msg: Message | None = None, **kwargs: str
|
|
221
|
+
) -> tuple[Message, ...] | None:
|
|
208
222
|
"""Remove a set of message(s) from the index.
|
|
209
223
|
|
|
210
224
|
Returns any messages that were removed.
|
|
@@ -215,6 +229,7 @@ class MessageIndex:
|
|
|
215
229
|
if msg:
|
|
216
230
|
kwargs["dtm"] = msg.dtm.isoformat(timespec="microseconds")
|
|
217
231
|
|
|
232
|
+
msgs = None
|
|
218
233
|
try: # make this operation atomic, i.e. update self._msgs only on success
|
|
219
234
|
# await self._lock.acquire()
|
|
220
235
|
msgs = self._delete_from(**kwargs)
|
|
@@ -277,8 +292,8 @@ class MessageIndex:
|
|
|
277
292
|
def all(self, include_expired: bool = False) -> tuple[Message, ...]:
|
|
278
293
|
"""Return all messages from the index."""
|
|
279
294
|
|
|
280
|
-
# self.
|
|
281
|
-
# return
|
|
295
|
+
# self._cu.execute("SELECT * FROM messages")
|
|
296
|
+
# return tuple(self._msgs[row[0]] for row in self._cu.fetchall())
|
|
282
297
|
|
|
283
298
|
return tuple(
|
|
284
299
|
m for m in self._msgs.values() if include_expired or not m._expired
|
ramses_rf/device/base.py
CHANGED
|
@@ -62,7 +62,7 @@ class DeviceBase(Entity):
|
|
|
62
62
|
|
|
63
63
|
# FIXME: ZZZ 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
|
-
self._z_idx = None # depends upon
|
|
65
|
+
self._z_idx = None # depends upon its location in the schema
|
|
66
66
|
|
|
67
67
|
self.id: DeviceIdT = dev_addr.id
|
|
68
68
|
|
|
@@ -95,7 +95,7 @@ class DeviceBase(Entity):
|
|
|
95
95
|
|
|
96
96
|
if traits.get(SZ_FAKED): # class & alias are done elsewhere
|
|
97
97
|
if not isinstance(self, Fakeable):
|
|
98
|
-
raise TypeError(f"Device is not
|
|
98
|
+
raise TypeError(f"Device is not fakeable: {self} (traits={traits})")
|
|
99
99
|
self._make_fake()
|
|
100
100
|
|
|
101
101
|
self._scheme = traits.get(SZ_SCHEME)
|
ramses_rf/device/hvac.py
CHANGED
|
@@ -449,10 +449,10 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
|
|
|
449
449
|
def fan_info(self) -> str | None:
|
|
450
450
|
"""
|
|
451
451
|
Extract fan info description from _31D9 or _31DA message payload, e.g. "speed 2, medium".
|
|
452
|
-
By its name, the result is
|
|
452
|
+
By its name, the result is picked up by a sensor in HA Climate UI.
|
|
453
453
|
Some manufacturers (Orcon, Vasco) include the fan mode (auto, manual), others don't (Itho).
|
|
454
454
|
|
|
455
|
-
:return: a string describing mode, speed
|
|
455
|
+
:return: a string describing fan mode, speed
|
|
456
456
|
"""
|
|
457
457
|
if Code._31D9 in self._msgs:
|
|
458
458
|
# Itho, Vasco D60 and ClimaRad (MiniBox fan) send mode/speed in _31D9
|
|
@@ -466,9 +466,10 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
|
|
|
466
466
|
@property
|
|
467
467
|
def indoor_humidity(self) -> float | None:
|
|
468
468
|
"""
|
|
469
|
-
Extract
|
|
469
|
+
Extract indoor_humidity from MessageIndex _12A0 or _31DA payload
|
|
470
|
+
Just a demo for SQLite query helper at the moment.
|
|
470
471
|
|
|
471
|
-
:return:
|
|
472
|
+
:return: float RH value from 0.0 to 1.0 = 100%
|
|
472
473
|
"""
|
|
473
474
|
if Code._12A0 in self._msgs and isinstance(
|
|
474
475
|
self._msgs[Code._12A0].payload, list
|
ramses_rf/dispatcher.py
CHANGED
ramses_rf/entity_base.py
CHANGED
|
@@ -190,7 +190,7 @@ class _MessageDB(_Entity):
|
|
|
190
190
|
def __init__(self, gwy: Gateway) -> None:
|
|
191
191
|
super().__init__(gwy)
|
|
192
192
|
|
|
193
|
-
self._msgs_: dict[Code, Message] = {} # code, should be code/ctx?
|
|
193
|
+
self._msgs_: dict[Code, Message] = {} # code, should be code/ctx?
|
|
194
194
|
self._msgz_: dict[
|
|
195
195
|
Code, dict[VerbT, dict[bool | str | None, Message]]
|
|
196
196
|
] = {} # code/verb/ctx, should be code/ctx/verb?
|
|
@@ -205,19 +205,19 @@ class _MessageDB(_Entity):
|
|
|
205
205
|
):
|
|
206
206
|
return # ZZZ: don't store these
|
|
207
207
|
|
|
208
|
+
# Store msg by code in flat self._msgs_ Dict (deprecated since 0.50.3)
|
|
208
209
|
if msg.verb in (I_, RP):
|
|
209
210
|
self._msgs_[msg.code] = msg
|
|
210
211
|
|
|
211
212
|
if msg.code not in self._msgz_:
|
|
213
|
+
# Store msg verb + ctx by code in nested self._msgz_ Dict (deprecated)
|
|
212
214
|
self._msgz_[msg.code] = {msg.verb: {msg._pkt._ctx: msg}}
|
|
213
215
|
elif msg.verb not in self._msgz_[msg.code]:
|
|
216
|
+
# Same, 1 level deeper
|
|
214
217
|
self._msgz_[msg.code][msg.verb] = {msg._pkt._ctx: msg}
|
|
215
|
-
else:
|
|
218
|
+
else:
|
|
219
|
+
# Same, replacing previous message
|
|
216
220
|
self._msgz_[msg.code][msg.verb][msg._pkt._ctx] = msg
|
|
217
|
-
# elif (
|
|
218
|
-
# self._msgz[msg.code][msg.verb][msg._pkt._ctx] is not msg
|
|
219
|
-
# ): # MsgIdx ensures this
|
|
220
|
-
# assert False # TODO: remove
|
|
221
221
|
|
|
222
222
|
@property
|
|
223
223
|
def _msg_db(self) -> list[Message]: # flattened version of _msgz[code][verb][indx]
|
|
@@ -228,7 +228,7 @@ class _MessageDB(_Entity):
|
|
|
228
228
|
- a compound ctx (e.g. 0005/000C/0418)
|
|
229
229
|
- True (an array of elements, each with its own idx),
|
|
230
230
|
- False (no idx, is usu. 00),
|
|
231
|
-
- None (not
|
|
231
|
+
- None (not determinable, rare)
|
|
232
232
|
"""
|
|
233
233
|
return [m for c in self._msgz.values() for v in c.values() for m in v.values()]
|
|
234
234
|
|
|
@@ -239,8 +239,8 @@ class _MessageDB(_Entity):
|
|
|
239
239
|
|
|
240
240
|
obj: _MessageDB
|
|
241
241
|
|
|
242
|
-
if self._gwy.
|
|
243
|
-
self._gwy.
|
|
242
|
+
if self._gwy.msg_db: # central SQLite MessageIndex
|
|
243
|
+
self._gwy.msg_db.rem(msg)
|
|
244
244
|
|
|
245
245
|
entities: list[_MessageDB] = []
|
|
246
246
|
if isinstance(msg.src, Device):
|
|
@@ -261,8 +261,8 @@ class _MessageDB(_Entity):
|
|
|
261
261
|
def _get_msg_by_hdr(self, hdr: HeaderT) -> Message | None:
|
|
262
262
|
"""Return a msg, if any, that matches a header."""
|
|
263
263
|
|
|
264
|
-
# if self._gwy.
|
|
265
|
-
# msgs = self._gwy.
|
|
264
|
+
# if self._gwy.msg_db: # central SQLite MessageIndex
|
|
265
|
+
# msgs = self._gwy.msg_db.get(hdr=hdr)
|
|
266
266
|
# return msgs[0] if msgs else None
|
|
267
267
|
|
|
268
268
|
msg: Message
|
|
@@ -394,19 +394,29 @@ class _MessageDB(_Entity):
|
|
|
394
394
|
|
|
395
395
|
@property
|
|
396
396
|
def _msgs(self) -> dict[Code, Message]:
|
|
397
|
-
|
|
397
|
+
"""
|
|
398
|
+
Get a flat Dict af all I/RP messages logged with this device as src or dst.
|
|
399
|
+
|
|
400
|
+
:return: nested Dict of messages by Code
|
|
401
|
+
"""
|
|
402
|
+
if not self._gwy.msg_db: # no central SQLite MessageIndex
|
|
398
403
|
return self._msgs_
|
|
399
404
|
|
|
400
405
|
sql = """
|
|
401
406
|
SELECT dtm from messages WHERE verb in (' I', 'RP') AND (src = ? OR dst = ?)
|
|
402
407
|
"""
|
|
403
|
-
return { # ? use context instead?
|
|
404
|
-
m.code: m for m in self._gwy.
|
|
408
|
+
return { # ? use ctx (context) instead of just the address?
|
|
409
|
+
m.code: m for m in self._gwy.msg_db.qry(sql, (self.id[:9], self.id[:9]))
|
|
405
410
|
} # e.g. 01:123456_HW
|
|
406
411
|
|
|
407
412
|
@property
|
|
408
413
|
def _msgz(self) -> dict[Code, dict[VerbT, dict[bool | str | None, Message]]]:
|
|
409
|
-
|
|
414
|
+
"""
|
|
415
|
+
Get a nested Dict of all I/RP messages logged with this device as src or dst.
|
|
416
|
+
|
|
417
|
+
:return: Dict of messages, nested by Code, Verb, Context
|
|
418
|
+
"""
|
|
419
|
+
if not self._gwy.msg_db: # no central SQLite MessageIndex
|
|
410
420
|
return self._msgz_
|
|
411
421
|
|
|
412
422
|
msgs_1: dict[Code, dict[VerbT, dict[bool | str | None, Message]]] = {}
|
|
@@ -437,7 +447,7 @@ class _Discovery(_MessageDB):
|
|
|
437
447
|
self._supported_cmds_ctx: dict[str, bool | None] = {}
|
|
438
448
|
|
|
439
449
|
if not gwy.config.disable_discovery:
|
|
440
|
-
# self._start_discovery_poller() # Can't use derived classes
|
|
450
|
+
# self._start_discovery_poller() # Can't use derived classes don't exist yet
|
|
441
451
|
gwy._loop.call_soon(self._start_discovery_poller)
|
|
442
452
|
|
|
443
453
|
@property # TODO: needs tidy up
|
|
@@ -464,7 +474,7 @@ class _Discovery(_MessageDB):
|
|
|
464
474
|
def _to_data_id(msg_id: MsgId | str) -> OtDataId:
|
|
465
475
|
return int(msg_id, 16) # type: ignore[return-value]
|
|
466
476
|
|
|
467
|
-
def _to_msg_id(data_id: OtDataId | int) -> MsgId:
|
|
477
|
+
def _to_msg_id(data_id: OtDataId | int) -> MsgId: # not used
|
|
468
478
|
return f"{data_id:02X}" # type: ignore[return-value]
|
|
469
479
|
|
|
470
480
|
return {
|
|
@@ -639,7 +649,7 @@ class _Discovery(_MessageDB):
|
|
|
639
649
|
task[_SZ_NEXT_DUE] = msg.dtm + task[_SZ_INTERVAL]
|
|
640
650
|
|
|
641
651
|
if task[_SZ_NEXT_DUE] > dt_now:
|
|
642
|
-
continue # if (most recent) last_msg is
|
|
652
|
+
continue # if (most recent) last_msg is not yet due...
|
|
643
653
|
|
|
644
654
|
# since we may do I/O, check if the code|msg_id is deprecated
|
|
645
655
|
task[_SZ_NEXT_DUE] = dt_now + task[_SZ_INTERVAL] # might undeprecate later
|
|
@@ -733,25 +743,6 @@ class Parent(Entity): # A System, Zone, DhwZone or a UfhController
|
|
|
733
743
|
self.child_by_id: dict[str, Child] = {}
|
|
734
744
|
self.childs: list[Child] = []
|
|
735
745
|
|
|
736
|
-
# def _handle_msg(self, msg: Message) -> None:
|
|
737
|
-
# def eavesdrop_ufh_circuits():
|
|
738
|
-
# if msg.code == Code._22C9:
|
|
739
|
-
# # .I --- 02:044446 --:------ 02:044446 22C9 024 00-076C0A28-01 01-06720A28-01 02-06A40A28-01 03-06A40A2-801 # NOTE: fragments
|
|
740
|
-
# # .I --- 02:044446 --:------ 02:044446 22C9 006 04-07D00A28-01 # [{'ufh_idx': '04',...
|
|
741
|
-
# circuit_idxs = [c[SZ_UFH_IDX] for c in msg.payload]
|
|
742
|
-
|
|
743
|
-
# for cct_idx in circuit_idxs:
|
|
744
|
-
# self.get_circuit(cct_idx, msg=msg)
|
|
745
|
-
|
|
746
|
-
# # BUG: this will fail with > 4 circuits, as uses two pkts for this msg
|
|
747
|
-
# # if [c for c in self.child_by_id if c not in circuit_idxs]:
|
|
748
|
-
# # raise CorruptStateError
|
|
749
|
-
|
|
750
|
-
# super()._handle_msg(msg)
|
|
751
|
-
|
|
752
|
-
# if self._gwy.config.enable_eavesdrop:
|
|
753
|
-
# eavesdrop_ufh_circuits()
|
|
754
|
-
|
|
755
746
|
@property
|
|
756
747
|
def zone_idx(self) -> str:
|
|
757
748
|
"""Return the domain id.
|
ramses_rf/gateway.py
CHANGED
|
@@ -129,7 +129,7 @@ class Gateway(Engine):
|
|
|
129
129
|
self.devices: list[Device] = []
|
|
130
130
|
self.device_by_id: dict[DeviceIdT, Device] = {}
|
|
131
131
|
|
|
132
|
-
self.
|
|
132
|
+
self.msg_db: MessageIndex | None = None # MessageIndex()
|
|
133
133
|
|
|
134
134
|
def __repr__(self) -> str:
|
|
135
135
|
if not self.ser_name:
|
|
@@ -196,8 +196,8 @@ class Gateway(Engine):
|
|
|
196
196
|
async def stop(self) -> None:
|
|
197
197
|
"""Stop the Gateway and tidy up."""
|
|
198
198
|
|
|
199
|
-
if self.
|
|
200
|
-
self.
|
|
199
|
+
if self.msg_db:
|
|
200
|
+
self.msg_db.stop()
|
|
201
201
|
await super().stop()
|
|
202
202
|
|
|
203
203
|
def _pause(self, *args: Any) -> None:
|
|
@@ -255,10 +255,10 @@ class Gateway(Engine):
|
|
|
255
255
|
msgs.extend([m for z in system.zones for m in z._msgs.values()])
|
|
256
256
|
# msgs.extend([m for z in system.dhw for m in z._msgs.values()]) # TODO
|
|
257
257
|
|
|
258
|
-
if self.
|
|
258
|
+
if self.msg_db:
|
|
259
259
|
pkts = {
|
|
260
260
|
f"{repr(msg._pkt)[:26]}": f"{repr(msg._pkt)[27:]}"
|
|
261
|
-
for msg in self.
|
|
261
|
+
for msg in self.msg_db.all(include_expired=True)
|
|
262
262
|
if wanted_msg(msg, include_expired=include_expired)
|
|
263
263
|
}
|
|
264
264
|
|
|
@@ -405,7 +405,7 @@ class Gateway(Engine):
|
|
|
405
405
|
_LOGGER.warning(f"The device is not fakeable: {dev}")
|
|
406
406
|
|
|
407
407
|
# TODO: the exact order of the following may need refining...
|
|
408
|
-
# TODO: some will be done
|
|
408
|
+
# TODO: some will be done by devices themselves?
|
|
409
409
|
|
|
410
410
|
# if schema: # Step 2: Only controllers have a schema...
|
|
411
411
|
# dev._update_schema(**schema) # TODO: schema/traits
|
|
@@ -422,7 +422,7 @@ class Gateway(Engine):
|
|
|
422
422
|
self,
|
|
423
423
|
device_id: DeviceIdT,
|
|
424
424
|
create_device: bool = False,
|
|
425
|
-
) -> Device:
|
|
425
|
+
) -> Device | Fakeable:
|
|
426
426
|
"""Create a faked device."""
|
|
427
427
|
|
|
428
428
|
if not is_valid_dev_id(device_id):
|
|
@@ -437,7 +437,7 @@ class Gateway(Engine):
|
|
|
437
437
|
dev._make_fake()
|
|
438
438
|
return dev
|
|
439
439
|
|
|
440
|
-
raise TypeError(f"The device is not
|
|
440
|
+
raise TypeError(f"The device is not fakeable: {device_id}")
|
|
441
441
|
|
|
442
442
|
@property
|
|
443
443
|
def tcs(self) -> Evohome | None:
|
|
@@ -547,14 +547,6 @@ class Gateway(Engine):
|
|
|
547
547
|
|
|
548
548
|
def _msg_handler(self, msg: Message) -> None:
|
|
549
549
|
"""A callback to handle messages from the protocol stack."""
|
|
550
|
-
# TODO: Remove this
|
|
551
|
-
# # HACK: if CLI, double-logging with client.py proc_msg() & setLevel(DEBUG)
|
|
552
|
-
# if (log_level := _LOGGER.getEffectiveLevel()) < logging.INFO:
|
|
553
|
-
# _LOGGER.info(msg)
|
|
554
|
-
# elif log_level <= logging.INFO and not (
|
|
555
|
-
# msg.verb == RQ and msg.src.type == DEV_TYPE_MAP.HGI
|
|
556
|
-
# ):
|
|
557
|
-
# _LOGGER.info(msg)
|
|
558
550
|
|
|
559
551
|
super()._msg_handler(msg)
|
|
560
552
|
|
ramses_rf/system/zones.py
CHANGED
|
@@ -699,8 +699,10 @@ class Zone(ZoneSchedule):
|
|
|
699
699
|
def name(self) -> str | None: # 0004
|
|
700
700
|
"""Return the name of the zone."""
|
|
701
701
|
|
|
702
|
-
if self._gwy.
|
|
703
|
-
msgs = self._gwy.
|
|
702
|
+
if self._gwy.msg_db:
|
|
703
|
+
msgs = self._gwy.msg_db.get(
|
|
704
|
+
code=Code._0004, src=self._z_id, ctx=self._z_idx
|
|
705
|
+
)
|
|
704
706
|
return msgs[0].payload.get(SZ_NAME) if msgs else None
|
|
705
707
|
|
|
706
708
|
return self._msg_value(Code._0004, key=SZ_NAME) # type: ignore[no-any-return]
|
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.6
|
|
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
|
|
@@ -67,6 +67,6 @@ pip install -e .
|
|
|
67
67
|
|
|
68
68
|
The CLI is called ``client.py`` and is included in the code root.
|
|
69
69
|
It has options to monitor and parse Ramses-II traffic to screen or a log file, and to parse a file containing Ramses-II messages to the screen.
|
|
70
|
-
See the [client.py CLI wiki page](https://github.com/ramses-rf/ramses_rf/wiki/The-client.py-command-line) for instructions.
|
|
70
|
+
See the [client.py CLI wiki page](https://github.com/ramses-rf/ramses_rf/wiki/2.-The-client.py-command-line) for instructions.
|
|
71
71
|
|
|
72
72
|
For code development, some more setup is required. Please follow the steps in our [Developer's Resource](README-developers.md)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
ramses_cli/__init__.py,sha256=uvGzWqOf4avvgzxJNSLFWEelIWqSZ-AeLAZzg5x58bc,397
|
|
2
|
-
ramses_cli/client.py,sha256=
|
|
2
|
+
ramses_cli/client.py,sha256=vbKS3KVPiGsDWLp5cR3SVBtXrs-TinzlxSbTgcb4G2k,19724
|
|
3
3
|
ramses_cli/debug.py,sha256=vgR0lOHoYjWarN948dI617WZZGNuqHbeq6Tc16Da7b4,608
|
|
4
4
|
ramses_cli/discovery.py,sha256=81XbmpNiCpUHVZBwo2g1eRwyJG-wZhpSsc44G3hHlFA,12972
|
|
5
5
|
ramses_cli/utils/cat_slow.py,sha256=AhUpM5gnegCitNKU-JGHn-DrRzSi-49ZR1Qw6lxe_t8,607
|
|
@@ -7,49 +7,49 @@ ramses_cli/utils/convert.py,sha256=D_YiCyX5na9pgC-_NhBlW9N1dgRKUK-uLtLBfofjzZM,1
|
|
|
7
7
|
ramses_rf/__init__.py,sha256=zONFBiRdf07cPTSxzr2V3t-b3CGokZjT9SGit4JUKRA,1055
|
|
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=
|
|
12
|
-
ramses_rf/entity_base.py,sha256=
|
|
10
|
+
ramses_rf/database.py,sha256=ZZZgucyuU1IHsSewGukZfDg2gu8KeNaEFriWKM0TUHs,10287
|
|
11
|
+
ramses_rf/dispatcher.py,sha256=JGkqSi1o-YhQ2rj8tNkXwYLLeJIC7F061xpHoH8sSsM,11201
|
|
12
|
+
ramses_rf/entity_base.py,sha256=V9m_Q5SOLP5ko3sok0NDvyz3YdYch1QsxM6tHCIE7cA,39212
|
|
13
13
|
ramses_rf/exceptions.py,sha256=rzVZDcYxFH7BjUAQ3U1fHWtgBpwk3BgjX1TO1L1iM8c,2538
|
|
14
|
-
ramses_rf/gateway.py,sha256=
|
|
14
|
+
ramses_rf/gateway.py,sha256=WdIIGgs87CYfXwSCSVb2YzqOgLC7W4bkpulWQb7PFNw,20564
|
|
15
15
|
ramses_rf/helpers.py,sha256=LcrVLqnF2qJWqXrC7UXKOQE8khCT3OhoTpZ_ZVBjw3A,4249
|
|
16
16
|
ramses_rf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
17
|
ramses_rf/schemas.py,sha256=mYOUZOH5OIDNBxRM2vd8POzDWEEmLhxh5UtqjTpFNek,13287
|
|
18
|
-
ramses_rf/version.py,sha256=
|
|
18
|
+
ramses_rf/version.py,sha256=QtONmxsLFzHSx9Z1ddbRf5_x-XAddalTD9dH8-naWpo,125
|
|
19
19
|
ramses_rf/device/__init__.py,sha256=sUbH5dhbYFXSoM_TPFRutpRutBRpup7_cQ9smPtDTy8,4858
|
|
20
|
-
ramses_rf/device/base.py,sha256=
|
|
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=H_PUfG_jrrvJgtnu6Bco6PLxHn7CHALwebZzZI1ygFo,23917
|
|
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
|
-
ramses_rf/system/zones.py,sha256=
|
|
28
|
-
ramses_tx/__init__.py,sha256=
|
|
29
|
-
ramses_tx/address.py,sha256=
|
|
30
|
-
ramses_tx/command.py,sha256=
|
|
31
|
-
ramses_tx/const.py,sha256=
|
|
27
|
+
ramses_rf/system/zones.py,sha256=9AH7ooN5QfiqvWuor2P1Dn8aILjQb2RWL9rWqDH1IjA,36075
|
|
28
|
+
ramses_tx/__init__.py,sha256=4FsVOzICJ4H80LJ0MknZCN0_V-g0k1nMkHUQ0IdrJW8,3161
|
|
29
|
+
ramses_tx/address.py,sha256=5swDr_SvOs1CxBmT-iJpldf8R00mOb7gKPMiEnxLz84,8452
|
|
30
|
+
ramses_tx/command.py,sha256=y69y9NYgQHuPbm7h6xC0osf3e1YIKY9jwmsfPiJ8N6U,58348
|
|
31
|
+
ramses_tx/const.py,sha256=QmwSS4BIN3ZFrLUiiFScP1RCUHuJ782V3ycRPQTtB_c,30297
|
|
32
32
|
ramses_tx/exceptions.py,sha256=FJSU9YkvpKjs3yeTqUJX1o3TPFSe_B01gRGIh9b3PNc,2632
|
|
33
33
|
ramses_tx/fingerprints.py,sha256=nfftA1E62HQnb-eLt2EqjEi_la0DAoT0wt-PtTMie0s,11974
|
|
34
34
|
ramses_tx/frame.py,sha256=9lUVh8gAMXNRAolfFw2WuWANjn24AWkmscuM9Tm5imE,22036
|
|
35
|
-
ramses_tx/gateway.py,sha256=
|
|
36
|
-
ramses_tx/helpers.py,sha256=
|
|
37
|
-
ramses_tx/logger.py,sha256=
|
|
38
|
-
ramses_tx/message.py,sha256=
|
|
35
|
+
ramses_tx/gateway.py,sha256=TXLYwT6tFpmSokD29Qyj1ze7UGCxKidooeyP557Jfoo,11266
|
|
36
|
+
ramses_tx/helpers.py,sha256=0zIvMY2zZ5V52fDAyVGXxNBND1HI-9fBN3dlgDNKNyo,33040
|
|
37
|
+
ramses_tx/logger.py,sha256=9xTxKVdZEoNZvvbjKSeKtMmlSzLhocTRW-61O6WpVsk,11397
|
|
38
|
+
ramses_tx/message.py,sha256=hl_gLfwrF79ftUNnsgNt3XGsIhM2Pts0MtZZuGjfaxk,13169
|
|
39
39
|
ramses_tx/opentherm.py,sha256=58PXz9l5x8Ou6Fm3y-R_UnGHCYahoi2RKIDdYStUMzk,42378
|
|
40
40
|
ramses_tx/packet.py,sha256=NGunaGCkEjhTp9t4mARK5e7kbqT-Z_JKCH7ibMYMJXU,7357
|
|
41
|
-
ramses_tx/parsers.py,sha256=
|
|
41
|
+
ramses_tx/parsers.py,sha256=ngKmZNFPp4k-HmpOtX8_zWnrjM4O68OzjqAOEMznHFE,109863
|
|
42
42
|
ramses_tx/protocol.py,sha256=ifj3qwcQivjQDaQUwM94xp-U8Pmef6zwSH7mav8DLWA,28867
|
|
43
43
|
ramses_tx/protocol_fsm.py,sha256=YhHkTqbl8w-myimsOjV50uIFgg9HiApwPU7xA_jg5nU,26827
|
|
44
44
|
ramses_tx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
45
|
-
ramses_tx/ramses.py,sha256=
|
|
45
|
+
ramses_tx/ramses.py,sha256=9R-JrInORWUNMPklrAPQWwtr_2aaruQmFqQPw5mFkrE,52223
|
|
46
46
|
ramses_tx/schemas.py,sha256=h2AcArVROy1_C4n6F0Crj4e-2BxXxH74xogFlc6nKHI,12769
|
|
47
47
|
ramses_tx/transport.py,sha256=aLpULRSivoJqzH8GDPRDcbehETOhFflEqmHbaniGLvg,56210
|
|
48
|
-
ramses_tx/typed_dicts.py,sha256=
|
|
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=Jvb0klvS67N7SxexepgSmtxSwb8sKcIR8MBBeUXTfSM,123
|
|
51
|
+
ramses_rf-0.51.6.dist-info/METADATA,sha256=OzEjMdl75qcH3T8URoBIKgSNQKAualdYRRD8giwa034,3909
|
|
52
|
+
ramses_rf-0.51.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
53
|
+
ramses_rf-0.51.6.dist-info/entry_points.txt,sha256=NnyK29baOCNg8DinPYiZ368h7MTH7bgTW26z2A1NeIE,50
|
|
54
|
+
ramses_rf-0.51.6.dist-info/licenses/LICENSE,sha256=-Kc35W7l1UkdiQ4314_yVWv7vDDrg7IrJfMLUiq6Nfs,1074
|
|
55
|
+
ramses_rf-0.51.6.dist-info/RECORD,,
|
ramses_tx/__init__.py
CHANGED
|
@@ -138,7 +138,7 @@ if TYPE_CHECKING:
|
|
|
138
138
|
async def set_pkt_logging_config(**config: Any) -> Logger:
|
|
139
139
|
"""
|
|
140
140
|
Set up ramses packet logging to a file or port.
|
|
141
|
-
Must
|
|
141
|
+
Must run async in executor to prevent HA blocking call opening packet log file (issue #200)
|
|
142
142
|
|
|
143
143
|
:param config: if file_name is included, opens packet_log file
|
|
144
144
|
:return: a logging.Logger
|
ramses_tx/address.py
CHANGED
|
@@ -45,7 +45,7 @@ class Address:
|
|
|
45
45
|
# device_id = NON_DEVICE_ID
|
|
46
46
|
|
|
47
47
|
self.id = device_id # TODO: check is a valid id...
|
|
48
|
-
self.type = device_id[:2] # dex,
|
|
48
|
+
self.type = device_id[:2] # dex, drops 2nd part, incl. ":"
|
|
49
49
|
self._hex_id: str = None # type: ignore[assignment]
|
|
50
50
|
|
|
51
51
|
if not self.is_valid(device_id):
|
ramses_tx/command.py
CHANGED
|
@@ -39,6 +39,10 @@ from .const import (
|
|
|
39
39
|
)
|
|
40
40
|
from .frame import Frame, pkt_header
|
|
41
41
|
from .helpers import (
|
|
42
|
+
air_quality_code,
|
|
43
|
+
capability_bits,
|
|
44
|
+
fan_info_flags,
|
|
45
|
+
fan_info_to_byte,
|
|
42
46
|
hex_from_bool,
|
|
43
47
|
hex_from_double,
|
|
44
48
|
hex_from_dtm,
|
|
@@ -854,7 +858,7 @@ class Command(Frame):
|
|
|
854
858
|
def put_indoor_humidity(
|
|
855
859
|
cls, dev_id: DeviceIdT | str, indoor_humidity: float | None
|
|
856
860
|
) -> Command:
|
|
857
|
-
"""Constructor to announce the current humidity of a sensor (12A0)."""
|
|
861
|
+
"""Constructor to announce the current humidity of a sensor or fan (12A0)."""
|
|
858
862
|
# .I --- 37:039266 --:------ 37:039266 1298 003 000316
|
|
859
863
|
|
|
860
864
|
payload = "00" + hex_from_percent(indoor_humidity, high_res=False)
|
|
@@ -1320,6 +1324,109 @@ class Command(Frame):
|
|
|
1320
1324
|
dt_str = hex_from_dtm(datetime, is_dst=is_dst, incl_seconds=True)
|
|
1321
1325
|
return cls.from_attrs(W_, ctl_id, Code._313F, f"0060{dt_str}")
|
|
1322
1326
|
|
|
1327
|
+
@classmethod # constructor for I|31DA
|
|
1328
|
+
def get_hvac_fan_31da(
|
|
1329
|
+
cls,
|
|
1330
|
+
dev_id: DeviceIdT | str,
|
|
1331
|
+
hvac_id: str,
|
|
1332
|
+
bypass_position: float | None,
|
|
1333
|
+
air_quality: int | None,
|
|
1334
|
+
co2_level: int | None,
|
|
1335
|
+
indoor_humidity: float | None,
|
|
1336
|
+
outdoor_humidity: float | None,
|
|
1337
|
+
exhaust_temp: float | None,
|
|
1338
|
+
supply_temp: float | None,
|
|
1339
|
+
indoor_temp: float | None,
|
|
1340
|
+
outdoor_temp: float | None,
|
|
1341
|
+
speed_capabilities: list[str],
|
|
1342
|
+
fan_info: str,
|
|
1343
|
+
_unknown_fan_info_flags: list[int], # skip? as starts with _
|
|
1344
|
+
exhaust_fan_speed: float | None,
|
|
1345
|
+
supply_fan_speed: float | None,
|
|
1346
|
+
remaining_mins: int | None,
|
|
1347
|
+
post_heat: int | None,
|
|
1348
|
+
pre_heat: int | None,
|
|
1349
|
+
supply_flow: float | None,
|
|
1350
|
+
exhaust_flow: float | None,
|
|
1351
|
+
**kwargs: Any, # option: air_quality_basis: str | None,
|
|
1352
|
+
) -> Command:
|
|
1353
|
+
"""Constructor to announce hvac fan (state, temps, flows, humidity etc.) of a HRU (31DA)."""
|
|
1354
|
+
# 00 EF00 7FFF 34 33 0898 0898 088A 0882 F800 00 15 14 14 0000 EF EF 05F5 0613:
|
|
1355
|
+
# {"hvac_id": '00', 'bypass_position': 0.000, 'air_quality': None,
|
|
1356
|
+
# 'co2_level': None, 'indoor_humidity': 0.52, 'outdoor_humidity': 0.51,
|
|
1357
|
+
# 'exhaust_temp': 22.0, 'supply_temp': 22.0, 'indoor_temp': 21.86,
|
|
1358
|
+
# 'outdoor_temp': 21.78, 'speed_capabilities': ['off', 'low_med_high',
|
|
1359
|
+
# 'timer', 'boost', 'auto'], 'fan_info': 'away',
|
|
1360
|
+
# '_unknown_fan_info_flags': [0, 0, 0], 'exhaust_fan_speed': 0.1,
|
|
1361
|
+
# 'supply_fan_speed': 0.1, 'remaining_mins': 0, 'post_heat': None,
|
|
1362
|
+
# 'pre_heat': None, 'supply_flow': 15.25, 'exhaust_flow': 15.55},
|
|
1363
|
+
|
|
1364
|
+
air_quality_basis: str = kwargs.pop("air_quality_basis", "00")
|
|
1365
|
+
extra: str = kwargs.pop("_extra", "")
|
|
1366
|
+
assert not kwargs, kwargs
|
|
1367
|
+
|
|
1368
|
+
payload = hvac_id
|
|
1369
|
+
payload += (
|
|
1370
|
+
f"{(int(air_quality * 200)):02X}" if air_quality is not None else "EF"
|
|
1371
|
+
)
|
|
1372
|
+
payload += (
|
|
1373
|
+
f"{air_quality_code(air_quality_basis)}"
|
|
1374
|
+
if air_quality_basis is not None
|
|
1375
|
+
else "00"
|
|
1376
|
+
)
|
|
1377
|
+
payload += f"{co2_level:04X}" if co2_level is not None else "7FFF"
|
|
1378
|
+
payload += (
|
|
1379
|
+
hex_from_percent(indoor_humidity, high_res=False)
|
|
1380
|
+
if indoor_humidity is not None
|
|
1381
|
+
else "EF"
|
|
1382
|
+
)
|
|
1383
|
+
payload += (
|
|
1384
|
+
hex_from_percent(outdoor_humidity, high_res=False)
|
|
1385
|
+
if outdoor_humidity is not None
|
|
1386
|
+
else "EF"
|
|
1387
|
+
)
|
|
1388
|
+
payload += hex_from_temp(exhaust_temp) if exhaust_temp is not None else "7FFF"
|
|
1389
|
+
payload += hex_from_temp(supply_temp) if supply_temp is not None else "7FFF"
|
|
1390
|
+
payload += hex_from_temp(indoor_temp) if indoor_temp is not None else "7FFF"
|
|
1391
|
+
payload += hex_from_temp(outdoor_temp) if outdoor_temp is not None else "7FFF"
|
|
1392
|
+
payload += (
|
|
1393
|
+
f"{capability_bits(speed_capabilities):04X}"
|
|
1394
|
+
if speed_capabilities is not None
|
|
1395
|
+
else "7FFF"
|
|
1396
|
+
)
|
|
1397
|
+
payload += (
|
|
1398
|
+
hex_from_percent(bypass_position, high_res=True)
|
|
1399
|
+
if bypass_position is not None
|
|
1400
|
+
else "EF"
|
|
1401
|
+
)
|
|
1402
|
+
payload += (
|
|
1403
|
+
f"{(fan_info_to_byte(fan_info) | fan_info_flags(_unknown_fan_info_flags)):02X}"
|
|
1404
|
+
if fan_info is not None
|
|
1405
|
+
else "EF"
|
|
1406
|
+
)
|
|
1407
|
+
payload += (
|
|
1408
|
+
hex_from_percent(exhaust_fan_speed, high_res=True)
|
|
1409
|
+
if exhaust_fan_speed is not None
|
|
1410
|
+
else "FF"
|
|
1411
|
+
)
|
|
1412
|
+
payload += (
|
|
1413
|
+
hex_from_percent(supply_fan_speed, high_res=True)
|
|
1414
|
+
if supply_fan_speed is not None
|
|
1415
|
+
else "FF"
|
|
1416
|
+
)
|
|
1417
|
+
payload += f"{remaining_mins:04X}" if remaining_mins is not None else "7FFF"
|
|
1418
|
+
payload += f"{int(post_heat * 200):02X}" if post_heat is not None else "EF"
|
|
1419
|
+
payload += f"{int(pre_heat * 200):02X}" if pre_heat is not None else "EF"
|
|
1420
|
+
payload += (
|
|
1421
|
+
f"{(int(supply_flow * 100)):04X}" if supply_flow is not None else "7FFF"
|
|
1422
|
+
)
|
|
1423
|
+
payload += (
|
|
1424
|
+
f"{(int(exhaust_flow * 100)):04X}" if exhaust_flow is not None else "7FFF"
|
|
1425
|
+
)
|
|
1426
|
+
payload += extra
|
|
1427
|
+
|
|
1428
|
+
return cls._from_attrs(I_, Code._31DA, payload, addr0=dev_id, addr2=dev_id)
|
|
1429
|
+
|
|
1323
1430
|
@classmethod # constructor for RQ|3220
|
|
1324
1431
|
def get_opentherm_data(cls, otb_id: DeviceIdT | str, msg_id: int | str) -> Command:
|
|
1325
1432
|
"""Constructor to get (Read-Data) opentherm msg value (c.f. parser_3220)."""
|
|
@@ -1412,7 +1519,7 @@ CODE_API_MAP = {
|
|
|
1412
1519
|
f"{I_}|{Code._1FC9}": Command.put_bind,
|
|
1413
1520
|
f"{W_}|{Code._1FC9}": Command.put_bind, # NOTE: same class method as I|1FC9
|
|
1414
1521
|
f"{W_}|{Code._22F7}": Command.set_bypass_position,
|
|
1415
|
-
f"{I_}|{Code._1298}": Command.put_co2_level,
|
|
1522
|
+
f"{I_}|{Code._1298}": Command.put_co2_level, # . has a test
|
|
1416
1523
|
f"{RQ}|{Code._1F41}": Command.get_dhw_mode,
|
|
1417
1524
|
f"{W_}|{Code._1F41}": Command.set_dhw_mode, # . has a test
|
|
1418
1525
|
f"{RQ}|{Code._10A0}": Command.get_dhw_params,
|
|
@@ -1421,7 +1528,7 @@ CODE_API_MAP = {
|
|
|
1421
1528
|
f"{I_}|{Code._1260}": Command.put_dhw_temp, # . has a test (empty)
|
|
1422
1529
|
f"{I_}|{Code._22F1}": Command.set_fan_mode,
|
|
1423
1530
|
f"{W_}|{Code._2411}": Command.set_fan_param,
|
|
1424
|
-
f"{I_}|{Code._12A0}": Command.put_indoor_humidity,
|
|
1531
|
+
f"{I_}|{Code._12A0}": Command.put_indoor_humidity, # . has a test
|
|
1425
1532
|
f"{RQ}|{Code._1030}": Command.get_mix_valve_params,
|
|
1426
1533
|
f"{W_}|{Code._1030}": Command.set_mix_valve_params, # . has a test
|
|
1427
1534
|
f"{RQ}|{Code._3220}": Command.get_opentherm_data,
|
|
@@ -1451,4 +1558,5 @@ CODE_API_MAP = {
|
|
|
1451
1558
|
f"{W_}|{Code._2309}": Command.set_zone_setpoint, # . has a test
|
|
1452
1559
|
f"{RQ}|{Code._30C9}": Command.get_zone_temp,
|
|
1453
1560
|
f"{RQ}|{Code._12B0}": Command.get_zone_window_state,
|
|
1561
|
+
f"{I_}|{Code._31DA}": Command.get_hvac_fan_31da, # . has a test
|
|
1454
1562
|
} # TODO: RQ|0404 (Zone & DHW)
|
ramses_tx/const.py
CHANGED
|
@@ -442,7 +442,7 @@ DEV_TYPE_MAP = attr_dict_factory(
|
|
|
442
442
|
"HEAT_ZONE_ACTUATORS": ("00", "02", "04", "13"),
|
|
443
443
|
"THM_DEVICES": ("03", "12", "22", "34"),
|
|
444
444
|
"TRV_DEVICES": ("00", "04"),
|
|
445
|
-
"CONTROLLERS": ("01", "12", "22", "23", "34"), # potentially controllers
|
|
445
|
+
"CONTROLLERS": ("01", "02", "12", "22", "23", "34"), # potentially controllers
|
|
446
446
|
"PROMOTABLE_SLUGS": (DevType.DEV, DevType.HEA, DevType.HVC),
|
|
447
447
|
"HVAC_SLUGS": {
|
|
448
448
|
DevType.CO2: "co2_sensor",
|
ramses_tx/gateway.py
CHANGED
|
@@ -331,7 +331,7 @@ class Engine:
|
|
|
331
331
|
) # may: raise ProtocolError/ProtocolSendFailed
|
|
332
332
|
|
|
333
333
|
def _msg_handler(self, msg: Message) -> None:
|
|
334
|
-
# HACK: This is one consequence of an
|
|
334
|
+
# HACK: This is one consequence of an unpleasant anachronism
|
|
335
335
|
msg.__class__ = Message # HACK (next line too)
|
|
336
336
|
msg._gwy = self # type: ignore[assignment]
|
|
337
337
|
|
ramses_tx/helpers.py
CHANGED
|
@@ -479,6 +479,13 @@ def parse_valve_demand(
|
|
|
479
479
|
return {SZ_HEAT_DEMAND: result}
|
|
480
480
|
|
|
481
481
|
|
|
482
|
+
AIR_QUALITY_BASIS: dict[str, str] = {
|
|
483
|
+
"10": "voc", # volatile compounds
|
|
484
|
+
"20": "co2", # carbon dioxide
|
|
485
|
+
"40": "rel_humidity", # relative humidity
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
|
|
482
489
|
# 31DA[2:6] and 12C8[2:6]
|
|
483
490
|
def parse_air_quality(value: HexStr4) -> PayDictT.AIR_QUALITY:
|
|
484
491
|
"""Return the air quality (%): poor (0.0) to excellent (1.0).
|
|
@@ -505,15 +512,21 @@ def parse_air_quality(value: HexStr4) -> PayDictT.AIR_QUALITY:
|
|
|
505
512
|
assert level <= 1.0, value[:2] # TODO: raise exception
|
|
506
513
|
|
|
507
514
|
assert value[2:] in ("10", "20", "40"), value[2:] # TODO: remove assert
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
}.get(value[2:], f"unknown_{value[2:]}") # TODO: remove get/unknown
|
|
515
|
+
|
|
516
|
+
basis: str = AIR_QUALITY_BASIS.get(
|
|
517
|
+
value[2:], f"unknown_{value[2:]}"
|
|
518
|
+
) # TODO: remove get/unknown
|
|
513
519
|
|
|
514
520
|
return {SZ_AIR_QUALITY: level, SZ_AIR_QUALITY_BASIS: basis}
|
|
515
521
|
|
|
516
522
|
|
|
523
|
+
def air_quality_code(desc: str) -> str:
|
|
524
|
+
for k, v in AIR_QUALITY_BASIS.items():
|
|
525
|
+
if v == desc:
|
|
526
|
+
return k
|
|
527
|
+
return "00"
|
|
528
|
+
|
|
529
|
+
|
|
517
530
|
# 31DA[6:10] and 1298[2:6]
|
|
518
531
|
def parse_co2_level(value: HexStr4) -> PayDictT.CO2_LEVEL:
|
|
519
532
|
"""Return the co2 level (ppm).
|
|
@@ -657,6 +670,26 @@ def _parse_hvac_temp(param_name: str, value: HexStr4) -> Mapping[str, float | No
|
|
|
657
670
|
return {param_name: temp}
|
|
658
671
|
|
|
659
672
|
|
|
673
|
+
ABILITIES = {
|
|
674
|
+
15: "off",
|
|
675
|
+
14: "low_med_high", # 3,2,1 = high,med,low?
|
|
676
|
+
13: "timer",
|
|
677
|
+
12: "boost",
|
|
678
|
+
11: "auto",
|
|
679
|
+
10: "speed_4",
|
|
680
|
+
9: "speed_5",
|
|
681
|
+
8: "speed_6",
|
|
682
|
+
7: "speed_7",
|
|
683
|
+
6: "speed_8",
|
|
684
|
+
5: "speed_9",
|
|
685
|
+
4: "speed_10",
|
|
686
|
+
3: "auto_night",
|
|
687
|
+
2: "reserved",
|
|
688
|
+
1: "post_heater",
|
|
689
|
+
0: "pre_heater",
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
|
|
660
693
|
# 31DA[30:34]
|
|
661
694
|
def parse_capabilities(value: HexStr4) -> PayDictT.CAPABILITIES:
|
|
662
695
|
"""Return the speed capabilities (a bitmask).
|
|
@@ -672,25 +705,6 @@ def parse_capabilities(value: HexStr4) -> PayDictT.CAPABILITIES:
|
|
|
672
705
|
if value == "7FFF": # TODO: Not implemented???
|
|
673
706
|
return {SZ_SPEED_CAPABILITIES: None}
|
|
674
707
|
|
|
675
|
-
ABILITIES = {
|
|
676
|
-
15: "off",
|
|
677
|
-
14: "low_med_high", # 3,2,1 = high,med,low?
|
|
678
|
-
13: "timer",
|
|
679
|
-
12: "boost",
|
|
680
|
-
11: "auto",
|
|
681
|
-
10: "speed_4",
|
|
682
|
-
9: "speed_5",
|
|
683
|
-
8: "speed_6",
|
|
684
|
-
7: "speed_7",
|
|
685
|
-
6: "speed_8",
|
|
686
|
-
5: "speed_9",
|
|
687
|
-
4: "speed_10",
|
|
688
|
-
3: "auto_night",
|
|
689
|
-
2: "reserved",
|
|
690
|
-
1: "post_heater",
|
|
691
|
-
0: "pre_heater",
|
|
692
|
-
}
|
|
693
|
-
|
|
694
708
|
# assert value in ("0002", "4000", "4808", "F000", "F001", "F800", "F808"), value
|
|
695
709
|
|
|
696
710
|
return {
|
|
@@ -700,6 +714,16 @@ def parse_capabilities(value: HexStr4) -> PayDictT.CAPABILITIES:
|
|
|
700
714
|
}
|
|
701
715
|
|
|
702
716
|
|
|
717
|
+
def capability_bits(cap_list: list[str]) -> int:
|
|
718
|
+
# 0xF800 = 0b1111100000000000
|
|
719
|
+
cap_res: int = 0
|
|
720
|
+
for cap in cap_list:
|
|
721
|
+
for k, v in ABILITIES.items():
|
|
722
|
+
if v == cap:
|
|
723
|
+
cap_res |= 2**k # set bit
|
|
724
|
+
return cap_res
|
|
725
|
+
|
|
726
|
+
|
|
703
727
|
# 31DA[34:36]
|
|
704
728
|
def parse_bypass_position(value: HexStr2) -> PayDictT.BYPASS_POSITION:
|
|
705
729
|
"""Return the bypass position (%), usually fully open or closed (0%, no bypass).
|
|
@@ -757,6 +781,21 @@ def parse_fan_info(value: HexStr2) -> PayDictT.FAN_INFO:
|
|
|
757
781
|
}
|
|
758
782
|
|
|
759
783
|
|
|
784
|
+
def fan_info_to_byte(info: str) -> int:
|
|
785
|
+
for k, v in _31DA_FAN_INFO.items():
|
|
786
|
+
if v == info:
|
|
787
|
+
return int(k) & 0x1F
|
|
788
|
+
return 0x0000
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def fan_info_flags(flags_list: list[int]) -> int:
|
|
792
|
+
flag_res: int = 0
|
|
793
|
+
for index, shft in enumerate(range(7, 4, -1)): # index = 7, 6 and 5
|
|
794
|
+
if flags_list[index] == 1:
|
|
795
|
+
flag_res |= 1 << shft # set bits
|
|
796
|
+
return flag_res
|
|
797
|
+
|
|
798
|
+
|
|
760
799
|
# 31DA[38:40], also 2210
|
|
761
800
|
def parse_exhaust_fan_speed(value: HexStr2) -> PayDictT.EXHAUST_FAN_SPEED:
|
|
762
801
|
"""Return the exhaust fan speed (% of max speed)."""
|
ramses_tx/logger.py
CHANGED
|
@@ -7,7 +7,6 @@ This module wraps logger to provide bespoke functionality, especially for timest
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import logging
|
|
10
|
-
import os
|
|
11
10
|
import re
|
|
12
11
|
import shutil
|
|
13
12
|
import sys
|
|
@@ -174,28 +173,32 @@ class TimedRotatingFileHandler(_TimedRotatingFileHandler):
|
|
|
174
173
|
# self.doRollover()
|
|
175
174
|
# return super().emit(record)
|
|
176
175
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
176
|
+
# To fix issue ramses_cc 293, test if this override is still required
|
|
177
|
+
# async def getFilesToDelete(self) -> list[str]: # zxdavb: my version
|
|
178
|
+
# """Determine the files to delete when rolling over.
|
|
179
|
+
#
|
|
180
|
+
# Overridden as old log files were not being deleted.
|
|
181
|
+
# """
|
|
182
|
+
# # See bpo-44753 (this code is as was before that commit), bpo45628, bpo-46063
|
|
183
|
+
# dirName, baseName = os.path.split(self.baseFilename)
|
|
184
|
+
# loop = asyncio.get_running_loop()
|
|
185
|
+
# # Must run async in executor to prevent HA blocking call on rollover (ramses_cc issue 293)
|
|
186
|
+
# file_names = await loop.run_in_executor(None, os.listdir, dirName) < doesn't work
|
|
187
|
+
#
|
|
188
|
+
# result = []
|
|
189
|
+
# prefix = baseName + "."
|
|
190
|
+
# plen = len(prefix)
|
|
191
|
+
# for fileName in file_names:
|
|
192
|
+
# if fileName[:plen] == prefix:
|
|
193
|
+
# suffix = fileName[plen:]
|
|
194
|
+
# if self.extMatch.match(suffix):
|
|
195
|
+
# result.append(os.path.join(dirName, fileName))
|
|
196
|
+
# if len(result) < self.backupCount:
|
|
197
|
+
# result = []
|
|
198
|
+
# else:
|
|
199
|
+
# result.sort()
|
|
200
|
+
# result = result[: len(result) - self.backupCount]
|
|
201
|
+
# return result
|
|
199
202
|
|
|
200
203
|
|
|
201
204
|
def getLogger( # permits a bespoke Logger class
|
ramses_tx/message.py
CHANGED
|
@@ -281,7 +281,7 @@ class MessageBase:
|
|
|
281
281
|
raise exc.PacketInvalid from err
|
|
282
282
|
|
|
283
283
|
|
|
284
|
-
class Message(MessageBase):
|
|
284
|
+
class Message(MessageBase):
|
|
285
285
|
"""Extend the Message class, so is useful to a stateful Gateway.
|
|
286
286
|
|
|
287
287
|
Adds _expired attr to the Message class.
|
ramses_tx/parsers.py
CHANGED
|
@@ -2220,10 +2220,9 @@ def parser_31d9(payload: str, msg: Message) -> dict[str, Any]:
|
|
|
2220
2220
|
# ventilation state (extended), HVAC
|
|
2221
2221
|
def parser_31da(payload: str, msg: Message) -> PayDictT._31DA:
|
|
2222
2222
|
# see: https://github.com/python/typing/issues/1445
|
|
2223
|
-
|
|
2223
|
+
result = {
|
|
2224
2224
|
**parse_exhaust_fan_speed(payload[38:40]), # maybe 31D9[4:6] for some?
|
|
2225
2225
|
**parse_fan_info(payload[36:38]), # 22F3-ish
|
|
2226
|
-
#
|
|
2227
2226
|
**parse_air_quality(payload[2:6]), # 12C8[2:6]
|
|
2228
2227
|
**parse_co2_level(payload[6:10]), # 1298[2:6]
|
|
2229
2228
|
**parse_indoor_humidity(payload[10:12]), # 12A0?
|
|
@@ -2241,6 +2240,11 @@ def parser_31da(payload: str, msg: Message) -> PayDictT._31DA:
|
|
|
2241
2240
|
**parse_supply_flow(payload[50:54]), # NOTE: is supply, not exhaust
|
|
2242
2241
|
**parse_exhaust_flow(payload[54:58]), # NOTE: order switched from others
|
|
2243
2242
|
}
|
|
2243
|
+
if len(payload) == 58:
|
|
2244
|
+
return result # type: ignore[return-value]
|
|
2245
|
+
|
|
2246
|
+
result.update({"_extra": payload[58:]}) # sporadic [58:60] always 00
|
|
2247
|
+
return result # type: ignore[return-value]
|
|
2244
2248
|
|
|
2245
2249
|
# From an Orcon 15RF Display
|
|
2246
2250
|
# 1 Software version
|
ramses_tx/ramses.py
CHANGED
|
@@ -871,11 +871,14 @@ _DEV_KLASSES_HEAT: dict[str, dict[Code, dict[VerbT, Any]]] = {
|
|
|
871
871
|
Code._000A: {RP: {}},
|
|
872
872
|
Code._000C: {RP: {}},
|
|
873
873
|
Code._1FC9: {I_: {}},
|
|
874
|
+
Code._1FD4: {I_: {}}, # Spider Autotemp, slave 'ticker'
|
|
874
875
|
Code._10E0: {I_: {}, RP: {}},
|
|
875
876
|
Code._22C9: {I_: {}}, # NOTE: No RP
|
|
876
877
|
Code._22D0: {I_: {}, RP: {}},
|
|
877
878
|
Code._2309: {RP: {}},
|
|
879
|
+
Code._3110: {I_: {}}, # Spider Autotemp
|
|
878
880
|
Code._3150: {I_: {}},
|
|
881
|
+
Code._4E01: {I_: {}}, # Spider Autotemp Zone controller
|
|
879
882
|
},
|
|
880
883
|
DevType.TRV: { # e.g. HR92/HR91: Radiator Controller
|
|
881
884
|
Code._0001: {W_: {r"^0[0-9A-F]"}},
|
|
@@ -1411,7 +1414,6 @@ _31DA_FAN_INFO: dict[int, str] = {
|
|
|
1411
1414
|
0x1F: "-unknown 0x1F-", # static field, used as filter in parser_31da so keep same
|
|
1412
1415
|
}
|
|
1413
1416
|
|
|
1414
|
-
|
|
1415
1417
|
#
|
|
1416
1418
|
########################################################################################
|
|
1417
1419
|
# CODES_BY_ZONE_TYPE
|
ramses_tx/typed_dicts.py
CHANGED
|
@@ -143,6 +143,8 @@ class ExhaustFlow(TypedDict):
|
|
|
143
143
|
|
|
144
144
|
|
|
145
145
|
class _VentilationState(
|
|
146
|
+
ExhaustFanSpeed,
|
|
147
|
+
FanInfo,
|
|
146
148
|
AirQuality,
|
|
147
149
|
Co2Level,
|
|
148
150
|
ExhaustTemp,
|
|
@@ -151,8 +153,6 @@ class _VentilationState(
|
|
|
151
153
|
OutdoorTemp,
|
|
152
154
|
Capabilities,
|
|
153
155
|
BypassPosition,
|
|
154
|
-
FanInfo,
|
|
155
|
-
ExhaustFanSpeed,
|
|
156
156
|
SupplyFanSpeed,
|
|
157
157
|
RemainingMins,
|
|
158
158
|
PostHeater,
|
|
@@ -162,6 +162,7 @@ class _VentilationState(
|
|
|
162
162
|
):
|
|
163
163
|
indoor_humidity: _HexToTempT
|
|
164
164
|
outdoor_humidity: _HexToTempT
|
|
165
|
+
extra: NotRequired[str | None]
|
|
165
166
|
|
|
166
167
|
|
|
167
168
|
# These are payload-specific...
|
ramses_tx/version.py
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|