ramses-rf 0.22.40__py3-none-any.whl → 0.51.2__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 +279 -171
- ramses_rf/dispatcher.py +153 -177
- ramses_rf/entity_base.py +478 -361
- ramses_rf/exceptions.py +82 -0
- ramses_rf/gateway.py +377 -513
- 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.2.dist-info/METADATA +72 -0
- ramses_rf-0.51.2.dist-info/RECORD +55 -0
- {ramses_rf-0.22.40.dist-info → ramses_rf-0.51.2.dist-info}/WHEEL +1 -2
- ramses_rf-0.51.2.dist-info/entry_points.txt +2 -0
- {ramses_rf-0.22.40.dist-info → ramses_rf-0.51.2.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_rf/protocol → ramses_tx}/parsers.py +1266 -1003
- 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 -1576
- 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/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.40.dist-info/METADATA +0 -64
- ramses_rf-0.22.40.dist-info/RECORD +0 -42
- ramses_rf-0.22.40.dist-info/top_level.txt +0 -1
ramses_rf/device/hvac.py
CHANGED
|
@@ -1,53 +1,52 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
|
|
3
|
-
#
|
|
4
|
-
"""RAMSES RF - a RAMSES-II protocol decoder & analyser.
|
|
2
|
+
"""RAMSES RF - devices from the HVAC domain."""
|
|
5
3
|
|
|
6
|
-
HVAC devices.
|
|
7
|
-
"""
|
|
8
4
|
from __future__ import annotations
|
|
9
5
|
|
|
10
6
|
import logging
|
|
11
|
-
from typing import Any,
|
|
7
|
+
from typing import Any, TypeVar
|
|
12
8
|
|
|
13
|
-
from
|
|
14
|
-
|
|
15
|
-
FAN_MODE,
|
|
9
|
+
from ramses_rf import exceptions as exc
|
|
10
|
+
from ramses_rf.const import (
|
|
16
11
|
SZ_AIR_QUALITY,
|
|
17
|
-
|
|
12
|
+
SZ_AIR_QUALITY_BASIS,
|
|
18
13
|
SZ_BOOST_TIMER,
|
|
14
|
+
SZ_BYPASS_MODE,
|
|
19
15
|
SZ_BYPASS_POSITION,
|
|
16
|
+
SZ_BYPASS_STATE,
|
|
20
17
|
SZ_CO2_LEVEL,
|
|
21
18
|
SZ_EXHAUST_FAN_SPEED,
|
|
22
19
|
SZ_EXHAUST_FLOW,
|
|
23
|
-
|
|
20
|
+
SZ_EXHAUST_TEMP,
|
|
24
21
|
SZ_FAN_INFO,
|
|
22
|
+
SZ_FAN_MODE,
|
|
23
|
+
SZ_FAN_RATE,
|
|
25
24
|
SZ_INDOOR_HUMIDITY,
|
|
26
|
-
|
|
25
|
+
SZ_INDOOR_TEMP,
|
|
27
26
|
SZ_OUTDOOR_HUMIDITY,
|
|
28
|
-
|
|
27
|
+
SZ_OUTDOOR_TEMP,
|
|
29
28
|
SZ_POST_HEAT,
|
|
30
29
|
SZ_PRE_HEAT,
|
|
31
30
|
SZ_PRESENCE_DETECTED,
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
SZ_REMAINING_DAYS,
|
|
32
|
+
SZ_REMAINING_MINS,
|
|
33
|
+
SZ_REMAINING_PERCENT,
|
|
34
|
+
SZ_SPEED_CAPABILITIES,
|
|
34
35
|
SZ_SUPPLY_FAN_SPEED,
|
|
35
36
|
SZ_SUPPLY_FLOW,
|
|
36
|
-
|
|
37
|
+
SZ_SUPPLY_TEMP,
|
|
37
38
|
SZ_TEMPERATURE,
|
|
38
|
-
|
|
39
|
+
DevType,
|
|
39
40
|
)
|
|
40
|
-
from
|
|
41
|
-
from
|
|
42
|
-
from
|
|
43
|
-
from
|
|
44
|
-
from
|
|
45
|
-
|
|
46
|
-
from
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
# skipcq: PY-W2000
|
|
50
|
-
from ..const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
|
|
41
|
+
from ramses_rf.entity_base import class_by_attr
|
|
42
|
+
from ramses_rf.helpers import shrink
|
|
43
|
+
from ramses_rf.schemas import SCH_VCS, SZ_REMOTES, SZ_SENSORS
|
|
44
|
+
from ramses_tx import Address, Command, Message, Packet, Priority
|
|
45
|
+
from ramses_tx.ramses import CODES_OF_HVAC_DOMAIN_ONLY, HVAC_KLASS_BY_VC_PAIR
|
|
46
|
+
|
|
47
|
+
from .base import BatteryState, DeviceHvac, Fakeable
|
|
48
|
+
|
|
49
|
+
from ramses_rf.const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
|
|
51
50
|
I_,
|
|
52
51
|
RP,
|
|
53
52
|
RQ,
|
|
@@ -55,12 +54,19 @@ from ..const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
|
|
|
55
54
|
Code,
|
|
56
55
|
)
|
|
57
56
|
|
|
57
|
+
# TODO: Switch this module to utilise the (run-time) decorator design pattern...
|
|
58
|
+
# - https://refactoring.guru/design-patterns/decorator/python/example
|
|
59
|
+
# - will probably need setattr()?
|
|
60
|
+
# BaseComponents: FAN (HRU, PIV, EXT), SENsor (CO2, HUM, TEMp), SWItch (RF gateway?)
|
|
61
|
+
# - a device could be a combination of above (e.g. Spider Gateway)
|
|
62
|
+
# Track binding for SWI (HA service call) & SEN (HA trigger) to FAN/other
|
|
63
|
+
|
|
64
|
+
# Challenges:
|
|
65
|
+
# - may need two-tier system (HVAC -> FAN|SEN|SWI -> command class)
|
|
66
|
+
# - thus, Composite design pattern may be more appropriate
|
|
58
67
|
|
|
59
|
-
DEV_MODE = __dev_mode__ # and False
|
|
60
68
|
|
|
61
69
|
_LOGGER = logging.getLogger(__name__)
|
|
62
|
-
if DEV_MODE:
|
|
63
|
-
_LOGGER.setLevel(logging.DEBUG)
|
|
64
70
|
|
|
65
71
|
|
|
66
72
|
_HvacRemoteBaseT = TypeVar("_HvacRemoteBaseT", bound="HvacRemoteBase")
|
|
@@ -75,33 +81,22 @@ class HvacSensorBase(DeviceHvac):
|
|
|
75
81
|
pass
|
|
76
82
|
|
|
77
83
|
|
|
78
|
-
class CarbonDioxide(
|
|
84
|
+
class CarbonDioxide(HvacSensorBase): # 1298
|
|
79
85
|
"""The CO2 sensor (cardinal code is 1298)."""
|
|
80
86
|
|
|
81
|
-
def _bind(self):
|
|
82
|
-
# .I --- 29:181813 63:262142 --:------ 1FC9 030 00-31E0-76C635 01-31E0-76C635 00-1298-76C635 67-10E0-76C635 00-1FC9-76C635
|
|
83
|
-
# .W --- 32:155617 29:181813 --:------ 1FC9 012 00-31D9-825FE1 00-31DA-825FE1 # The HRU
|
|
84
|
-
# .I --- 29:181813 32:155617 --:------ 1FC9 001 00
|
|
85
|
-
|
|
86
|
-
def callback(msg):
|
|
87
|
-
"""Use the accept pkt to determine the zone/domain id."""
|
|
88
|
-
_ = msg.payload[SZ_BINDINGS][0][0]
|
|
89
|
-
# self.set_parent(msg.src, child_id=child_id, is_sensor=True)
|
|
90
|
-
|
|
91
|
-
super()._bind()
|
|
92
|
-
self._bind_request((Code._1298, Code._31E0), callback=callback)
|
|
93
|
-
|
|
94
87
|
@property
|
|
95
|
-
def co2_level(self) ->
|
|
88
|
+
def co2_level(self) -> int | None: # 1298
|
|
96
89
|
return self._msg_value(Code._1298, key=SZ_CO2_LEVEL)
|
|
97
90
|
|
|
98
|
-
# @check_faking_enabled
|
|
99
91
|
@co2_level.setter
|
|
100
|
-
def co2_level(self, value) -> None:
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
self.
|
|
104
|
-
|
|
92
|
+
def co2_level(self, value: int | None) -> None:
|
|
93
|
+
"""Fake the CO2 level of the sensor."""
|
|
94
|
+
|
|
95
|
+
if not self.is_faked:
|
|
96
|
+
raise exc.DeviceNotFaked(f"{self}: Faking is not enabled")
|
|
97
|
+
|
|
98
|
+
cmd = Command.put_co2_level(self.id, value)
|
|
99
|
+
self._gwy.send_cmd(cmd, num_repeats=2, priority=Priority.HIGH)
|
|
105
100
|
|
|
106
101
|
@property
|
|
107
102
|
def status(self) -> dict[str, Any]:
|
|
@@ -111,33 +106,22 @@ class CarbonDioxide(Fakeable, HvacSensorBase): # 1298
|
|
|
111
106
|
}
|
|
112
107
|
|
|
113
108
|
|
|
114
|
-
class IndoorHumidity(
|
|
109
|
+
class IndoorHumidity(HvacSensorBase): # 12A0
|
|
115
110
|
"""The relative humidity sensor (12A0)."""
|
|
116
111
|
|
|
117
|
-
def _bind(self):
|
|
118
|
-
# .I ---
|
|
119
|
-
# .W ---
|
|
120
|
-
# .I ---
|
|
121
|
-
|
|
122
|
-
def callback(msg):
|
|
123
|
-
"""Use the accept pkt to determine the zone/domain id."""
|
|
124
|
-
_ = msg.payload[SZ_BINDINGS][0][0]
|
|
125
|
-
# self.set_parent(msg.src, child_id=child_id, is_sensor=True)
|
|
126
|
-
|
|
127
|
-
super()._bind()
|
|
128
|
-
self._bind_request((Code._12A0, Code._31E0), callback=callback)
|
|
129
|
-
|
|
130
112
|
@property
|
|
131
|
-
def indoor_humidity(self) ->
|
|
113
|
+
def indoor_humidity(self) -> float | None: # 12A0
|
|
132
114
|
return self._msg_value(Code._12A0, key=SZ_INDOOR_HUMIDITY)
|
|
133
115
|
|
|
134
|
-
# @check_faking_enabled
|
|
135
116
|
@indoor_humidity.setter
|
|
136
|
-
def indoor_humidity(self, value) -> None:
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
self.
|
|
140
|
-
|
|
117
|
+
def indoor_humidity(self, value: float | None) -> None:
|
|
118
|
+
"""Fake the indoor humidity of the sensor."""
|
|
119
|
+
|
|
120
|
+
if not self.is_faked:
|
|
121
|
+
raise exc.DeviceNotFaked(f"{self}: Faking is not enabled")
|
|
122
|
+
|
|
123
|
+
cmd = Command.put_indoor_humidity(self.id, value)
|
|
124
|
+
self._gwy.send_cmd(cmd, num_repeats=2, priority=Priority.HIGH)
|
|
141
125
|
|
|
142
126
|
@property
|
|
143
127
|
def status(self) -> dict[str, Any]:
|
|
@@ -147,33 +131,26 @@ class IndoorHumidity(Fakeable, HvacSensorBase): # 12A0
|
|
|
147
131
|
}
|
|
148
132
|
|
|
149
133
|
|
|
150
|
-
class PresenceDetect(
|
|
134
|
+
class PresenceDetect(HvacSensorBase): # 2E10
|
|
151
135
|
"""The presence sensor (2E10/31E0)."""
|
|
152
136
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
# .I --- 37:154011 28:126620 --:------ 1FC9 001 00 # CO2, incl. integrated control, PIR
|
|
157
|
-
|
|
158
|
-
def callback(msg):
|
|
159
|
-
"""Use the accept pkt to determine the zone/domain id."""
|
|
160
|
-
_ = msg.payload[SZ_BINDINGS][0][0]
|
|
161
|
-
# self.set_parent(msg.src, child_id=child_id, is_sensor=True)
|
|
162
|
-
|
|
163
|
-
super()._bind()
|
|
164
|
-
self._bind_request((Code._2E10, Code._31E0), callback=callback)
|
|
137
|
+
# .I --- 37:154011 --:------ 37:154011 1FC9 030 00-31E0-96599B 00-1298-96599B 00-2E10-96599B 01-10E0-96599B 00-1FC9-96599B # CO2, idx|10E0 == 01
|
|
138
|
+
# .W --- 28:126620 37:154011 --:------ 1FC9 012 00-31D9-49EE9C 00-31DA-49EE9C # FAN, BRDG-02A55
|
|
139
|
+
# .I --- 37:154011 28:126620 --:------ 1FC9 001 00 # CO2, incl. integrated control, PIR
|
|
165
140
|
|
|
166
141
|
@property
|
|
167
|
-
def presence_detected(self) ->
|
|
142
|
+
def presence_detected(self) -> bool | None:
|
|
168
143
|
return self._msg_value(Code._2E10, key=SZ_PRESENCE_DETECTED)
|
|
169
144
|
|
|
170
|
-
# @check_faking_enabled
|
|
171
145
|
@presence_detected.setter
|
|
172
|
-
def presence_detected(self, value) -> None:
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
self.
|
|
176
|
-
|
|
146
|
+
def presence_detected(self, value: bool | None) -> None:
|
|
147
|
+
"""Fake the presence state of the sensor."""
|
|
148
|
+
|
|
149
|
+
if not self.is_faked:
|
|
150
|
+
raise exc.DeviceNotFaked(f"{self}: Faking is not enabled")
|
|
151
|
+
|
|
152
|
+
cmd = Command.put_presence_detected(self.id, value)
|
|
153
|
+
self._gwy.send_cmd(cmd, num_repeats=2, priority=Priority.HIGH)
|
|
177
154
|
|
|
178
155
|
@property
|
|
179
156
|
def status(self) -> dict[str, Any]:
|
|
@@ -194,16 +171,24 @@ class FilterChange(DeviceHvac): # FAN: 10D0
|
|
|
194
171
|
)
|
|
195
172
|
|
|
196
173
|
@property
|
|
197
|
-
def filter_remaining(self) ->
|
|
198
|
-
|
|
174
|
+
def filter_remaining(self) -> int | None:
|
|
175
|
+
_val = self._msg_value(Code._10D0, key=SZ_REMAINING_DAYS)
|
|
176
|
+
assert isinstance(_val, (int | type(None)))
|
|
177
|
+
return _val
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def filter_remaining_percent(self) -> float | None:
|
|
181
|
+
_val = self._msg_value(Code._10D0, key=SZ_REMAINING_PERCENT)
|
|
182
|
+
assert isinstance(_val, (float | type(None)))
|
|
183
|
+
return _val
|
|
199
184
|
|
|
200
185
|
|
|
201
186
|
class RfsGateway(DeviceHvac): # RFS: (spIDer gateway)
|
|
202
187
|
"""The spIDer gateway base class."""
|
|
203
188
|
|
|
204
|
-
_SLUG: str =
|
|
189
|
+
_SLUG: str = DevType.RFS
|
|
205
190
|
|
|
206
|
-
def __init__(self, *args, **kwargs) -> None:
|
|
191
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
207
192
|
super().__init__(*args, **kwargs)
|
|
208
193
|
|
|
209
194
|
self.ctl = None
|
|
@@ -211,20 +196,20 @@ class RfsGateway(DeviceHvac): # RFS: (spIDer gateway)
|
|
|
211
196
|
self.tcs = None
|
|
212
197
|
|
|
213
198
|
|
|
214
|
-
class HvacHumiditySensor(BatteryState, IndoorHumidity): # HUM: I/12A0
|
|
199
|
+
class HvacHumiditySensor(BatteryState, IndoorHumidity, Fakeable): # HUM: I/12A0
|
|
215
200
|
"""The class for a humidity sensor.
|
|
216
201
|
|
|
217
202
|
The cardinal code is 12A0.
|
|
218
203
|
"""
|
|
219
204
|
|
|
220
|
-
_SLUG: str =
|
|
205
|
+
_SLUG: str = DevType.HUM
|
|
221
206
|
|
|
222
207
|
@property
|
|
223
|
-
def temperature(self) ->
|
|
208
|
+
def temperature(self) -> float | None: # Celsius
|
|
224
209
|
return self._msg_value(Code._12A0, key=SZ_TEMPERATURE)
|
|
225
210
|
|
|
226
211
|
@property
|
|
227
|
-
def dewpoint_temp(self) ->
|
|
212
|
+
def dewpoint_temp(self) -> float | None: # Celsius
|
|
228
213
|
return self._msg_value(Code._12A0, key="dewpoint_temp")
|
|
229
214
|
|
|
230
215
|
@property
|
|
@@ -236,13 +221,22 @@ class HvacHumiditySensor(BatteryState, IndoorHumidity): # HUM: I/12A0
|
|
|
236
221
|
}
|
|
237
222
|
|
|
238
223
|
|
|
239
|
-
class HvacCarbonDioxideSensor(CarbonDioxide): # CO2: I/1298
|
|
224
|
+
class HvacCarbonDioxideSensor(CarbonDioxide, Fakeable): # CO2: I/1298
|
|
240
225
|
"""The class for a CO2 sensor.
|
|
241
226
|
|
|
242
227
|
The cardinal code is 1298.
|
|
243
228
|
"""
|
|
244
229
|
|
|
245
|
-
_SLUG: str =
|
|
230
|
+
_SLUG: str = DevType.CO2
|
|
231
|
+
|
|
232
|
+
# .I --- 29:181813 63:262142 --:------ 1FC9 030 00-31E0-76C635 01-31E0-76C635 00-1298-76C635 67-10E0-76C635 00-1FC9-76C635
|
|
233
|
+
# .W --- 32:155617 29:181813 --:------ 1FC9 012 00-31D9-825FE1 00-31DA-825FE1 # The HRU
|
|
234
|
+
# .I --- 29:181813 32:155617 --:------ 1FC9 001 00
|
|
235
|
+
|
|
236
|
+
async def initiate_binding_process(self) -> Packet:
|
|
237
|
+
return await super()._initiate_binding_process(
|
|
238
|
+
(Code._31E0, Code._1298, Code._2E10)
|
|
239
|
+
)
|
|
246
240
|
|
|
247
241
|
|
|
248
242
|
class HvacRemote(BatteryState, Fakeable, HvacRemoteBase): # REM: I/22F[138]
|
|
@@ -251,48 +245,48 @@ class HvacRemote(BatteryState, Fakeable, HvacRemoteBase): # REM: I/22F[138]
|
|
|
251
245
|
The cardinal codes are 22F1, 22F3 (also 22F8?).
|
|
252
246
|
"""
|
|
253
247
|
|
|
254
|
-
_SLUG: str =
|
|
248
|
+
_SLUG: str = DevType.REM
|
|
255
249
|
|
|
256
|
-
def
|
|
257
|
-
# .I --- 37:155617 --:------ 37:155617 1FC9 024
|
|
258
|
-
# .W --- 32:155617 37:155617 --:------ 1FC9 012
|
|
250
|
+
async def initiate_binding_process(self) -> Packet:
|
|
251
|
+
# .I --- 37:155617 --:------ 37:155617 1FC9 024 00-22F1-965FE1 00-22F3-965FE1 67-10E09-65FE1 00-1FC9-965FE1
|
|
252
|
+
# .W --- 32:155617 37:155617 --:------ 1FC9 012 00-31D9-825FE1 00-31DA-825FE1
|
|
259
253
|
# .I --- 37:155617 32:155617 --:------ 1FC9 001 00
|
|
260
254
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
# self.set_parent(msg.src, child_id=child_id, is_sensor=True)
|
|
265
|
-
|
|
266
|
-
super()._bind()
|
|
267
|
-
self._bind_request((Code._22F1, Code._22F3), callback=callback)
|
|
255
|
+
return await super()._initiate_binding_process(
|
|
256
|
+
Code._22F1 if self._scheme == "nuaire" else (Code._22F1, Code._22F3)
|
|
257
|
+
)
|
|
268
258
|
|
|
269
259
|
@property
|
|
270
|
-
def fan_rate(self) ->
|
|
260
|
+
def fan_rate(self) -> str | None: # 22F1
|
|
261
|
+
# NOTE: WIP: rate can be int or str
|
|
271
262
|
return self._msg_value(Code._22F1, key="rate")
|
|
272
263
|
|
|
273
|
-
# @check_faking_enabled
|
|
274
264
|
@fan_rate.setter
|
|
275
|
-
def fan_rate(self,
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
265
|
+
def fan_rate(self, value: int) -> None: # NOTE: value can be int or str, not None
|
|
266
|
+
"""Fake a fan rate from a remote (to a FAN, is a WIP)."""
|
|
267
|
+
|
|
268
|
+
if not self.is_faked: # NOTE: some remotes are stateless (i.e. except seqn)
|
|
269
|
+
raise exc.DeviceNotFaked(f"{self}: Faking is not enabled")
|
|
270
|
+
|
|
271
|
+
# TODO: num_repeats=2, or wait_for_reply=True ?
|
|
272
|
+
|
|
273
|
+
# NOTE: this is not completely understood (i.e. diffs between vendor schemes)
|
|
274
|
+
cmd = Command.set_fan_mode(self.id, int(4 * value), src_id=self.id)
|
|
275
|
+
self._gwy.send_cmd(cmd, num_repeats=2, priority=Priority.HIGH)
|
|
282
276
|
|
|
283
277
|
@property
|
|
284
|
-
def fan_mode(self) ->
|
|
285
|
-
return self._msg_value(Code._22F1, key=
|
|
278
|
+
def fan_mode(self) -> str | None:
|
|
279
|
+
return self._msg_value(Code._22F1, key=SZ_FAN_MODE)
|
|
286
280
|
|
|
287
281
|
@property
|
|
288
|
-
def boost_timer(self) ->
|
|
282
|
+
def boost_timer(self) -> int | None:
|
|
289
283
|
return self._msg_value(Code._22F3, key=SZ_BOOST_TIMER)
|
|
290
284
|
|
|
291
285
|
@property
|
|
292
286
|
def status(self) -> dict[str, Any]:
|
|
293
287
|
return {
|
|
294
288
|
**super().status,
|
|
295
|
-
|
|
289
|
+
SZ_FAN_MODE: self.fan_mode,
|
|
296
290
|
SZ_BOOST_TIMER: self.boost_timer,
|
|
297
291
|
}
|
|
298
292
|
|
|
@@ -300,23 +294,33 @@ class HvacRemote(BatteryState, Fakeable, HvacRemoteBase): # REM: I/22F[138]
|
|
|
300
294
|
class HvacDisplayRemote(HvacRemote): # DIS
|
|
301
295
|
"""The DIS (display switch)."""
|
|
302
296
|
|
|
303
|
-
_SLUG: str =
|
|
297
|
+
_SLUG: str = DevType.DIS
|
|
298
|
+
|
|
299
|
+
# async def initiate_binding_process(self) -> Packet:
|
|
300
|
+
# return await super()._initiate_binding_process(
|
|
301
|
+
# (Code._31E0, Code._1298, Code._2E10)
|
|
302
|
+
# )
|
|
304
303
|
|
|
305
304
|
|
|
306
305
|
class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
|
|
307
306
|
"""The FAN (ventilation) class.
|
|
308
307
|
|
|
309
|
-
The cardinal
|
|
308
|
+
The cardinal codes are 31D9, 31DA. Signature is RP/31DA.
|
|
310
309
|
"""
|
|
311
310
|
|
|
312
311
|
# Itho Daalderop (NL)
|
|
313
312
|
# Heatrae Sadia (UK)
|
|
314
313
|
# Nuaire (UK), e.g. DRI-ECO-PIV
|
|
315
314
|
# Orcon/Ventiline
|
|
315
|
+
# ClimaRad (NL)
|
|
316
|
+
# Vasco (B)
|
|
316
317
|
|
|
317
|
-
_SLUG: str =
|
|
318
|
+
_SLUG: str = DevType.FAN
|
|
318
319
|
|
|
319
|
-
def
|
|
320
|
+
def _handle_msg(self, *args: Any, **kwargs: Any) -> None:
|
|
321
|
+
return super()._handle_msg(*args, **kwargs)
|
|
322
|
+
|
|
323
|
+
def _update_schema(self, **schema: Any) -> None:
|
|
320
324
|
"""Update a FAN with new schema attrs.
|
|
321
325
|
|
|
322
326
|
Raise an exception if the new schema is not a superset of the existing schema.
|
|
@@ -324,11 +328,11 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
|
|
|
324
328
|
|
|
325
329
|
schema = shrink(SCH_VCS(schema))
|
|
326
330
|
|
|
327
|
-
for dev_id in schema.get(SZ_REMOTES, {})
|
|
328
|
-
self._gwy.get_device(
|
|
331
|
+
for dev_id in schema.get(SZ_REMOTES, {}):
|
|
332
|
+
self._gwy.get_device(dev_id)
|
|
329
333
|
|
|
330
|
-
for dev_id in schema.get(SZ_SENSORS, {})
|
|
331
|
-
self._gwy.get_device(
|
|
334
|
+
for dev_id in schema.get(SZ_SENSORS, {}):
|
|
335
|
+
self._gwy.get_device(dev_id)
|
|
332
336
|
|
|
333
337
|
def _setup_discovery_cmds(self) -> None:
|
|
334
338
|
super()._setup_discovery_cmds()
|
|
@@ -357,80 +361,173 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
|
|
|
357
361
|
)
|
|
358
362
|
|
|
359
363
|
@property
|
|
360
|
-
def air_quality(self) ->
|
|
364
|
+
def air_quality(self) -> float | None:
|
|
361
365
|
return self._msg_value(Code._31DA, key=SZ_AIR_QUALITY)
|
|
362
366
|
|
|
363
367
|
@property
|
|
364
|
-
def air_quality_base(self) ->
|
|
365
|
-
return self._msg_value(Code._31DA, key=
|
|
368
|
+
def air_quality_base(self) -> float | None:
|
|
369
|
+
return self._msg_value(Code._31DA, key=SZ_AIR_QUALITY_BASIS)
|
|
366
370
|
|
|
367
371
|
@property
|
|
368
|
-
def
|
|
369
|
-
|
|
372
|
+
def bypass_mode(self) -> str | None:
|
|
373
|
+
"""
|
|
374
|
+
:return: bypass mode as on|off|auto
|
|
375
|
+
"""
|
|
376
|
+
return self._msg_value(Code._22F7, key=SZ_BYPASS_MODE)
|
|
370
377
|
|
|
371
378
|
@property
|
|
372
|
-
def
|
|
373
|
-
|
|
379
|
+
def bypass_position(self) -> float | str | None:
|
|
380
|
+
"""
|
|
381
|
+
Position info is found in 22F7 and in 31DA. The most recent packet is returned.
|
|
382
|
+
:return: bypass position as percentage: 0.0 (closed) or 1.0 (open), on error: "x_faulted"
|
|
383
|
+
"""
|
|
384
|
+
# if both packets exist and both have the key, returns the most recent
|
|
385
|
+
return self._msg_value([Code._22F7, Code._31DA], key=SZ_BYPASS_POSITION)
|
|
386
|
+
|
|
387
|
+
@property
|
|
388
|
+
def bypass_state(self) -> str | None:
|
|
389
|
+
"""
|
|
390
|
+
Orcon, others?
|
|
391
|
+
:return: bypass position as on/off
|
|
392
|
+
"""
|
|
393
|
+
return self._msg_value(Code._22F7, key=SZ_BYPASS_STATE)
|
|
374
394
|
|
|
375
395
|
@property
|
|
376
|
-
def
|
|
377
|
-
return self._msg_value(Code._31DA, key=
|
|
396
|
+
def co2_level(self) -> int | None:
|
|
397
|
+
return self._msg_value(Code._31DA, key=SZ_CO2_LEVEL)
|
|
378
398
|
|
|
379
399
|
@property
|
|
380
|
-
def
|
|
400
|
+
def exhaust_fan_speed(
|
|
401
|
+
self,
|
|
402
|
+
) -> float | None:
|
|
403
|
+
"""
|
|
404
|
+
Some fans (Vasco, Itho) use Code._31D9 for speed + mode,
|
|
405
|
+
Orcon sends SZ_EXHAUST_FAN_SPEED in 31DA. See parser for details.
|
|
406
|
+
:return: speed as percentage
|
|
407
|
+
"""
|
|
408
|
+
speed: float = -1
|
|
409
|
+
for code in [c for c in (Code._31D9, Code._31DA) if c in self._msgs]:
|
|
410
|
+
if v := self._msgs[code].payload.get(SZ_EXHAUST_FAN_SPEED):
|
|
411
|
+
# if both packets exist and both have the key, use the highest value
|
|
412
|
+
if v is not None:
|
|
413
|
+
speed = max(v, speed)
|
|
414
|
+
if speed >= 0:
|
|
415
|
+
return speed
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
@property
|
|
419
|
+
def exhaust_flow(self) -> float | None:
|
|
381
420
|
return self._msg_value(Code._31DA, key=SZ_EXHAUST_FLOW)
|
|
382
421
|
|
|
383
422
|
@property
|
|
384
|
-
def
|
|
385
|
-
return self._msg_value(Code._31DA, key=
|
|
423
|
+
def exhaust_temp(self) -> float | None:
|
|
424
|
+
return self._msg_value(Code._31DA, key=SZ_EXHAUST_TEMP)
|
|
386
425
|
|
|
387
426
|
@property
|
|
388
|
-
def
|
|
389
|
-
|
|
427
|
+
def fan_rate(self) -> str | None:
|
|
428
|
+
"""
|
|
429
|
+
Lookup fan mode description from _22F4 message payload, e.g. "low", "medium", "boost".
|
|
430
|
+
For manufacturers Orcon, Vasco, ClimaRad.
|
|
431
|
+
|
|
432
|
+
:return: int or str describing rate of fan
|
|
433
|
+
"""
|
|
434
|
+
return self._msg_value(Code._22F4, key=SZ_FAN_RATE)
|
|
390
435
|
|
|
391
436
|
@property
|
|
392
|
-
def
|
|
393
|
-
|
|
437
|
+
def fan_mode(self) -> str | None:
|
|
438
|
+
"""
|
|
439
|
+
Lookup fan mode description from _22F4 message payload, e.g. "auto", "manual", "off".
|
|
440
|
+
For manufacturers Orcon, Vasco, ClimaRad.
|
|
441
|
+
|
|
442
|
+
:return: a string describing mode
|
|
443
|
+
"""
|
|
444
|
+
return self._msg_value(Code._22F4, key=SZ_FAN_MODE)
|
|
394
445
|
|
|
395
446
|
@property
|
|
396
|
-
def
|
|
397
|
-
|
|
447
|
+
def fan_info(self) -> str | None:
|
|
448
|
+
"""
|
|
449
|
+
Extract fan info description from _31D9 or _31DA message payload, e.g. "speed 2, medium".
|
|
450
|
+
By its name, the result is automatically displayed in HA Climate UI.
|
|
451
|
+
Some manufacturers (Orcon, Vasco) include the fan mode (auto, manual), others don't (Itho).
|
|
452
|
+
|
|
453
|
+
:return: a string describing mode, speed
|
|
454
|
+
"""
|
|
455
|
+
if Code._31D9 in self._msgs:
|
|
456
|
+
# Itho, Vasco D60 and ClimaRad (MiniBox fan) send mode/speed in _31D9
|
|
457
|
+
v: str
|
|
458
|
+
for k, v in self._msgs[Code._31D9].payload.items():
|
|
459
|
+
if k == SZ_FAN_MODE and len(v) > 2: # prevent non-lookups to pass
|
|
460
|
+
return v
|
|
461
|
+
# continue to 31DA
|
|
462
|
+
return str(self._msg_value(Code._31DA, key=SZ_FAN_INFO)) # Itho lookup
|
|
398
463
|
|
|
399
464
|
@property
|
|
400
|
-
def
|
|
465
|
+
def indoor_humidity(self) -> float | None:
|
|
466
|
+
"""
|
|
467
|
+
Extract humidity value from _12A0 or _31DA JSON message payload
|
|
468
|
+
|
|
469
|
+
:return: percentage <= 1.0
|
|
470
|
+
"""
|
|
471
|
+
if Code._12A0 in self._msgs and isinstance(
|
|
472
|
+
self._msgs[Code._12A0].payload, list
|
|
473
|
+
): # FAN Ventura sends RH/temps as a list; element [0] contains indoor_hum
|
|
474
|
+
if v := self._msgs[Code._12A0].payload[0].get(SZ_INDOOR_HUMIDITY):
|
|
475
|
+
assert isinstance(v, (float | type(None)))
|
|
476
|
+
return v
|
|
477
|
+
return self._msg_value([Code._12A0, Code._31DA], key=SZ_INDOOR_HUMIDITY)
|
|
478
|
+
|
|
479
|
+
@property
|
|
480
|
+
def indoor_temp(self) -> float | None:
|
|
481
|
+
return self._msg_value(Code._31DA, key=SZ_INDOOR_TEMP)
|
|
482
|
+
|
|
483
|
+
@property
|
|
484
|
+
def outdoor_humidity(self) -> float | None:
|
|
485
|
+
if Code._12A0 in self._msgs and isinstance(
|
|
486
|
+
self._msgs[Code._12A0].payload, list
|
|
487
|
+
): # FAN Ventura sends RH/temps as a list; element [1] contains outdoor_hum
|
|
488
|
+
if v := self._msgs[Code._12A0].payload[1].get(SZ_OUTDOOR_HUMIDITY):
|
|
489
|
+
assert isinstance(v, (float | type(None)))
|
|
490
|
+
return v
|
|
401
491
|
return self._msg_value(Code._31DA, key=SZ_OUTDOOR_HUMIDITY)
|
|
402
492
|
|
|
403
493
|
@property
|
|
404
|
-
def
|
|
405
|
-
return self._msg_value(Code._31DA, key=
|
|
494
|
+
def outdoor_temp(self) -> float | None:
|
|
495
|
+
return self._msg_value(Code._31DA, key=SZ_OUTDOOR_TEMP)
|
|
406
496
|
|
|
407
497
|
@property
|
|
408
|
-
def post_heat(self) ->
|
|
498
|
+
def post_heat(self) -> int | None:
|
|
409
499
|
return self._msg_value(Code._31DA, key=SZ_POST_HEAT)
|
|
410
500
|
|
|
411
501
|
@property
|
|
412
|
-
def pre_heat(self) ->
|
|
502
|
+
def pre_heat(self) -> int | None:
|
|
413
503
|
return self._msg_value(Code._31DA, key=SZ_PRE_HEAT)
|
|
414
504
|
|
|
415
505
|
@property
|
|
416
|
-
def
|
|
417
|
-
return self._msg_value(Code._31DA, key=
|
|
506
|
+
def remaining_mins(self) -> int | None:
|
|
507
|
+
return self._msg_value(Code._31DA, key=SZ_REMAINING_MINS)
|
|
418
508
|
|
|
419
509
|
@property
|
|
420
|
-
def speed_cap(self) ->
|
|
421
|
-
return self._msg_value(Code._31DA, key=
|
|
510
|
+
def speed_cap(self) -> int | None:
|
|
511
|
+
return self._msg_value(Code._31DA, key=SZ_SPEED_CAPABILITIES)
|
|
422
512
|
|
|
423
513
|
@property
|
|
424
|
-
def supply_fan_speed(self) ->
|
|
514
|
+
def supply_fan_speed(self) -> float | None:
|
|
425
515
|
return self._msg_value(Code._31DA, key=SZ_SUPPLY_FAN_SPEED)
|
|
426
516
|
|
|
427
517
|
@property
|
|
428
|
-
def supply_flow(self) ->
|
|
518
|
+
def supply_flow(self) -> float | None:
|
|
429
519
|
return self._msg_value(Code._31DA, key=SZ_SUPPLY_FLOW)
|
|
430
520
|
|
|
431
521
|
@property
|
|
432
|
-
def
|
|
433
|
-
|
|
522
|
+
def supply_temp(self) -> float | None:
|
|
523
|
+
if Code._12A0 in self._msgs and isinstance(
|
|
524
|
+
self._msgs[Code._12A0].payload, list
|
|
525
|
+
): # FAN Ventura sends RH/temps as a list;
|
|
526
|
+
# pass element [0] in place of supply_temp, which is always None in VenturaV1x 31DA
|
|
527
|
+
if v := self._msgs[Code._12A0].payload[1].get(SZ_TEMPERATURE):
|
|
528
|
+
assert isinstance(v, (float | type(None)))
|
|
529
|
+
return v
|
|
530
|
+
return self._msg_value(Code._31DA, key=SZ_SUPPLY_TEMP)
|
|
434
531
|
|
|
435
532
|
@property
|
|
436
533
|
def status(self) -> dict[str, Any]:
|
|
@@ -439,13 +536,23 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
|
|
|
439
536
|
SZ_EXHAUST_FAN_SPEED: self.exhaust_fan_speed,
|
|
440
537
|
**{
|
|
441
538
|
k: v
|
|
442
|
-
for code in (Code._31D9, Code._31DA)
|
|
539
|
+
for code in [c for c in (Code._31D9, Code._31DA) if c in self._msgs]
|
|
443
540
|
for k, v in self._msgs[code].payload.items()
|
|
444
|
-
if code in self._msgs
|
|
445
541
|
if k != SZ_EXHAUST_FAN_SPEED
|
|
446
542
|
},
|
|
447
543
|
}
|
|
448
544
|
|
|
545
|
+
@property
|
|
546
|
+
def temperature(self) -> float | None: # Celsius
|
|
547
|
+
if Code._12A0 in self._msgs and isinstance(
|
|
548
|
+
self._msgs[Code._12A0].payload, list
|
|
549
|
+
): # FAN Ventura sends RH/temps as a list; use element [1]
|
|
550
|
+
if v := self._msgs[Code._12A0].payload[0].get(SZ_TEMPERATURE):
|
|
551
|
+
assert isinstance(v, (float | type(None)))
|
|
552
|
+
return v
|
|
553
|
+
# ClimaRad minibox FAN sends (indoor) temp in 12A0
|
|
554
|
+
return self._msg_value(Code._12A0, key=SZ_TEMPERATURE)
|
|
555
|
+
|
|
449
556
|
|
|
450
557
|
# class HvacFanHru(HvacVentilator):
|
|
451
558
|
# """A Heat recovery unit (aka: HRU, WTW)."""
|
|
@@ -458,12 +565,13 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
|
|
|
458
565
|
# _SLUG: str = DEV_TYPE.PIV
|
|
459
566
|
|
|
460
567
|
|
|
461
|
-
|
|
568
|
+
# e.g. {"HUM": HvacHumiditySensor}
|
|
569
|
+
HVAC_CLASS_BY_SLUG: dict[str, type[DeviceHvac]] = class_by_attr(__name__, "_SLUG")
|
|
462
570
|
|
|
463
571
|
|
|
464
572
|
def class_dev_hvac(
|
|
465
|
-
dev_addr: Address, *, msg: Message = None, eavesdrop: bool = False
|
|
466
|
-
) -> type[
|
|
573
|
+
dev_addr: Address, *, msg: Message | None = None, eavesdrop: bool = False
|
|
574
|
+
) -> type[DeviceHvac]:
|
|
467
575
|
"""Return a device class, but only if the device must be from the HVAC group.
|
|
468
576
|
|
|
469
577
|
May return a base clase, DeviceHvac, which will need promotion.
|