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.
Files changed (72) hide show
  1. ramses_cli/__init__.py +18 -0
  2. ramses_cli/client.py +597 -0
  3. ramses_cli/debug.py +20 -0
  4. ramses_cli/discovery.py +405 -0
  5. ramses_cli/utils/cat_slow.py +17 -0
  6. ramses_cli/utils/convert.py +60 -0
  7. ramses_rf/__init__.py +31 -10
  8. ramses_rf/binding_fsm.py +787 -0
  9. ramses_rf/const.py +124 -105
  10. ramses_rf/database.py +297 -0
  11. ramses_rf/device/__init__.py +69 -39
  12. ramses_rf/device/base.py +187 -376
  13. ramses_rf/device/heat.py +540 -552
  14. ramses_rf/device/hvac.py +286 -171
  15. ramses_rf/dispatcher.py +153 -177
  16. ramses_rf/entity_base.py +478 -361
  17. ramses_rf/exceptions.py +82 -0
  18. ramses_rf/gateway.py +378 -514
  19. ramses_rf/helpers.py +57 -19
  20. ramses_rf/py.typed +0 -0
  21. ramses_rf/schemas.py +148 -194
  22. ramses_rf/system/__init__.py +16 -23
  23. ramses_rf/system/faultlog.py +363 -0
  24. ramses_rf/system/heat.py +295 -302
  25. ramses_rf/system/schedule.py +312 -198
  26. ramses_rf/system/zones.py +318 -238
  27. ramses_rf/version.py +2 -8
  28. ramses_rf-0.51.1.dist-info/METADATA +72 -0
  29. ramses_rf-0.51.1.dist-info/RECORD +55 -0
  30. {ramses_rf-0.22.2.dist-info → ramses_rf-0.51.1.dist-info}/WHEEL +1 -2
  31. ramses_rf-0.51.1.dist-info/entry_points.txt +2 -0
  32. {ramses_rf-0.22.2.dist-info → ramses_rf-0.51.1.dist-info/licenses}/LICENSE +1 -1
  33. ramses_tx/__init__.py +160 -0
  34. {ramses_rf/protocol → ramses_tx}/address.py +65 -59
  35. ramses_tx/command.py +1454 -0
  36. ramses_tx/const.py +903 -0
  37. ramses_tx/exceptions.py +92 -0
  38. {ramses_rf/protocol → ramses_tx}/fingerprints.py +56 -15
  39. {ramses_rf/protocol → ramses_tx}/frame.py +132 -131
  40. ramses_tx/gateway.py +338 -0
  41. ramses_tx/helpers.py +883 -0
  42. {ramses_rf/protocol → ramses_tx}/logger.py +67 -53
  43. {ramses_rf/protocol → ramses_tx}/message.py +155 -191
  44. ramses_tx/opentherm.py +1260 -0
  45. ramses_tx/packet.py +210 -0
  46. ramses_tx/parsers.py +2957 -0
  47. ramses_tx/protocol.py +801 -0
  48. ramses_tx/protocol_fsm.py +672 -0
  49. ramses_tx/py.typed +0 -0
  50. {ramses_rf/protocol → ramses_tx}/ramses.py +262 -185
  51. {ramses_rf/protocol → ramses_tx}/schemas.py +150 -133
  52. ramses_tx/transport.py +1471 -0
  53. ramses_tx/typed_dicts.py +492 -0
  54. ramses_tx/typing.py +181 -0
  55. ramses_tx/version.py +4 -0
  56. ramses_rf/discovery.py +0 -398
  57. ramses_rf/protocol/__init__.py +0 -59
  58. ramses_rf/protocol/backports.py +0 -42
  59. ramses_rf/protocol/command.py +0 -1561
  60. ramses_rf/protocol/const.py +0 -697
  61. ramses_rf/protocol/exceptions.py +0 -111
  62. ramses_rf/protocol/helpers.py +0 -390
  63. ramses_rf/protocol/opentherm.py +0 -1170
  64. ramses_rf/protocol/packet.py +0 -235
  65. ramses_rf/protocol/parsers.py +0 -2673
  66. ramses_rf/protocol/protocol.py +0 -613
  67. ramses_rf/protocol/transport.py +0 -1011
  68. ramses_rf/protocol/version.py +0 -10
  69. ramses_rf/system/hvac.py +0 -82
  70. ramses_rf-0.22.2.dist-info/METADATA +0 -64
  71. ramses_rf-0.22.2.dist-info/RECORD +0 -42
  72. ramses_rf-0.22.2.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 datetime import timedelta as td
