ramses-rf 0.22.40__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 +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.1.dist-info/METADATA +72 -0
- ramses_rf-0.51.1.dist-info/RECORD +55 -0
- {ramses_rf-0.22.40.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.40.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_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/base.py
CHANGED
|
@@ -1,28 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
# -*- coding: utf-8 -*-
|
|
3
|
-
#
|
|
4
2
|
"""RAMSES RF - a RAMSES-II protocol decoder & analyser.
|
|
5
3
|
|
|
6
4
|
Base for all devices.
|
|
7
5
|
"""
|
|
6
|
+
|
|
8
7
|
from __future__ import annotations
|
|
9
8
|
|
|
10
9
|
import logging
|
|
11
|
-
from
|
|
12
|
-
from typing import
|
|
13
|
-
|
|
14
|
-
from
|
|
15
|
-
from
|
|
16
|
-
from
|
|
17
|
-
from
|
|
18
|
-
from
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from ramses_rf.binding_fsm import BindContext, Vendor
|
|
14
|
+
from ramses_rf.const import DEV_TYPE_MAP, SZ_OEM_CODE, DevType
|
|
15
|
+
from ramses_rf.entity_base import Child, Entity, class_by_attr
|
|
16
|
+
from ramses_rf.helpers import shrink
|
|
17
|
+
from ramses_rf.schemas import (
|
|
18
|
+
SCH_TRAITS,
|
|
19
|
+
SZ_ALIAS,
|
|
20
|
+
SZ_CLASS,
|
|
21
|
+
SZ_FAKED,
|
|
22
|
+
SZ_KNOWN_LIST,
|
|
23
|
+
SZ_SCHEME,
|
|
24
|
+
)
|
|
25
|
+
from ramses_tx import Command, Packet, Priority, QosParams
|
|
26
|
+
from ramses_tx.ramses import CODES_BY_DEV_SLUG, CODES_ONLY_FROM_CTL
|
|
27
|
+
|
|
28
|
+
from ramses_rf.const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
|
|
26
29
|
I_,
|
|
27
30
|
RP,
|
|
28
31
|
RQ,
|
|
@@ -30,30 +33,12 @@ from ..const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
|
|
|
30
33
|
Code,
|
|
31
34
|
)
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
#
|
|
40
|
-
# DHW/THM, TRV -> CTL (temp, valve_position), or:
|
|
41
|
-
# CTL -> BDR/OTB (heat_demand)
|
|
42
|
-
# [ REQUEST -> WAITING ]
|
|
43
|
-
# unbound -- unbound
|
|
44
|
-
# unbound -- listening
|
|
45
|
-
# offering -> listening
|
|
46
|
-
# offering <- accepting
|
|
47
|
-
# (confirming) bound -> accepting
|
|
48
|
-
# bound -- bound
|
|
49
|
-
#
|
|
50
|
-
UNKNOWN = ("None",)
|
|
51
|
-
UNBOUND = ("unb",) # unbound
|
|
52
|
-
LISTENING = ("l",) # waiting for offer
|
|
53
|
-
OFFERING = ("of",) # waiting for accept: -> sent offer
|
|
54
|
-
ACCEPTING = ("a",) # waiting for confirm: rcvd offer -> sent accept
|
|
55
|
-
# NFIRMED = "c", # bound: rcvd accept -> sent confirm
|
|
56
|
-
BOUND = ("bound",) # bound: rcvd confirm
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
from ramses_rf import Gateway
|
|
40
|
+
from ramses_rf.system import Zone
|
|
41
|
+
from ramses_tx import Address, DeviceIdT, IndexT, Message
|
|
57
42
|
|
|
58
43
|
|
|
59
44
|
BIND_WAITING_TIMEOUT = 300 # how long to wait, listening for an offer
|
|
@@ -61,42 +46,25 @@ BIND_REQUEST_TIMEOUT = 5 # how long to wait for an accept after sending an offe
|
|
|
61
46
|
BIND_CONFIRM_TIMEOUT = 5 # how long to wait for a confirm after sending an accept
|
|
62
47
|
|
|
63
48
|
|
|
64
|
-
DEV_MODE = __dev_mode__ # and False
|
|
65
|
-
|
|
66
49
|
_LOGGER = logging.getLogger(__name__)
|
|
67
|
-
if DEV_MODE:
|
|
68
|
-
_LOGGER.setLevel(logging.DEBUG)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def check_faking_enabled(fnc):
|
|
72
|
-
def wrapper(self, *args, **kwargs):
|
|
73
|
-
if not self._faked:
|
|
74
|
-
raise RuntimeError(f"Faking is not enabled for {self}")
|
|
75
|
-
return fnc(self, *args, **kwargs)
|
|
76
|
-
|
|
77
|
-
return wrapper
|
|
78
50
|
|
|
79
51
|
|
|
80
52
|
class DeviceBase(Entity):
|
|
81
53
|
"""The Device base class - can also be used for unknown device types."""
|
|
82
54
|
|
|
83
|
-
_SLUG: str =
|
|
84
|
-
|
|
85
|
-
_STATE_ATTR: str = None # type: ignore[assignment]
|
|
55
|
+
_SLUG: str = DevType.DEV
|
|
56
|
+
_STATE_ATTR: str = None
|
|
86
57
|
|
|
87
|
-
|
|
88
|
-
_LOGGER.debug("Creating a Device: %s (%s)", dev_addr.id, self.__class__)
|
|
89
|
-
|
|
90
|
-
# if not check_valid(dev_addr.id): # TODO
|
|
91
|
-
# raise ValueError(f"Invalid device id: {dev_addr.id}")
|
|
92
|
-
if dev_addr.id in gwy.device_by_id:
|
|
93
|
-
raise LookupError(f"Duplicate DEV: {dev_addr.id}")
|
|
94
|
-
gwy.device_by_id[dev_addr.id] = self
|
|
95
|
-
gwy.devices.append(self)
|
|
58
|
+
_bind_context: BindContext | None = None
|
|
96
59
|
|
|
60
|
+
def __init__(self, gwy: Gateway, dev_addr: Address, **kwargs: Any) -> None:
|
|
97
61
|
super().__init__(gwy)
|
|
98
62
|
|
|
99
|
-
|
|
63
|
+
# FIXME: ZZZ entities must know their parent device ID and their own idx
|
|
64
|
+
self._z_id = dev_addr.id # the responsible device is itself
|
|
65
|
+
self._z_idx = None # depends upon it's location in the schema
|
|
66
|
+
|
|
67
|
+
self.id: DeviceIdT = dev_addr.id
|
|
100
68
|
|
|
101
69
|
# self.tcs = None # NOTE: Heat (CH/DHW) devices only
|
|
102
70
|
# self.ctl = None
|
|
@@ -105,19 +73,19 @@ class DeviceBase(Entity):
|
|
|
105
73
|
self.addr = dev_addr
|
|
106
74
|
self.type = dev_addr.type # DEX # TODO: remove this attr? use SLUG?
|
|
107
75
|
|
|
108
|
-
self.
|
|
76
|
+
self._scheme: Vendor = None
|
|
109
77
|
|
|
110
78
|
def __str__(self) -> str:
|
|
111
79
|
if self._STATE_ATTR:
|
|
112
80
|
return f"{self.id} ({self._SLUG}): {getattr(self, self._STATE_ATTR)}"
|
|
113
81
|
return f"{self.id} ({self._SLUG})"
|
|
114
82
|
|
|
115
|
-
def __lt__(self, other) -> bool:
|
|
83
|
+
def __lt__(self, other: object) -> bool:
|
|
116
84
|
if not hasattr(other, "id"):
|
|
117
85
|
return NotImplemented
|
|
118
|
-
return self.id < other.id
|
|
86
|
+
return self.id < other.id # type: ignore[no-any-return]
|
|
119
87
|
|
|
120
|
-
def _update_traits(self, **traits):
|
|
88
|
+
def _update_traits(self, **traits: Any) -> None:
|
|
121
89
|
"""Update a device with new schema attrs.
|
|
122
90
|
|
|
123
91
|
Raise an exception if the new schema is not a superset of the existing schema.
|
|
@@ -130,8 +98,12 @@ class DeviceBase(Entity):
|
|
|
130
98
|
raise TypeError(f"Device is not fakable: {self} (traits={traits})")
|
|
131
99
|
self._make_fake()
|
|
132
100
|
|
|
101
|
+
self._scheme = traits.get(SZ_SCHEME)
|
|
102
|
+
|
|
133
103
|
@classmethod
|
|
134
|
-
def create_from_schema(
|
|
104
|
+
def create_from_schema(
|
|
105
|
+
cls, gwy: Gateway, dev_addr: Address, **schema: Any
|
|
106
|
+
) -> DeviceBase:
|
|
135
107
|
"""Create a device (for a GWY) and set its schema attrs (aka traits).
|
|
136
108
|
|
|
137
109
|
All devices have traits, but also controllers (CTL, UFC) have a system schema.
|
|
@@ -149,41 +121,38 @@ class DeviceBase(Entity):
|
|
|
149
121
|
# sometimes, battery-powered devices will respond to an RQ (e.g. bind mode)
|
|
150
122
|
|
|
151
123
|
# if discover_flag & Discover.TRAITS:
|
|
152
|
-
# self._add_discovery_cmd(
|
|
153
|
-
# self._add_discovery_cmd(
|
|
124
|
+
# self._add_discovery_cmd(cmd(RQ, Code._1FC9, "00", self.id), 60 * 60 * 24)
|
|
125
|
+
# self._add_discovery_cmd(cmd(RQ, Code._0016, "00", self.id), 60 * 60)
|
|
154
126
|
|
|
155
127
|
pass
|
|
156
128
|
|
|
157
|
-
def
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def _send_cmd(self, cmd, **kwargs) -> None:
|
|
161
|
-
if getattr(self, "has_battery", None) and cmd.dst.id == self.id:
|
|
129
|
+
def _send_cmd(self, cmd: Command, **kwargs: Any) -> None:
|
|
130
|
+
if self.has_battery and not self.is_faked and cmd.dst.id == self.id:
|
|
162
131
|
_LOGGER.info(f"{cmd} < Sending inadvisable for {self} (it has a battery)")
|
|
163
132
|
|
|
164
133
|
super()._send_cmd(cmd, **kwargs)
|
|
165
134
|
|
|
166
135
|
def _handle_msg(self, msg: Message) -> None:
|
|
167
|
-
assert msg.src is self or (
|
|
168
|
-
|
|
169
|
-
), f"msg from {msg.src} inappropriately routed to {self}"
|
|
136
|
+
# # assert msg.src is self or (
|
|
137
|
+
# # msg.code == Code._1FC9 and msg.payload[SZ_PHASE] == SZ_OFFER
|
|
138
|
+
# # ), f"msg from {msg.src} inappropriately routed to {self}"
|
|
170
139
|
|
|
171
140
|
super()._handle_msg(msg)
|
|
172
141
|
|
|
173
|
-
if self._SLUG in DEV_TYPE_MAP.PROMOTABLE_SLUGS:
|
|
174
|
-
# HACK: can get precise class?
|
|
142
|
+
if self._SLUG in DEV_TYPE_MAP.PROMOTABLE_SLUGS: # HACK: can get precise class?
|
|
175
143
|
from . import best_dev_role
|
|
176
144
|
|
|
177
145
|
cls = best_dev_role(
|
|
178
146
|
self.addr, msg=msg, eavesdrop=self._gwy.config.enable_eavesdrop
|
|
179
147
|
)
|
|
180
|
-
|
|
148
|
+
|
|
149
|
+
if cls._SLUG in (DevType.DEV, self._SLUG):
|
|
181
150
|
return # either a demotion (DEV), or not promotion (HEA/HVC)
|
|
182
151
|
|
|
183
|
-
if self._SLUG ==
|
|
152
|
+
if self._SLUG == DevType.HEA and cls._SLUG in DEV_TYPE_MAP.HVAC_SLUGS:
|
|
184
153
|
return # TODO: should raise error if CODES_OF_HVAC_DOMAIN_ONLY?
|
|
185
154
|
|
|
186
|
-
if self._SLUG ==
|
|
155
|
+
if self._SLUG == DevType.HVC and cls._SLUG not in DEV_TYPE_MAP.HVAC_SLUGS:
|
|
187
156
|
return # TODO: should raise error if CODES_OF_HEAT_DOMAIN_ONLY?
|
|
188
157
|
|
|
189
158
|
_LOGGER.warning(
|
|
@@ -195,13 +164,21 @@ class DeviceBase(Entity):
|
|
|
195
164
|
|
|
196
165
|
@property
|
|
197
166
|
def has_battery(self) -> None | bool: # 1060
|
|
198
|
-
"""Return True if
|
|
167
|
+
"""Return True if the device is battery powered (excludes battery-backup)."""
|
|
199
168
|
|
|
200
169
|
return isinstance(self, BatteryState) or Code._1060 in self._msgz
|
|
201
170
|
|
|
202
171
|
@property
|
|
203
|
-
def is_faked(self) -> bool:
|
|
204
|
-
|
|
172
|
+
def is_faked(self) -> bool:
|
|
173
|
+
"""Return True if the device is faked."""
|
|
174
|
+
|
|
175
|
+
return bool(self._bind_context) # isinstance(self, Fakeable) and...
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def _is_binding(self) -> bool:
|
|
179
|
+
"""Return True if the (faked) device is actively binding."""
|
|
180
|
+
|
|
181
|
+
return self._bind_context and self._bind_context.is_binding
|
|
205
182
|
|
|
206
183
|
@property
|
|
207
184
|
def _is_present(self) -> bool:
|
|
@@ -245,19 +222,18 @@ class DeviceBase(Entity):
|
|
|
245
222
|
|
|
246
223
|
|
|
247
224
|
class BatteryState(DeviceBase): # 1060
|
|
248
|
-
|
|
249
225
|
BATTERY_LOW = "battery_low" # boolean
|
|
250
226
|
BATTERY_STATE = "battery_state" # percentage (0.0-1.0)
|
|
251
227
|
|
|
252
228
|
@property
|
|
253
229
|
def battery_low(self) -> None | bool: # 1060
|
|
254
|
-
if self.
|
|
230
|
+
if self.is_faked:
|
|
255
231
|
return False
|
|
256
232
|
return self._msg_value(Code._1060, key=self.BATTERY_LOW)
|
|
257
233
|
|
|
258
234
|
@property
|
|
259
|
-
def battery_state(self) ->
|
|
260
|
-
if self.
|
|
235
|
+
def battery_state(self) -> dict[str, Any] | None: # 1060
|
|
236
|
+
if self.is_faked:
|
|
261
237
|
return None
|
|
262
238
|
return self._msg_value(Code._1060)
|
|
263
239
|
|
|
@@ -277,12 +253,11 @@ class DeviceInfo(DeviceBase): # 10E0
|
|
|
277
253
|
if self._SLUG not in CODES_BY_DEV_SLUG or RP in CODES_BY_DEV_SLUG[
|
|
278
254
|
self._SLUG
|
|
279
255
|
].get(Code._10E0, {}):
|
|
280
|
-
self.
|
|
281
|
-
|
|
282
|
-
)
|
|
256
|
+
cmd = Command.from_attrs(RQ, self.id, Code._10E0, "00")
|
|
257
|
+
self._add_discovery_cmd(cmd, 60 * 60 * 24)
|
|
283
258
|
|
|
284
259
|
@property
|
|
285
|
-
def device_info(self) ->
|
|
260
|
+
def device_info(self) -> dict | None: # 10E0
|
|
286
261
|
return self._msg_value(Code._10E0)
|
|
287
262
|
|
|
288
263
|
@property
|
|
@@ -299,6 +274,7 @@ class DeviceInfo(DeviceBase): # 10E0
|
|
|
299
274
|
return result
|
|
300
275
|
|
|
301
276
|
|
|
277
|
+
# NOTE: devices (Thermostat) not attrs (Temperature) are faked
|
|
302
278
|
class Fakeable(DeviceBase):
|
|
303
279
|
"""There are two types of Faking: impersonation (of real devices) and full-faking.
|
|
304
280
|
|
|
@@ -310,314 +286,149 @@ class Fakeable(DeviceBase):
|
|
|
310
286
|
such packets via RF.
|
|
311
287
|
"""
|
|
312
288
|
|
|
313
|
-
|
|
314
|
-
# STA set to BindState.OFFERING: sends "1FC9| I|63:262142" to the ether (the controller is listening)
|
|
315
|
-
# - receives "1FC9| W|34:021943" to: Fakable._bind_request().proc_accept() via callback?
|
|
316
|
-
# STA set to BindState.ACCEPTING: sends "1FC9| I|01:145038" to the ether (the controller is ignoring?)
|
|
317
|
-
# - receives "1FC9| I|01:145038" to: Fakable._bind_request().proc_confirm() via callback -- OPTIONAL!!
|
|
318
|
-
# STA set to BindState.BOUND via callback, and...
|
|
319
|
-
|
|
320
|
-
# Faked Evohome controller binding to a Round Thermostat as a zone sensor
|
|
321
|
-
# CTL set to BindState.LISTENING
|
|
322
|
-
# - receives "1FC9| I|63:262142" to: Fakable._bind_waiting().proc_offer() via callback?
|
|
323
|
-
# CTL set to BindState.ACCEPTING: sends "1FC9| W|34:021943" to the ether
|
|
324
|
-
# - receives "1FC9| I|01:145038" to: Fakable._bind_waiting().proc_confirm() via callback -- OPTIONAL!!
|
|
325
|
-
# CTL set to BindState.BOUND
|
|
326
|
-
|
|
327
|
-
def __init__(self, gwy, *args, **kwargs) -> None:
|
|
289
|
+
def __init__(self, gwy: Gateway, *args: Any, **kwargs: Any) -> None:
|
|
328
290
|
super().__init__(gwy, *args, **kwargs)
|
|
329
291
|
|
|
330
|
-
self.
|
|
331
|
-
|
|
332
|
-
self._1fc9_state: dict[str, Any] = {"state": BindState.UNKNOWN}
|
|
292
|
+
self._bind_context: BindContext | None = None
|
|
333
293
|
|
|
294
|
+
# TOD: this is messy - device schema vs device traits
|
|
334
295
|
if self.id in gwy._include and gwy._include[self.id].get(SZ_FAKED):
|
|
335
296
|
self._make_fake()
|
|
336
297
|
|
|
337
298
|
if kwargs.get(SZ_FAKED):
|
|
338
299
|
self._make_fake()
|
|
339
300
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
def _make_fake(self, bind=None) -> Fakeable:
|
|
345
|
-
if not self._faked:
|
|
346
|
-
self._faked = True
|
|
347
|
-
self._gwy._include[self.id] = {SZ_FAKED: True}
|
|
348
|
-
_LOGGER.info(f"Faking now enabled for: {self}") # TODO: be info/debug
|
|
349
|
-
if bind:
|
|
350
|
-
self._bind()
|
|
351
|
-
return self
|
|
352
|
-
|
|
353
|
-
def _bind_waiting(self, codes, idx="00", callback: Callable = None) -> None:
|
|
354
|
-
"""Wait for (listen for) a bind handshake."""
|
|
355
|
-
|
|
356
|
-
# Bind waiting: BDR set to listen, CTL initiates handshake
|
|
357
|
-
# 19:30:44.749 051 I --- 01:054173 --:------ 01:054173 1FC9 024 FC-0008-04D39D FC-3150-04D39D FB-3150-04D39D FC-1FC9-04D39D
|
|
358
|
-
# 19:30:45.342 053 W --- 13:049798 01:054173 --:------ 1FC9 012 00-3EF0-34C286 00-3B00-34C286
|
|
359
|
-
# 19:30:45.504 049 I --- 01:054173 13:049798 --:------ 1FC9 006 00-FFFF-04D39D
|
|
360
|
-
|
|
361
|
-
# Bind waiting: OTB set to listen, CTL initiates handshake
|
|
362
|
-
# 00:25:02.779 045 I --- 01:145038 --:------ 01:145038 1FC9 024 FC-0008-06368E FC-3150-06368E FB-3150-06368E FC-1FC9-06368E # opentherm bridge
|
|
363
|
-
# 00:25:02.792 045 W --- 10:048122 01:145038 --:------ 1FC9 006 00-3EF0-28BBFA
|
|
364
|
-
# 00:25:02.944 045 I --- 01:145038 10:048122 --:------ 1FC9 006 00-FFFF-06368E
|
|
365
|
-
|
|
366
|
-
_LOGGER.warning(f"Binding {self}: waiting for {codes} for 300 secs") # info
|
|
367
|
-
# SUPPORTED_CODES = (Code._0008,)
|
|
368
|
-
|
|
369
|
-
# assert code in SUPPORTED_CODES, f"Binding {self}: {code} is not supported"
|
|
370
|
-
self._1fc9_state["codes"] = codes
|
|
371
|
-
self._1fc9_state["state"] = BindState.LISTENING
|
|
372
|
-
self._1fc9_state["timeout"] = self._gwy._dt_now() + td(seconds=300)
|
|
373
|
-
|
|
374
|
-
def _bind_request(self, codes, callback: Callable = None) -> None:
|
|
375
|
-
"""Initiate a bind handshake: send the 1st packet of the handshake."""
|
|
376
|
-
|
|
377
|
-
# Bind request: CTL set to listen, STA initiates handshake (note 3C09/2309)
|
|
378
|
-
# 22:13:52.527 070 I --- 34:021943 --:------ 34:021943 1FC9 024 00-2309-8855B7 00-30C9-8855B7 00-0008-8855B7 00-1FC9-8855B7
|
|
379
|
-
# 22:13:52.540 052 W --- 01:145038 34:021943 --:------ 1FC9 006 00-2309-06368E
|
|
380
|
-
# 22:13:52.572 071 I --- 34:021943 01:145038 --:------ 1FC9 006 00-2309-8855B7
|
|
381
|
-
|
|
382
|
-
# Bind request: CTL set to listen, DHW sensor initiates handshake
|
|
383
|
-
# 19:45:16.733 045 I --- 07:045960 --:------ 07:045960 1FC9 012 00-1260-1CB388 00-1FC9-1CB388
|
|
384
|
-
# 19:45:16.896 045 W --- 01:054173 07:045960 --:------ 1FC9 006 00-10A0-04D39D
|
|
385
|
-
# 19:45:16.919 045 I --- 07:045960 01:054173 --:------ 1FC9 006 00-1260-1CB388
|
|
301
|
+
def _make_fake(self) -> None:
|
|
302
|
+
if self._bind_context:
|
|
303
|
+
return
|
|
386
304
|
|
|
387
|
-
|
|
305
|
+
self._bind_context = BindContext(self)
|
|
306
|
+
self._gwy._include[self.id][SZ_FAKED] = True # TODO: remove this
|
|
307
|
+
_LOGGER.info(f"Faking now enabled for: {self}")
|
|
388
308
|
|
|
389
|
-
|
|
390
|
-
|
|
309
|
+
async def _async_send_cmd(
|
|
310
|
+
self,
|
|
311
|
+
cmd: Command,
|
|
312
|
+
priority: Priority | None = None,
|
|
313
|
+
qos: QosParams | None = None,
|
|
314
|
+
) -> Packet | None:
|
|
315
|
+
"""Wrapper to CC: any relevant Commands to the binding Context."""
|
|
391
316
|
|
|
392
|
-
self.
|
|
393
|
-
|
|
394
|
-
|
|
317
|
+
if self._bind_context and self._bind_context.is_binding:
|
|
318
|
+
# cmd.code in (Code._1FC9, Code._10E0)
|
|
319
|
+
self._bind_context.sent_cmd(cmd) # other codes needed for edge cases
|
|
395
320
|
|
|
396
|
-
|
|
321
|
+
return await super()._async_send_cmd(cmd, priority=priority, qos=qos)
|
|
397
322
|
|
|
398
323
|
def _handle_msg(self, msg: Message) -> None:
|
|
399
|
-
|
|
400
|
-
"""Accept a valid offer (if listening).
|
|
401
|
-
|
|
402
|
-
If able to do so, update the binding state.
|
|
403
|
-
"""
|
|
404
|
-
assert msg and msg.payload["phase"] == "offer"
|
|
405
|
-
assert self._1fc9_state["state"] == BindState.LISTENING
|
|
406
|
-
|
|
407
|
-
if self._1fc9_state["timeout"] < self._gwy._dt_now():
|
|
408
|
-
return BindState.UNKNOWN
|
|
409
|
-
|
|
410
|
-
if msg.verb != I_ or msg.src is not msg.dst: # or msg.dst is self
|
|
411
|
-
return self._1fc9_state["state"]
|
|
412
|
-
|
|
413
|
-
cmd = Command.put_bind(
|
|
414
|
-
W_,
|
|
415
|
-
self._1fc9_state["codes"],
|
|
416
|
-
self.id,
|
|
417
|
-
idx="00", # self._1fc9_state["idx"],
|
|
418
|
-
dst_id=msg.src.id,
|
|
419
|
-
)
|
|
420
|
-
self._send_cmd(cmd)
|
|
421
|
-
|
|
422
|
-
self._1fc9_state["msg"] = msg # the offer
|
|
423
|
-
return BindState.ACCEPTING
|
|
424
|
-
|
|
425
|
-
def proc_accept_and_confirm(msg: Message): # process 2nd pkt of handshake
|
|
426
|
-
"""Confirm a valid accept (if offering).
|
|
427
|
-
|
|
428
|
-
If able to do so, update the binding state.
|
|
429
|
-
"""
|
|
430
|
-
assert msg and msg.payload["phase"] == "accept"
|
|
431
|
-
assert self._1fc9_state["state"] == BindState.OFFERING
|
|
324
|
+
"""Wrapper to CC: any relevant Packets to the binding Context."""
|
|
432
325
|
|
|
433
|
-
|
|
434
|
-
return BindState.UNKNOWN
|
|
435
|
-
|
|
436
|
-
if msg.verb != W_:
|
|
437
|
-
return self._1fc9_state["state"]
|
|
438
|
-
|
|
439
|
-
cmd = Command.put_bind(I_, None, self.id, dst_id=msg.src.id)
|
|
440
|
-
self._send_cmd(cmd)
|
|
441
|
-
|
|
442
|
-
self._1fc9_state["msg"] = msg # the offer
|
|
443
|
-
return BindState.BOUND
|
|
444
|
-
|
|
445
|
-
def proc_confirm_and_done(msg: Message): # . process 3rd pkt of handshake
|
|
446
|
-
"""Process a valid confirmation (if accepting).
|
|
447
|
-
|
|
448
|
-
If able to do so, update the binding state.
|
|
449
|
-
"""
|
|
450
|
-
assert msg and msg.payload["phase"] == "confirm"
|
|
451
|
-
assert self._1fc9_state["state"] == BindState.ACCEPTING
|
|
452
|
-
|
|
453
|
-
if self._1fc9_state["timeout"] < self._gwy._dt_now():
|
|
454
|
-
return BindState.BOUND # HACK?
|
|
326
|
+
super()._handle_msg(msg)
|
|
455
327
|
|
|
456
|
-
|
|
457
|
-
|
|
328
|
+
if self._bind_context and self._bind_context.is_binding:
|
|
329
|
+
# msg.code in (Code._1FC9, Code._10E0)
|
|
330
|
+
self._bind_context.rcvd_msg(msg) # maybe other codes needed for edge cases
|
|
331
|
+
|
|
332
|
+
async def _wait_for_binding_request(
|
|
333
|
+
self,
|
|
334
|
+
accept_codes: Iterable[Code],
|
|
335
|
+
/,
|
|
336
|
+
*,
|
|
337
|
+
idx: IndexT = "00",
|
|
338
|
+
require_ratify: bool = False,
|
|
339
|
+
) -> tuple[Packet, Packet, Packet, Packet | None]:
|
|
340
|
+
"""Listen for a binding and return the Offer, or raise an exception."""
|
|
341
|
+
|
|
342
|
+
if not self._bind_context:
|
|
343
|
+
raise TypeError(f"{self}: Faking not enabled")
|
|
344
|
+
|
|
345
|
+
msgs = await self._bind_context.wait_for_binding_request(
|
|
346
|
+
accept_codes, idx=idx, require_ratify=require_ratify
|
|
347
|
+
)
|
|
348
|
+
return msgs
|
|
349
|
+
|
|
350
|
+
async def wait_for_binding_request(
|
|
351
|
+
self,
|
|
352
|
+
accept_codes: Iterable[Code],
|
|
353
|
+
/,
|
|
354
|
+
*,
|
|
355
|
+
idx: IndexT = "00",
|
|
356
|
+
require_ratify: bool = False,
|
|
357
|
+
) -> tuple[Packet, Packet, Packet, Packet | None]:
|
|
358
|
+
raise NotImplementedError
|
|
458
359
|
|
|
459
|
-
|
|
460
|
-
|
|
360
|
+
async def _initiate_binding_process(
|
|
361
|
+
self,
|
|
362
|
+
offer_codes: Code | Iterable[Code],
|
|
363
|
+
/,
|
|
364
|
+
*,
|
|
365
|
+
confirm_code: Code | None = None,
|
|
366
|
+
ratify_cmd: Command | None = None,
|
|
367
|
+
) -> tuple[Packet, Packet, Packet, Packet | None]:
|
|
368
|
+
"""Start a binding and return the Accept, or raise an exception."""
|
|
369
|
+
# confirm_code can be FFFF.
|
|
370
|
+
|
|
371
|
+
if not self._bind_context:
|
|
372
|
+
raise TypeError(f"{self}: Faking not enabled")
|
|
373
|
+
|
|
374
|
+
if isinstance(offer_codes, Iterable):
|
|
375
|
+
codes: tuple[Code] = offer_codes
|
|
376
|
+
else:
|
|
377
|
+
codes = tuple([offer_codes])
|
|
378
|
+
|
|
379
|
+
msgs = await self._bind_context.initiate_binding_process(
|
|
380
|
+
codes, confirm_code=confirm_code, ratify_cmd=ratify_cmd
|
|
381
|
+
) # TODO: if successful, re-discover schema?
|
|
382
|
+
return msgs
|
|
383
|
+
|
|
384
|
+
async def initiate_binding_process(self) -> Packet:
|
|
385
|
+
raise NotImplementedError
|
|
461
386
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
387
|
+
@property
|
|
388
|
+
def oem_code(self) -> str | None:
|
|
389
|
+
"""Return the OEM code (a 2-char ascii str) for this device, if there is one."""
|
|
390
|
+
# raise NotImplementedError # self.traits is a @property
|
|
391
|
+
if not self.traits.get(SZ_OEM_CODE):
|
|
392
|
+
self.traits[SZ_OEM_CODE] = self._msg_value(Code._10E0, key=SZ_OEM_CODE)
|
|
393
|
+
return self.traits.get(SZ_OEM_CODE)
|
|
465
394
|
|
|
466
|
-
if msg.payload["phase"] == "offer":
|
|
467
|
-
if msg.src is self:
|
|
468
|
-
return
|
|
469
|
-
assert self._1fc9_state["state"] == BindState.LISTENING
|
|
470
|
-
self._1fc9_state["state"] = proc_offer_and_accept(msg)
|
|
471
395
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
return
|
|
475
|
-
assert self._1fc9_state["state"] == BindState.OFFERING
|
|
476
|
-
self._1fc9_state["state"] = proc_accept_and_confirm(msg)
|
|
396
|
+
class Device(Child, DeviceBase):
|
|
397
|
+
"""The base class for all devices."""
|
|
477
398
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
399
|
+
def __init__(self, gwy: Gateway, dev_addr: Address, **kwargs: Any) -> None:
|
|
400
|
+
_LOGGER.debug("Creating a Device: %s (%s)", dev_addr.id, self.__class__)
|
|
401
|
+
super().__init__(gwy, dev_addr)
|
|
481
402
|
|
|
482
|
-
|
|
403
|
+
gwy._add_device(self)
|
|
483
404
|
|
|
484
405
|
|
|
485
|
-
class HgiGateway(
|
|
406
|
+
class HgiGateway(Device): # HGI (18:)
|
|
486
407
|
"""The HGI80 base class."""
|
|
487
408
|
|
|
488
|
-
_SLUG: str =
|
|
409
|
+
_SLUG: str = DevType.HGI
|
|
489
410
|
|
|
490
|
-
def __init__(self, *args, **kwargs) -> None:
|
|
411
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
491
412
|
super().__init__(*args, **kwargs)
|
|
492
413
|
|
|
493
|
-
self.ctl = None
|
|
414
|
+
self.ctl = None # FIXME: a mess
|
|
494
415
|
self._child_id = "gw" # TODO
|
|
495
416
|
self.tcs = None
|
|
496
417
|
|
|
497
|
-
self._faked_bdr: Device = None # type: ignore[assignment]
|
|
498
|
-
self._faked_ext: Device = None # type: ignore[assignment]
|
|
499
|
-
self._faked_thm: Device = None # type: ignore[assignment]
|
|
500
|
-
|
|
501
|
-
# self. _proc_schema(**kwargs)
|
|
502
|
-
|
|
503
|
-
# def _proc_schema(self, schema) -> None:
|
|
504
|
-
# if schema.get("fake_bdr"):
|
|
505
|
-
# self._faked_bdr = self._gwy.get_device(
|
|
506
|
-
# self.id, class_=DEV_TYPE.BDR, faked=True
|
|
507
|
-
# ) # also for THM, OUT
|
|
508
|
-
|
|
509
|
-
def _handle_msg(self, msg: Message) -> None:
|
|
510
|
-
def fake_addrs(msg, faked_dev):
|
|
511
|
-
msg.src = faked_dev if msg.src is self else self
|
|
512
|
-
msg.dst = faked_dev if msg.dst is self else self
|
|
513
|
-
return msg
|
|
514
|
-
|
|
515
|
-
super()._handle_msg(msg)
|
|
516
|
-
|
|
517
|
-
# the following is for aliased devices (not fully-faked devices)
|
|
518
|
-
if msg.code in (Code._3EF0,) and self._faked_bdr:
|
|
519
|
-
self._faked_bdr._handle_msg(fake_addrs(msg, self._faked_bdr))
|
|
520
|
-
|
|
521
|
-
if msg.code in (Code._0002,) and self._faked_ext:
|
|
522
|
-
self._faked_ext._handle_msg(fake_addrs(msg, self._faked_ext))
|
|
523
|
-
|
|
524
|
-
if msg.code in (Code._30C9,) and self._faked_thm:
|
|
525
|
-
self._faked_thm._handle_msg(fake_addrs(msg, self._faked_thm))
|
|
526
|
-
|
|
527
|
-
def _create_fake_dev(self, dev_type, device_id) -> Device: # TODO:
|
|
528
|
-
if device_id[:2] != dev_type:
|
|
529
|
-
raise TypeError(f"Invalid device ID {device_id} for type '{dev_type}:'")
|
|
530
|
-
|
|
531
|
-
# dev = self.device_by_id.get(device_id)
|
|
532
|
-
# if dev: # TODO: BUG: is broken
|
|
533
|
-
# _LOGGER.warning("Destroying %s", dev)
|
|
534
|
-
# if dev._parent:
|
|
535
|
-
# del dev._parent.child_by_id[dev.id]
|
|
536
|
-
# dev._parent.childs.remove(dev)
|
|
537
|
-
# dev._parent = None
|
|
538
|
-
# del self.device_by_id[dev.id]
|
|
539
|
-
# self.devices.remove(dev)
|
|
540
|
-
# dev = None
|
|
541
|
-
|
|
542
|
-
# dev = self._gwy.get_device(device_id)
|
|
543
|
-
# dev._make_fake(bind=True)
|
|
544
|
-
# return dev
|
|
545
|
-
|
|
546
|
-
raise NotImplementedError
|
|
547
|
-
|
|
548
|
-
def create_fake_bdr(self, device_id=DEFAULT_BDR_ID) -> Device:
|
|
549
|
-
"""Bind a faked relay (BDR91A) to a controller (i.e. to a domain/zone).
|
|
550
|
-
|
|
551
|
-
Will alias the gateway (as "13:888888", TBD), or create a fully-faked 13:.
|
|
552
|
-
|
|
553
|
-
HGI80s can only alias one device of a type (use_gateway), but evofw3-based
|
|
554
|
-
gateways can also fully fake multiple devices of the same type.
|
|
555
|
-
"""
|
|
556
|
-
if device_id in (self.id, None):
|
|
557
|
-
device_id = DEFAULT_BDR_ID
|
|
558
|
-
device = self._create_fake_dev(DEV_TYPE_MAP.BDR, device_id=device_id)
|
|
559
|
-
|
|
560
|
-
if device.id == DEFAULT_BDR_ID:
|
|
561
|
-
self._faked_bdr = device
|
|
562
|
-
return device
|
|
563
|
-
|
|
564
|
-
def create_fake_ext(self, device_id=DEFAULT_EXT_ID) -> Device:
|
|
565
|
-
"""Bind a faked external sensor (???) to a controller.
|
|
566
|
-
|
|
567
|
-
Will alias the gateway (as "17:888888", TBD), or create a fully-faked 17:.
|
|
568
|
-
|
|
569
|
-
HGI80s can only alias one device of a type (use_gateway), but evofw3-based
|
|
570
|
-
gateways can also fully fake multiple devices of the same type.
|
|
571
|
-
"""
|
|
572
|
-
|
|
573
|
-
if device_id in (self.id, None):
|
|
574
|
-
device_id = DEFAULT_EXT_ID
|
|
575
|
-
device = self._create_fake_dev(DEV_TYPE_MAP.OUT, device_id=device_id)
|
|
576
|
-
|
|
577
|
-
if device.id == DEFAULT_EXT_ID:
|
|
578
|
-
self._faked_ext = device
|
|
579
|
-
return device
|
|
580
|
-
|
|
581
|
-
def create_fake_thm(self, device_id=DEFAULT_THM_ID) -> Device:
|
|
582
|
-
"""Bind a faked zone sensor (TR87RF) to a controller (i.e. to a zone).
|
|
583
|
-
|
|
584
|
-
Will alias the gateway (as "03:888888", TBD), or create a fully-faked 34:,
|
|
585
|
-
albeit named "03:xxxxxx".
|
|
586
|
-
|
|
587
|
-
HGI80s can only alias one device of a type (use_gateway), but evofw3-based
|
|
588
|
-
gateways can also fully fake multiple devices of the same type.
|
|
589
|
-
"""
|
|
590
|
-
if device_id in (self.id, None):
|
|
591
|
-
device_id = DEFAULT_THM_ID
|
|
592
|
-
device = self._create_fake_dev(DEV_TYPE_MAP.HCW, device_id=device_id)
|
|
593
|
-
|
|
594
|
-
if device.id == DEFAULT_THM_ID:
|
|
595
|
-
self._faked_thm = device
|
|
596
|
-
return device
|
|
597
|
-
|
|
598
418
|
@property
|
|
599
|
-
def schema(self):
|
|
600
|
-
return {
|
|
601
|
-
SZ_DEVICE_ID: self.id,
|
|
602
|
-
"faked_bdr": self._faked_bdr and self._faked_bdr.id,
|
|
603
|
-
"faked_ext": self._faked_ext and self._faked_ext.id,
|
|
604
|
-
"faked_thm": self._faked_thm and self._faked_thm.id,
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
class Device(Child, DeviceBase):
|
|
609
|
-
pass
|
|
419
|
+
def schema(self) -> dict[str, Any]:
|
|
420
|
+
return {}
|
|
610
421
|
|
|
611
422
|
|
|
612
|
-
class DeviceHeat(Device): # Honeywell CH/DHW or compatible
|
|
613
|
-
"""The base class for Honeywell CH/DHW-compatible devices.
|
|
423
|
+
class DeviceHeat(Device): # Heat domain: Honeywell CH/DHW or compatible
|
|
424
|
+
"""The base class for the heat domain (Honeywell CH/DHW-compatible devices).
|
|
614
425
|
|
|
615
426
|
Includes UFH and heatpumps (which can also cool).
|
|
616
427
|
"""
|
|
617
428
|
|
|
618
|
-
_SLUG: str =
|
|
429
|
+
_SLUG: str = DevType.HEA # shouldn't be any of these instantiated
|
|
619
430
|
|
|
620
|
-
def __init__(self, gwy, dev_addr, **kwargs):
|
|
431
|
+
def __init__(self, gwy: Gateway, dev_addr: Address, **kwargs: Any) -> None:
|
|
621
432
|
super().__init__(gwy, dev_addr, **kwargs)
|
|
622
433
|
|
|
623
434
|
self.ctl = None
|
|
@@ -639,7 +450,9 @@ class DeviceHeat(Device): # Honeywell CH/DHW or compatible
|
|
|
639
450
|
elif self._iz_controller is False: # TODO: raise CorruptStateError
|
|
640
451
|
_LOGGER.error(f"{msg!r} # IS_CONTROLLER (01): was FALSE, now True")
|
|
641
452
|
|
|
642
|
-
def _make_tcs_controller(
|
|
453
|
+
def _make_tcs_controller(
|
|
454
|
+
self, *, msg: Message | None = None, **schema: Any
|
|
455
|
+
) -> None: # CH/DHW
|
|
643
456
|
"""Attach a TCS (create/update as required) after passing it any msg."""
|
|
644
457
|
|
|
645
458
|
if self.type not in DEV_TYPE_MAP.CONTROLLERS: # potentially can be controllers
|
|
@@ -655,7 +468,6 @@ class DeviceHeat(Device): # Honeywell CH/DHW or compatible
|
|
|
655
468
|
|
|
656
469
|
@property
|
|
657
470
|
def _is_controller(self) -> None | bool:
|
|
658
|
-
|
|
659
471
|
if self._iz_controller is not None:
|
|
660
472
|
return bool(self._iz_controller) # True, False, or msg
|
|
661
473
|
|
|
@@ -665,19 +477,19 @@ class DeviceHeat(Device): # Honeywell CH/DHW or compatible
|
|
|
665
477
|
return False
|
|
666
478
|
|
|
667
479
|
@property
|
|
668
|
-
def zone(self) ->
|
|
480
|
+
def zone(self) -> Zone | None:
|
|
669
481
|
"""Return the device's parent zone, if known."""
|
|
670
482
|
|
|
671
483
|
return self._parent
|
|
672
484
|
|
|
673
485
|
|
|
674
|
-
class DeviceHvac(Device): # HVAC
|
|
675
|
-
"""The Device base class for HVAC (ventilation, PIV, MV/HR)."""
|
|
486
|
+
class DeviceHvac(Device): # HVAC domain: ventilation, PIV, MV/HR
|
|
487
|
+
"""The Device base class for the HVAC domain (ventilation, PIV, MV/HR)."""
|
|
676
488
|
|
|
677
|
-
_SLUG: str =
|
|
489
|
+
_SLUG: str = DevType.HVC # these may be instantiated, and promoted later on
|
|
678
490
|
|
|
679
|
-
def __init__(self,
|
|
680
|
-
super().__init__(
|
|
491
|
+
def __init__(self, gwy: Gateway, dev_addr: Address, **kwargs: Any) -> None:
|
|
492
|
+
super().__init__(gwy, dev_addr, **kwargs)
|
|
681
493
|
|
|
682
494
|
self._child_id = "hv" # TODO: domain_id/deprecate
|
|
683
495
|
|
|
@@ -699,7 +511,6 @@ class DeviceHvac(Device): # HVAC (ventilation, PIV, MV/HR)
|
|
|
699
511
|
# if msg.code in (Code._1298, Code._12A0, Code._22F1, Code._22F3):
|
|
700
512
|
# self._hvac_trick()
|
|
701
513
|
|
|
702
|
-
pass
|
|
703
|
-
|
|
704
514
|
|
|
705
|
-
|
|
515
|
+
# e.g. {"HGI": HgiGateway}
|
|
516
|
+
BASE_CLASS_BY_SLUG: dict[str, type[Device]] = class_by_attr(__name__, "_SLUG")
|