ramses-rf 0.22.2__py3-none-any.whl → 0.51.1__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/__init__.py +18 -0
- ramses_cli/client.py +597 -0
- ramses_cli/debug.py +20 -0
- ramses_cli/discovery.py +405 -0
- ramses_cli/utils/cat_slow.py +17 -0
- ramses_cli/utils/convert.py +60 -0
- ramses_rf/__init__.py +31 -10
- ramses_rf/binding_fsm.py +787 -0
- ramses_rf/const.py +124 -105
- ramses_rf/database.py +297 -0
- ramses_rf/device/__init__.py +69 -39
- ramses_rf/device/base.py +187 -376
- ramses_rf/device/heat.py +540 -552
- ramses_rf/device/hvac.py +286 -171
- ramses_rf/dispatcher.py +153 -177
- ramses_rf/entity_base.py +478 -361
- ramses_rf/exceptions.py +82 -0
- ramses_rf/gateway.py +378 -514
- ramses_rf/helpers.py +57 -19
- ramses_rf/py.typed +0 -0
- ramses_rf/schemas.py +148 -194
- ramses_rf/system/__init__.py +16 -23
- ramses_rf/system/faultlog.py +363 -0
- ramses_rf/system/heat.py +295 -302
- ramses_rf/system/schedule.py +312 -198
- ramses_rf/system/zones.py +318 -238
- ramses_rf/version.py +2 -8
- ramses_rf-0.51.1.dist-info/METADATA +72 -0
- ramses_rf-0.51.1.dist-info/RECORD +55 -0
- {ramses_rf-0.22.2.dist-info → ramses_rf-0.51.1.dist-info}/WHEEL +1 -2
- ramses_rf-0.51.1.dist-info/entry_points.txt +2 -0
- {ramses_rf-0.22.2.dist-info → ramses_rf-0.51.1.dist-info/licenses}/LICENSE +1 -1
- ramses_tx/__init__.py +160 -0
- {ramses_rf/protocol → ramses_tx}/address.py +65 -59
- ramses_tx/command.py +1454 -0
- ramses_tx/const.py +903 -0
- ramses_tx/exceptions.py +92 -0
- {ramses_rf/protocol → ramses_tx}/fingerprints.py +56 -15
- {ramses_rf/protocol → ramses_tx}/frame.py +132 -131
- ramses_tx/gateway.py +338 -0
- ramses_tx/helpers.py +883 -0
- {ramses_rf/protocol → ramses_tx}/logger.py +67 -53
- {ramses_rf/protocol → ramses_tx}/message.py +155 -191
- ramses_tx/opentherm.py +1260 -0
- ramses_tx/packet.py +210 -0
- ramses_tx/parsers.py +2957 -0
- ramses_tx/protocol.py +801 -0
- ramses_tx/protocol_fsm.py +672 -0
- ramses_tx/py.typed +0 -0
- {ramses_rf/protocol → ramses_tx}/ramses.py +262 -185
- {ramses_rf/protocol → ramses_tx}/schemas.py +150 -133
- ramses_tx/transport.py +1471 -0
- ramses_tx/typed_dicts.py +492 -0
- ramses_tx/typing.py +181 -0
- ramses_tx/version.py +4 -0
- ramses_rf/discovery.py +0 -398
- ramses_rf/protocol/__init__.py +0 -59
- ramses_rf/protocol/backports.py +0 -42
- ramses_rf/protocol/command.py +0 -1561
- ramses_rf/protocol/const.py +0 -697
- ramses_rf/protocol/exceptions.py +0 -111
- ramses_rf/protocol/helpers.py +0 -390
- ramses_rf/protocol/opentherm.py +0 -1170
- ramses_rf/protocol/packet.py +0 -235
- ramses_rf/protocol/parsers.py +0 -2673
- ramses_rf/protocol/protocol.py +0 -613
- ramses_rf/protocol/transport.py +0 -1011
- ramses_rf/protocol/version.py +0 -10
- ramses_rf/system/hvac.py +0 -82
- ramses_rf-0.22.2.dist-info/METADATA +0 -64
- ramses_rf-0.22.2.dist-info/RECORD +0 -42
- ramses_rf-0.22.2.dist-info/top_level.txt +0 -1
ramses_rf/system/heat.py
CHANGED
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
# -*- coding: utf-8 -*-
|
|
3
|
-
#
|
|
4
2
|
"""RAMSES RF - The evohome-compatible system."""
|
|
3
|
+
|
|
5
4
|
from __future__ import annotations
|
|
6
5
|
|
|
7
6
|
import asyncio
|
|
8
7
|
import logging
|
|
9
|
-
from
|
|
10
|
-
from datetime import datetime as dt
|
|
11
|
-
from datetime import timedelta as td
|
|
8
|
+
from datetime import datetime as dt, timedelta as td
|
|
12
9
|
from threading import Lock
|
|
13
10
|
from types import SimpleNamespace
|
|
14
|
-
from typing import
|
|
11
|
+
from typing import TYPE_CHECKING, Any, NoReturn, TypeVar
|
|
15
12
|
|
|
16
|
-
from
|
|
13
|
+
from ramses_rf.const import (
|
|
17
14
|
SYS_MODE_MAP,
|
|
18
15
|
SZ_ACTUATORS,
|
|
19
16
|
SZ_CHANGE_COUNTER,
|
|
@@ -30,32 +27,18 @@ from ..const import (
|
|
|
30
27
|
SZ_ZONE_MASK,
|
|
31
28
|
SZ_ZONE_TYPE,
|
|
32
29
|
SZ_ZONES,
|
|
33
|
-
__dev_mode__,
|
|
34
30
|
)
|
|
35
|
-
from
|
|
31
|
+
from ramses_rf.device import (
|
|
36
32
|
BdrSwitch,
|
|
37
33
|
Controller,
|
|
38
34
|
Device,
|
|
39
|
-
DeviceHeat,
|
|
40
35
|
OtbGateway,
|
|
41
36
|
Temperature,
|
|
42
37
|
UfhController,
|
|
43
38
|
)
|
|
44
|
-
from
|
|
45
|
-
from
|
|
46
|
-
from
|
|
47
|
-
DEV_ROLE_MAP,
|
|
48
|
-
DEV_TYPE_MAP,
|
|
49
|
-
ZON_ROLE_MAP,
|
|
50
|
-
Address,
|
|
51
|
-
Command,
|
|
52
|
-
ExpiredCallbackError,
|
|
53
|
-
Message,
|
|
54
|
-
Priority,
|
|
55
|
-
)
|
|
56
|
-
from ..protocol.command import FaultLog, _mk_cmd
|
|
57
|
-
from ..protocol.const import SZ_PRIORITY, SZ_RETRIES
|
|
58
|
-
from ..schemas import (
|
|
39
|
+
from ramses_rf.entity_base import Entity, Parent, class_by_attr
|
|
40
|
+
from ramses_rf.helpers import shrink
|
|
41
|
+
from ramses_rf.schemas import (
|
|
59
42
|
DEFAULT_MAX_ZONES,
|
|
60
43
|
SCH_TCS,
|
|
61
44
|
SCH_TCS_DHW,
|
|
@@ -68,33 +51,54 @@ from ..schemas import (
|
|
|
68
51
|
SZ_SYSTEM,
|
|
69
52
|
SZ_UFH_SYSTEM,
|
|
70
53
|
)
|
|
71
|
-
from
|
|
54
|
+
from ramses_tx import (
|
|
55
|
+
DEV_ROLE_MAP,
|
|
56
|
+
DEV_TYPE_MAP,
|
|
57
|
+
ZON_ROLE_MAP,
|
|
58
|
+
Command,
|
|
59
|
+
DeviceIdT,
|
|
60
|
+
Message,
|
|
61
|
+
Priority,
|
|
62
|
+
)
|
|
63
|
+
from ramses_tx.typed_dicts import PayDictT
|
|
64
|
+
|
|
65
|
+
from .faultlog import FaultLog
|
|
66
|
+
from .zones import zone_factory
|
|
67
|
+
|
|
68
|
+
if TYPE_CHECKING:
|
|
69
|
+
from ramses_tx import Address, Packet
|
|
70
|
+
|
|
71
|
+
from .faultlog import FaultIdxT, FaultLogEntry
|
|
72
|
+
from .zones import DhwZone, Zone
|
|
73
|
+
|
|
72
74
|
|
|
73
75
|
# TODO: refactor packet routing (filter *before* routing)
|
|
74
76
|
|
|
75
77
|
|
|
76
|
-
#
|
|
77
|
-
from ..protocol import ( # noqa: F401, isort: skip, pylint: disable=unused-import
|
|
78
|
-
I_,
|
|
79
|
-
RP,
|
|
80
|
-
RQ,
|
|
81
|
-
W_,
|
|
78
|
+
from ramses_rf.const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
|
|
82
79
|
F9,
|
|
83
80
|
FA,
|
|
84
81
|
FC,
|
|
85
82
|
FF,
|
|
86
|
-
Code,
|
|
87
83
|
)
|
|
88
84
|
|
|
85
|
+
from ramses_rf.const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
|
|
86
|
+
I_,
|
|
87
|
+
RP,
|
|
88
|
+
RQ,
|
|
89
|
+
W_,
|
|
90
|
+
Code,
|
|
91
|
+
)
|
|
89
92
|
|
|
90
|
-
DEV_MODE = __dev_mode__
|
|
91
93
|
|
|
92
94
|
_LOGGER = logging.getLogger(__name__)
|
|
93
|
-
if DEV_MODE:
|
|
94
|
-
_LOGGER.setLevel(logging.DEBUG)
|
|
95
95
|
|
|
96
96
|
|
|
97
|
-
_SystemT = TypeVar("_SystemT", bound="
|
|
97
|
+
_SystemT = TypeVar("_SystemT", bound="Evohome")
|
|
98
|
+
|
|
99
|
+
_StoredHwT = TypeVar("_StoredHwT", bound="StoredHw")
|
|
100
|
+
_LogbookT = TypeVar("_LogbookT", bound="Logbook")
|
|
101
|
+
_MultiZoneT = TypeVar("_MultiZoneT", bound="MultiZone")
|
|
98
102
|
|
|
99
103
|
|
|
100
104
|
SYS_KLASS = SimpleNamespace(
|
|
@@ -109,7 +113,10 @@ class SystemBase(Parent, Entity): # 3B00 (multi-relay)
|
|
|
109
113
|
|
|
110
114
|
_SLUG: str = None # type: ignore[assignment]
|
|
111
115
|
|
|
112
|
-
|
|
116
|
+
# TODO: check (code so complex, not sure if this is true)
|
|
117
|
+
childs: list[Device] # type: ignore[assignment]
|
|
118
|
+
|
|
119
|
+
def __init__(self, ctl: Controller) -> None:
|
|
113
120
|
_LOGGER.debug("Creating a TCS for CTL: %s (%s)", ctl.id, self.__class__)
|
|
114
121
|
|
|
115
122
|
if ctl.id in ctl._gwy.system_by_id:
|
|
@@ -119,45 +126,18 @@ class SystemBase(Parent, Entity): # 3B00 (multi-relay)
|
|
|
119
126
|
|
|
120
127
|
super().__init__(ctl._gwy)
|
|
121
128
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
self.
|
|
125
|
-
self.tcs = self
|
|
126
|
-
self._child_id = FF # NOTE: domain_id
|
|
129
|
+
# FIXME: ZZZ entities must know their parent device ID and their own idx
|
|
130
|
+
self._z_id = ctl.id # the responsible device is the controller
|
|
131
|
+
self._z_idx = None # ? True (sentinel value to pick up arrays?)
|
|
127
132
|
|
|
128
|
-
self.
|
|
129
|
-
self._heat_demand = None # state attr
|
|
133
|
+
self.id: DeviceIdT = ctl.id
|
|
130
134
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
Raise an exception if the new schema is not a superset of the existing schema.
|
|
135
|
-
"""
|
|
136
|
-
|
|
137
|
-
schema = shrink(SCH_TCS(schema))
|
|
138
|
-
|
|
139
|
-
if schema.get(SZ_SYSTEM) and (
|
|
140
|
-
dev_id := schema[SZ_SYSTEM].get(SZ_APPLIANCE_CONTROL)
|
|
141
|
-
):
|
|
142
|
-
self._app_cntrl = self._gwy.get_device(dev_id, parent=self, child_id=FC)
|
|
143
|
-
|
|
144
|
-
if _schema := (schema.get(SZ_DHW_SYSTEM)):
|
|
145
|
-
self.get_dhw_zone(**_schema) # self._dhw = ...
|
|
146
|
-
|
|
147
|
-
if _schema := (schema.get(SZ_ZONES)):
|
|
148
|
-
[self.get_htg_zone(idx, **s) for idx, s in _schema.items()]
|
|
135
|
+
self.ctl: Controller = ctl
|
|
136
|
+
self.tcs: Evohome = self # type: ignore[assignment]
|
|
137
|
+
self._child_id = FF # NOTE: domain_id
|
|
149
138
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
"""Create a CH/DHW system for a CTL and set its schema attrs.
|
|
153
|
-
|
|
154
|
-
The appropriate System class should have been determined by a factory.
|
|
155
|
-
Schema attrs include: class (klass) & others.
|
|
156
|
-
"""
|
|
157
|
-
|
|
158
|
-
tcs = cls(ctl)
|
|
159
|
-
tcs._update_schema(**schema)
|
|
160
|
-
return tcs
|
|
139
|
+
self._app_cntrl: BdrSwitch | OtbGateway | None = None
|
|
140
|
+
self._heat_demand = None
|
|
161
141
|
|
|
162
142
|
def __repr__(self) -> str:
|
|
163
143
|
return f"{self.ctl.id} ({self._SLUG})"
|
|
@@ -170,14 +150,16 @@ class SystemBase(Parent, Entity): # 3B00 (multi-relay)
|
|
|
170
150
|
f"00{DEV_ROLE_MAP.HTG}", # hotwater_valve
|
|
171
151
|
f"01{DEV_ROLE_MAP.HTG}", # heating_valve
|
|
172
152
|
):
|
|
173
|
-
self.
|
|
174
|
-
|
|
175
|
-
)
|
|
153
|
+
cmd = Command.from_attrs(RQ, self.ctl.id, Code._000C, payload)
|
|
154
|
+
self._add_discovery_cmd(cmd, 60 * 60 * 24, delay=0)
|
|
176
155
|
|
|
177
|
-
|
|
156
|
+
cmd = Command.get_tpi_params(self.id)
|
|
157
|
+
self._add_discovery_cmd(cmd, 60 * 60 * 6, delay=5)
|
|
178
158
|
|
|
179
159
|
def _handle_msg(self, msg: Message) -> None:
|
|
180
|
-
def eavesdrop_appliance_control(
|
|
160
|
+
def eavesdrop_appliance_control(
|
|
161
|
+
this: Message, *, prev: Message | None = None
|
|
162
|
+
) -> None:
|
|
181
163
|
"""Discover the heat relay (10: or 13:) for this system.
|
|
182
164
|
|
|
183
165
|
There's' 3 ways to find a controller's heat relay (in order of reliability):
|
|
@@ -186,7 +168,7 @@ class SystemBase(Parent, Entity): # 3B00 (multi-relay)
|
|
|
186
168
|
2b. The 3EF0 RQ (no RP) *to a 13:* (3x/60min)
|
|
187
169
|
3. The 3B00 I/I exchange between a CTL & a 13: (TPI cycle rate, usu. 6x/hr)
|
|
188
170
|
|
|
189
|
-
Data from the CTL is considered '
|
|
171
|
+
Data from the CTL is considered 'authoritative'. The 1FC9 RQ/RP exchange
|
|
190
172
|
to/from a CTL is too rare to be useful.
|
|
191
173
|
"""
|
|
192
174
|
|
|
@@ -210,84 +192,67 @@ class SystemBase(Parent, Entity): # 3B00 (multi-relay)
|
|
|
210
192
|
if (
|
|
211
193
|
this.code in (Code._22D9, Code._3220) and this.verb == RQ
|
|
212
194
|
): # TODO: RPs too?
|
|
213
|
-
|
|
214
|
-
|
|
195
|
+
# dst could be an Address...
|
|
196
|
+
if this.src is self.ctl and isinstance(this.dst, OtbGateway): # type: ignore[unreachable]
|
|
197
|
+
app_cntrl = this.dst # type: ignore[unreachable]
|
|
215
198
|
|
|
216
199
|
elif this.code == Code._3EF0 and this.verb == RQ:
|
|
200
|
+
# dst could be an Address...
|
|
217
201
|
if this.src is self.ctl and isinstance(
|
|
218
|
-
this.dst,
|
|
202
|
+
this.dst, # type: ignore[unreachable]
|
|
203
|
+
BdrSwitch | OtbGateway,
|
|
219
204
|
):
|
|
220
|
-
app_cntrl = this.dst
|
|
205
|
+
app_cntrl = this.dst # type: ignore[unreachable]
|
|
221
206
|
|
|
222
207
|
elif this.code == Code._3B00 and this.verb == I_ and prev is not None:
|
|
223
|
-
if this.src is self.ctl and isinstance(prev.src, BdrSwitch):
|
|
224
|
-
if prev.code == this.code and prev.verb == this.verb:
|
|
208
|
+
if this.src is self.ctl and isinstance(prev.src, BdrSwitch): # type: ignore[unreachable]
|
|
209
|
+
if prev.code == this.code and prev.verb == this.verb: # type: ignore[unreachable]
|
|
225
210
|
app_cntrl = prev.src
|
|
226
211
|
|
|
227
212
|
if app_cntrl is not None:
|
|
228
|
-
app_cntrl.set_parent(self, child_id=FC) #
|
|
213
|
+
app_cntrl.set_parent(self, child_id=FC) # type: ignore[unreachable]
|
|
229
214
|
|
|
230
|
-
assert msg.src is self.ctl, f"msg inappropriately routed to {self}"
|
|
215
|
+
# # assert msg.src is self.ctl, f"msg inappropriately routed to {self}"
|
|
231
216
|
|
|
232
217
|
super()._handle_msg(msg)
|
|
233
218
|
|
|
234
|
-
if
|
|
235
|
-
msg.
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
) # sets self._app_cntrl
|
|
219
|
+
if msg.code == Code._000C:
|
|
220
|
+
if msg.payload[SZ_ZONE_TYPE] == DEV_ROLE_MAP.APP and msg.payload.get(
|
|
221
|
+
SZ_DEVICES
|
|
222
|
+
):
|
|
223
|
+
self._gwy.get_device(
|
|
224
|
+
msg.payload[SZ_DEVICES][0], parent=self, child_id=FC
|
|
225
|
+
) # sets self._app_cntrl
|
|
242
226
|
return
|
|
243
227
|
|
|
244
|
-
if msg.code == Code.
|
|
245
|
-
if (domain_id := msg.payload.get(SZ_DOMAIN_ID)) and msg.verb in (I_, RP):
|
|
246
|
-
self._relay_demands[domain_id] = msg
|
|
247
|
-
if domain_id == F9:
|
|
248
|
-
device = self.dhw.heating_valve if self.dhw else None
|
|
249
|
-
elif domain_id == "xFA": # TODO, FIXME
|
|
250
|
-
device = self.dhw.hotwater_valve if self.dhw else None
|
|
251
|
-
elif domain_id == FC:
|
|
252
|
-
device = self.appliance_control
|
|
253
|
-
else:
|
|
254
|
-
device = None
|
|
255
|
-
|
|
256
|
-
if False and device is not None: # TODO: FIXME
|
|
257
|
-
qos = {SZ_PRIORITY: Priority.LOW, SZ_RETRIES: 2}
|
|
258
|
-
for code in (Code._0008, Code._3EF1):
|
|
259
|
-
device._make_cmd(code, qos)
|
|
260
|
-
|
|
261
|
-
elif msg.code == Code._3150:
|
|
228
|
+
if msg.code == Code._3150:
|
|
262
229
|
if msg.payload.get(SZ_DOMAIN_ID) == FC and msg.verb in (I_, RP):
|
|
263
230
|
self._heat_demand = msg.payload
|
|
264
231
|
|
|
265
232
|
if self._gwy.config.enable_eavesdrop and not self.appliance_control:
|
|
266
233
|
eavesdrop_appliance_control(msg)
|
|
267
234
|
|
|
268
|
-
def _make_cmd(self, code, payload="00", **kwargs) -> None: # skipcq: PYL-W0221
|
|
269
|
-
super()._make_cmd(code, self.ctl.id, payload=payload, **kwargs)
|
|
270
|
-
|
|
271
235
|
@property
|
|
272
|
-
def appliance_control(self) ->
|
|
236
|
+
def appliance_control(self) -> BdrSwitch | OtbGateway | None:
|
|
273
237
|
"""The TCS relay, aka 'appliance control' (BDR or OTB)."""
|
|
274
238
|
if self._app_cntrl:
|
|
275
239
|
return self._app_cntrl
|
|
276
240
|
app_cntrl = [d for d in self.childs if d._child_id == FC]
|
|
277
|
-
return app_cntrl[0] if len(app_cntrl) == 1 else None #
|
|
241
|
+
return app_cntrl[0] if len(app_cntrl) == 1 else None # type: ignore[return-value]
|
|
278
242
|
|
|
279
243
|
@property
|
|
280
|
-
def tpi_params(self) ->
|
|
281
|
-
return self._msg_value(Code._1100)
|
|
244
|
+
def tpi_params(self) -> PayDictT._1100 | None: # 1100
|
|
245
|
+
return self._msg_value(Code._1100) # type: ignore[return-value]
|
|
282
246
|
|
|
283
247
|
@property
|
|
284
|
-
def heat_demand(self) ->
|
|
285
|
-
return self._msg_value(Code._3150, domain_id=FC, key=SZ_HEAT_DEMAND)
|
|
248
|
+
def heat_demand(self) -> float | None: # 3150/FC
|
|
249
|
+
return self._msg_value(Code._3150, domain_id=FC, key=SZ_HEAT_DEMAND) # type: ignore[return-value]
|
|
286
250
|
|
|
287
251
|
@property
|
|
288
|
-
def is_calling_for_heat(self) ->
|
|
289
|
-
|
|
290
|
-
|
|
252
|
+
def is_calling_for_heat(self) -> NoReturn:
|
|
253
|
+
raise NotImplementedError(
|
|
254
|
+
f"{self}: is_calling_for_heat attr is deprecated, use bool(heat_demand)"
|
|
255
|
+
)
|
|
291
256
|
|
|
292
257
|
@property
|
|
293
258
|
def schema(self) -> dict[str, Any]:
|
|
@@ -338,6 +303,12 @@ class SystemBase(Parent, Entity): # 3B00 (multi-relay)
|
|
|
338
303
|
if zones:
|
|
339
304
|
result[SZ_ZONES] = zones
|
|
340
305
|
|
|
306
|
+
result |= {
|
|
307
|
+
k: v
|
|
308
|
+
for k, v in schema.items()
|
|
309
|
+
if k in ("orphans",) and v # add UFH?
|
|
310
|
+
}
|
|
311
|
+
|
|
341
312
|
return result # TODO: check against vol schema
|
|
342
313
|
|
|
343
314
|
@property
|
|
@@ -355,30 +326,31 @@ class SystemBase(Parent, Entity): # 3B00 (multi-relay)
|
|
|
355
326
|
status: dict[str, Any] = {SZ_SYSTEM: {}}
|
|
356
327
|
status[SZ_SYSTEM]["heat_demand"] = self.heat_demand
|
|
357
328
|
|
|
358
|
-
status[SZ_DEVICES] = {
|
|
329
|
+
status[SZ_DEVICES] = {
|
|
330
|
+
d.id: d.status for d in sorted(self.childs, key=lambda x: x.id)
|
|
331
|
+
}
|
|
359
332
|
|
|
360
333
|
return status
|
|
361
334
|
|
|
362
335
|
|
|
363
336
|
class MultiZone(SystemBase): # 0005 (+/- 000C?)
|
|
364
|
-
def __init__(self, *args, **kwargs) -> None:
|
|
337
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
365
338
|
super().__init__(*args, **kwargs)
|
|
366
339
|
|
|
367
|
-
self.zones = []
|
|
368
|
-
self.zone_by_idx = {}
|
|
369
|
-
self._max_zones = getattr(
|
|
340
|
+
self.zones: list[Zone] = []
|
|
341
|
+
self.zone_by_idx: dict[str, Zone] = {} # should not include HW
|
|
342
|
+
self._max_zones: int = getattr(
|
|
343
|
+
self._gwy.config, SZ_MAX_ZONES, DEFAULT_MAX_ZONES
|
|
344
|
+
)
|
|
370
345
|
|
|
371
|
-
self._prev_30c9 = None # used to eavesdrop zone sensors
|
|
346
|
+
self._prev_30c9: Message | None = None # used to eavesdrop zone sensors
|
|
372
347
|
|
|
373
348
|
def _setup_discovery_cmds(self) -> None:
|
|
374
349
|
super()._setup_discovery_cmds()
|
|
375
350
|
|
|
376
351
|
for zone_type in list(ZON_ROLE_MAP.HEAT_ZONES) + [ZON_ROLE_MAP.SEN]:
|
|
377
|
-
self.
|
|
378
|
-
|
|
379
|
-
60 * 60 * 24,
|
|
380
|
-
delay=0,
|
|
381
|
-
)
|
|
352
|
+
cmd = Command.from_attrs(RQ, self.id, Code._0005, f"00{zone_type}")
|
|
353
|
+
self._add_discovery_cmd(cmd, 60 * 60 * 24, delay=0)
|
|
382
354
|
|
|
383
355
|
def _handle_msg(self, msg: Message) -> None:
|
|
384
356
|
"""Process any relevant message.
|
|
@@ -386,7 +358,7 @@ class MultiZone(SystemBase): # 0005 (+/- 000C?)
|
|
|
386
358
|
If `zone_idx` in payload, route any messages to the corresponding zone.
|
|
387
359
|
"""
|
|
388
360
|
|
|
389
|
-
def eavesdrop_zones(this, *, prev=None) -> None:
|
|
361
|
+
def eavesdrop_zones(this: Message, *, prev: Message | None = None) -> None:
|
|
390
362
|
[
|
|
391
363
|
self.get_htg_zone(v)
|
|
392
364
|
for d in msg.payload
|
|
@@ -394,29 +366,31 @@ class MultiZone(SystemBase): # 0005 (+/- 000C?)
|
|
|
394
366
|
if k == SZ_ZONE_IDX
|
|
395
367
|
]
|
|
396
368
|
|
|
397
|
-
def eavesdrop_zone_sensors(
|
|
369
|
+
def eavesdrop_zone_sensors(
|
|
370
|
+
this: Message, *, prev: Message | None = None
|
|
371
|
+
) -> None:
|
|
398
372
|
"""Determine each zone's sensor by matching zone/sensor temperatures."""
|
|
399
373
|
|
|
400
|
-
def _testable_zones(changed_zones) -> dict:
|
|
374
|
+
def _testable_zones(changed_zones: dict[str, float]) -> dict[float, str]:
|
|
401
375
|
return {
|
|
402
|
-
|
|
403
|
-
for
|
|
404
|
-
if self.zone_by_idx[
|
|
405
|
-
and
|
|
376
|
+
t1: i1
|
|
377
|
+
for i1, t1 in changed_zones.items()
|
|
378
|
+
if self.zone_by_idx[i1].sensor is None
|
|
379
|
+
and t1 not in [t2 for i2, t2 in changed_zones.items() if i2 != i1]
|
|
406
380
|
}
|
|
407
381
|
|
|
408
382
|
self._prev_30c9, prev = this, self._prev_30c9
|
|
409
383
|
if prev is None:
|
|
410
|
-
return
|
|
384
|
+
return # type: ignore[unreachable]
|
|
411
385
|
|
|
412
386
|
# TODO: use msgz/I, not RP
|
|
413
|
-
secs = self._msg_value(Code._1F09, key="remaining_seconds")
|
|
387
|
+
secs: int = self._msg_value(Code._1F09, key="remaining_seconds") # type: ignore[assignment]
|
|
414
388
|
if secs is None or this.dtm > prev.dtm + td(seconds=secs + 5):
|
|
415
389
|
return # can only compare against 30C9 pkt from the last cycle
|
|
416
390
|
|
|
417
391
|
# _LOGGER.warning("System state (before): %s", self.schema)
|
|
418
392
|
|
|
419
|
-
changed_zones = {
|
|
393
|
+
changed_zones: dict[str, float] = {
|
|
420
394
|
z[SZ_ZONE_IDX]: z[SZ_TEMPERATURE]
|
|
421
395
|
for z in this.payload
|
|
422
396
|
if z not in prev.payload and z[SZ_TEMPERATURE] is not None
|
|
@@ -469,12 +443,11 @@ class MultiZone(SystemBase): # 0005 (+/- 000C?)
|
|
|
469
443
|
|
|
470
444
|
# _LOGGER.warning("System state (finally): %s", self.schema)
|
|
471
445
|
|
|
472
|
-
def handle_msg_by_zone_idx(zone_idx: str, msg):
|
|
446
|
+
def handle_msg_by_zone_idx(zone_idx: str, msg: Message) -> None:
|
|
473
447
|
if zone := self.zone_by_idx.get(zone_idx):
|
|
474
448
|
zone._handle_msg(msg)
|
|
475
449
|
# elif self._gwy.config.enable_eavesdrop:
|
|
476
450
|
# self.get_htg_zone(zone_idx)._handle_msg(msg)
|
|
477
|
-
pass
|
|
478
451
|
|
|
479
452
|
super()._handle_msg(msg)
|
|
480
453
|
|
|
@@ -532,7 +505,8 @@ class MultiZone(SystemBase): # 0005 (+/- 000C?)
|
|
|
532
505
|
elif isinstance(msg.payload, list) and len(msg.payload):
|
|
533
506
|
# TODO: elif msg.payload.get(SZ_DOMAIN_ID) == FA: # DHW
|
|
534
507
|
if isinstance(msg.payload[0], dict): # e.g. 1FC9 is a list of lists:
|
|
535
|
-
|
|
508
|
+
for z_dict in msg.payload:
|
|
509
|
+
handle_msg_by_zone_idx(z_dict.get(SZ_ZONE_IDX), msg)
|
|
536
510
|
|
|
537
511
|
# If some zones still don't have a sensor, maybe eavesdrop?
|
|
538
512
|
if ( # TODO: edge case: 1 zone with CTL as SEN
|
|
@@ -543,7 +517,10 @@ class MultiZone(SystemBase): # 0005 (+/- 000C?)
|
|
|
543
517
|
):
|
|
544
518
|
eavesdrop_zone_sensors(msg)
|
|
545
519
|
|
|
546
|
-
|
|
520
|
+
# TODO: should be a private method
|
|
521
|
+
def get_htg_zone(
|
|
522
|
+
self, zone_idx: str, *, msg: Message | None = None, **schema: Any
|
|
523
|
+
) -> Zone:
|
|
547
524
|
"""Return a heating zone, create it if required.
|
|
548
525
|
|
|
549
526
|
First, use the schema to create/update it, then pass it any msg to handle.
|
|
@@ -552,13 +529,11 @@ class MultiZone(SystemBase): # 0005 (+/- 000C?)
|
|
|
552
529
|
If a zone is created, attach it to this TCS.
|
|
553
530
|
"""
|
|
554
531
|
|
|
555
|
-
from .zones import zone_factory
|
|
556
|
-
|
|
557
532
|
schema = shrink(SCH_TCS_ZONES_ZON(schema))
|
|
558
533
|
|
|
559
|
-
zon = self.zone_by_idx.get(zone_idx)
|
|
560
|
-
if
|
|
561
|
-
zon = zone_factory(self, zone_idx, msg=msg, **schema)
|
|
534
|
+
zon: Zone = self.zone_by_idx.get(zone_idx) # type: ignore[assignment]
|
|
535
|
+
if zon is None:
|
|
536
|
+
zon = zone_factory(self, zone_idx, msg=msg, **schema) # type: ignore[unreachable]
|
|
562
537
|
self.zone_by_idx[zon.idx] = zon
|
|
563
538
|
self.zones.append(zon)
|
|
564
539
|
|
|
@@ -592,31 +567,36 @@ class MultiZone(SystemBase): # 0005 (+/- 000C?)
|
|
|
592
567
|
|
|
593
568
|
|
|
594
569
|
class ScheduleSync(SystemBase): # 0006 (+/- 0404?)
|
|
595
|
-
def __init__(self, *args, **kwargs) -> None:
|
|
570
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
596
571
|
super().__init__(*args, **kwargs)
|
|
597
572
|
|
|
598
573
|
self._msg_0006: Message = None # type: ignore[assignment]
|
|
599
574
|
|
|
600
|
-
|
|
601
|
-
self.
|
|
575
|
+
# used to stop concurrent get_schedules
|
|
576
|
+
self.zone_lock = Lock() # FIXME: threading lock, or asyncio lock?
|
|
577
|
+
self.zone_lock_idx: str | None = None
|
|
602
578
|
|
|
603
579
|
def _setup_discovery_cmds(self) -> None:
|
|
604
580
|
super()._setup_discovery_cmds()
|
|
605
581
|
|
|
606
|
-
|
|
582
|
+
cmd = Command.get_schedule_version(self.id)
|
|
583
|
+
self._add_discovery_cmd(cmd, 60 * 5, delay=5)
|
|
607
584
|
|
|
608
585
|
def _handle_msg(self, msg: Message) -> None: # NOTE: active
|
|
609
|
-
"""Periodically
|
|
586
|
+
"""Periodically retrieve the latest global change counter."""
|
|
587
|
+
|
|
610
588
|
super()._handle_msg(msg)
|
|
611
589
|
|
|
612
590
|
if msg.code == Code._0006:
|
|
613
591
|
self._msg_0006 = msg
|
|
614
592
|
|
|
615
|
-
async def _schedule_version(self, *, force_io: bool = False) ->
|
|
593
|
+
async def _schedule_version(self, *, force_io: bool = False) -> tuple[int, bool]:
|
|
616
594
|
"""Return the global schedule version number, and an indication if I/O was done.
|
|
617
595
|
|
|
618
596
|
If `force_io`, then RQ the latest change counter from the TCS rather than
|
|
619
597
|
rely upon a recent (cached) value.
|
|
598
|
+
|
|
599
|
+
Cached values are only used if less than 3 minutes old.
|
|
620
600
|
"""
|
|
621
601
|
|
|
622
602
|
# RQ --- 30:185469 01:037519 --:------ 0006 001 00
|
|
@@ -632,31 +612,26 @@ class ScheduleSync(SystemBase): # 0006 (+/- 0404?)
|
|
|
632
612
|
False,
|
|
633
613
|
) # global_ver, did_io
|
|
634
614
|
|
|
635
|
-
|
|
636
|
-
|
|
615
|
+
cmd = Command.get_schedule_version(self.ctl.id)
|
|
616
|
+
pkt = await self._gwy.async_send_cmd(
|
|
617
|
+
cmd, wait_for_reply=True, priority=Priority.HIGH
|
|
637
618
|
)
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
self._msg_0006, Message
|
|
641
|
-
), f"_schedule_version(): {self._msg_0006} (is not a message)"
|
|
619
|
+
if pkt:
|
|
620
|
+
self._msg_0006 = Message(pkt)
|
|
642
621
|
|
|
643
622
|
return self._msg_0006.payload[SZ_CHANGE_COUNTER], True # global_ver, did_io
|
|
644
623
|
|
|
645
624
|
def _refresh_schedules(self) -> None:
|
|
646
|
-
|
|
647
|
-
raise RuntimeError("Sending is disabled")
|
|
625
|
+
zone: Zone
|
|
648
626
|
|
|
649
|
-
# schedules based upon 'active' (not most recent) 0006 pkt
|
|
650
627
|
for zone in getattr(self, SZ_ZONES, []):
|
|
651
628
|
self._gwy._loop.create_task(zone.get_schedule(force_io=True))
|
|
652
|
-
if
|
|
653
|
-
self._gwy._loop.create_task(dhw.get_schedule(force_io=True))
|
|
654
|
-
|
|
655
|
-
async def _obtain_lock(self, zone_idx) -> None:
|
|
629
|
+
if isinstance(self, StoredHw) and self.dhw:
|
|
630
|
+
self._gwy._loop.create_task(self.dhw.get_schedule(force_io=True))
|
|
656
631
|
|
|
632
|
+
async def _obtain_lock(self, zone_idx: str) -> None:
|
|
657
633
|
timeout_dtm = dt.now() + td(minutes=3)
|
|
658
634
|
while dt.now() < timeout_dtm:
|
|
659
|
-
|
|
660
635
|
self.zone_lock.acquire()
|
|
661
636
|
if self.zone_lock_idx is None:
|
|
662
637
|
self.zone_lock_idx = zone_idx
|
|
@@ -672,14 +647,13 @@ class ScheduleSync(SystemBase): # 0006 (+/- 0404?)
|
|
|
672
647
|
)
|
|
673
648
|
|
|
674
649
|
def _release_lock(self) -> None:
|
|
675
|
-
|
|
676
650
|
self.zone_lock.acquire()
|
|
677
651
|
self.zone_lock_idx = None
|
|
678
652
|
self.zone_lock.release()
|
|
679
653
|
|
|
680
654
|
@property
|
|
681
|
-
def schedule_version(self) ->
|
|
682
|
-
return self._msg_value(Code._0006, key=SZ_CHANGE_COUNTER)
|
|
655
|
+
def schedule_version(self) -> int | None:
|
|
656
|
+
return self._msg_value(Code._0006, key=SZ_CHANGE_COUNTER) # type: ignore[return-value]
|
|
683
657
|
|
|
684
658
|
@property
|
|
685
659
|
def status(self) -> dict[str, Any]:
|
|
@@ -693,13 +667,12 @@ class Language(SystemBase): # 0100
|
|
|
693
667
|
def _setup_discovery_cmds(self) -> None:
|
|
694
668
|
super()._setup_discovery_cmds()
|
|
695
669
|
|
|
696
|
-
self.
|
|
697
|
-
|
|
698
|
-
)
|
|
670
|
+
cmd = Command.get_system_language(self.id)
|
|
671
|
+
self._add_discovery_cmd(cmd, 60 * 60 * 24, delay=60 * 15)
|
|
699
672
|
|
|
700
673
|
@property
|
|
701
|
-
def language(self) ->
|
|
702
|
-
return self._msg_value(Code._0100, key=SZ_LANGUAGE)
|
|
674
|
+
def language(self) -> str | None:
|
|
675
|
+
return self._msg_value(Code._0100, key=SZ_LANGUAGE) # type: ignore[return-value]
|
|
703
676
|
|
|
704
677
|
@property
|
|
705
678
|
def params(self) -> dict[str, Any]:
|
|
@@ -709,7 +682,7 @@ class Language(SystemBase): # 0100
|
|
|
709
682
|
|
|
710
683
|
|
|
711
684
|
class Logbook(SystemBase): # 0418
|
|
712
|
-
def __init__(self, *args, **kwargs) -> None:
|
|
685
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
713
686
|
super().__init__(*args, **kwargs)
|
|
714
687
|
|
|
715
688
|
self._prev_event: Message = None # type: ignore[assignment]
|
|
@@ -718,93 +691,63 @@ class Logbook(SystemBase): # 0418
|
|
|
718
691
|
self._prev_fault: Message = None # type: ignore[assignment]
|
|
719
692
|
self._this_fault: Message = None # type: ignore[assignment]
|
|
720
693
|
|
|
721
|
-
|
|
722
|
-
self._faultlog: FaultLog = None # type: ignore[assignment]
|
|
723
|
-
self._faultlog_outdated: bool = True
|
|
694
|
+
self._faultlog: FaultLog = FaultLog(self)
|
|
724
695
|
|
|
725
696
|
def _setup_discovery_cmds(self) -> None:
|
|
726
697
|
super()._setup_discovery_cmds()
|
|
727
698
|
|
|
728
|
-
self.
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
#
|
|
699
|
+
cmd = Command.get_system_log_entry(self.id, 0)
|
|
700
|
+
self._add_discovery_cmd(cmd, 60 * 5, delay=5)
|
|
701
|
+
# self._gwy.add_task(
|
|
702
|
+
# self._gwy._loop.create_task(self.get_faultlog())
|
|
703
|
+
# )
|
|
732
704
|
|
|
733
705
|
def _handle_msg(self, msg: Message) -> None: # NOTE: active
|
|
734
706
|
super()._handle_msg(msg)
|
|
735
707
|
|
|
736
|
-
if msg.code
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
if msg.payload["log_idx"] == "00":
|
|
740
|
-
if not self._this_event or (
|
|
741
|
-
msg.payload["log_entry"] != self._this_event.payload["log_entry"]
|
|
742
|
-
):
|
|
743
|
-
self._this_event, self._prev_event = msg, self._this_event
|
|
744
|
-
# TODO: self._faultlog_outdated = msg.verb == I_ or self._prev_event and (
|
|
745
|
-
# msg.payload["log_entry"] != self._prev_event.payload["log_entry"]
|
|
746
|
-
# )
|
|
747
|
-
|
|
748
|
-
if msg.payload["log_entry"] and msg.payload["log_entry"][1] == "fault":
|
|
749
|
-
if not self._this_fault or (
|
|
750
|
-
msg.payload["log_entry"] != self._this_fault.payload["log_entry"]
|
|
751
|
-
):
|
|
752
|
-
self._this_fault, self._prev_fault = msg, self._this_fault
|
|
753
|
-
|
|
754
|
-
# if msg.payload["log_entry"][1] == "restore" and not self._this_fault:
|
|
755
|
-
# self._send_cmd(Command.get_system_log_entry(self.ctl.id, 1))
|
|
756
|
-
|
|
757
|
-
# TODO: if self._faultlog_outdated:
|
|
758
|
-
# if not self._gwy.config.disable_sending:
|
|
759
|
-
# self._loop.create_task(self.get_faultlog(force_io=True))
|
|
708
|
+
if msg.code == Code._0418: # and msg.verb in (I_, RP):
|
|
709
|
+
self._faultlog.handle_msg(msg)
|
|
760
710
|
|
|
761
711
|
async def get_faultlog(
|
|
762
|
-
self,
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
return None
|
|
773
|
-
|
|
774
|
-
# @property
|
|
775
|
-
# def faultlog_outdated(self) -> bool:
|
|
776
|
-
# return self._this_event.verb == I_ or self._prev_event and (
|
|
777
|
-
# self._this_event.payload != self._prev_event.payload
|
|
778
|
-
# )
|
|
779
|
-
|
|
780
|
-
# @property
|
|
781
|
-
# def faultlog(self) -> dict:
|
|
782
|
-
# return self._faultlog.faultlog
|
|
712
|
+
self,
|
|
713
|
+
/,
|
|
714
|
+
*,
|
|
715
|
+
start: int = 0,
|
|
716
|
+
limit: int | None = None,
|
|
717
|
+
force_refresh: bool = False,
|
|
718
|
+
) -> dict[FaultIdxT, FaultLogEntry] | None:
|
|
719
|
+
return await self._faultlog.get_faultlog(
|
|
720
|
+
start=start, limit=limit, force_refresh=force_refresh
|
|
721
|
+
)
|
|
783
722
|
|
|
784
723
|
@property
|
|
785
|
-
def
|
|
786
|
-
"""Return the most recently logged
|
|
787
|
-
if self.
|
|
724
|
+
def active_faults(self) -> tuple[str, ...] | None:
|
|
725
|
+
"""Return the most recently logged faults that are not restored."""
|
|
726
|
+
if self._faultlog.active_faults is None:
|
|
788
727
|
return None
|
|
789
|
-
return self.
|
|
728
|
+
return tuple(str(f) for f in self._faultlog.active_faults)
|
|
790
729
|
|
|
791
730
|
@property
|
|
792
|
-
def latest_event(self) ->
|
|
731
|
+
def latest_event(self) -> str | None:
|
|
793
732
|
"""Return the most recently logged event (fault or restore), if any."""
|
|
794
|
-
|
|
733
|
+
if not self._faultlog.latest_event:
|
|
734
|
+
return None
|
|
735
|
+
return str(self._faultlog.latest_event)
|
|
795
736
|
|
|
796
737
|
@property
|
|
797
|
-
def latest_fault(self) ->
|
|
738
|
+
def latest_fault(self) -> str | None:
|
|
798
739
|
"""Return the most recently logged fault, if any."""
|
|
799
|
-
|
|
740
|
+
if not self._faultlog.latest_fault:
|
|
741
|
+
return None
|
|
742
|
+
return str(self._faultlog.latest_fault)
|
|
800
743
|
|
|
801
744
|
@property
|
|
802
745
|
def status(self) -> dict[str, Any]:
|
|
803
746
|
return {
|
|
804
747
|
**super().status,
|
|
748
|
+
"active_faults": self.active_faults,
|
|
805
749
|
"latest_event": self.latest_event,
|
|
806
|
-
"
|
|
807
|
-
# "faultlog": self.faultlog,
|
|
750
|
+
"latest_fault": self.latest_fault,
|
|
808
751
|
}
|
|
809
752
|
|
|
810
753
|
|
|
@@ -813,7 +756,7 @@ class StoredHw(SystemBase): # 10A0, 1260, 1F41
|
|
|
813
756
|
MAX_SETPOINT = 85.0
|
|
814
757
|
DEFAULT_SETPOINT = 50.0
|
|
815
758
|
|
|
816
|
-
def __init__(self, *args, **kwargs) -> None:
|
|
759
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
817
760
|
super().__init__(*args, **kwargs)
|
|
818
761
|
self._dhw: DhwZone = None # type: ignore[assignment]
|
|
819
762
|
|
|
@@ -825,12 +768,10 @@ class StoredHw(SystemBase): # 10A0, 1260, 1F41
|
|
|
825
768
|
# f"00{DEV_ROLE_MAP.HTG}", # hotwater_valve
|
|
826
769
|
# f"01{DEV_ROLE_MAP.HTG}", # heating_valve
|
|
827
770
|
):
|
|
828
|
-
self.
|
|
829
|
-
|
|
830
|
-
)
|
|
771
|
+
cmd = Command.from_attrs(RQ, self.id, Code._000C, payload)
|
|
772
|
+
self._add_discovery_cmd(cmd, 60 * 60 * 24, delay=0)
|
|
831
773
|
|
|
832
774
|
def _handle_msg(self, msg: Message) -> None:
|
|
833
|
-
|
|
834
775
|
super()._handle_msg(msg)
|
|
835
776
|
|
|
836
777
|
if (
|
|
@@ -858,7 +799,8 @@ class StoredHw(SystemBase): # 10A0, 1260, 1F41
|
|
|
858
799
|
# Route all messages to their zones, incl. 000C, 0404, others
|
|
859
800
|
self.get_dhw_zone(msg=msg)
|
|
860
801
|
|
|
861
|
-
|
|
802
|
+
# TODO: should be a private method
|
|
803
|
+
def get_dhw_zone(self, *, msg: Message | None = None, **schema: Any) -> DhwZone:
|
|
862
804
|
"""Return a DHW zone, create it if required.
|
|
863
805
|
|
|
864
806
|
First, use the schema to create/update it, then pass it any msg to handle.
|
|
@@ -867,12 +809,10 @@ class StoredHw(SystemBase): # 10A0, 1260, 1F41
|
|
|
867
809
|
If a DHW zone is created, attach it to this TCS.
|
|
868
810
|
"""
|
|
869
811
|
|
|
870
|
-
from .zones import zone_factory
|
|
871
|
-
|
|
872
812
|
schema = shrink(SCH_TCS_DHW(schema))
|
|
873
813
|
|
|
874
814
|
if not self._dhw:
|
|
875
|
-
self._dhw = zone_factory(self, "HW", msg=msg, **schema)
|
|
815
|
+
self._dhw = zone_factory(self, "HW", msg=msg, **schema) # type: ignore[assignment]
|
|
876
816
|
|
|
877
817
|
elif schema:
|
|
878
818
|
self._dhw._update_schema(**schema)
|
|
@@ -882,19 +822,19 @@ class StoredHw(SystemBase): # 10A0, 1260, 1F41
|
|
|
882
822
|
return self._dhw
|
|
883
823
|
|
|
884
824
|
@property
|
|
885
|
-
def dhw(self) -> DhwZone:
|
|
825
|
+
def dhw(self) -> DhwZone | None:
|
|
886
826
|
return self._dhw
|
|
887
827
|
|
|
888
828
|
@property
|
|
889
|
-
def dhw_sensor(self) ->
|
|
829
|
+
def dhw_sensor(self) -> Device | None:
|
|
890
830
|
return self._dhw.sensor if self._dhw else None
|
|
891
831
|
|
|
892
832
|
@property
|
|
893
|
-
def hotwater_valve(self) ->
|
|
833
|
+
def hotwater_valve(self) -> Device | None:
|
|
894
834
|
return self._dhw.hotwater_valve if self._dhw else None
|
|
895
835
|
|
|
896
836
|
@property
|
|
897
|
-
def heating_valve(self) ->
|
|
837
|
+
def heating_valve(self) -> Device | None:
|
|
898
838
|
return self._dhw.heating_valve if self._dhw else None
|
|
899
839
|
|
|
900
840
|
@property
|
|
@@ -923,23 +863,31 @@ class SysMode(SystemBase): # 2E04
|
|
|
923
863
|
def _setup_discovery_cmds(self) -> None:
|
|
924
864
|
super()._setup_discovery_cmds()
|
|
925
865
|
|
|
926
|
-
|
|
866
|
+
cmd = Command.get_system_mode(self.id)
|
|
867
|
+
self._add_discovery_cmd(cmd, 60 * 5, delay=5)
|
|
927
868
|
|
|
928
869
|
@property
|
|
929
|
-
def system_mode(self) ->
|
|
930
|
-
return self._msg_value(Code._2E04)
|
|
870
|
+
def system_mode(self) -> dict[str, Any] | None: # 2E04
|
|
871
|
+
return self._msg_value(Code._2E04) # type: ignore[return-value]
|
|
931
872
|
|
|
932
|
-
def set_mode(
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
873
|
+
def set_mode(
|
|
874
|
+
self, system_mode: int | str | None, *, until: dt | str | None = None
|
|
875
|
+
) -> asyncio.Task[Packet]:
|
|
876
|
+
"""
|
|
877
|
+
Set a system mode for a specified duration, or indefinitely.
|
|
878
|
+
|
|
879
|
+
:param system_mode: 2-digit item from SYS_MODE_MAP, positional
|
|
880
|
+
:param until: optional: end of set period
|
|
881
|
+
:return:
|
|
882
|
+
"""
|
|
883
|
+
cmd = Command.set_system_mode(self.id, system_mode, until=until)
|
|
884
|
+
return self._gwy.send_cmd(cmd, priority=Priority.HIGH, wait_for_reply=True)
|
|
937
885
|
|
|
938
|
-
def set_auto(self) ->
|
|
886
|
+
def set_auto(self) -> asyncio.Task[Packet]:
|
|
939
887
|
"""Revert system to Auto, set non-PermanentOverride zones to FollowSchedule."""
|
|
940
888
|
return self.set_mode(SYS_MODE_MAP.AUTO)
|
|
941
889
|
|
|
942
|
-
def reset_mode(self) ->
|
|
890
|
+
def reset_mode(self) -> asyncio.Task[Packet]:
|
|
943
891
|
"""Revert system to Auto, force *all* zones to FollowSchedule."""
|
|
944
892
|
return self.set_mode(SYS_MODE_MAP.AUTO_WITH_RESET)
|
|
945
893
|
|
|
@@ -954,26 +902,33 @@ class Datetime(SystemBase): # 313F
|
|
|
954
902
|
def _setup_discovery_cmds(self) -> None:
|
|
955
903
|
super()._setup_discovery_cmds()
|
|
956
904
|
|
|
957
|
-
|
|
905
|
+
cmd = Command.get_system_time(self.id)
|
|
906
|
+
self._add_discovery_cmd(cmd, 60 * 60, delay=0)
|
|
958
907
|
|
|
959
908
|
def _handle_msg(self, msg: Message) -> None:
|
|
960
909
|
super()._handle_msg(msg)
|
|
961
910
|
|
|
962
|
-
|
|
911
|
+
# FIXME: refactoring protocol stack
|
|
912
|
+
if msg.code == Code._313F and msg.verb in (I_, RP) and self._gwy._transport:
|
|
963
913
|
diff = abs(dt.fromisoformat(msg.payload[SZ_DATETIME]) - self._gwy._dt_now())
|
|
964
914
|
if diff > td(minutes=5):
|
|
965
915
|
_LOGGER.warning(f"{msg!r} < excessive datetime difference: {diff}")
|
|
966
916
|
|
|
967
|
-
async def get_datetime(self) ->
|
|
968
|
-
|
|
917
|
+
async def get_datetime(self) -> dt | None:
|
|
918
|
+
cmd = Command.get_system_time(self.id)
|
|
919
|
+
pkt = await self._gwy.async_send_cmd(cmd, wait_for_reply=True)
|
|
920
|
+
msg = Message._from_pkt(pkt)
|
|
969
921
|
return dt.fromisoformat(msg.payload[SZ_DATETIME])
|
|
970
922
|
|
|
971
|
-
async def set_datetime(self, dtm: dt) ->
|
|
972
|
-
|
|
923
|
+
async def set_datetime(self, dtm: dt) -> Packet:
|
|
924
|
+
"""Set the date and time of the system."""
|
|
925
|
+
|
|
926
|
+
cmd = Command.set_system_time(self.id, dtm)
|
|
927
|
+
return await self._gwy.async_send_cmd(cmd, priority=Priority.HIGH)
|
|
973
928
|
|
|
974
929
|
|
|
975
930
|
class UfHeating(SystemBase):
|
|
976
|
-
def _ufh_ctls(self):
|
|
931
|
+
def _ufh_ctls(self) -> list[UfhController]:
|
|
977
932
|
return sorted([d for d in self.childs if isinstance(d, UfhController)])
|
|
978
933
|
|
|
979
934
|
@property
|
|
@@ -999,21 +954,59 @@ class UfHeating(SystemBase):
|
|
|
999
954
|
|
|
1000
955
|
|
|
1001
956
|
class System(StoredHw, Datetime, Logbook, SystemBase):
|
|
1002
|
-
"""The
|
|
957
|
+
"""The Temperature Control System class."""
|
|
1003
958
|
|
|
1004
|
-
_SLUG: str = SYS_KLASS.
|
|
959
|
+
_SLUG: str = SYS_KLASS.SYS
|
|
1005
960
|
|
|
1006
|
-
def __init__(self, ctl, **kwargs) -> None:
|
|
961
|
+
def __init__(self, ctl: Controller, **kwargs: Any) -> None:
|
|
1007
962
|
super().__init__(ctl, **kwargs)
|
|
1008
963
|
|
|
1009
964
|
self._heat_demands: dict[str, Any] = {}
|
|
1010
965
|
self._relay_demands: dict[str, Any] = {}
|
|
1011
966
|
self._relay_failsafes: dict[str, Any] = {}
|
|
1012
967
|
|
|
968
|
+
def _update_schema(self, **schema: Any) -> None:
|
|
969
|
+
"""Update a CH/DHW system with new schema attrs.
|
|
970
|
+
|
|
971
|
+
Raise an exception if the new schema is not a superset of the existing schema.
|
|
972
|
+
"""
|
|
973
|
+
|
|
974
|
+
_schema: dict[str, Any]
|
|
975
|
+
schema = shrink(SCH_TCS(schema))
|
|
976
|
+
|
|
977
|
+
if schema.get(SZ_SYSTEM) and (
|
|
978
|
+
dev_id := schema[SZ_SYSTEM].get(SZ_APPLIANCE_CONTROL)
|
|
979
|
+
):
|
|
980
|
+
self._app_cntrl = self._gwy.get_device(dev_id, parent=self, child_id=FC) # type: ignore[assignment]
|
|
981
|
+
|
|
982
|
+
if _schema := (schema.get(SZ_DHW_SYSTEM)): # type: ignore[assignment]
|
|
983
|
+
self.get_dhw_zone(**_schema) # self._dhw = ...
|
|
984
|
+
|
|
985
|
+
if not isinstance(self, MultiZone):
|
|
986
|
+
return
|
|
987
|
+
|
|
988
|
+
if _schema := (schema.get(SZ_ZONES)): # type: ignore[assignment]
|
|
989
|
+
[self.get_htg_zone(idx, **s) for idx, s in _schema.items()]
|
|
990
|
+
|
|
991
|
+
@classmethod
|
|
992
|
+
def create_from_schema(cls, ctl: Controller, **schema: Any) -> System:
|
|
993
|
+
"""Create a CH/DHW system for a CTL and set its schema attrs.
|
|
994
|
+
|
|
995
|
+
The appropriate System class should have been determined by a factory.
|
|
996
|
+
Schema attrs include: class (klass) & others.
|
|
997
|
+
"""
|
|
998
|
+
|
|
999
|
+
tcs = cls(ctl)
|
|
1000
|
+
tcs._update_schema(**schema)
|
|
1001
|
+
return tcs
|
|
1002
|
+
|
|
1013
1003
|
def _handle_msg(self, msg: Message) -> None:
|
|
1014
1004
|
super()._handle_msg(msg)
|
|
1015
1005
|
|
|
1016
|
-
if
|
|
1006
|
+
if not isinstance(msg.payload, dict):
|
|
1007
|
+
return
|
|
1008
|
+
|
|
1009
|
+
if (idx := msg.payload.get(SZ_DOMAIN_ID)) and msg.verb in (I_, RP):
|
|
1017
1010
|
idx = msg.payload[SZ_DOMAIN_ID]
|
|
1018
1011
|
if msg.code == Code._0008:
|
|
1019
1012
|
self._relay_demands[idx] = msg
|
|
@@ -1032,24 +1025,24 @@ class System(StoredHw, Datetime, Logbook, SystemBase):
|
|
|
1032
1025
|
assert False, f"Unexpected code with a domain_id: {msg.code}"
|
|
1033
1026
|
|
|
1034
1027
|
@property
|
|
1035
|
-
def heat_demands(self) ->
|
|
1028
|
+
def heat_demands(self) -> dict[str, Any] | None: # 3150
|
|
1036
1029
|
# FC: 00-C8 (no F9, FA), TODO: deprecate as FC only?
|
|
1037
1030
|
if not self._heat_demands:
|
|
1038
1031
|
return None
|
|
1039
1032
|
return {k: v.payload["heat_demand"] for k, v in self._heat_demands.items()}
|
|
1040
1033
|
|
|
1041
1034
|
@property
|
|
1042
|
-
def relay_demands(self) ->
|
|
1035
|
+
def relay_demands(self) -> dict[str, Any] | None: # 0008
|
|
1043
1036
|
# FC: 00-C8, F9: 00-C8, FA: 00 or C8 only (01: all 3, 02: FC/FA only)
|
|
1044
1037
|
if not self._relay_demands:
|
|
1045
1038
|
return None
|
|
1046
1039
|
return {k: v.payload["relay_demand"] for k, v in self._relay_demands.items()}
|
|
1047
1040
|
|
|
1048
1041
|
@property
|
|
1049
|
-
def relay_failsafes(self) ->
|
|
1042
|
+
def relay_failsafes(self) -> dict[str, Any] | None: # 0009
|
|
1050
1043
|
if not self._relay_failsafes:
|
|
1051
1044
|
return None
|
|
1052
|
-
return {} #
|
|
1045
|
+
return {} # FIXME: failsafe_enabled
|
|
1053
1046
|
|
|
1054
1047
|
@property
|
|
1055
1048
|
def status(self) -> dict[str, Any]:
|
|
@@ -1066,28 +1059,25 @@ class System(StoredHw, Datetime, Logbook, SystemBase):
|
|
|
1066
1059
|
|
|
1067
1060
|
|
|
1068
1061
|
class Evohome(ScheduleSync, Language, SysMode, MultiZone, UfHeating, System):
|
|
1069
|
-
|
|
1062
|
+
_SLUG: str = SYS_KLASS.TCS # evohome
|
|
1070
1063
|
|
|
1071
1064
|
# older evohome don't have zone_type=ELE
|
|
1072
1065
|
|
|
1073
|
-
_SLUG: str = SYS_KLASS.TCS
|
|
1074
|
-
|
|
1075
1066
|
|
|
1076
1067
|
class Chronotherm(Evohome):
|
|
1077
|
-
|
|
1078
1068
|
_SLUG: str = SYS_KLASS.SYS
|
|
1079
1069
|
|
|
1080
1070
|
|
|
1081
1071
|
class Hometronics(System):
|
|
1072
|
+
_SLUG: str = SYS_KLASS.SYS
|
|
1073
|
+
|
|
1082
1074
|
# These are only ever been seen from a Hometronics controller
|
|
1083
1075
|
# .I --- 01:023389 --:------ 01:023389 2D49 003 00C800
|
|
1084
1076
|
# .I --- 01:023389 --:------ 01:023389 2D49 003 01C800
|
|
1085
1077
|
# .I --- 01:023389 --:------ 01:023389 2D49 003 880000
|
|
1086
1078
|
# .I --- 01:023389 --:------ 01:023389 2D49 003 FD0000
|
|
1087
1079
|
|
|
1088
|
-
# Hometronic does not react to W/2349 but rather
|
|
1089
|
-
|
|
1090
|
-
_SLUG: str = SYS_KLASS.SYS
|
|
1080
|
+
# Hometronic does not react to W/2349 but rather requires W/2309
|
|
1091
1081
|
|
|
1092
1082
|
#
|
|
1093
1083
|
# def _setup_discovery_cmds(self) -> None:
|
|
@@ -1101,39 +1091,42 @@ class Hometronics(System):
|
|
|
1101
1091
|
|
|
1102
1092
|
|
|
1103
1093
|
class Programmer(Evohome):
|
|
1104
|
-
|
|
1105
1094
|
_SLUG: str = SYS_KLASS.PRG
|
|
1106
1095
|
|
|
1107
1096
|
|
|
1108
1097
|
class Sundial(Evohome):
|
|
1109
|
-
|
|
1110
1098
|
_SLUG: str = SYS_KLASS.SYS
|
|
1111
1099
|
|
|
1112
1100
|
|
|
1113
|
-
|
|
1101
|
+
# e.g. {"evohome": Evohome}
|
|
1102
|
+
SYS_CLASS_BY_SLUG: dict[str, type[System]] = class_by_attr(__name__, "_SLUG")
|
|
1114
1103
|
|
|
1115
1104
|
|
|
1116
|
-
def system_factory(
|
|
1105
|
+
def system_factory(
|
|
1106
|
+
ctl: Controller, *, msg: Message | None = None, **schema: Any
|
|
1107
|
+
) -> System:
|
|
1117
1108
|
"""Return the system class for a given controller/schema (defaults to evohome)."""
|
|
1118
1109
|
|
|
1119
1110
|
def best_tcs_class(
|
|
1120
1111
|
ctl_addr: Address,
|
|
1121
1112
|
*,
|
|
1122
|
-
msg: Message = None,
|
|
1113
|
+
msg: Message | None = None,
|
|
1123
1114
|
eavesdrop: bool = False,
|
|
1124
|
-
**schema,
|
|
1125
|
-
) -> type[
|
|
1115
|
+
**schema: Any,
|
|
1116
|
+
) -> type[System]:
|
|
1126
1117
|
"""Return the system class for a given CTL/schema (defaults to evohome)."""
|
|
1127
1118
|
|
|
1128
|
-
|
|
1129
|
-
|
|
1119
|
+
klass: str = schema.get(SZ_CLASS) # type: ignore[assignment]
|
|
1120
|
+
|
|
1121
|
+
# a specified system class always takes precedence (even if it is wrong)...
|
|
1122
|
+
if klass and (cls := SYS_CLASS_BY_SLUG.get(klass)):
|
|
1130
1123
|
_LOGGER.debug(
|
|
1131
1124
|
f"Using an explicitly-defined system class for: {ctl_addr} ({cls._SLUG})"
|
|
1132
1125
|
)
|
|
1133
1126
|
return cls
|
|
1134
1127
|
|
|
1135
1128
|
# otherwise, use the default system class...
|
|
1136
|
-
_LOGGER.debug(f"Using a generic system class for: {ctl_addr} ({
|
|
1129
|
+
_LOGGER.debug(f"Using a generic system class for: {ctl_addr} ({Device._SLUG})")
|
|
1137
1130
|
return Evohome
|
|
1138
1131
|
|
|
1139
1132
|
return best_tcs_class(
|