localtrv 0.2.0__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 (83) hide show
  1. localtrv/__init__.py +10 -0
  2. localtrv/__main__.py +7 -0
  3. localtrv/bridge.py +292 -0
  4. localtrv/bridge_cache.py +244 -0
  5. localtrv/bridge_discovery.py +435 -0
  6. localtrv/bridge_owner.py +154 -0
  7. localtrv/bridge_poll.py +268 -0
  8. localtrv/bridge_setpoint.py +179 -0
  9. localtrv/bridge_state.py +414 -0
  10. localtrv/bridge_types.py +152 -0
  11. localtrv/bridge_verify.py +100 -0
  12. localtrv/cli.py +201 -0
  13. localtrv/py.typed +0 -0
  14. localtrv/testing/__init__.py +41 -0
  15. localtrv/testing/errors.py +28 -0
  16. localtrv/testing/gatt.py +534 -0
  17. localtrv/testing/gatt_client.py +192 -0
  18. localtrv/testing/gatt_faults.py +93 -0
  19. localtrv/testing/gatt_services.py +218 -0
  20. localtrv/testing/network.py +477 -0
  21. localtrv/testing/network_aps.py +131 -0
  22. localtrv/testing/network_frames.py +319 -0
  23. localtrv/testing/network_link.py +203 -0
  24. localtrv/testing/network_recorder.py +70 -0
  25. localtrv/testing/network_topology.py +98 -0
  26. localtrv/testing/scenario.py +128 -0
  27. localtrv/testing/valve.py +464 -0
  28. localtrv/testing/valve_table.py +124 -0
  29. localtrv/testing/zdd.py +241 -0
  30. localtrv/valves/__init__.py +210 -0
  31. localtrv/valves/constants.py +230 -0
  32. localtrv/valves/decode.py +226 -0
  33. localtrv/valves/models.py +207 -0
  34. localtrv/zigbee_direct/__init__.py +167 -0
  35. localtrv/zigbee_direct/clock.py +349 -0
  36. localtrv/zigbee_direct/codec/__init__.py +256 -0
  37. localtrv/zigbee_direct/codec/_cursor.py +72 -0
  38. localtrv/zigbee_direct/codec/aps.py +238 -0
  39. localtrv/zigbee_direct/codec/commissioning.py +332 -0
  40. localtrv/zigbee_direct/codec/fallback.py +315 -0
  41. localtrv/zigbee_direct/codec/nwk.py +231 -0
  42. localtrv/zigbee_direct/codec/nwk_commands.py +452 -0
  43. localtrv/zigbee_direct/codec/nwk_mesh.py +159 -0
  44. localtrv/zigbee_direct/codec/tlv.py +132 -0
  45. localtrv/zigbee_direct/codec/zcl.py +433 -0
  46. localtrv/zigbee_direct/codec/zcl_types.py +218 -0
  47. localtrv/zigbee_direct/codec/zdo.py +460 -0
  48. localtrv/zigbee_direct/codec/zdo_mgmt.py +166 -0
  49. localtrv/zigbee_direct/connection.py +324 -0
  50. localtrv/zigbee_direct/connection_backoff.py +114 -0
  51. localtrv/zigbee_direct/connection_failure.py +63 -0
  52. localtrv/zigbee_direct/connection_session.py +189 -0
  53. localtrv/zigbee_direct/connection_supervisor.py +473 -0
  54. localtrv/zigbee_direct/connection_types.py +137 -0
  55. localtrv/zigbee_direct/counters.py +100 -0
  56. localtrv/zigbee_direct/crypto.py +320 -0
  57. localtrv/zigbee_direct/debug.py +360 -0
  58. localtrv/zigbee_direct/discovery.py +136 -0
  59. localtrv/zigbee_direct/dispatch.py +454 -0
  60. localtrv/zigbee_direct/errors.py +211 -0
  61. localtrv/zigbee_direct/frames.py +213 -0
  62. localtrv/zigbee_direct/mmo.py +81 -0
  63. localtrv/zigbee_direct/node.py +278 -0
  64. localtrv/zigbee_direct/node_acks.py +111 -0
  65. localtrv/zigbee_direct/node_core.py +294 -0
  66. localtrv/zigbee_direct/node_join.py +184 -0
  67. localtrv/zigbee_direct/node_match.py +173 -0
  68. localtrv/zigbee_direct/node_requests.py +354 -0
  69. localtrv/zigbee_direct/node_types.py +250 -0
  70. localtrv/zigbee_direct/redact.py +363 -0
  71. localtrv/zigbee_direct/requests.py +513 -0
  72. localtrv/zigbee_direct/routing.py +457 -0
  73. localtrv/zigbee_direct/session.py +442 -0
  74. localtrv/zigbee_direct/transport.py +543 -0
  75. localtrv/zigbee_direct/transport_handshake.py +191 -0
  76. localtrv/zigbee_direct/transport_writer.py +203 -0
  77. localtrv/zigbee_direct/types.py +113 -0
  78. localtrv/zigbee_direct/uuids.py +67 -0
  79. localtrv-0.2.0.dist-info/METADATA +316 -0
  80. localtrv-0.2.0.dist-info/RECORD +83 -0
  81. localtrv-0.2.0.dist-info/WHEEL +4 -0
  82. localtrv-0.2.0.dist-info/entry_points.txt +2 -0
  83. localtrv-0.2.0.dist-info/licenses/LICENSE +21 -0
