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.
Files changed (62) hide show
  1. {python_izone-1.3.10 → python_izone-1.3.11}/PKG-INFO +1 -1
  2. python_izone-1.3.11/docs/release-notes/v1.3.11.md +21 -0
  3. python_izone-1.3.11/docs/v2-confirmation.md +71 -0
  4. {python_izone-1.3.10 → python_izone-1.3.11}/pizone/controller.py +295 -33
  5. {python_izone-1.3.10 → python_izone-1.3.11}/pizone/discovery.py +69 -36
  6. python_izone-1.3.11/pizone/types.py +26 -0
  7. python_izone-1.3.11/pizone/v2.py +585 -0
  8. {python_izone-1.3.10 → python_izone-1.3.11}/pizone/version.py +2 -2
  9. {python_izone-1.3.10 → python_izone-1.3.11}/pizone/zone.py +11 -1
  10. {python_izone-1.3.10 → python_izone-1.3.11}/scripts/izone_v1_probe.py +6 -5
  11. {python_izone-1.3.10 → python_izone-1.3.11}/tests/conftest.py +36 -0
  12. {python_izone-1.3.10 → python_izone-1.3.11}/tests/http_fakes.py +16 -2
  13. {python_izone-1.3.10 → python_izone-1.3.11}/tests/test_controller.py +288 -2
  14. {python_izone-1.3.10 → python_izone-1.3.11}/tests/test_discovery_api.py +84 -2
  15. python_izone-1.3.11/tests/test_discovery_close_timeout.py +63 -0
  16. {python_izone-1.3.10 → python_izone-1.3.11}/tests/test_fullstack.py +47 -1
  17. python_izone-1.3.11/tests/test_v2.py +238 -0
  18. python_izone-1.3.11/tests/test_v2_confirmation.py +312 -0
  19. python_izone-1.3.11/tests/test_v2_fan_capability.py +26 -0
  20. python_izone-1.3.11/tests/test_v2_vendor_enums.py +51 -0
  21. python_izone-1.3.11/tests/test_v2_zone_validation.py +125 -0
  22. {python_izone-1.3.10 → python_izone-1.3.11}/tests/test_zone.py +144 -1
  23. python_izone-1.3.11/tests/v2_fixtures.py +145 -0
  24. python_izone-1.3.10/pizone/types.py +0 -11
  25. {python_izone-1.3.10 → python_izone-1.3.11}/.gitattributes +0 -0
  26. {python_izone-1.3.10 → python_izone-1.3.11}/.github/workflows/main.yml +0 -0
  27. {python_izone-1.3.10 → python_izone-1.3.11}/.gitignore +0 -0
  28. {python_izone-1.3.10 → python_izone-1.3.11}/.pre-commit-config.yaml +0 -0
  29. {python_izone-1.3.10 → python_izone-1.3.11}/.python-version +0 -0
  30. {python_izone-1.3.10 → python_izone-1.3.11}/.vscode/.ropeproject/config.py +0 -0
  31. {python_izone-1.3.10 → python_izone-1.3.11}/.vscode/extensions.json +0 -0
  32. {python_izone-1.3.10 → python_izone-1.3.11}/.vscode/launch.json +0 -0
  33. {python_izone-1.3.10 → python_izone-1.3.11}/.vscode/settings.json +0 -0
  34. {python_izone-1.3.10 → python_izone-1.3.11}/README.md +0 -0
  35. {python_izone-1.3.10 → python_izone-1.3.11}/api_docs/AC-DOC-1401-11_iZoneEthernetInterface.pdf +0 -0
  36. {python_izone-1.3.10 → python_izone-1.3.11}/api_docs/iPower_JSON_datastrings_Out.h +0 -0
  37. {python_izone-1.3.10 → python_izone-1.3.11}/api_docs/iZone_JSON_datastrings.h +0 -0
  38. {python_izone-1.3.10 → python_izone-1.3.11}/docs/release-notes/v1.3.10.md +0 -0
  39. {python_izone-1.3.10 → python_izone-1.3.11}/docs/release-notes/v1.3.4.md +0 -0
  40. {python_izone-1.3.10 → python_izone-1.3.11}/docs/release-notes/v1.3.5.md +0 -0
  41. {python_izone-1.3.10 → python_izone-1.3.11}/docs/release-notes/v1.3.6.md +0 -0
  42. {python_izone-1.3.10 → python_izone-1.3.11}/docs/release-notes/v1.3.7.md +0 -0
  43. {python_izone-1.3.10 → python_izone-1.3.11}/docs/release-notes/v1.3.8.md +0 -0
  44. {python_izone-1.3.10 → python_izone-1.3.11}/docs/release-notes/v1.3.9.md +0 -0
  45. {python_izone-1.3.10 → python_izone-1.3.11}/licence.txt +0 -0
  46. {python_izone-1.3.10 → python_izone-1.3.11}/pizone/__init__.py +0 -0
  47. {python_izone-1.3.10 → python_izone-1.3.11}/pizone/const.py +0 -0
  48. {python_izone-1.3.10 → python_izone-1.3.11}/pizone/exceptions.py +0 -0
  49. {python_izone-1.3.10 → python_izone-1.3.11}/pizone/power.py +0 -0
  50. {python_izone-1.3.10 → python_izone-1.3.11}/pizone/py.typed +0 -0
  51. {python_izone-1.3.10 → python_izone-1.3.11}/pyproject.toml +0 -0
  52. {python_izone-1.3.10 → python_izone-1.3.11}/scripts/check +0 -0
  53. {python_izone-1.3.10 → python_izone-1.3.11}/scripts/coverage +0 -0
  54. {python_izone-1.3.10 → python_izone-1.3.11}/tests/__init__.py +0 -0
  55. {python_izone-1.3.10 → python_izone-1.3.11}/tests/power_data.py +0 -0
  56. {python_izone-1.3.10 → python_izone-1.3.11}/tests/resources.py +0 -0
  57. {python_izone-1.3.10 → python_izone-1.3.11}/tests/test_control_zone.py +0 -0
  58. {python_izone-1.3.10 → python_izone-1.3.11}/tests/test_discovery.py +0 -0
  59. {python_izone-1.3.10 → python_izone-1.3.11}/tests/test_dump_state.py +0 -0
  60. {python_izone-1.3.10 → python_izone-1.3.11}/tests/test_http.py +0 -0
  61. {python_izone-1.3.10 → python_izone-1.3.11}/tests/test_power.py +0 -0
  62. {python_izone-1.3.10 → python_izone-1.3.11}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-izone
