python-izone 1.3.10__tar.gz → 1.3.11__tar.gz
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.
- {python_izone-1.3.10 → python_izone-1.3.11}/PKG-INFO +1 -1
- python_izone-1.3.11/docs/release-notes/v1.3.11.md +21 -0
- python_izone-1.3.11/docs/v2-confirmation.md +71 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/pizone/controller.py +295 -33
- {python_izone-1.3.10 → python_izone-1.3.11}/pizone/discovery.py +69 -36
- python_izone-1.3.11/pizone/types.py +26 -0
- python_izone-1.3.11/pizone/v2.py +585 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/pizone/version.py +2 -2
- {python_izone-1.3.10 → python_izone-1.3.11}/pizone/zone.py +11 -1
- {python_izone-1.3.10 → python_izone-1.3.11}/scripts/izone_v1_probe.py +6 -5
- {python_izone-1.3.10 → python_izone-1.3.11}/tests/conftest.py +36 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/tests/http_fakes.py +16 -2
- {python_izone-1.3.10 → python_izone-1.3.11}/tests/test_controller.py +288 -2
- {python_izone-1.3.10 → python_izone-1.3.11}/tests/test_discovery_api.py +84 -2
- python_izone-1.3.11/tests/test_discovery_close_timeout.py +63 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/tests/test_fullstack.py +47 -1
- python_izone-1.3.11/tests/test_v2.py +238 -0
- python_izone-1.3.11/tests/test_v2_confirmation.py +312 -0
- python_izone-1.3.11/tests/test_v2_fan_capability.py +26 -0
- python_izone-1.3.11/tests/test_v2_vendor_enums.py +51 -0
- python_izone-1.3.11/tests/test_v2_zone_validation.py +125 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/tests/test_zone.py +144 -1
- python_izone-1.3.11/tests/v2_fixtures.py +145 -0
- python_izone-1.3.10/pizone/types.py +0 -11
- {python_izone-1.3.10 → python_izone-1.3.11}/.gitattributes +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/.github/workflows/main.yml +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/.gitignore +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/.pre-commit-config.yaml +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/.python-version +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/.vscode/.ropeproject/config.py +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/.vscode/extensions.json +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/.vscode/launch.json +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/.vscode/settings.json +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/README.md +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/api_docs/AC-DOC-1401-11_iZoneEthernetInterface.pdf +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/api_docs/iPower_JSON_datastrings_Out.h +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/api_docs/iZone_JSON_datastrings.h +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/docs/release-notes/v1.3.10.md +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/docs/release-notes/v1.3.4.md +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/docs/release-notes/v1.3.5.md +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/docs/release-notes/v1.3.6.md +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/docs/release-notes/v1.3.7.md +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/docs/release-notes/v1.3.8.md +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/docs/release-notes/v1.3.9.md +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/licence.txt +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/pizone/__init__.py +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/pizone/const.py +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/pizone/exceptions.py +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/pizone/power.py +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/pizone/py.typed +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/pyproject.toml +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/scripts/check +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/scripts/coverage +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/tests/__init__.py +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/tests/power_data.py +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/tests/resources.py +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/tests/test_control_zone.py +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/tests/test_discovery.py +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/tests/test_dump_state.py +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/tests/test_http.py +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/tests/test_power.py +0 -0
- {python_izone-1.3.10 → python_izone-1.3.11}/uv.lock +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Release notes — v1.3.11
|
|
2
|
+
|
|
3
|
+
**Compare range:** https://github.com/Swamp-Ig/pizone/compare/v1.3.10...v1.3.11
|
|
4
|
+
|
|
5
|
+
## Since v1.3.10
|
|
6
|
+
|
|
7
|
+
### API v2 (new)
|
|
8
|
+
|
|
9
|
+
- Prefer the bridge's V2 API for reads when it returns useful data, and send writes through `iZoneCommandV2`. V2 data is normalized into the same V1-shaped caches, so the public API is unchanged (#35)
|
|
10
|
+
- Confirm V2 zone mode changes by reading them back, within fixed time limits, and yield between polling reads so queued commands are not held up by a full zone sweep. See [V2 zone mode confirmation](../v2-confirmation.md)
|
|
11
|
+
- Keep the zone setpoint when selecting AUTO mode over V2
|
|
12
|
+
- Validate V2 zone snapshots, and keep a zero airflow value instead of dropping it
|
|
13
|
+
- Correct the V2 sensor and fan enums against the vendor header
|
|
14
|
+
- Free Air: try the V2 `iSaveOn` command first and fall back to the V1 `FreeAir` endpoint if the bridge rejects it, remembering the result per controller
|
|
15
|
+
- Free Air status is now read from `iSaveEnable`/`iSaveOn`. Before, V2 bridges always reported it as off
|
|
16
|
+
|
|
17
|
+
### Fixes / hardening
|
|
18
|
+
|
|
19
|
+
- Allow controllers with no zones (unit-only systems). A negative zone count is still treated as a fault (#33, #34)
|
|
20
|
+
- Put a time limit on closing the discovery UDP transport. If the socket is not released within 1 s, abort it instead of hanging shutdown (Windows Proactor) (#41, #42)
|
|
21
|
+
- Document that setting a sleep timer can turn the unit on with some bridges
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# V2 zone mode confirmation
|
|
2
|
+
|
|
3
|
+
This proposal changes only the native V2 path, not the legacy polling pathway
|
|
4
|
+
or V1 writes. It does not promise faster mechanical damper movement.
|
|
5
|
+
|
|
6
|
+
## Contract
|
|
7
|
+
|
|
8
|
+
- Mode selection returns after the existing command transport acknowledges it.
|
|
9
|
+
- A controller-owned worker first attempts readback after 250 ms, subject to
|
|
10
|
+
the existing serialized transport and a shared two-second read-start limit.
|
|
11
|
+
- Multiple selections of one zone replace the earlier pending expectation.
|
|
12
|
+
Commands remain ordered and are not dropped or resent by confirmation.
|
|
13
|
+
- A fresh contradictory mode is masked for at most six seconds after ACK.
|
|
14
|
+
Afterwards the observed mode is published, while tracking continues for at
|
|
15
|
+
most 30 seconds. Other fields, including temperatures, remain observed data.
|
|
16
|
+
- Normal polling can satisfy a pending confirmation. It no longer holds the
|
|
17
|
+
command lock across the entire system/zone sweep; requests are still serial.
|
|
18
|
+
- A confirmation deadline includes time waiting for the lock and doing I/O.
|
|
19
|
+
Expiry logs an unconfirmed selection but does not by itself mark the bridge
|
|
20
|
+
offline. A real ConnectionError retains the existing disconnected/scan path.
|
|
21
|
+
- Controller close or discovery shutdown cancels owned confirmation work.
|
|
22
|
+
|
|
23
|
+
Explicit setpoint, fan, system-power and airflow commands retain their existing
|
|
24
|
+
confirmation behaviour. The existing AUTO-mode-only contribution is needed for
|
|
25
|
+
AUTO to take this fast path; otherwise the parent branch encodes AUTO as a
|
|
26
|
+
setpoint write. These changes should be reviewed together before release.
|
|
27
|
+
|
|
28
|
+
## Type=7 bulk-read assessment
|
|
29
|
+
|
|
30
|
+
The official [API header v1.41](https://developer.izone.com.au/downloads/iZone_JSON_datastrings.h)
|
|
31
|
+
documents request Type=7 as system status. A separate local adapter on one
|
|
32
|
+
V2-only bridge has successfully read `iZoneStatusV2.Zones` with that request.
|
|
33
|
+
This is a documented endpoint with single-installation read evidence, not a
|
|
34
|
+
guarantee of support across all firmware. The native branch currently has
|
|
35
|
+
Type=1/2 fixtures. Type=7 carries dynamic zone fields but does not replace
|
|
36
|
+
the full Type=2 metadata, such as zone names and types.
|
|
37
|
+
|
|
38
|
+
This PR therefore does not silently switch polling to Type=7. A later bulk-read
|
|
39
|
+
implementation should have all of the following acceptance cases:
|
|
40
|
+
|
|
41
|
+
1. Explicit capability detection, with bounded Type=2 fallback for unsupported
|
|
42
|
+
or incomplete responses; no parallel request storm.
|
|
43
|
+
2. Expected bridge identity, unique zone indices and complete zone coverage.
|
|
44
|
+
3. No merging of a partial failed bulk response with an unrelated fallback read.
|
|
45
|
+
4. Defined refresh/invalidation of names, types and any other cached metadata,
|
|
46
|
+
including changed zone counts, endpoint replacement and controller reload.
|
|
47
|
+
5. Tests for rapid commands, stale in-flight snapshots and bounded confirmation
|
|
48
|
+
that preserve the newest acknowledged selection.
|
|
49
|
+
6. Hardware validation on both a V2-only and a dual-stack bridge, plus unchanged
|
|
50
|
+
V1 regressions. A complete native-branch hardware test remains outstanding.
|
|
51
|
+
|
|
52
|
+
The lower-risk immediate improvement is command interleaving between Type=2
|
|
53
|
+
reads plus bounded read-only confirmation. It needs no new firmware assumption.
|
|
54
|
+
|
|
55
|
+
## Documentation boundaries
|
|
56
|
+
|
|
57
|
+
The [zone reference](https://developer.izone.com.au/docs/reference/zone/)
|
|
58
|
+
defines mode selection separately from setpoint updates, supporting the
|
|
59
|
+
mode-only AUTO contribution. Neither this page nor the Postman examples
|
|
60
|
+
specifies a guaranteed acknowledgement or mechanical damper latency. The
|
|
61
|
+
confirmation timings above remain measured/tested implementation choices.
|
|
62
|
+
|
|
63
|
+
The [cloud event service](https://developer.izone.com.au/docs/getting-started/cloud/event/)
|
|
64
|
+
is a separate vendor-arranged service, not a documented local WebSocket API
|
|
65
|
+
that this library can enable for instant zone updates. Existing local UDP
|
|
66
|
+
change notifications should not be confused with cloud event subscriptions.
|
|
67
|
+
|
|
68
|
+
The [discovery guide](https://developer.izone.com.au/docs/getting-started/local/discovery/)
|
|
69
|
+
says V2 devices retain V1 support, but issue #35 and the tested V2-only bridge
|
|
70
|
+
contradict that assumption. Keep usable-response probing and actual V2 writes;
|
|
71
|
+
an HTTP 200 response with an empty V1 body is not proof of V1 compatibility.
|
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
"""iZone controller interface."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
-
from asyncio import Condition, Lock
|
|
4
|
+
from asyncio import Condition, Event, Lock, Task
|
|
5
5
|
from collections.abc import AsyncIterator, Awaitable, Callable, Coroutine
|
|
6
6
|
from contextlib import asynccontextmanager, nullcontext
|
|
7
|
+
from dataclasses import dataclass
|
|
7
8
|
from enum import Enum
|
|
8
9
|
from functools import wraps
|
|
9
10
|
import json
|
|
10
11
|
import logging
|
|
11
|
-
from typing import TYPE_CHECKING, Any, Self, cast
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Literal, Self, cast
|
|
12
13
|
|
|
13
14
|
import aiohttp
|
|
14
15
|
|
|
15
|
-
from . import power as power_mod
|
|
16
|
+
from . import power as power_mod, v2 as v2_mod
|
|
16
17
|
from .exceptions import ControllerCommandError, ResponseDecodeError
|
|
17
18
|
from .power import Power
|
|
18
|
-
from .types import ControllerEndpoint
|
|
19
|
+
from .types import ControllerEndpoint, ControllerProbe
|
|
19
20
|
from .zone import Zone
|
|
20
21
|
|
|
21
22
|
if TYPE_CHECKING:
|
|
@@ -24,6 +25,16 @@ if TYPE_CHECKING:
|
|
|
24
25
|
_LOG = logging.getLogger("pizone.controller")
|
|
25
26
|
|
|
26
27
|
|
|
28
|
+
@dataclass
|
|
29
|
+
class _ZoneModeConfirmation:
|
|
30
|
+
"""One acknowledged mode command, owned by one controller instance."""
|
|
31
|
+
|
|
32
|
+
mode: str
|
|
33
|
+
issued: float
|
|
34
|
+
next_read: float
|
|
35
|
+
deadline: float
|
|
36
|
+
|
|
37
|
+
|
|
27
38
|
def _refresh_api(
|
|
28
39
|
method: Callable[..., Awaitable[Any]],
|
|
29
40
|
) -> Callable[..., Coroutine[Any, Any, Any]]:
|
|
@@ -90,6 +101,11 @@ class Controller:
|
|
|
90
101
|
UPDATE_REFRESH_DELAY = 5.0
|
|
91
102
|
"""Delay after sending a command before refreshing data, in seconds."""
|
|
92
103
|
|
|
104
|
+
ZONE_CONFIRM_DELAY = 0.25
|
|
105
|
+
ZONE_CONFIRM_INTERVAL = 2.0
|
|
106
|
+
ZONE_CONFIRM_WINDOW = 30.0
|
|
107
|
+
ZONE_OPTIMISTIC_WINDOW = 6.0
|
|
108
|
+
|
|
93
109
|
_VALID_FAN_MODES = {
|
|
94
110
|
"disabled": [Fan.LOW, Fan.MED, Fan.HIGH],
|
|
95
111
|
"unknown": [Fan.LOW, Fan.MED, Fan.HIGH, Fan.TOP, Fan.AUTO],
|
|
@@ -145,6 +161,14 @@ class Controller:
|
|
|
145
161
|
self._device_uid = device_uid
|
|
146
162
|
self._is_v2 = is_v2
|
|
147
163
|
self._is_ipower = is_ipower
|
|
164
|
+
# Reads stay V1 until 1.4 create sets this from a useful SystemV2 probe.
|
|
165
|
+
# UDP ``iZoneV2`` alone must not flip the read path.
|
|
166
|
+
self._read_api: Literal["v1", "v2"] = "v1"
|
|
167
|
+
self._v2_use_content_type: bool | None = None
|
|
168
|
+
# Set once iSaveOn (V2 FreeAir write) returns {ERROR} on this controller,
|
|
169
|
+
# so later FreeAir commands go straight to V1 instead of re-probing a
|
|
170
|
+
# write already known to fail here.
|
|
171
|
+
self._isave_v2_unsupported: bool = False
|
|
148
172
|
|
|
149
173
|
self.zones: list[Zone] = []
|
|
150
174
|
self.fan_modes: list[Controller.Fan] = []
|
|
@@ -161,6 +185,10 @@ class Controller:
|
|
|
161
185
|
self._scan_condition = Condition()
|
|
162
186
|
self._refresh_depth: int = 0
|
|
163
187
|
self._refresh_fail_ex: ConnectionError | None = None
|
|
188
|
+
self._zone_mode_confirmations: dict[int, _ZoneModeConfirmation] = {}
|
|
189
|
+
self._zone_confirm_task: Task[None] | None = None
|
|
190
|
+
self._zone_confirm_wake = Event()
|
|
191
|
+
self._zone_confirm_last_read = float("-inf")
|
|
164
192
|
|
|
165
193
|
@property
|
|
166
194
|
def _legacy_pathway(self) -> bool:
|
|
@@ -238,8 +266,7 @@ class Controller:
|
|
|
238
266
|
discovery_service: DiscoveryService,
|
|
239
267
|
event_coordinator: Listener,
|
|
240
268
|
*,
|
|
241
|
-
|
|
242
|
-
system_settings: Controller.ControllerData,
|
|
269
|
+
probe: ControllerProbe,
|
|
243
270
|
on_address_changed: Callable[[ControllerEndpoint], None] | None = None,
|
|
244
271
|
) -> Self:
|
|
245
272
|
"""Create and initialize a controller from an HTTP probe result.
|
|
@@ -248,16 +275,21 @@ class Controller:
|
|
|
248
275
|
service from :func:`~pizone.discovery.create_discovery` rather than
|
|
249
276
|
calling this from application code.
|
|
250
277
|
"""
|
|
278
|
+
endpoint = probe.endpoint
|
|
251
279
|
controller = cls(
|
|
252
280
|
discovery_service,
|
|
253
281
|
event_coordinator,
|
|
254
282
|
device_uid=endpoint.uid,
|
|
255
283
|
device_ip=endpoint.host,
|
|
256
|
-
is_v2=
|
|
284
|
+
is_v2=probe.read_api == "v2",
|
|
257
285
|
is_ipower=power_mod.ENABLE_POWER,
|
|
258
286
|
)
|
|
259
287
|
controller._on_address_changed = on_address_changed
|
|
260
|
-
|
|
288
|
+
controller._read_api = probe.read_api
|
|
289
|
+
controller._v2_use_content_type = probe.v2_use_content_type
|
|
290
|
+
await controller._initialize(
|
|
291
|
+
system_settings=cast(Controller.ControllerData, probe.system_settings)
|
|
292
|
+
)
|
|
261
293
|
return controller
|
|
262
294
|
|
|
263
295
|
async def _initialize(
|
|
@@ -289,7 +321,9 @@ class Controller:
|
|
|
289
321
|
else:
|
|
290
322
|
self.fan_modes = Controller._VALID_FAN_MODES["disabled"]
|
|
291
323
|
|
|
292
|
-
|
|
324
|
+
# 1.4 create already chose read_api; legacy still soft-probes V2.
|
|
325
|
+
if system_settings is None:
|
|
326
|
+
await self._probe_v2_api()
|
|
293
327
|
|
|
294
328
|
zone_count = int(settings["NoOfZones"])
|
|
295
329
|
self.zones = [Zone(self, i) for i in range(zone_count)]
|
|
@@ -309,15 +343,25 @@ class Controller:
|
|
|
309
343
|
self._discovery_service.create_task(self._poll_loop())
|
|
310
344
|
|
|
311
345
|
async def _probe_v2_api(self) -> None:
|
|
312
|
-
"""
|
|
346
|
+
"""Legacy-path V2 capability probe; non-fatal on failure.
|
|
347
|
+
|
|
348
|
+
The 1.4 create path sets :attr:`is_v2` / ``_read_api`` from connect and
|
|
349
|
+
does not call this method.
|
|
350
|
+
"""
|
|
313
351
|
try:
|
|
314
352
|
response = await self._http_post(
|
|
315
353
|
"iZoneRequestV2",
|
|
316
354
|
{"iZoneV2Request": {"Type": 1, "No": 0, "No1": 0}},
|
|
317
355
|
)
|
|
318
356
|
data = json.loads(response)
|
|
319
|
-
|
|
320
|
-
|
|
357
|
+
if (
|
|
358
|
+
isinstance(data, dict)
|
|
359
|
+
and data.get("AirStreamDeviceUId") == self._device_uid
|
|
360
|
+
and v2_mod.system_v2_useful(data)
|
|
361
|
+
):
|
|
362
|
+
self._is_v2 = True
|
|
363
|
+
else:
|
|
364
|
+
self._is_v2 = False
|
|
321
365
|
except ConnectionError, ControllerCommandError, json.JSONDecodeError, KeyError:
|
|
322
366
|
self._is_v2 = False
|
|
323
367
|
|
|
@@ -400,6 +444,10 @@ class Controller:
|
|
|
400
444
|
if self._closed:
|
|
401
445
|
return
|
|
402
446
|
self._closed = True
|
|
447
|
+
self._zone_mode_confirmations.clear()
|
|
448
|
+
if self._zone_confirm_task is not None:
|
|
449
|
+
self._zone_confirm_task.cancel()
|
|
450
|
+
await asyncio.gather(self._zone_confirm_task, return_exceptions=True)
|
|
403
451
|
self._on_address_changed = None
|
|
404
452
|
self._discovery_service._controller_closed(self) # noqa: SLF001
|
|
405
453
|
async with self._scan_condition:
|
|
@@ -553,7 +601,8 @@ class Controller:
|
|
|
553
601
|
async def set_sleep_timer(self, value: int) -> None:
|
|
554
602
|
"""Set the sleep timer.
|
|
555
603
|
|
|
556
|
-
Valid settings are 0, 30, 60, 90, and 120.
|
|
604
|
+
Valid settings are 0, 30, 60, 90, and 120. Some bridges turn the unit
|
|
605
|
+
on when a non-zero sleep timer is set, treating it as "run, then stop".
|
|
557
606
|
|
|
558
607
|
Raises:
|
|
559
608
|
AttributeError: If the value is out of range or not divisible by 30.
|
|
@@ -775,12 +824,53 @@ class Controller:
|
|
|
775
824
|
raise first_other
|
|
776
825
|
|
|
777
826
|
async def _fetch_system(self, notify: bool = True) -> ControllerData | None:
|
|
778
|
-
"""Fetch
|
|
827
|
+
"""Fetch system settings and apply to cache (no refresh scope)."""
|
|
828
|
+
if self._read_api == "v2":
|
|
829
|
+
async with self._sending_lock:
|
|
830
|
+
return await self._fetch_system_v2_unlocked(notify=notify)
|
|
779
831
|
values: Controller.ControllerData = await self._request_get("SystemSettings")
|
|
780
832
|
return self._apply_system_settings(values, notify=notify)
|
|
781
833
|
|
|
834
|
+
async def _fetch_system_v2_unlocked(
|
|
835
|
+
self, notify: bool = True
|
|
836
|
+
) -> ControllerData | None:
|
|
837
|
+
"""Fetch SystemV2 and apply. Caller must hold ``_sending_lock``."""
|
|
838
|
+
data = await self._v2_request(1, 0)
|
|
839
|
+
if data is None or not v2_mod.system_v2_useful(data):
|
|
840
|
+
raise ConnectionError("Unable to refresh system via V2")
|
|
841
|
+
uid = cast(str, data["AirStreamDeviceUId"])
|
|
842
|
+
if uid != self._device_uid:
|
|
843
|
+
_LOG.error("_fetch_system_v2 called with non-matching device ID")
|
|
844
|
+
return None
|
|
845
|
+
values = v2_mod.system_v2_to_settings(
|
|
846
|
+
cast(dict[str, Any], data["SystemV2"]), uid
|
|
847
|
+
)
|
|
848
|
+
return self._apply_system_settings(values, notify=notify)
|
|
849
|
+
|
|
850
|
+
async def _v2_request(self, req_type: int, no: int = 0) -> dict[str, Any] | None:
|
|
851
|
+
"""POST iZoneRequestV2 using the cached Content-Type mode."""
|
|
852
|
+
session = self._discovery_service.session
|
|
853
|
+
if session is None:
|
|
854
|
+
raise ConnectionError("Discovery service is not started")
|
|
855
|
+
try:
|
|
856
|
+
parsed, mode = await v2_mod.request_v2(
|
|
857
|
+
session,
|
|
858
|
+
self.device_ip,
|
|
859
|
+
req_type=req_type,
|
|
860
|
+
no=no,
|
|
861
|
+
use_content_type=self._v2_use_content_type,
|
|
862
|
+
timeout=Controller.REQUEST_TIMEOUT,
|
|
863
|
+
)
|
|
864
|
+
except (TimeoutError, aiohttp.ClientError, OSError) as ex:
|
|
865
|
+
raise ConnectionError("Unable to connect to the controller") from ex
|
|
866
|
+
if mode is not None and self._v2_use_content_type is None:
|
|
867
|
+
self._v2_use_content_type = mode
|
|
868
|
+
return parsed
|
|
869
|
+
|
|
782
870
|
async def _fetch_zone_group(self, group: int, notify: bool = True) -> None:
|
|
783
871
|
"""Fetch one zone group and update cache (no refresh scope)."""
|
|
872
|
+
if self._read_api == "v2":
|
|
873
|
+
raise RuntimeError("V2 path uses per-zone Type=2 fetches, not zone groups")
|
|
784
874
|
if group not in (0, 4, 8, 12):
|
|
785
875
|
raise ValueError(f"Unsupported zone group start index {group}")
|
|
786
876
|
|
|
@@ -792,10 +882,21 @@ class Controller:
|
|
|
792
882
|
self.zones[i + group]._update_zone(zone_data, notify) # noqa: SLF001
|
|
793
883
|
|
|
794
884
|
async def _fetch_zones(self, notify: bool = True) -> None:
|
|
795
|
-
"""Fetch all
|
|
885
|
+
"""Fetch all zones (no refresh scope).
|
|
886
|
+
|
|
887
|
+
V1 uses overlapped group GETs. V2 uses sequential Type=2 POSTs (local
|
|
888
|
+
bridges queue concurrent waits; sequential is faster and avoids storms).
|
|
889
|
+
"""
|
|
796
890
|
zones = len(self.zones)
|
|
797
891
|
if zones == 0:
|
|
798
892
|
return
|
|
893
|
+
if self._read_api == "v2":
|
|
894
|
+
# Keep HTTP sequential, but yield the lock to queued user commands
|
|
895
|
+
# between zones rather than holding it for an entire polling sweep.
|
|
896
|
+
for index in range(zones):
|
|
897
|
+
async with self._sending_lock:
|
|
898
|
+
await self._fetch_zone_v2_unlocked(index, notify=notify)
|
|
899
|
+
return
|
|
799
900
|
await self._gather_refresh(
|
|
800
901
|
*[self._fetch_zone_group(i, notify) for i in range(0, zones, 4)]
|
|
801
902
|
)
|
|
@@ -818,7 +919,12 @@ class Controller:
|
|
|
818
919
|
self._event_coordinator.power_update(self)
|
|
819
920
|
|
|
820
921
|
async def _fetch_all(self, notify: bool = True) -> None:
|
|
821
|
-
"""
|
|
922
|
+
"""System + zones [+ power] fetch (no refresh scope)."""
|
|
923
|
+
if self._read_api == "v2":
|
|
924
|
+
await self._fetch_system(notify)
|
|
925
|
+
await self._fetch_zones(notify)
|
|
926
|
+
await self._fetch_power(notify)
|
|
927
|
+
return
|
|
822
928
|
zones = len(self.zones)
|
|
823
929
|
coros: list[Awaitable[Any]] = [self._fetch_system(notify)]
|
|
824
930
|
if power_mod.ENABLE_POWER and self._power is not None and self._power.enabled:
|
|
@@ -829,12 +935,12 @@ class Controller:
|
|
|
829
935
|
|
|
830
936
|
@_refresh_api
|
|
831
937
|
async def refresh_system(self) -> None:
|
|
832
|
-
"""Refresh system settings from the device
|
|
938
|
+
"""Refresh system settings from the device."""
|
|
833
939
|
await self._fetch_system(notify=True)
|
|
834
940
|
|
|
835
941
|
@_refresh_api
|
|
836
942
|
async def refresh_zones(self) -> None:
|
|
837
|
-
"""Refresh all zones from the device
|
|
943
|
+
"""Refresh all zones from the device."""
|
|
838
944
|
await self._fetch_zones(notify=True)
|
|
839
945
|
|
|
840
946
|
@_refresh_api
|
|
@@ -844,7 +950,7 @@ class Controller:
|
|
|
844
950
|
|
|
845
951
|
@_refresh_api
|
|
846
952
|
async def refresh_all(self) -> None:
|
|
847
|
-
"""Refresh system, zones, and power (if enabled)
|
|
953
|
+
"""Refresh system, zones, and power (if enabled)."""
|
|
848
954
|
await self._fetch_all(notify=True)
|
|
849
955
|
|
|
850
956
|
# disposition: deprecate — prefer public refresh_*; used by legacy poll
|
|
@@ -928,9 +1034,9 @@ class Controller:
|
|
|
928
1034
|
|
|
929
1035
|
@staticmethod
|
|
930
1036
|
def _system_settings_valid(values: ControllerData) -> bool:
|
|
931
|
-
"""Return whether *values* look
|
|
1037
|
+
"""Return whether *values* look healthy; unit-only systems have zero zones."""
|
|
932
1038
|
return (
|
|
933
|
-
int(values["NoOfZones"])
|
|
1039
|
+
int(values["NoOfZones"]) >= 0
|
|
934
1040
|
and str(values["SysFan"]) != "error"
|
|
935
1041
|
and str(values["RAS"]) != "error"
|
|
936
1042
|
)
|
|
@@ -971,23 +1077,178 @@ class Controller:
|
|
|
971
1077
|
return
|
|
972
1078
|
|
|
973
1079
|
async with self._sending_lock, self._refresh_scope():
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1080
|
+
if self._read_api == "v2":
|
|
1081
|
+
v2_payload = v2_mod.system_command_to_v2(state, value, send)
|
|
1082
|
+
if state == "FreeAir" and self._isave_v2_unsupported:
|
|
1083
|
+
v2_payload = None
|
|
1084
|
+
if v2_payload is not None:
|
|
1085
|
+
try:
|
|
1086
|
+
await self._http_command_v2(v2_payload)
|
|
1087
|
+
except ControllerCommandError:
|
|
1088
|
+
if state != "FreeAir":
|
|
1089
|
+
raise
|
|
1090
|
+
self._isave_v2_unsupported = True
|
|
1091
|
+
await self._http_post(command, {command: send})
|
|
1092
|
+
else:
|
|
1093
|
+
await self._http_post(command, {command: send})
|
|
1094
|
+
self._system_settings[state] = value
|
|
1095
|
+
self._event_coordinator.controller_update(self)
|
|
1096
|
+
await self._fetch_system_v2_unlocked(notify=True)
|
|
1097
|
+
else:
|
|
1098
|
+
await self._http_post(command, {command: send})
|
|
1099
|
+
self._system_settings[state] = value
|
|
1100
|
+
self._event_coordinator.controller_update(self)
|
|
1101
|
+
await self._fetch_system(notify=True)
|
|
978
1102
|
|
|
979
1103
|
async def _execute_zone_command(
|
|
980
1104
|
self, command: str, data: dict[str, Any], zone_index: int
|
|
981
1105
|
) -> None:
|
|
982
|
-
"""POST a zone command; on 1.4 path confirm with
|
|
1106
|
+
"""POST a zone command; on 1.4 path confirm with a zone refresh."""
|
|
983
1107
|
if self._legacy_pathway:
|
|
984
1108
|
await self._send_command_async(command, data)
|
|
985
1109
|
return
|
|
986
1110
|
|
|
987
|
-
group = (zone_index // 4) * 4
|
|
988
1111
|
async with self._sending_lock, self._refresh_scope():
|
|
989
|
-
|
|
990
|
-
|
|
1112
|
+
if self._read_api == "v2":
|
|
1113
|
+
if self._closed:
|
|
1114
|
+
raise ConnectionError("Controller is closed")
|
|
1115
|
+
payload = v2_mod.zone_command_to_v2(command, data, zone_index)
|
|
1116
|
+
await self._http_command_v2(payload)
|
|
1117
|
+
if "ZoneMode" in payload:
|
|
1118
|
+
mode = v2_mod.zone_mode_from_wire(payload["ZoneMode"]["Mode"])
|
|
1119
|
+
self._track_zone_mode(zone_index, mode.value)
|
|
1120
|
+
else:
|
|
1121
|
+
await self._fetch_zone_v2_unlocked(zone_index, notify=True)
|
|
1122
|
+
else:
|
|
1123
|
+
group = (zone_index // 4) * 4
|
|
1124
|
+
await self._http_post(command, data)
|
|
1125
|
+
await self._fetch_zone_group(group, notify=True)
|
|
1126
|
+
|
|
1127
|
+
async def _fetch_zone_v2_unlocked(self, index: int, notify: bool = True) -> None:
|
|
1128
|
+
"""Refresh one zone via Type=2. Caller must hold ``_sending_lock``."""
|
|
1129
|
+
if index < 0 or index >= len(self.zones):
|
|
1130
|
+
return
|
|
1131
|
+
data = await self._v2_request(2, index)
|
|
1132
|
+
if data is None or not v2_mod.zones_v2_useful(
|
|
1133
|
+
data, index=index, uid=self._device_uid
|
|
1134
|
+
):
|
|
1135
|
+
raise ConnectionError(f"Invalid V2 zone response for index {index}")
|
|
1136
|
+
zone_data = v2_mod.zones_v2_to_zone_data(cast(dict[str, Any], data["ZonesV2"]))
|
|
1137
|
+
self._apply_v2_zone_state(index, zone_data, notify)
|
|
1138
|
+
|
|
1139
|
+
def _track_zone_mode(self, index: int, mode: str) -> None:
|
|
1140
|
+
"""Coalesce confirmations after ACK without delaying the caller."""
|
|
1141
|
+
if self._closed:
|
|
1142
|
+
return
|
|
1143
|
+
now = asyncio.get_running_loop().time()
|
|
1144
|
+
self._zone_mode_confirmations[index] = _ZoneModeConfirmation(
|
|
1145
|
+
mode, now, now + self.ZONE_CONFIRM_DELAY, now + self.ZONE_CONFIRM_WINDOW
|
|
1146
|
+
)
|
|
1147
|
+
self._zone_confirm_wake.set()
|
|
1148
|
+
if self._zone_confirm_task is None or self._zone_confirm_task.done():
|
|
1149
|
+
self._zone_confirm_task = self._discovery_service.create_task(
|
|
1150
|
+
self._confirm_zone_modes()
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
def _expire_zone_modes(self, now: float) -> None:
|
|
1154
|
+
"""Expire unconfirmed intent without declaring a reachable hub offline."""
|
|
1155
|
+
for index, pending in list(self._zone_mode_confirmations.items()):
|
|
1156
|
+
if now >= pending.deadline:
|
|
1157
|
+
del self._zone_mode_confirmations[index]
|
|
1158
|
+
_LOG.warning("V2 mode selection unconfirmed for zone %s", index)
|
|
1159
|
+
|
|
1160
|
+
def _apply_v2_zone_state(
|
|
1161
|
+
self, index: int, zone_data: Zone.ZoneData, notify: bool
|
|
1162
|
+
) -> None:
|
|
1163
|
+
"""Allow a short settling window, then show fresh contradictory state."""
|
|
1164
|
+
now = asyncio.get_running_loop().time()
|
|
1165
|
+
self._expire_zone_modes(now)
|
|
1166
|
+
pending = self._zone_mode_confirmations.get(index)
|
|
1167
|
+
if pending is not None:
|
|
1168
|
+
if zone_data["Mode"] == pending.mode:
|
|
1169
|
+
del self._zone_mode_confirmations[index]
|
|
1170
|
+
self._zone_confirm_wake.set()
|
|
1171
|
+
elif now < pending.issued + self.ZONE_OPTIMISTIC_WINDOW:
|
|
1172
|
+
zone_data = {**zone_data, "Mode": pending.mode}
|
|
1173
|
+
self.zones[index]._update_zone(zone_data, notify) # noqa: SLF001
|
|
1174
|
+
|
|
1175
|
+
async def _wait_for_zone_confirm_wake(self, wake_at: float) -> None:
|
|
1176
|
+
"""Wait for a new command or the next scheduled confirmation deadline."""
|
|
1177
|
+
try:
|
|
1178
|
+
async with asyncio.timeout_at(wake_at):
|
|
1179
|
+
await self._zone_confirm_wake.wait()
|
|
1180
|
+
except TimeoutError:
|
|
1181
|
+
pass
|
|
1182
|
+
|
|
1183
|
+
async def _confirm_zone_modes(self) -> None:
|
|
1184
|
+
"""Bounded, read-only confirmation; never resend a zone command."""
|
|
1185
|
+
loop = asyncio.get_running_loop()
|
|
1186
|
+
try:
|
|
1187
|
+
while self._zone_mode_confirmations and not self._closed:
|
|
1188
|
+
self._expire_zone_modes(loop.time())
|
|
1189
|
+
if not self._zone_mode_confirmations:
|
|
1190
|
+
break
|
|
1191
|
+
index, pending = min(
|
|
1192
|
+
self._zone_mode_confirmations.items(),
|
|
1193
|
+
key=lambda item: item[1].next_read,
|
|
1194
|
+
)
|
|
1195
|
+
due = max(
|
|
1196
|
+
pending.next_read,
|
|
1197
|
+
self._zone_confirm_last_read + self.ZONE_CONFIRM_INTERVAL,
|
|
1198
|
+
)
|
|
1199
|
+
# Wake for the earliest deadline even when another zone is due first.
|
|
1200
|
+
wake_at = min(
|
|
1201
|
+
due, *(p.deadline for p in self._zone_mode_confirmations.values())
|
|
1202
|
+
)
|
|
1203
|
+
self._zone_confirm_wake.clear()
|
|
1204
|
+
if wake_at > loop.time():
|
|
1205
|
+
await self._wait_for_zone_confirm_wake(wake_at)
|
|
1206
|
+
continue
|
|
1207
|
+
try:
|
|
1208
|
+
async with asyncio.timeout_at(pending.deadline), self._sending_lock:
|
|
1209
|
+
if (
|
|
1210
|
+
self._closed
|
|
1211
|
+
or self._zone_mode_confirmations.get(index) is not pending
|
|
1212
|
+
):
|
|
1213
|
+
continue
|
|
1214
|
+
self._zone_confirm_last_read = loop.time()
|
|
1215
|
+
await self._fetch_zone_v2_unlocked(index, notify=True)
|
|
1216
|
+
except TimeoutError:
|
|
1217
|
+
# This deadline bounds confirmation, not bridge availability.
|
|
1218
|
+
pass
|
|
1219
|
+
except ConnectionError as ex:
|
|
1220
|
+
self._set_bridge_ok(False, ex)
|
|
1221
|
+
self._nudge_scan()
|
|
1222
|
+
_LOG.debug("V2 zone confirmation read failed", exc_info=True)
|
|
1223
|
+
else:
|
|
1224
|
+
self._set_bridge_ok(True)
|
|
1225
|
+
if self._zone_mode_confirmations.get(index) is pending:
|
|
1226
|
+
pending.next_read = loop.time() + self.ZONE_CONFIRM_INTERVAL
|
|
1227
|
+
finally:
|
|
1228
|
+
self._zone_confirm_task = None
|
|
1229
|
+
if self._closed or self._discovery_service.is_closed:
|
|
1230
|
+
self._zone_mode_confirmations.clear()
|
|
1231
|
+
|
|
1232
|
+
async def _http_command_v2(self, payload: dict[str, Any]) -> str:
|
|
1233
|
+
"""POST iZoneCommandV2 using the cached Content-Type mode."""
|
|
1234
|
+
session = self._discovery_service.session
|
|
1235
|
+
if session is None:
|
|
1236
|
+
raise ConnectionError("Discovery service is not started")
|
|
1237
|
+
try:
|
|
1238
|
+
body, mode = await v2_mod.command_v2(
|
|
1239
|
+
session,
|
|
1240
|
+
self.device_ip,
|
|
1241
|
+
payload,
|
|
1242
|
+
use_content_type=self._v2_use_content_type,
|
|
1243
|
+
timeout=Controller.REQUEST_TIMEOUT,
|
|
1244
|
+
)
|
|
1245
|
+
except (TimeoutError, aiohttp.ClientError, OSError) as ex:
|
|
1246
|
+
raise ConnectionError("Unable to connect to the controller") from ex
|
|
1247
|
+
if mode is not None and self._v2_use_content_type is None:
|
|
1248
|
+
self._v2_use_content_type = mode
|
|
1249
|
+
if body.strip().startswith("{ERROR"):
|
|
1250
|
+
raise ControllerCommandError(f"Server returned error state {body}")
|
|
1251
|
+
return body.removesuffix("{OK}")
|
|
991
1252
|
|
|
992
1253
|
def _set_bridge_ok(self, ok: bool, ex: Exception | None = None) -> None:
|
|
993
1254
|
was_connected = self.connected
|
|
@@ -1077,10 +1338,11 @@ class Controller:
|
|
|
1077
1338
|
raise ControllerCommandError(
|
|
1078
1339
|
f"HTTP {response.status} for http://{self.device_ip}/{resource}"
|
|
1079
1340
|
)
|
|
1341
|
+
raw = await response.read()
|
|
1342
|
+
text = v2_mod.decode_device_body(raw)
|
|
1080
1343
|
try:
|
|
1081
|
-
return
|
|
1344
|
+
return json.loads(text)
|
|
1082
1345
|
except json.JSONDecodeError as ex:
|
|
1083
|
-
text = await response.text()
|
|
1084
1346
|
if text[-4:] == "{OK}":
|
|
1085
1347
|
return json.loads(text[:-4])
|
|
1086
1348
|
_LOG.error('Decode error for "%s"', text, exc_info=True)
|
|
@@ -1106,7 +1368,7 @@ class Controller:
|
|
|
1106
1368
|
session = self._discovery_service.session
|
|
1107
1369
|
if session is None:
|
|
1108
1370
|
raise ConnectionError("Discovery service is not started")
|
|
1109
|
-
body = json.dumps(data).encode("
|
|
1371
|
+
body = json.dumps(data).encode("utf-8")
|
|
1110
1372
|
lock = self._sending_lock if self._legacy_pathway else nullcontext()
|
|
1111
1373
|
try:
|
|
1112
1374
|
async with (
|
|
@@ -1126,7 +1388,7 @@ class Controller:
|
|
|
1126
1388
|
f"Unable to connect to: http://{self.device_ip}/{command}"
|
|
1127
1389
|
f" response={response.status} message={response.reason}"
|
|
1128
1390
|
)
|
|
1129
|
-
result = await response.
|
|
1391
|
+
result = v2_mod.decode_device_body(await response.read())
|
|
1130
1392
|
except (TimeoutError, aiohttp.ClientError) as ex:
|
|
1131
1393
|
raise ConnectionError("Unable to connect to the controller") from ex
|
|
1132
1394
|
|