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.
- localtrv/__init__.py +10 -0
- localtrv/__main__.py +7 -0
- localtrv/bridge.py +292 -0
- localtrv/bridge_cache.py +244 -0
- localtrv/bridge_discovery.py +435 -0
- localtrv/bridge_owner.py +154 -0
- localtrv/bridge_poll.py +268 -0
- localtrv/bridge_setpoint.py +179 -0
- localtrv/bridge_state.py +414 -0
- localtrv/bridge_types.py +152 -0
- localtrv/bridge_verify.py +100 -0
- localtrv/cli.py +201 -0
- localtrv/py.typed +0 -0
- localtrv/testing/__init__.py +41 -0
- localtrv/testing/errors.py +28 -0
- localtrv/testing/gatt.py +534 -0
- localtrv/testing/gatt_client.py +192 -0
- localtrv/testing/gatt_faults.py +93 -0
- localtrv/testing/gatt_services.py +218 -0
- localtrv/testing/network.py +477 -0
- localtrv/testing/network_aps.py +131 -0
- localtrv/testing/network_frames.py +319 -0
- localtrv/testing/network_link.py +203 -0
- localtrv/testing/network_recorder.py +70 -0
- localtrv/testing/network_topology.py +98 -0
- localtrv/testing/scenario.py +128 -0
- localtrv/testing/valve.py +464 -0
- localtrv/testing/valve_table.py +124 -0
- localtrv/testing/zdd.py +241 -0
- localtrv/valves/__init__.py +210 -0
- localtrv/valves/constants.py +230 -0
- localtrv/valves/decode.py +226 -0
- localtrv/valves/models.py +207 -0
- localtrv/zigbee_direct/__init__.py +167 -0
- localtrv/zigbee_direct/clock.py +349 -0
- localtrv/zigbee_direct/codec/__init__.py +256 -0
- localtrv/zigbee_direct/codec/_cursor.py +72 -0
- localtrv/zigbee_direct/codec/aps.py +238 -0
- localtrv/zigbee_direct/codec/commissioning.py +332 -0
- localtrv/zigbee_direct/codec/fallback.py +315 -0
- localtrv/zigbee_direct/codec/nwk.py +231 -0
- localtrv/zigbee_direct/codec/nwk_commands.py +452 -0
- localtrv/zigbee_direct/codec/nwk_mesh.py +159 -0
- localtrv/zigbee_direct/codec/tlv.py +132 -0
- localtrv/zigbee_direct/codec/zcl.py +433 -0
- localtrv/zigbee_direct/codec/zcl_types.py +218 -0
- localtrv/zigbee_direct/codec/zdo.py +460 -0
- localtrv/zigbee_direct/codec/zdo_mgmt.py +166 -0
- localtrv/zigbee_direct/connection.py +324 -0
- localtrv/zigbee_direct/connection_backoff.py +114 -0
- localtrv/zigbee_direct/connection_failure.py +63 -0
- localtrv/zigbee_direct/connection_session.py +189 -0
- localtrv/zigbee_direct/connection_supervisor.py +473 -0
- localtrv/zigbee_direct/connection_types.py +137 -0
- localtrv/zigbee_direct/counters.py +100 -0
- localtrv/zigbee_direct/crypto.py +320 -0
- localtrv/zigbee_direct/debug.py +360 -0
- localtrv/zigbee_direct/discovery.py +136 -0
- localtrv/zigbee_direct/dispatch.py +454 -0
- localtrv/zigbee_direct/errors.py +211 -0
- localtrv/zigbee_direct/frames.py +213 -0
- localtrv/zigbee_direct/mmo.py +81 -0
- localtrv/zigbee_direct/node.py +278 -0
- localtrv/zigbee_direct/node_acks.py +111 -0
- localtrv/zigbee_direct/node_core.py +294 -0
- localtrv/zigbee_direct/node_join.py +184 -0
- localtrv/zigbee_direct/node_match.py +173 -0
- localtrv/zigbee_direct/node_requests.py +354 -0
- localtrv/zigbee_direct/node_types.py +250 -0
- localtrv/zigbee_direct/redact.py +363 -0
- localtrv/zigbee_direct/requests.py +513 -0
- localtrv/zigbee_direct/routing.py +457 -0
- localtrv/zigbee_direct/session.py +442 -0
- localtrv/zigbee_direct/transport.py +543 -0
- localtrv/zigbee_direct/transport_handshake.py +191 -0
- localtrv/zigbee_direct/transport_writer.py +203 -0
- localtrv/zigbee_direct/types.py +113 -0
- localtrv/zigbee_direct/uuids.py +67 -0
- localtrv-0.2.0.dist-info/METADATA +316 -0
- localtrv-0.2.0.dist-info/RECORD +83 -0
- localtrv-0.2.0.dist-info/WHEEL +4 -0
- localtrv-0.2.0.dist-info/entry_points.txt +2 -0
- 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
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
|
+
]
|
localtrv/bridge_cache.py
ADDED
|
@@ -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"]
|