3
- Version: 1.3.10
3
+ Version: 1.3.11
4
4
  Summary: A python interface to the iZone airconditioner controller
5
5
  Project-URL: Homepage, https://github.com/Swamp-Ig/pizone
6
6
  Author-email: Penny Wood <pypl@ninjateaparty.com>
@@ -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
- endpoint: ControllerEndpoint,
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=False,
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
- await controller._initialize(system_settings=system_settings)
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
- await self._probe_v2_api()
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
- """Detect V2 API support; non-fatal on failure."""
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
- uid = data["AirStreamDeviceUId"]
320
- self._is_v2 = uid == self._device_uid and "SystemV2" in data
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 SystemSettings and apply to cache (no refresh scope)."""
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 zone groups with overlapped GETs (no refresh scope)."""
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
- """Overlapped system + zones [+ power] fetch (no refresh scope)."""
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 via V1 HTTP."""
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 via V1 HTTP."""
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) via overlapping V1 GETs."""
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 like healthy AC subsystem data."""
1037
+ """Return whether *values* look healthy; unit-only systems have zero zones."""
932
1038
  return (
933
- int(values["NoOfZones"]) > 0
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
- await self._http_post(command, {command: send})
975
- self._system_settings[state] = value
976
- self._event_coordinator.controller_update(self)
977
- await self._fetch_system(notify=True)
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 that zone group's GET."""
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
- await self._http_post(command, data)
990
- await self._fetch_zone_group(group, notify=True)
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 await response.json(content_type=None)
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("latin_1")
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.text(encoding="latin_1")
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