zendure 0.1.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.

Potentially problematic release.


This version of zendure might be problematic. Click here for more details.

zendure/__init__.py ADDED
@@ -0,0 +1,78 @@
1
+ """Async local-network client for Zendure energy devices.
2
+
3
+ Talks to a device on the LAN over the zenSDK HTTP protocol (``GET
4
+ /properties/report`` + ``POST /properties/write``). No cloud, no MQTT, no app
5
+ token. This package never imports ``homeassistant``: a thin Home Assistant
6
+ integration consumes it in a later phase.
7
+
8
+ Public surface is re-exported here; everything else is an implementation detail.
9
+ """
10
+
11
+ from .client import ZendureClient
12
+ from .events import (
13
+ BatteryAdded,
14
+ BatteryPropertiesChanged,
15
+ BatteryRemoved,
16
+ DeviceAvailabilityChanged,
17
+ DevicePropertiesChanged,
18
+ EventListener,
19
+ ProtocolWarning,
20
+ ZendureEvent,
21
+ )
22
+ from .exceptions import (
23
+ ClientClosedError,
24
+ DeviceMismatchError,
25
+ MalformedResponseError,
26
+ UnsupportedDeviceError,
27
+ UnsupportedPropertyError,
28
+ ValidationError,
29
+ WriteNotConfirmedError,
30
+ WriteRejectedError,
31
+ ZendureConnectionError,
32
+ ZendureError,
33
+ ZendureProtocolError,
34
+ )
35
+ from .local import create_local_client
36
+ from .models import (
37
+ Availability,
38
+ BatterySnapshot,
39
+ ControlDescription,
40
+ DeviceSnapshot,
41
+ EntityKind,
42
+ PropertyValue,
43
+ SensorDescription,
44
+ )
45
+
46
+ __version__ = "0.1.0"
47
+
48
+ __all__ = [
49
+ "Availability",
50
+ "BatteryAdded",
51
+ "BatteryPropertiesChanged",
52
+ "BatteryRemoved",
53
+ "BatterySnapshot",
54
+ "ClientClosedError",
55
+ "ControlDescription",
56
+ "DeviceAvailabilityChanged",
57
+ "DeviceMismatchError",
58
+ "DevicePropertiesChanged",
59
+ "DeviceSnapshot",
60
+ "EntityKind",
61
+ "EventListener",
62
+ "MalformedResponseError",
63
+ "PropertyValue",
64
+ "ProtocolWarning",
65
+ "SensorDescription",
66
+ "UnsupportedDeviceError",
67
+ "UnsupportedPropertyError",
68
+ "ValidationError",
69
+ "WriteNotConfirmedError",
70
+ "WriteRejectedError",
71
+ "ZendureClient",
72
+ "ZendureConnectionError",
73
+ "ZendureError",
74
+ "ZendureEvent",
75
+ "ZendureProtocolError",
76
+ "__version__",
77
+ "create_local_client",
78
+ ]
zendure/_redact.py ADDED
@@ -0,0 +1,14 @@
1
+ """Helpers that keep sensitive identifiers out of logs and error messages."""
2
+
3
+
4
+ def mask_serial(serial_number: str) -> str:
5
+ """Keep a 4-character prefix for correlation and hide the rest.
6
+
7
+ ``HEC4ADP5P210250`` -> ``HEC4***``.
8
+ """
9
+ if len(serial_number) <= 4:
10
+ return "***"
11
+ return f"{serial_number[:4]}***"
12
+
13
+
14
+ __all__ = ["mask_serial"]
@@ -0,0 +1,28 @@
1
+ """Static capability definitions: protocol-agnostic, no runtime state.
2
+
3
+ Everything here is declared data (device models, property descriptions, battery
4
+ rules) plus the pure functions that look it up. These tables replace the
5
+ inheritance tree and runtime entity-building of the old integration.
6
+ """
7
+
8
+ from .controls import CONTROL_DESCRIPTIONS, control, validate_control
9
+ from .devices import DeviceCapability, lookup_device, normalize_product
10
+ from .sensors import (
11
+ BATTERY_SENSOR_DESCRIPTIONS,
12
+ DEVICE_SENSOR_DESCRIPTIONS,
13
+ battery_sensor,
14
+ device_sensor,
15
+ )
16
+
17
+ __all__ = [
18
+ "BATTERY_SENSOR_DESCRIPTIONS",
19
+ "CONTROL_DESCRIPTIONS",
20
+ "DEVICE_SENSOR_DESCRIPTIONS",
21
+ "DeviceCapability",
22
+ "battery_sensor",
23
+ "control",
24
+ "device_sensor",
25
+ "lookup_device",
26
+ "normalize_product",
27
+ "validate_control",
28
+ ]
@@ -0,0 +1,171 @@
1
+ """Writable property (control) descriptions and pre-write validation.
2
+
3
+ Static declaration of the 9 Max 5000 Pro controls plus the pure validation that
4
+ guards every write BEFORE anything leaves the library. An out-of-range, bad-enum
5
+ or read-only write raises ``ValidationError`` and nothing is sent; the library
6
+ never silently clamps (unlike the old integration).
7
+
8
+ Raw<->user encoding (e.g. ``socSet`` x10) is a protocol detail handled on the
9
+ write path, not here. This table holds user-facing ranges and enum options.
10
+ """
11
+
12
+ from collections.abc import Mapping
13
+
14
+ from ..exceptions import UnsupportedPropertyError, ValidationError
15
+ from ..models import ControlDescription, EntityKind, PropertyValue
16
+
17
+ _STEP_TOLERANCE = 1e-9
18
+
19
+ _CONTROLS: tuple[ControlDescription, ...] = (
20
+ ControlDescription(
21
+ key="acMode",
22
+ kind=EntityKind.SELECT,
23
+ options={1: "input", 2: "output"},
24
+ ),
25
+ ControlDescription(
26
+ key="gridOffMode",
27
+ kind=EntityKind.SELECT,
28
+ options={0: "normal", 1: "eco", 2: "off"},
29
+ ),
30
+ ControlDescription(
31
+ key="gridReverse",
32
+ kind=EntityKind.SELECT,
33
+ # 1=allow / 2=forbidden from the old integration; 0's meaning is a
34
+ # candidate pending firmware confirmation.
35
+ options={0: "disabled", 1: "allow", 2: "forbidden"},
36
+ ),
37
+ ControlDescription(
38
+ key="inputLimit",
39
+ kind=EntityKind.NUMBER,
40
+ unit="W",
41
+ native_min=0,
42
+ max_source_key="chargeMaxLimit",
43
+ ),
44
+ ControlDescription(
45
+ key="outputLimit",
46
+ kind=EntityKind.NUMBER,
47
+ unit="W",
48
+ native_min=0,
49
+ max_source_key="inverseMaxPower",
50
+ ),
51
+ # Regulatory output limit: read-only until firmware confirms it is safe and
52
+ # legal to write locally (it tracks gridStandard / regional caps).
53
+ ControlDescription(
54
+ key="inverseMaxPower",
55
+ kind=EntityKind.NUMBER,
56
+ unit="W",
57
+ writable=False,
58
+ ),
59
+ # zenSDK docs mark lampSwitch read-only, but the product's control list
60
+ # exposes Light as a switch; writability is a candidate pending firmware.
61
+ ControlDescription(key="lampSwitch", kind=EntityKind.SWITCH),
62
+ ControlDescription(
63
+ key="socSet",
64
+ kind=EntityKind.NUMBER,
65
+ unit="%",
66
+ native_min=70,
67
+ native_max=100,
68
+ step=1,
69
+ ),
70
+ # NOT a "smart strategy": smartMode=1 means params are not written to flash
71
+ # and revert after a device restart. Exposed verbatim.
72
+ ControlDescription(key="smartMode", kind=EntityKind.SWITCH),
73
+ )
74
+
75
+ CONTROL_DESCRIPTIONS: Mapping[str, ControlDescription] = {c.key: c for c in _CONTROLS}
76
+
77
+
78
+ def control(key: str) -> ControlDescription | None:
79
+ """Return the control description for ``key``, or ``None``."""
80
+ return CONTROL_DESCRIPTIONS.get(key)
81
+
82
+
83
+ def validate_control(
84
+ key: str,
85
+ value: PropertyValue,
86
+ *,
87
+ dynamic_max: float | None = None,
88
+ ) -> int | float:
89
+ """Validate a user-facing control value and return it normalized.
90
+
91
+ ``dynamic_max`` is the device's current reported upper bound (e.g.
92
+ ``inverseMaxPower``); it only applies to controls that declare a
93
+ ``max_source_key``.
94
+
95
+ Raises:
96
+ UnsupportedPropertyError: ``key`` is not a known control.
97
+ ValidationError: the control is read-only, or the value is out of
98
+ range / off-step / not a valid option.
99
+ """
100
+ description = CONTROL_DESCRIPTIONS.get(key)
101
+ if description is None:
102
+ raise UnsupportedPropertyError(key)
103
+ if not description.writable:
104
+ raise ValidationError(f"{key} is read-only")
105
+ if value is None:
106
+ raise ValidationError(f"{key} value must not be None")
107
+
108
+ if description.kind is EntityKind.SELECT:
109
+ return _validate_select(description, value)
110
+ if description.kind is EntityKind.SWITCH:
111
+ return _validate_switch(key, value)
112
+ if description.kind is EntityKind.NUMBER:
113
+ return _validate_number(description, value, dynamic_max)
114
+ raise ValidationError(f"{key} is not a writable control kind")
115
+
116
+
117
+ def _validate_select(description: ControlDescription, value: PropertyValue) -> int:
118
+ options = description.options
119
+ if options is None:
120
+ raise ValidationError(f"{description.key} has no options")
121
+ if isinstance(value, bool) or not isinstance(value, int):
122
+ raise ValidationError(f"{description.key} expects an integer option")
123
+ if value not in options:
124
+ raise ValidationError(
125
+ f"{description.key}={value} not in options {sorted(options)}"
126
+ )
127
+ return value
128
+
129
+
130
+ def _validate_switch(key: str, value: PropertyValue) -> int:
131
+ if isinstance(value, bool):
132
+ return int(value)
133
+ if value in (0, 1):
134
+ return int(value)
135
+ raise ValidationError(f"{key} expects 0, 1 or a bool")
136
+
137
+
138
+ def _validate_number(
139
+ description: ControlDescription,
140
+ value: PropertyValue,
141
+ dynamic_max: float | None,
142
+ ) -> int | float:
143
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
144
+ raise ValidationError(f"{description.key} expects a number")
145
+
146
+ low = description.native_min
147
+ if description.max_source_key is not None and dynamic_max is not None:
148
+ high: float | None = dynamic_max
149
+ else:
150
+ high = description.native_max
151
+
152
+ if low is not None and value < low:
153
+ raise ValidationError(f"{description.key}={value} below minimum {low}")
154
+ if high is not None and value > high:
155
+ raise ValidationError(f"{description.key}={value} above maximum {high}")
156
+
157
+ if description.step is not None:
158
+ base = description.native_min if description.native_min is not None else 0
159
+ steps = (value - base) / description.step
160
+ if abs(steps - round(steps)) > _STEP_TOLERANCE:
161
+ raise ValidationError(
162
+ f"{description.key}={value} not aligned to step {description.step}"
163
+ )
164
+ return value
165
+
166
+
167
+ __all__ = [
168
+ "CONTROL_DESCRIPTIONS",
169
+ "control",
170
+ "validate_control",
171
+ ]
@@ -0,0 +1,76 @@
1
+ """Device model capability table.
2
+
3
+ A device's reported ``product`` string is normalized (lower-cased, whitespace
4
+ removed) and looked up here, replacing the old per-model inheritance tree. An
5
+ unregistered product raises ``UnsupportedDeviceError`` rather than guessing.
6
+
7
+ Only ``max_5000_pro`` and ``solarflow_2400ac_plus`` are registered. They share
8
+ the same property set. Power limits are deliberately NOT stored here: the
9
+ device reports its own dynamic limits (``inverseMaxPower``, ``chargeMaxLimit``),
10
+ which always win over any static value. Other models are added as they are
11
+ verified on real hardware.
12
+ """
13
+
14
+ from dataclasses import dataclass
15
+
16
+
17
+ def normalize_product(product: str) -> str:
18
+ """Normalize a reported product string for case/whitespace-insensitive match.
19
+
20
+ ``"+"`` is significant (e.g. ``solarFlow2400AC+``) and is kept.
21
+ """
22
+ return "".join(product.split()).lower()
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class DeviceCapability:
27
+ """What the library knows about one device model, independent of protocol."""
28
+
29
+ model_key: str
30
+ display_name: str
31
+ solar_channels: int
32
+ aliases: tuple[str, ...]
33
+
34
+
35
+ # Keyed by model_key. `aliases` hold already-normalized product strings.
36
+ _CAPABILITIES: tuple[DeviceCapability, ...] = (
37
+ DeviceCapability(
38
+ model_key="max_5000_pro",
39
+ display_name="SolarFlow Max 5000 Pro",
40
+ # 4 PV inputs per the Max 5000 Pro feature list (candidate until a real
41
+ # Max 5000 Pro report is seen; its property set matches 2400 AC+).
42
+ solar_channels=4,
43
+ aliases=("solarflowmax5000pro",),
44
+ ),
45
+ DeviceCapability(
46
+ model_key="solarflow_2400ac_plus",
47
+ display_name="SolarFlow 2400 AC+",
48
+ solar_channels=4,
49
+ aliases=("solarflow2400ac+",),
50
+ ),
51
+ )
52
+
53
+ _BY_ALIAS: dict[str, DeviceCapability] = {
54
+ alias: cap for cap in _CAPABILITIES for alias in cap.aliases
55
+ }
56
+
57
+
58
+ def lookup_device(product: str) -> DeviceCapability:
59
+ """Resolve a reported product string to its capability, or reject it.
60
+
61
+ Raises:
62
+ UnsupportedDeviceError: the product is not a registered model.
63
+ """
64
+ from ..exceptions import UnsupportedDeviceError
65
+
66
+ capability = _BY_ALIAS.get(normalize_product(product))
67
+ if capability is None:
68
+ raise UnsupportedDeviceError(product)
69
+ return capability
70
+
71
+
72
+ __all__ = [
73
+ "DeviceCapability",
74
+ "lookup_device",
75
+ "normalize_product",
76
+ ]
@@ -0,0 +1,130 @@
1
+ """Read-only property descriptions (protocol-agnostic).
2
+
3
+ Static declaration of every sensor the library exposes for the Max 5000 Pro:
4
+ its entity kind, unit, and HA-style classes. Raw-to-value decoding is NOT here
5
+ (it is a protocol detail in ``protocols/local_http/codec.py``); this table only
6
+ says what each entity *is*.
7
+
8
+ Keys use the vendor's original casing (``electricLevel``, ``packNum``) on
9
+ purpose. A reported key that is absent here is not turned into an entity.
10
+ """
11
+
12
+ from collections.abc import Mapping
13
+
14
+ from ..models import EntityKind, SensorDescription
15
+
16
+
17
+ def _power(key: str) -> SensorDescription:
18
+ return SensorDescription(
19
+ key=key, unit="W", device_class="power", state_class="measurement"
20
+ )
21
+
22
+
23
+ def _voltage(key: str) -> SensorDescription:
24
+ return SensorDescription(
25
+ key=key,
26
+ unit="V",
27
+ device_class="voltage",
28
+ state_class="measurement",
29
+ precision=2,
30
+ )
31
+
32
+
33
+ def _temperature(key: str) -> SensorDescription:
34
+ return SensorDescription(
35
+ key=key,
36
+ unit="°C",
37
+ device_class="temperature",
38
+ state_class="measurement",
39
+ precision=1,
40
+ )
41
+
42
+
43
+ def _binary(key: str, device_class: str | None = None) -> SensorDescription:
44
+ return SensorDescription(
45
+ key=key, kind=EntityKind.BINARY_SENSOR, device_class=device_class
46
+ )
47
+
48
+
49
+ def _enum(key: str) -> SensorDescription:
50
+ # Integer state code; the meaning table is validated on real hardware.
51
+ return SensorDescription(key=key)
52
+
53
+
54
+ def _index(
55
+ descriptions: tuple[SensorDescription, ...],
56
+ ) -> Mapping[str, SensorDescription]:
57
+ return {d.key: d for d in descriptions}
58
+
59
+
60
+ # --- Device-level sensors (from the Max 5000 Pro feature list) ---------------
61
+ _DEVICE: tuple[SensorDescription, ...] = (
62
+ SensorDescription(
63
+ key="electricLevel", unit="%", device_class="battery", state_class="measurement"
64
+ ),
65
+ _power("gridInputPower"), # On-grid input (charge from grid)
66
+ _power("gridOffPower"), # Off-grid outlet: + output / - input (sign candidate)
67
+ _power("outputHomePower"), # On-grid output
68
+ _power("outputPackPower"), # Battery charge power
69
+ _power("packInputPower"), # Battery discharge power
70
+ _power("solarInputPower"), # Total PV input
71
+ _power("solarPower1"),
72
+ _power("solarPower2"),
73
+ _power("solarPower3"),
74
+ _power("solarPower4"),
75
+ _temperature("hyperTmp"), # Enclosure temperature
76
+ SensorDescription(key="packNum", state_class="measurement"), # pack count
77
+ _enum("packState"), # 0..2 (candidate)
78
+ SensorDescription(
79
+ key="remainOutTime",
80
+ unit="min",
81
+ device_class="duration",
82
+ state_class="measurement",
83
+ ),
84
+ _binary("heatState", device_class="heat"),
85
+ _binary("pass"), # bypass state
86
+ _binary("reverseState"), # reverse-flow state
87
+ _binary("socStatus"), # SOC calibration state
88
+ )
89
+
90
+ # --- Per-battery sensors -----------------------------------------------------
91
+ _BATTERY: tuple[SensorDescription, ...] = (
92
+ SensorDescription(
93
+ key="socLevel", unit="%", device_class="battery", state_class="measurement"
94
+ ),
95
+ _power("power"), # + charge / - discharge (sign candidate)
96
+ _temperature("maxTemp"),
97
+ _voltage("totalVol"),
98
+ _voltage("maxVol"),
99
+ _voltage("minVol"),
100
+ SensorDescription(
101
+ key="batcur",
102
+ unit="A",
103
+ device_class="current",
104
+ state_class="measurement",
105
+ precision=1,
106
+ ),
107
+ _enum("state"), # 0 standby / 1 charging / 2 discharging (candidate)
108
+ SensorDescription(key="softVersion"), # decoded to a version string
109
+ )
110
+
111
+ DEVICE_SENSOR_DESCRIPTIONS: Mapping[str, SensorDescription] = _index(_DEVICE)
112
+ BATTERY_SENSOR_DESCRIPTIONS: Mapping[str, SensorDescription] = _index(_BATTERY)
113
+
114
+
115
+ def device_sensor(key: str) -> SensorDescription | None:
116
+ """Return the device-level sensor description for ``key``, or ``None``."""
117
+ return DEVICE_SENSOR_DESCRIPTIONS.get(key)
118
+
119
+
120
+ def battery_sensor(key: str) -> SensorDescription | None:
121
+ """Return the per-battery sensor description for ``key``, or ``None``."""
122
+ return BATTERY_SENSOR_DESCRIPTIONS.get(key)
123
+
124
+
125
+ __all__ = [
126
+ "BATTERY_SENSOR_DESCRIPTIONS",
127
+ "DEVICE_SENSOR_DESCRIPTIONS",
128
+ "battery_sensor",
129
+ "device_sensor",
130
+ ]
zendure/client.py ADDED
@@ -0,0 +1,195 @@
1
+ """``ZendureClient``: the public facade (read path).
2
+
3
+ Depends only on the abstract ``DeviceProtocol``, never on a concrete protocol.
4
+ Every read goes through ``async_refresh`` (whether triggered by the built-in
5
+ poller or by the caller, e.g. a Home Assistant coordinator), so failure
6
+ counting and availability are consistent no matter who drives refreshes.
7
+ """
8
+
9
+ import asyncio
10
+ import logging
11
+ from collections.abc import Callable
12
+ from typing import Final, Self
13
+
14
+ from .catalog.controls import control, validate_control
15
+ from .events import EventListener, ZendureEvent
16
+ from .exceptions import (
17
+ ClientClosedError,
18
+ ValidationError,
19
+ WriteNotConfirmedError,
20
+ ZendureConnectionError,
21
+ ZendureProtocolError,
22
+ )
23
+ from .models import Availability, DeviceSnapshot, PropertyValue
24
+ from .protocols.base import DeviceProtocol
25
+ from .state.store import DEFAULT_OFFLINE_THRESHOLD, DeviceStore
26
+ from .transport.poller import DEFAULT_MAX_BACKOFF, DEFAULT_SCAN_INTERVAL, Poller
27
+
28
+ _LOGGER = logging.getLogger(__name__)
29
+
30
+ # Device reports can lag a successful write. Retry the confirmation GET only;
31
+ # the write itself is never retried.
32
+ _CONFIRM_ATTEMPTS: Final = 7
33
+ _CONFIRM_DELAY_S: Final = 1.0
34
+
35
+
36
+ class ZendureClient:
37
+ """One device, one client. Async context manager; ``close()`` is idempotent."""
38
+
39
+ def __init__(
40
+ self,
41
+ protocol: DeviceProtocol,
42
+ *,
43
+ offline_threshold: int = DEFAULT_OFFLINE_THRESHOLD,
44
+ expected_serial_number: str | None = None,
45
+ scan_interval: float = DEFAULT_SCAN_INTERVAL,
46
+ max_backoff: float = DEFAULT_MAX_BACKOFF,
47
+ ) -> None:
48
+ self._protocol = protocol
49
+ self._store = DeviceStore(
50
+ offline_threshold=offline_threshold,
51
+ expected_serial_number=expected_serial_number,
52
+ )
53
+ self._poller = Poller(
54
+ self.async_refresh,
55
+ interval=scan_interval,
56
+ max_interval=max_backoff,
57
+ backoff_after=offline_threshold,
58
+ )
59
+ self._listeners: list[EventListener] = []
60
+ self._inflight: asyncio.Task[DeviceSnapshot] | None = None
61
+ self._closed = False
62
+ self._write_lock = asyncio.Lock()
63
+
64
+ async def __aenter__(self) -> Self:
65
+ return self
66
+
67
+ async def __aexit__(self, *_exc: object) -> None:
68
+ await self.close()
69
+
70
+ @property
71
+ def snapshot(self) -> DeviceSnapshot | None:
72
+ """Latest snapshot, or ``None`` before the first successful read."""
73
+ return self._store.snapshot() if self._store.has_identity else None
74
+
75
+ @property
76
+ def availability(self) -> Availability:
77
+ """Current availability."""
78
+ return self._store.availability
79
+
80
+ def subscribe(self, listener: EventListener) -> Callable[[], None]:
81
+ """Register a change listener; returns a function that unsubscribes it."""
82
+ self._listeners.append(listener)
83
+
84
+ def unsubscribe() -> None:
85
+ if listener in self._listeners:
86
+ self._listeners.remove(listener)
87
+
88
+ return unsubscribe
89
+
90
+ async def async_refresh(self) -> DeviceSnapshot:
91
+ """Read the device once and merge the result.
92
+
93
+ Concurrent callers share one in-flight request, so a burst of refreshes
94
+ sends one GET and counts at most one failure.
95
+
96
+ Raises:
97
+ ClientClosedError: called after ``close()``.
98
+ ZendureConnectionError / ZendureProtocolError: the read failed
99
+ (counted toward the offline threshold).
100
+ UnsupportedDeviceError: the model is not supported (not counted).
101
+ """
102
+ if self._closed:
103
+ raise ClientClosedError("client is closed")
104
+ # Join only a request that is still running; a finished one (whose
105
+ # done-callback has not run yet) must not hand out a stale result.
106
+ if self._inflight is None or self._inflight.done():
107
+ self._inflight = asyncio.create_task(self._refresh_once())
108
+ self._inflight.add_done_callback(self._clear_inflight)
109
+ return await asyncio.shield(self._inflight)
110
+
111
+ def _clear_inflight(self, task: asyncio.Task[DeviceSnapshot]) -> None:
112
+ if self._inflight is task:
113
+ self._inflight = None
114
+ if not task.cancelled():
115
+ task.exception() # Mark as retrieved; the awaiting caller re-raises.
116
+
117
+ async def _refresh_once(self) -> DeviceSnapshot:
118
+ try:
119
+ report = await self._protocol.async_read()
120
+ snapshot, events = self._store.merge(report)
121
+ except (ZendureConnectionError, ZendureProtocolError) as err:
122
+ self._dispatch(self._store.record_failure(err))
123
+ raise
124
+ self._dispatch(events)
125
+ return snapshot
126
+
127
+ async def async_set(self, key: str, value: PropertyValue) -> DeviceSnapshot:
128
+ """Validate, write once, then confirm against a fresh device report.
129
+
130
+ A timeout is outcome-unknown and never retried. An accepted write whose
131
+ value is not yet reported raises WriteNotConfirmedError, not rejection.
132
+ """
133
+ async with self._write_lock:
134
+ if self._closed:
135
+ raise ClientClosedError("client is closed")
136
+ description = control(key)
137
+ # Reject unknown/read-only/type errors before requesting identity.
138
+ if description is None or not description.writable:
139
+ validate_control(key, value)
140
+ snapshot = await self.async_refresh()
141
+ dynamic_max = None
142
+ if description is not None and description.max_source_key is not None:
143
+ bound = snapshot.properties.get(description.max_source_key)
144
+ if isinstance(bound, bool) or not isinstance(bound, (int, float)):
145
+ raise ValidationError(f"{key} upper bound is unavailable")
146
+ dynamic_max = float(bound)
147
+ normalized = validate_control(key, value, dynamic_max=dynamic_max)
148
+ await self._protocol.async_write(snapshot.serial_number, {key: normalized})
149
+ if self._inflight is not None and not self._inflight.done():
150
+ await asyncio.shield(self._inflight)
151
+ confirmed = await self._confirm_write(key, normalized)
152
+ if confirmed.properties.get(key) != normalized:
153
+ raise WriteNotConfirmedError(f"{key} write not confirmed by read-back")
154
+ return confirmed
155
+
156
+ async def _confirm_write(self, key: str, expected: PropertyValue) -> DeviceSnapshot:
157
+ """Re-read until the device reports ``expected``, or attempts are exhausted."""
158
+ snapshot = await self.async_refresh()
159
+ for _ in range(_CONFIRM_ATTEMPTS - 1):
160
+ if snapshot.properties.get(key) == expected:
161
+ return snapshot
162
+ await asyncio.sleep(_CONFIRM_DELAY_S)
163
+ snapshot = await self.async_refresh()
164
+ return snapshot
165
+
166
+ def start(self) -> None:
167
+ """Start the built-in poller (standalone use; HA drives refreshes itself)."""
168
+ if self._closed:
169
+ raise ClientClosedError("client is closed")
170
+ self._poller.start()
171
+
172
+ async def close(self) -> None:
173
+ """Stop polling and cancel any in-flight read. Does not close the session."""
174
+ if self._closed:
175
+ return
176
+ self._closed = True
177
+ await self._poller.stop()
178
+ if (task := self._inflight) is not None:
179
+ task.cancel()
180
+ await asyncio.gather(task, return_exceptions=True)
181
+ self._listeners.clear()
182
+
183
+ def _dispatch(self, events: list[ZendureEvent]) -> None:
184
+ if self._closed:
185
+ return
186
+ for event in events:
187
+ for listener in tuple(self._listeners):
188
+ try:
189
+ listener(event)
190
+ except Exception:
191
+ # One faulty listener must not stop delivery to the others.
192
+ _LOGGER.exception("Listener failed on %s", type(event).__name__)
193
+
194
+
195
+ __all__ = ["ZendureClient"]