12
- from typing import Any, Callable, Optional
13
-
14
- from ..const import DEV_TYPE, DEV_TYPE_MAP, SZ_DEVICE_ID, __dev_mode__
15
- from ..entity_base import Child, Entity, class_by_attr
16
- from ..helpers import shrink
17
- from ..protocol.address import Address
18
- from ..protocol.backports import StrEnum
19
- from ..protocol.command import Command, _mk_cmd
20
- from ..protocol.message import Message
21
- from ..protocol.ramses import CODES_BY_DEV_SLUG, CODES_ONLY_FROM_CTL
22
- from ..schemas import SCH_TRAITS, SZ_ALIAS, SZ_CLASS, SZ_FAKED, SZ_KNOWN_LIST
23
-
24
- # skipcq: PY-W2000
25
- from ..const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
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
- DEFAULT_BDR_ID = "13:888888"
34
- DEFAULT_EXT_ID = "17:888888"
35
- DEFAULT_THM_ID = "03:888888"
36
-
37
-
38
- class BindState(StrEnum):
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 = DEV_TYPE.DEV # type: ignore[assignment]
84
-
85
- _STATE_ATTR: str = None # type: ignore[assignment]
55
+ _SLUG: str = DevType.DEV
56
+ _STATE_ATTR: str = None
86
57
 
87
- def __init__(self, gwy, dev_addr, **kwargs) -> None:
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
- self.id: str = dev_addr.id
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._faked: bool = False
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(cls, gwy, dev_addr: Address, **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(_mk_cmd(RQ, Code._1FC9, "00", self.id), 60 * 60 * 24)
153
- # self._add_discovery_cmd(_mk_cmd(RQ, Code._0016, "00", self.id), 60 * 60)
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 _make_cmd(self, code, payload="00", **kwargs) -> None: # type: ignore[override] # skipcq: PYL-W0221
158
- super()._make_cmd(code, self.id, payload=payload, **kwargs)
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
- msg.code == Code._1FC9 and msg.payload["phase"] == "offer"
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
- if cls._SLUG in (DEV_TYPE.DEV, self._SLUG):
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 == DEV_TYPE.HEA and cls._SLUG in DEV_TYPE_MAP.HVAC_SLUGS:
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 == DEV_TYPE.HVC and cls._SLUG not in DEV_TYPE_MAP.HVAC_SLUGS:
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 a device is battery powered (excludes battery-backup)."""
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: # TODO: impersonated vs virtual
204
- return bool(self._faked)
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._faked:
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) -> Optional[dict]: # 1060
260
- if self._faked:
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._add_discovery_cmd(
281
- _mk_cmd(RQ, Code._10E0, "00", self.id), 60 * 60 * 24
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) -> Optional[dict]: # 10E0
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
- # Faked Round Thermostat binding to a Evohome controller as a zone sensor
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._faked: bool = None # type: ignore[assignment]
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
- @check_faking_enabled
341
- def _bind(self):
342
- self._1fc9_state["state"] = BindState.UNBOUND
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
- _LOGGER.warning(f"Binding {self}: requesting {codes}") # TODO: info
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
- if not isinstance(codes, tuple):
390
- codes = (codes,)
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._1fc9_state["codes"] = codes
393
- self._1fc9_state["state"] = BindState.OFFERING
394
- self._1fc9_state["timeout"] = self._gwy._dt_now() + td(seconds=60)
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
- self._send_cmd(Command.put_bind(I_, codes, self.id))
321
+ return await super()._async_send_cmd(cmd, priority=priority, qos=qos)
397
322
 
398
323
  def _handle_msg(self, msg: Message) -> None:
399
- def proc_offer_and_accept(msg: Message): # . process 1st pkt of handshake
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
- if self._1fc9_state["timeout"] < self._gwy._dt_now():
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
- if msg.verb != I_:
457
- return self._1fc9_state["state"]
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
- # self._1fc9_state["msg"] = msg # keep offer, not confirm
460
- return BindState.BOUND
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
- if msg.code != Code._1FC9:
463
- super()._handle_msg(msg)
464
- return
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
- elif msg.payload["phase"] == "accept":
473
- if msg.src is self:
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
- elif msg.payload["phase"] == "confirm":
479
- assert self._1fc9_state["state"] == BindState.ACCEPTING
480
- self._1fc9_state["state"] = proc_confirm_and_done(msg)
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
- super()._handle_msg(msg)
403
+ gwy._add_device(self)
483
404
 
484
405
 
485
- class HgiGateway(DeviceInfo): # HGI (18:)
406
+ class HgiGateway(Device): # HGI (18:)
486
407
  """The HGI80 base class."""
487
408
 
488
- _SLUG: str = DEV_TYPE.HGI
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 = DEV_TYPE.HEA # shouldn't be any of these instantiated
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(self, *, msg=None, **schema) -> None: # CH/DHW
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) -> Optional[Entity]: # should be: Optional[Zone]
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 (ventilation, PIV, MV/HR)
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 = DEV_TYPE.HVC # these may be instantiated, and promoted later on
489
+ _SLUG: str = DevType.HVC # these may be instantiated, and promoted later on
678
490
 
679
- def __init__(self, *args, **kwargs) -> None:
680
- super().__init__(*args, **kwargs)
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
- BASE_CLASS_BY_SLUG = class_by_attr(__name__, "_SLUG") # e.g. "HGI": HgiGateway
515
+ # e.g. {"HGI": HgiGateway}
516
+ BASE_CLASS_BY_SLUG: dict[str, type[Device]] = class_by_attr(__name__, "_SLUG")