localtrv/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """localtrv: local Zigbee Direct (BLE) client for Viessmann ZK03840 valves."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("localtrv")
7
+ except PackageNotFoundError: # pragma: no cover - running from an uninstalled tree
8
+ __version__ = "0.0.0+unknown"
9
+
10
+ __all__ = ["__version__"]
localtrv/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """``python -m localtrv``."""
2
+
3
+ import sys
4
+
5
+ from localtrv.cli import main
6
+
7
+ sys.exit(main())
localtrv/bridge.py ADDED
@@ -0,0 +1,292 @@
1
+ """ViCareBridge: discovery, polling, setpoints and reports on top of a ZdConnection.
2
+
3
+ Orchestration only: every cluster, attribute, scale, sentinel, model id and read plan comes from
4
+ ``localtrv.valves``; no Zigbee literals here.
5
+
6
+ Lifecycle and ownership
7
+ ``attach()`` registers a ready listener on the connection. On READY(gen) (sync):
8
+ persist ``gen.join.own_short`` (``on_cache_changed``), register report / Device_annce /
9
+ address-conflict listeners on ``gen.node`` (address conflict: forget the cached own short;
10
+ the connection itself rejoins), then ``connection.spawn(gen, self._discover(gen))``.
11
+ Every background coroutine (discovery, retries, identification, the initial poll,
12
+ confirm reads, topology re-walks) is spawned on its generation, so the connection cancels
13
+ and awaits it at teardown. Each completion checks that its generation is still current
14
+ before mutating state; stale completions are dropped.
15
+
16
+ Polling
17
+ ``poll()`` is driven by the HA coordinator schedule (never by pushes). Valves run in
18
+ parallel; each first ``try``-acquires its transaction lock (busy -> skipped, neither miss
19
+ nor success). Plans: ``valves.FAST`` every poll, ``valves.SLOW`` every
20
+ ``SLOW_EVERY_N_POLLS``-th, ``valves.STATIC`` at discovery and every ``STATIC_REFRESH_S``.
21
+ Any response resets ``misses``; all attempts failing -> ``misses += 1``; at
22
+ ``MISSES_UNAVAILABLE`` the valve is unavailable and a topology walk is spawned. Not READY ->
23
+ the cycle is skipped and every known valve counts a miss. Rejoin rule: >= 1 known valve,
24
+ every known valve missed ``SILENT_POLLS_REJOIN`` consecutive polls, READY throughout ->
25
+ ``connection.request_rejoin(gen, "all_valves_silent")``. Without any valve answering
26
+ since the last such rejoin, the next one also waits ``SILENT_REJOIN_BACKOFF`` doubling
27
+ per rejoin (1 h, 2 h, 4 h, cap ``SILENT_REJOIN_BACKOFF_MAX`` 6 h; see ``bridge_poll``).
28
+
29
+ Setpoints (``set_setpoint``), per valve, in this order
30
+ 1. Validate (``KeyError`` unknown valve, ``ValueError`` non-finite) and clamp with
31
+ ``valves.clamp_setpoint``.
32
+ 2. BEFORE waiting for the lock: ``version += 1``, ``requested_c`` = clamped value,
33
+ ``pending = True``, ``on_update`` (the UI updates optimistically at once).
34
+ 3. Preempt: if an older version's background confirm task holds the lock, cancel it (its
35
+ exit path releases the lock; it no longer touches ``pending``, it is not the latest).
36
+ 4. Acquire the per-valve transaction lock, verify the valve's short (IEEE_addr_req: the
37
+ device there must name the valve, else ``ValveAddressUnknownError`` and nothing is
38
+ written) and write ``valves.setpoint_record`` with ``node.zcl_write``, all inside
39
+ ``clock.timeout`` bounded by ``SETPOINT_ACK_DEADLINE`` from step 2. If a newer version
40
+ arrived meanwhile, this call still writes its own value (the newer write follows it
41
+ under the lock; last writer wins on the valve).
42
+ 5. Success: ``acknowledged_c`` = clamped value, ``on_update``; spawn the confirm task on the
43
+ generation and return the ``SetpointStatus``. The HA call ends here. Lock ownership
44
+ passes to the confirm task.
45
+ 6. Confirm task: ``zcl_read(valves.CONFIRM_SETPOINT)`` bounded so it ends before
46
+ ``SETPOINT_TOTAL_DEADLINE`` from step 2. Success -> ``confirmed_c``/``confirmed_at`` and
47
+ ``state.setpoint_c``. Timeout or error -> acknowledged but unconfirmed (``confirmed_c``
48
+ unchanged, never reported as confirmed).
49
+
50
+ Every exit path runs one ``finally``: release the lock, and if this version is still the
51
+ latest, set ``pending = False`` (and on failure before the ack also clear ``requested_c``)
52
+ and call ``on_update``. Exit paths covered: write failure or ack deadline (then raise),
53
+ the HA caller cancelled while waiting for the lock or the write (``CancelledError``
54
+ propagates), ``connection.spawn`` raising ``ConnectionLostError`` between ack and confirm
55
+ (the ack is still returned; pending cleared, no confirm), the confirm task cancelled at
56
+ generation teardown or by preemption, and confirm success or timeout. An older version
57
+ never overwrites a newer version's fields; only the latest version clears ``pending``.
58
+
59
+ Raised to HA: ``ZclStatusError``, ``RequestTimeoutError`` or a bare ``TimeoutError`` (the
60
+ ack deadline; ``RequestTimeoutError`` subclasses ``TimeoutError``, so HA catches
61
+ ``TimeoutError``), ``ConnectionLostError``, ``ValveAddressUnknownError`` (a ``RouteError``:
62
+ the valve's cached short now belongs to another valve and its own is not re-learned yet;
63
+ nothing was written, or the ack came from that other valve).
64
+
65
+ Short ownership (``bridge_owner``, ``bridge_verify``): one EUI per short. Only authoritative
66
+ evidence moves or evicts it: a Device_annce, a frame whose NWK header names its source IEEE
67
+ (``node.on_sender``), an IEEE_addr response from the short asked. Walk results are hints: a known
68
+ valve listed at another short, or an unaddressed one, is verified with IEEE_addr_req (once per
69
+ valve per walk) and adopted only if the device names itself; a walk never evicts. Restored
70
+ (cached) shorts are trusted for reads. Every ZCL request for a valve runs under a lease, checked
71
+ before queueing and at transmission of every attempt (the node's ``guard``) and on the answer,
72
+ which must not name another source IEEE (``expect_ieee``). An unaddressed valve gets no ZCL
73
+ request (a poll counts a miss; the usual re-walk follows). An identification holds an epoch
74
+ lease on its short; if evidence changes meanwhile, its result is discarded (not added, not
75
+ ignored).
76
+
77
+ HA wiring: ``on_update`` and ``connection.add_state_listener`` both push
78
+ ``bridge.snapshot()`` to the coordinator (``async_update_listeners``, no schedule reset), so the
79
+ bridge connectivity entity flips at once on a state change, not at the next poll.
80
+
81
+ Callbacks: ``on_update(snapshot)`` after every state change (sync, must not await; cheap
82
+ ``snapshot()``), ``on_new_valve(valve)`` once per newly identified valve,
83
+ ``on_cache_changed(cache)`` whenever ``export_cache()`` would differ. Callback exceptions are
84
+ logged and swallowed.
85
+
86
+ Logging (``localtrv.bridge``): INFO discovery results; WARNING valve unavailable,
87
+ heating demand above the observed scale (once per valve); DEBUG ``valves.read_issues`` once per
88
+ valve and attribute. EUIs only in DEBUG.
89
+ """
90
+
91
+ from __future__ import annotations
92
+
93
+ import logging
94
+ from typing import TYPE_CHECKING
95
+
96
+ from localtrv import bridge_discovery, bridge_poll, bridge_setpoint
97
+ from localtrv.bridge_cache import CACHE_VERSION, BridgeCache, CachedValve, JsonValue
98
+ from localtrv.bridge_state import BridgeCore
99
+ from localtrv.bridge_types import (
100
+ ANNCE_IDENTIFY_DEBOUNCE,
101
+ ANNCE_IDENTIFY_RETRY,
102
+ DISCOVERY_RETRY_DELAYS,
103
+ IDENTIFY_CONCURRENCY,
104
+ IDENTIFY_DIAGNOSTIC_AFTER,
105
+ MISSES_UNAVAILABLE,
106
+ SETPOINT_ACK_DEADLINE,
107
+ SETPOINT_TOTAL_DEADLINE,
108
+ SILENT_POLLS_REJOIN,
109
+ SILENT_REJOIN_BACKOFF,
110
+ SILENT_REJOIN_BACKOFF_MAX,
111
+ TOPOLOGY_REFRESH,
112
+ BridgeSnapshot,
113
+ DiscoveryStatus,
114
+ SetpointStatus,
115
+ TopologySummary,
116
+ ValveAddressUnknownError,
117
+ ValveAddressUnverifiedError,
118
+ )
119
+
120
+ if TYPE_CHECKING:
121
+ from collections.abc import Callable
122
+
123
+ from localtrv.valves import Valve
124
+ from localtrv.zigbee_direct.clock import Clock
125
+ from localtrv.zigbee_direct.codec.zdo import DeviceAnnce
126
+ from localtrv.zigbee_direct.connection import Generation, ZdConnection
127
+ from localtrv.zigbee_direct.node import ReportEvent
128
+ from localtrv.zigbee_direct.types import Eui64
129
+
130
+ _LOGGER = logging.getLogger(__name__)
131
+
132
+
133
+ class ViCareBridge:
134
+ """Valve orchestration over one ``ZdConnection`` (see module docstring).
135
+
136
+ ``cache`` seeds the known valves (shown at once, unavailable until polled), the ignore list
137
+ and the preferred own short. The constructor does not touch the connection; call
138
+ ``attach()``. Loop thread only.
139
+ """
140
+
141
+ def __init__(
142
+ self,
143
+ connection: ZdConnection,
144
+ *,
145
+ clock: Clock,
146
+ cache: BridgeCache | None = None,
147
+ on_update: Callable[[BridgeSnapshot], None] | None = None,
148
+ on_new_valve: Callable[[Valve], None] | None = None,
149
+ on_cache_changed: Callable[[BridgeCache], None] | None = None,
150
+ ) -> None:
151
+ self._core = BridgeCore(
152
+ connection,
153
+ clock=clock,
154
+ cache=cache,
155
+ on_update=on_update,
156
+ on_new_valve=on_new_valve,
157
+ on_cache_changed=on_cache_changed,
158
+ )
159
+ self._unsubscribe: list[Callable[[], None]] = []
160
+ self._node_unsubscribe: list[Callable[[], None]] = []
161
+
162
+ def attach(self) -> Callable[[], None]:
163
+ """Register the connection listeners and push ``preferred_short``/``avoid_shorts`` from
164
+ the cache with ``connection.update_node_config``. Returns a detach callable (also
165
+ removes node listeners). Call before ``connection.start()``.
166
+
167
+ ``avoid_shorts`` pushed by the bridge are the known device shorts only (refreshed after
168
+ every discovery). The connection keeps its own conflict set and merges it in at each
169
+ join, so a bridge push can never erase a conflicting short; likewise the bridge never
170
+ re-sets ``preferred_short`` after an address conflict cleared it (it forgets the cached
171
+ own short on the node's address-conflict callback)."""
172
+ core = self._core
173
+ changes: dict[str, object] = {"avoid_shorts": core.known_shorts()}
174
+ if core.own_short is not None:
175
+ changes["preferred_short"] = core.own_short
176
+ core.connection.update_node_config(**changes)
177
+ self._unsubscribe.append(core.connection.add_ready_listener(self._on_ready))
178
+ return self._detach
179
+
180
+ def _detach(self) -> None:
181
+ for unsubscribe in (*self._unsubscribe, *self._node_unsubscribe):
182
+ unsubscribe()
183
+ self._unsubscribe.clear()
184
+ self._node_unsubscribe.clear()
185
+
186
+ # --- READY (synchronous) ---
187
+
188
+ def _on_ready(self, gen: Generation) -> None:
189
+ core = self._core
190
+ for unsubscribe in self._node_unsubscribe:
191
+ unsubscribe()
192
+ core.start_generation(gen)
193
+ core.own_short = gen.join.own_short
194
+ core.zdd_eui = str(gen.join.zdd_eui)
195
+ node = gen.node
196
+ self._node_unsubscribe = [
197
+ node.on_report(lambda event: self._on_report(gen, event)),
198
+ node.on_device_annce(lambda annce, _src: self._on_annce(gen, annce)),
199
+ node.on_address_conflict(lambda: self._on_conflict(gen)),
200
+ node.on_sender(
201
+ lambda short, ieee: bridge_discovery.on_address(
202
+ core, gen, ieee, short, identify=False
203
+ )
204
+ ),
205
+ ]
206
+ core.changed()
207
+ bridge_discovery.spawn_safely(
208
+ core, gen, bridge_discovery.discovery_task(core, gen), "vicare discovery"
209
+ )
210
+
211
+ def _on_report(self, gen: Generation, event: ReportEvent) -> None:
212
+ core = self._core
213
+ if not core.is_current(gen):
214
+ return
215
+ _LOGGER.debug(
216
+ "report from 0x%04X: cluster 0x%04X attrs %s",
217
+ event.src_short,
218
+ event.cluster,
219
+ ",".join(f"0x{r.attribute_id:04X}" for r in event.records),
220
+ )
221
+ if event.src_ieee is not None: # address evidence (maybe on a reused valve short)
222
+ bridge_discovery.on_sender_ieee(core, gen, event.src_ieee, event.src_short)
223
+ eui = core.eui_for_short(event.src_short, event.src_ieee)
224
+ if eui is None:
225
+ return
226
+ changed = core.apply(eui, event.cluster, event.records, event.manufacturer_code)
227
+ if core.responded(eui) or changed:
228
+ core.changed()
229
+
230
+ def _on_annce(self, gen: Generation, annce: DeviceAnnce) -> None:
231
+ bridge_discovery.on_device_annce(self._core, gen, annce)
232
+
233
+ def _on_conflict(self, gen: Generation) -> None:
234
+ core = self._core
235
+ if core.gen is not gen or core.own_short is None:
236
+ return
237
+ _LOGGER.info("address conflict on our short: forgetting it")
238
+ core.own_short = None
239
+ core.changed()
240
+
241
+ # --- public API ---
242
+
243
+ def snapshot(self) -> BridgeSnapshot:
244
+ """Current state; cheap, never awaits, safe in any connection state."""
245
+ return self._core.snapshot()
246
+
247
+ async def poll(self) -> BridgeSnapshot:
248
+ """One poll cycle (module docstring); never raises for valve failures."""
249
+ return await bridge_poll.poll(self._core)
250
+
251
+ async def set_setpoint(self, eui: Eui64, celsius: float) -> SetpointStatus:
252
+ """Write a setpoint and return at the acknowledgement (module docstring).
253
+
254
+ Raises ``KeyError`` (unknown valve), ``ValueError`` (non-finite), ``ZclStatusError``
255
+ (write rejected), ``RequestTimeoutError`` or bare ``TimeoutError`` (no acknowledgement
256
+ within ``SETPOINT_ACK_DEADLINE``; HA must catch ``TimeoutError``),
257
+ ``ConnectionLostError`` (not READY, or lost meanwhile) or ``ValveAddressUnknownError``
258
+ (a ``RouteError``; the valve's short is not established, see module docstring).
259
+ ``CancelledError`` of the caller propagates after the cleanup described in the module
260
+ docstring.
261
+ """
262
+ return await bridge_setpoint.set_setpoint(self._core, eui, celsius)
263
+
264
+ def export_cache(self) -> BridgeCache:
265
+ return self._core.export_cache()
266
+
267
+
268
+ __all__ = [
269
+ "ANNCE_IDENTIFY_DEBOUNCE",
270
+ "ANNCE_IDENTIFY_RETRY",
271
+ "CACHE_VERSION",
272
+ "DISCOVERY_RETRY_DELAYS",
273
+ "IDENTIFY_CONCURRENCY",
274
+ "IDENTIFY_DIAGNOSTIC_AFTER",
275
+ "MISSES_UNAVAILABLE",
276
+ "SETPOINT_ACK_DEADLINE",
277
+ "SETPOINT_TOTAL_DEADLINE",
278
+ "SILENT_POLLS_REJOIN",
279
+ "SILENT_REJOIN_BACKOFF",
280
+ "SILENT_REJOIN_BACKOFF_MAX",
281
+ "TOPOLOGY_REFRESH",
282
+ "BridgeCache",
283
+ "BridgeSnapshot",
284
+ "CachedValve",
285
+ "DiscoveryStatus",
286
+ "JsonValue",
287
+ "SetpointStatus",
288
+ "TopologySummary",
289
+ "ValveAddressUnknownError",
290
+ "ValveAddressUnverifiedError",
291
+ "ViCareBridge",
292
+ ]
@@ -0,0 +1,244 @@
1
+ """Persisted bridge state: ``BridgeCache``/``CachedValve`` and their JSON round trip.
2
+
3
+ Re-exported from ``localtrv.bridge``. No key material, ever: only shorts, EUIs (display
4
+ form), identification strings and validated limits.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import dataclasses
10
+ import logging
11
+ import math
12
+ from dataclasses import dataclass, field
13
+ from typing import TYPE_CHECKING, Final, Literal, Self
14
+
15
+ from localtrv.valves import SetpointLimits, Valve
16
+ from localtrv.zigbee_direct.types import Eui64, validate_short
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Mapping
20
+
21
+ CACHE_VERSION: Final = 1
22
+
23
+ type JsonValue = str | int | float | bool | list[JsonValue] | dict[str, JsonValue] | None
24
+ #: The ``SetpointLimits.source`` values a cache records (``"cached"`` is stored as ``None``).
25
+ type _StoredSource = Literal["limits", "abs"]
26
+
27
+ _LOGGER = logging.getLogger("localtrv.bridge")
28
+ _ENDPOINT_RANGE: Final = (1, 240)
29
+
30
+
31
+ class _BadCacheError(ValueError):
32
+ """Internal: the persisted data does not have the expected shape."""
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class CachedValve:
37
+ """Persisted identity of a valve (``eui`` in display form); limits in °C.
38
+
39
+ ``limits_source`` is the ``SetpointLimits.source`` of the limits. ``None`` (caches written
40
+ by 0.1.0a1 and earlier have no such field) loads them as provisional ``"cached"`` limits.
41
+ """
42
+
43
+ eui: str
44
+ short: int
45
+ endpoint: int
46
+ manufacturer: str | None
47
+ model: str | None
48
+ sw_build: str | None
49
+ min_c: float | None = None
50
+ max_c: float | None = None
51
+ limits_source: _StoredSource | None = None
52
+
53
+ @classmethod
54
+ def from_valve(cls, valve: Valve) -> Self:
55
+ limits = valve.limits
56
+ keep = limits.source != "default"
57
+ source: _StoredSource | None
58
+ match limits.source:
59
+ case "limits" | "abs":
60
+ source = limits.source
61
+ case _:
62
+ source = None
63
+ return cls(
64
+ eui=str(valve.eui),
65
+ short=valve.short,
66
+ endpoint=valve.endpoint,
67
+ manufacturer=valve.manufacturer,
68
+ model=valve.model,
69
+ sw_build=valve.sw_build,
70
+ min_c=limits.min_c if keep else None,
71
+ max_c=limits.max_c if keep else None,
72
+ limits_source=source,
73
+ )
74
+
75
+ def to_valve(self) -> Valve:
76
+ """Back to a ``Valve``; limits revalidated through ``valves.SetpointLimits``."""
77
+ return Valve(
78
+ eui=Eui64.from_str(self.eui),
79
+ short=self.short,
80
+ endpoint=self.endpoint,
81
+ manufacturer=self.manufacturer,
82
+ model=self.model,
83
+ sw_build=self.sw_build,
84
+ limits=self._limits(),
85
+ )
86
+
87
+ def _limits(self) -> SetpointLimits:
88
+ if self.limits_source == "abs":
89
+ return SetpointLimits.from_attrs(
90
+ min_c=None, max_c=None, abs_min_c=self.min_c, abs_max_c=self.max_c
91
+ )
92
+ limits = SetpointLimits.from_attrs(min_c=self.min_c, max_c=self.max_c)
93
+ if self.limits_source is None and limits.source != "default":
94
+ return dataclasses.replace(limits, source="cached")
95
+ return limits
96
+
97
+ def to_json(self) -> dict[str, JsonValue]:
98
+ return {
99
+ "eui": self.eui,
100
+ "short": self.short,
101
+ "endpoint": self.endpoint,
102
+ "manufacturer": self.manufacturer,
103
+ "model": self.model,
104
+ "sw_build": self.sw_build,
105
+ "min_c": self.min_c,
106
+ "max_c": self.max_c,
107
+ "limits_source": self.limits_source,
108
+ }
109
+
110
+ @classmethod
111
+ def from_json(cls, data: object) -> Self:
112
+ """Strict inverse of ``to_json``; ``_BadCacheError`` on any deviation."""
113
+ if not isinstance(data, dict):
114
+ raise _BadCacheError("valve entry is not an object")
115
+ endpoint = _int(data.get("endpoint"), *_ENDPOINT_RANGE)
116
+ return cls(
117
+ eui=_eui(data.get("eui")),
118
+ short=_short(data.get("short")),
119
+ endpoint=endpoint,
120
+ manufacturer=_opt_str(data.get("manufacturer")),
121
+ model=_opt_str(data.get("model")),
122
+ sw_build=_opt_str(data.get("sw_build")),
123
+ min_c=_opt_float(data.get("min_c")),
124
+ max_c=_opt_float(data.get("max_c")),
125
+ limits_source=_opt_source(data.get("limits_source")),
126
+ )
127
+
128
+
129
+ @dataclass(frozen=True, slots=True)
130
+ class BridgeCache:
131
+ """Everything the integration persists between restarts (HA ``Store``). No key material.
132
+
133
+ ``own_short`` feeds ``NodeConfig.preferred_short``; valve shorts feed ``avoid_shorts``.
134
+ ``ignored`` are EUIs identified as not supported (not re-identified every discovery).
135
+ """
136
+
137
+ own_short: int | None = None
138
+ zdd_eui: str | None = None
139
+ valves: Mapping[str, CachedValve] = field(default_factory=dict)
140
+ ignored: frozenset[str] = frozenset()
141
+
142
+ def to_json(self) -> dict[str, JsonValue]:
143
+ """JSON-serialisable dict with ``"version": CACHE_VERSION``."""
144
+ return {
145
+ "version": CACHE_VERSION,
146
+ "own_short": self.own_short,
147
+ "zdd_eui": self.zdd_eui,
148
+ "valves": {eui: v.to_json() for eui, v in sorted(self.valves.items())},
149
+ "ignored": list[JsonValue](sorted(self.ignored)),
150
+ }
151
+
152
+ @classmethod
153
+ def from_json(cls, data: object) -> Self:
154
+ """Inverse of ``to_json``. Malformed data, or another version, yields an empty cache
155
+ (logged at WARNING) rather than raising: a cache is never worth a failed setup."""
156
+ try:
157
+ return cls._parse(data)
158
+ except (_BadCacheError, ValueError, TypeError) as exc:
159
+ _LOGGER.warning("ignoring the stored bridge cache: %s", _reason(exc))
160
+ return cls()
161
+
162
+ @classmethod
163
+ def _parse(cls, data: object) -> Self:
164
+ if not isinstance(data, dict):
165
+ raise _BadCacheError("not an object")
166
+ version = data.get("version")
167
+ if type(version) is not int or version != CACHE_VERSION:
168
+ raise _BadCacheError("unsupported version")
169
+ own = data.get("own_short")
170
+ zdd = data.get("zdd_eui")
171
+ raw_valves = data.get("valves", {})
172
+ raw_ignored = data.get("ignored", [])
173
+ if not isinstance(raw_valves, dict) or not isinstance(raw_ignored, list):
174
+ raise _BadCacheError("valves or ignored have the wrong type")
175
+ valves: dict[str, CachedValve] = {}
176
+ for key, entry in raw_valves.items():
177
+ valve = CachedValve.from_json(entry)
178
+ if _eui(key) != valve.eui:
179
+ raise _BadCacheError("valve key does not match its EUI")
180
+ valves[valve.eui] = valve
181
+ return cls(
182
+ own_short=None if own is None else _short(own),
183
+ zdd_eui=None if zdd is None else _eui(zdd),
184
+ valves=valves,
185
+ ignored=frozenset(_eui(item) for item in raw_ignored),
186
+ )
187
+
188
+
189
+ def _reason(exc: BaseException) -> str:
190
+ """A log-safe reason: our own messages only (never echo the stored values)."""
191
+ return str(exc) if isinstance(exc, _BadCacheError) else "malformed data"
192
+
193
+
194
+ def _int(value: object, low: int, high: int) -> int:
195
+ if type(value) is not int or not low <= value <= high:
196
+ raise _BadCacheError("integer field out of range")
197
+ return value
198
+
199
+
200
+ def _short(value: object) -> int:
201
+ if type(value) is not int:
202
+ raise _BadCacheError("short address has the wrong type")
203
+ try:
204
+ return validate_short(value)
205
+ except ValueError as exc:
206
+ raise _BadCacheError("short address out of range") from exc
207
+
208
+
209
+ def _opt_str(value: object) -> str | None:
210
+ if value is None or isinstance(value, str):
211
+ return value
212
+ raise _BadCacheError("string field has the wrong type")
213
+
214
+
215
+ def _opt_float(value: object) -> float | None:
216
+ if value is None:
217
+ return None
218
+ if isinstance(value, bool) or not isinstance(value, int | float):
219
+ raise _BadCacheError("number field has the wrong type")
220
+ if not math.isfinite(value):
221
+ raise _BadCacheError("number field is not finite")
222
+ return float(value)
223
+
224
+
225
+ def _opt_source(value: object) -> _StoredSource | None:
226
+ if value is None:
227
+ return None
228
+ if value == "limits":
229
+ return "limits"
230
+ if value == "abs":
231
+ return "abs"
232
+ raise _BadCacheError("limits source is not recognised")
233
+
234
+
235
+ def _eui(value: object) -> str:
236
+ if not isinstance(value, str):
237
+ raise _BadCacheError("EUI is not a string")
238
+ try:
239
+ return str(Eui64.from_str(value))
240
+ except ValueError as exc:
241
+ raise _BadCacheError("invalid EUI") from exc
242
+
243
+
244
+ __all__ = ["CACHE_VERSION", "BridgeCache", "CachedValve", "JsonValue"]