ramses-rf 0.51.5__py3-none-any.whl → 0.51.7__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/helpers.py +1 -1
- ramses_rf/system/zones.py +4 -2
- ramses_rf/version.py +1 -1
- {ramses_rf-0.51.5.dist-info → ramses_rf-0.51.7.dist-info}/METADATA +2 -2
- {ramses_rf-0.51.5.dist-info → ramses_rf-0.51.7.dist-info}/RECORD +28 -28
- 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 +67 -31
- ramses_tx/logger.py +0 -24
- ramses_tx/message.py +1 -1
- ramses_tx/parsers.py +27 -21
- ramses_tx/ramses.py +3 -1
- ramses_tx/transport.py +44 -3
- ramses_tx/typed_dicts.py +3 -2
- ramses_tx/version.py +1 -1
- {ramses_rf-0.51.5.dist-info → ramses_rf-0.51.7.dist-info}/WHEEL +0 -0
- {ramses_rf-0.51.5.dist-info → ramses_rf-0.51.7.dist-info}/entry_points.txt +0 -0
- {ramses_rf-0.51.5.dist-info → ramses_rf-0.51.7.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/helpers.py
CHANGED
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.7
|
|
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=
|
|
15
|
-
ramses_rf/helpers.py,sha256=
|
|
14
|
+
ramses_rf/gateway.py,sha256=WdIIGgs87CYfXwSCSVb2YzqOgLC7W4bkpulWQb7PFNw,20564
|
|
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=mYOUZOH5OIDNBxRM2vd8POzDWEEmLhxh5UtqjTpFNek,13287
|
|
18
|
-
ramses_rf/version.py,sha256=
|
|
18
|
+
ramses_rf/version.py,sha256=4EfbWfYC4nSL7JCP_xnhFTpV6Yt5Vc4kNosAUW-CKNs,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=0VAJ505kpq4K9b9ZeskWI1o2sWwyCbdnKOKZviKFdgY,32913
|
|
37
|
+
ramses_tx/logger.py,sha256=qYbUoNPnPaFWKVsYvLG6uTVuPTdZ8HsMzBbGx0DpBqc,10177
|
|
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=PVTbPqcYPUko3BKDaOQoFDwIo4LWAUx5kfRb2KURAMI,109917
|
|
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
|
-
ramses_tx/transport.py,sha256=
|
|
48
|
-
ramses_tx/typed_dicts.py,sha256=
|
|
47
|
+
ramses_tx/transport.py,sha256=MwPnkQ0L-2qJt4mIJy3-C9XmHwBDjT7Kg-1LthPByVw,58331
|
|
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=hWp2I2S7p1_tlItYBqDNAFpsM7kL_J8xYU-6mC2e_Ws,123
|
|
51
|
+
ramses_rf-0.51.7.dist-info/METADATA,sha256=w3QiOIRJncgPc3R3h1MaxjMDOjzm0NYBHFEoeAr6o6A,3909
|
|
52
|
+
ramses_rf-0.51.7.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
53
|
+
ramses_rf-0.51.7.dist-info/entry_points.txt,sha256=NnyK29baOCNg8DinPYiZ368h7MTH7bgTW26z2A1NeIE,50
|
|
54
|
+
ramses_rf-0.51.7.dist-info/licenses/LICENSE,sha256=-Kc35W7l1UkdiQ4314_yVWv7vDDrg7IrJfMLUiq6Nfs,1074
|
|
55
|
+
ramses_rf-0.51.7.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
|
@@ -470,7 +470,7 @@ def parse_valve_demand(
|
|
|
470
470
|
if int(value, 16) & 0xF0 == 0xF0:
|
|
471
471
|
return _faulted_device(SZ_HEAT_DEMAND, value)
|
|
472
472
|
|
|
473
|
-
result = int(value, 16) / 200 # c.f.
|
|
473
|
+
result = int(value, 16) / 200 # c.f. hex_to_percent
|
|
474
474
|
if result == 1.01: # HACK - does it mean maximum?
|
|
475
475
|
result = 1.0
|
|
476
476
|
elif result > 1.0:
|
|
@@ -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).
|
|
@@ -593,12 +606,9 @@ def _parse_hvac_humidity(
|
|
|
593
606
|
if int(value, 16) & 0xF0 == 0xF0:
|
|
594
607
|
return _faulted_sensor(param_name, value)
|
|
595
608
|
|
|
596
|
-
percentage =
|
|
597
|
-
assert percentage <= 1.0, value # TODO: raise exception if > 1.0?
|
|
609
|
+
percentage = hex_to_percent(value, False) # TODO: confirm not /200
|
|
598
610
|
|
|
599
|
-
result: dict[str, float | str | None] = {
|
|
600
|
-
param_name: percentage
|
|
601
|
-
} # was: percent_from_hex(value, high_res=False)
|
|
611
|
+
result: dict[str, float | str | None] = {param_name: percentage}
|
|
602
612
|
if temp:
|
|
603
613
|
result |= {SZ_TEMPERATURE: hex_to_temp(temp)}
|
|
604
614
|
if dewpoint:
|
|
@@ -657,6 +667,26 @@ def _parse_hvac_temp(param_name: str, value: HexStr4) -> Mapping[str, float | No
|
|
|
657
667
|
return {param_name: temp}
|
|
658
668
|
|
|
659
669
|
|
|
670
|
+
ABILITIES = {
|
|
671
|
+
15: "off",
|
|
672
|
+
14: "low_med_high", # 3,2,1 = high,med,low?
|
|
673
|
+
13: "timer",
|
|
674
|
+
12: "boost",
|
|
675
|
+
11: "auto",
|
|
676
|
+
10: "speed_4",
|
|
677
|
+
9: "speed_5",
|
|
678
|
+
8: "speed_6",
|
|
679
|
+
7: "speed_7",
|
|
680
|
+
6: "speed_8",
|
|
681
|
+
5: "speed_9",
|
|
682
|
+
4: "speed_10",
|
|
683
|
+
3: "auto_night",
|
|
684
|
+
2: "reserved",
|
|
685
|
+
1: "post_heater",
|
|
686
|
+
0: "pre_heater",
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
|
|
660
690
|
# 31DA[30:34]
|
|
661
691
|
def parse_capabilities(value: HexStr4) -> PayDictT.CAPABILITIES:
|
|
662
692
|
"""Return the speed capabilities (a bitmask).
|
|
@@ -672,25 +702,6 @@ def parse_capabilities(value: HexStr4) -> PayDictT.CAPABILITIES:
|
|
|
672
702
|
if value == "7FFF": # TODO: Not implemented???
|
|
673
703
|
return {SZ_SPEED_CAPABILITIES: None}
|
|
674
704
|
|
|
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
705
|
# assert value in ("0002", "4000", "4808", "F000", "F001", "F800", "F808"), value
|
|
695
706
|
|
|
696
707
|
return {
|
|
@@ -700,6 +711,16 @@ def parse_capabilities(value: HexStr4) -> PayDictT.CAPABILITIES:
|
|
|
700
711
|
}
|
|
701
712
|
|
|
702
713
|
|
|
714
|
+
def capability_bits(cap_list: list[str]) -> int:
|
|
715
|
+
# 0xF800 = 0b1111100000000000
|
|
716
|
+
cap_res: int = 0
|
|
717
|
+
for cap in cap_list:
|
|
718
|
+
for k, v in ABILITIES.items():
|
|
719
|
+
if v == cap:
|
|
720
|
+
cap_res |= 2**k # set bit
|
|
721
|
+
return cap_res
|
|
722
|
+
|
|
723
|
+
|
|
703
724
|
# 31DA[34:36]
|
|
704
725
|
def parse_bypass_position(value: HexStr2) -> PayDictT.BYPASS_POSITION:
|
|
705
726
|
"""Return the bypass position (%), usually fully open or closed (0%, no bypass).
|
|
@@ -757,6 +778,21 @@ def parse_fan_info(value: HexStr2) -> PayDictT.FAN_INFO:
|
|
|
757
778
|
}
|
|
758
779
|
|
|
759
780
|
|
|
781
|
+
def fan_info_to_byte(info: str) -> int:
|
|
782
|
+
for k, v in _31DA_FAN_INFO.items():
|
|
783
|
+
if v == info:
|
|
784
|
+
return int(k) & 0x1F
|
|
785
|
+
return 0x0000
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def fan_info_flags(flags_list: list[int]) -> int:
|
|
789
|
+
flag_res: int = 0
|
|
790
|
+
for index, shft in enumerate(range(7, 4, -1)): # index = 7, 6 and 5
|
|
791
|
+
if flags_list[index] == 1:
|
|
792
|
+
flag_res |= 1 << shft # set bits
|
|
793
|
+
return flag_res
|
|
794
|
+
|
|
795
|
+
|
|
760
796
|
# 31DA[38:40], also 2210
|
|
761
797
|
def parse_exhaust_fan_speed(value: HexStr2) -> PayDictT.EXHAUST_FAN_SPEED:
|
|
762
798
|
"""Return the exhaust fan speed (% of max speed)."""
|
|
@@ -842,7 +878,7 @@ def _parse_fan_heater(param_name: str, value: HexStr2) -> Mapping[str, float | N
|
|
|
842
878
|
if int(value, 16) & 0xF0 == 0xF0:
|
|
843
879
|
return _faulted_sensor(param_name, value) # type: ignore[return-value]
|
|
844
880
|
|
|
845
|
-
percentage = int(value, 16) / 200 # Siber DF EVO 2 is /200, not /100 (?
|
|
881
|
+
percentage = int(value, 16) / 200 # Siber DF EVO 2 is /200, not /100 (Others?)
|
|
846
882
|
assert percentage <= 1.0, value # TODO: raise exception if > 1.0?
|
|
847
883
|
|
|
848
884
|
return {param_name: percentage} # was: percent_from_hex(value, high_res=False)
|
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,29 +173,6 @@ class TimedRotatingFileHandler(_TimedRotatingFileHandler):
|
|
|
174
173
|
# self.doRollover()
|
|
175
174
|
# return super().emit(record)
|
|
176
175
|
|
|
177
|
-
def getFilesToDelete(self) -> list[str]: # zxdavb: my version
|
|
178
|
-
"""Determine the files to delete when rolling over.
|
|
179
|
-
|
|
180
|
-
Overridden as old log files 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
|
-
fileNames = os.listdir(dirName)
|
|
185
|
-
result = []
|
|
186
|
-
prefix = baseName + "."
|
|
187
|
-
plen = len(prefix)
|
|
188
|
-
for fileName in fileNames:
|
|
189
|
-
if fileName[:plen] == prefix:
|
|
190
|
-
suffix = fileName[plen:]
|
|
191
|
-
if self.extMatch.match(suffix):
|
|
192
|
-
result.append(os.path.join(dirName, fileName))
|
|
193
|
-
if len(result) < self.backupCount:
|
|
194
|
-
result = []
|
|
195
|
-
else:
|
|
196
|
-
result.sort()
|
|
197
|
-
result = result[: len(result) - self.backupCount]
|
|
198
|
-
return result
|
|
199
|
-
|
|
200
176
|
|
|
201
177
|
def getLogger( # permits a bespoke Logger class
|
|
202
178
|
name: str | None = None, pkt_log: bool = False
|
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
|
@@ -1648,21 +1648,17 @@ def parser_22f3(payload: str, msg: Message) -> dict[str, Any]:
|
|
|
1648
1648
|
_LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({err})")
|
|
1649
1649
|
|
|
1650
1650
|
new_speed = { # from now, until timer expiry
|
|
1651
|
-
0x00: "fan_boost", #
|
|
1652
|
-
0x01: "per_request", #
|
|
1653
|
-
0x02: "
|
|
1651
|
+
0x00: "fan_boost", # set fan off, or 'boost' mode?
|
|
1652
|
+
0x01: "per_request?", # set fan as per payload[6:10]?
|
|
1653
|
+
0x02: "per_request", # set fan as per payload[6:10]
|
|
1654
1654
|
}.get(int(payload[2:4], 0x10) & 0x07) # 0b0000-0111
|
|
1655
1655
|
|
|
1656
|
-
fallback_speed
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
# set fan as per
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
0x08: "fan_off", # # set fan off?
|
|
1663
|
-
0x10: "per_request", # # set fan as per payload[6:10], or payload[10:]?
|
|
1664
|
-
0x18: "per_vent_speed", # set fan as per current fan mode/speed?
|
|
1665
|
-
}.get(int(payload[2:4], 0x10) & 0x38) # 0b0011-1000
|
|
1656
|
+
fallback_speed = { # after timer expiry
|
|
1657
|
+
0x00: "per_vent_speed", # set fan as per current fan mode
|
|
1658
|
+
0x08: "fan_off", # set fan off?
|
|
1659
|
+
0x10: "per_request", # set fan as per payload[10:14]
|
|
1660
|
+
0x18: "per_vent_speed?", # set fan as per current fan mode/speed?
|
|
1661
|
+
}.get(int(payload[2:4], 0x10) & 0x38) # 0b0011-1000
|
|
1666
1662
|
|
|
1667
1663
|
units = {
|
|
1668
1664
|
0x00: "minutes",
|
|
@@ -1677,15 +1673,21 @@ def parser_22f3(payload: str, msg: Message) -> dict[str, Any]:
|
|
|
1677
1673
|
result = {
|
|
1678
1674
|
"minutes" if units != "index" else "index": duration,
|
|
1679
1675
|
"flags": hex_to_flag8(payload[2:4]),
|
|
1680
|
-
"
|
|
1681
|
-
"
|
|
1676
|
+
"new_speed_mode": new_speed,
|
|
1677
|
+
"fallback_speed_mode": fallback_speed,
|
|
1682
1678
|
}
|
|
1683
1679
|
|
|
1684
|
-
if msg.
|
|
1685
|
-
result["
|
|
1680
|
+
if msg._addrs[0] == NON_DEV_ADDR and msg.len <= 3:
|
|
1681
|
+
result["_scheme"] = "itho"
|
|
1682
|
+
|
|
1683
|
+
if msg.len >= 5 and payload[6:10] != "0000": # new speed
|
|
1684
|
+
mode_info = parser_22f1(f"00{payload[6:10]}", msg)
|
|
1685
|
+
result["_scheme"] = mode_info.get("_scheme")
|
|
1686
|
+
result["fan_mode"] = mode_info.get("fan_mode")
|
|
1686
1687
|
|
|
1687
|
-
if msg.len >= 7: # fallback speed
|
|
1688
|
-
|
|
1688
|
+
if msg.len >= 7 and payload[10:14] != "0000": # fallback speed
|
|
1689
|
+
mode_info = parser_22f1(f"00{payload[10:14]}", msg)
|
|
1690
|
+
result["fallback_fan_mode"] = mode_info.get("fan_mode")
|
|
1689
1691
|
|
|
1690
1692
|
return result
|
|
1691
1693
|
|
|
@@ -2220,10 +2222,9 @@ def parser_31d9(payload: str, msg: Message) -> dict[str, Any]:
|
|
|
2220
2222
|
# ventilation state (extended), HVAC
|
|
2221
2223
|
def parser_31da(payload: str, msg: Message) -> PayDictT._31DA:
|
|
2222
2224
|
# see: https://github.com/python/typing/issues/1445
|
|
2223
|
-
|
|
2225
|
+
result = {
|
|
2224
2226
|
**parse_exhaust_fan_speed(payload[38:40]), # maybe 31D9[4:6] for some?
|
|
2225
2227
|
**parse_fan_info(payload[36:38]), # 22F3-ish
|
|
2226
|
-
#
|
|
2227
2228
|
**parse_air_quality(payload[2:6]), # 12C8[2:6]
|
|
2228
2229
|
**parse_co2_level(payload[6:10]), # 1298[2:6]
|
|
2229
2230
|
**parse_indoor_humidity(payload[10:12]), # 12A0?
|
|
@@ -2241,6 +2242,11 @@ def parser_31da(payload: str, msg: Message) -> PayDictT._31DA:
|
|
|
2241
2242
|
**parse_supply_flow(payload[50:54]), # NOTE: is supply, not exhaust
|
|
2242
2243
|
**parse_exhaust_flow(payload[54:58]), # NOTE: order switched from others
|
|
2243
2244
|
}
|
|
2245
|
+
if len(payload) == 58:
|
|
2246
|
+
return result # type: ignore[return-value]
|
|
2247
|
+
|
|
2248
|
+
result.update({"_extra": payload[58:]}) # sporadic [58:60] always 00
|
|
2249
|
+
return result # type: ignore[return-value]
|
|
2244
2250
|
|
|
2245
2251
|
# From an Orcon 15RF Display
|
|
2246
2252
|
# 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/transport.py
CHANGED
|
@@ -1033,6 +1033,8 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
|
|
|
1033
1033
|
self._topic_base = validate_topic_path(self._broker_url.path)
|
|
1034
1034
|
self._topic_pub = ""
|
|
1035
1035
|
self._topic_sub = ""
|
|
1036
|
+
# Track if we've subscribed to a wildcard data topic (e.g. ".../+/rx")
|
|
1037
|
+
self._data_wildcard_topic = ""
|
|
1036
1038
|
|
|
1037
1039
|
self._mqtt_qos = int(parse_qs(self._broker_url.query).get("qos", ["0"])[0])
|
|
1038
1040
|
|
|
@@ -1143,17 +1145,30 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
|
|
|
1143
1145
|
# Subscribe to base topic to see 'online' messages
|
|
1144
1146
|
self.client.subscribe(self._topic_base) # hope to see 'online' message
|
|
1145
1147
|
|
|
1146
|
-
# Also subscribe to data topics with wildcard for reliability
|
|
1147
|
-
#
|
|
1148
|
-
|
|
1148
|
+
# Also subscribe to data topics with wildcard for reliability, but only
|
|
1149
|
+
# until a specific device topic is known. Once _topic_sub is set, avoid
|
|
1150
|
+
# overlapping subscriptions that would duplicate messages.
|
|
1151
|
+
if self._topic_base.endswith("/+") and not (
|
|
1152
|
+
hasattr(self, "_topic_sub") and self._topic_sub
|
|
1153
|
+
):
|
|
1149
1154
|
data_wildcard = self._topic_base.replace("/+", "/+/rx")
|
|
1150
1155
|
self.client.subscribe(data_wildcard, qos=self._mqtt_qos)
|
|
1156
|
+
self._data_wildcard_topic = data_wildcard
|
|
1151
1157
|
_LOGGER.debug(f"Subscribed to data wildcard: {data_wildcard}")
|
|
1152
1158
|
|
|
1153
1159
|
# If we already have specific topics, re-subscribe to them
|
|
1154
1160
|
if hasattr(self, "_topic_sub") and self._topic_sub:
|
|
1155
1161
|
self.client.subscribe(self._topic_sub, qos=self._mqtt_qos)
|
|
1156
1162
|
_LOGGER.debug(f"Re-subscribed to specific topic: {self._topic_sub}")
|
|
1163
|
+
# If we had a wildcard subscription, drop it to prevent duplicates
|
|
1164
|
+
if getattr(self, "_data_wildcard_topic", ""):
|
|
1165
|
+
try:
|
|
1166
|
+
self.client.unsubscribe(self._data_wildcard_topic)
|
|
1167
|
+
_LOGGER.debug(
|
|
1168
|
+
f"Unsubscribed data wildcard after specific subscribe: {self._data_wildcard_topic}"
|
|
1169
|
+
)
|
|
1170
|
+
finally:
|
|
1171
|
+
self._data_wildcard_topic = ""
|
|
1157
1172
|
|
|
1158
1173
|
def _on_connect_fail(
|
|
1159
1174
|
self,
|
|
@@ -1225,6 +1240,17 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
|
|
|
1225
1240
|
|
|
1226
1241
|
self.client.subscribe(self._topic_sub, qos=self._mqtt_qos)
|
|
1227
1242
|
|
|
1243
|
+
# If we previously subscribed to a wildcard data topic, unsubscribe now
|
|
1244
|
+
# to avoid duplicate delivery (wildcard and specific both matching)
|
|
1245
|
+
if getattr(self, "_data_wildcard_topic", ""):
|
|
1246
|
+
try:
|
|
1247
|
+
self.client.unsubscribe(self._data_wildcard_topic)
|
|
1248
|
+
_LOGGER.debug(
|
|
1249
|
+
f"Unsubscribed data wildcard after device online: {self._data_wildcard_topic}"
|
|
1250
|
+
)
|
|
1251
|
+
finally:
|
|
1252
|
+
self._data_wildcard_topic = ""
|
|
1253
|
+
|
|
1228
1254
|
# Only call connection_made on first connection, not reconnections
|
|
1229
1255
|
if not self._connection_established:
|
|
1230
1256
|
self._connection_established = True
|
|
@@ -1295,6 +1321,21 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
|
|
|
1295
1321
|
self._connection_established = True
|
|
1296
1322
|
self._make_connection(gwy_id=gateway_id) # type: ignore[arg-type]
|
|
1297
1323
|
|
|
1324
|
+
# Ensure we subscribe specifically to the device topic and drop the
|
|
1325
|
+
# wildcard subscription to prevent duplicates
|
|
1326
|
+
try:
|
|
1327
|
+
self.client.subscribe(self._topic_sub, qos=self._mqtt_qos)
|
|
1328
|
+
except Exception as err: # pragma: no cover - defensive
|
|
1329
|
+
_LOGGER.debug(f"Error subscribing specific topic: {err}")
|
|
1330
|
+
if getattr(self, "_data_wildcard_topic", ""):
|
|
1331
|
+
try:
|
|
1332
|
+
self.client.unsubscribe(self._data_wildcard_topic)
|
|
1333
|
+
_LOGGER.debug(
|
|
1334
|
+
f"Unsubscribed data wildcard after inferring device: {self._data_wildcard_topic}"
|
|
1335
|
+
)
|
|
1336
|
+
finally:
|
|
1337
|
+
self._data_wildcard_topic = ""
|
|
1338
|
+
|
|
1298
1339
|
try:
|
|
1299
1340
|
payload = json.loads(msg.payload)
|
|
1300
1341
|
except json.JSONDecodeError:
|
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
|