python-izone 1.3.9__py3-none-any.whl → 1.3.11__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.
- pizone/controller.py +342 -35
- pizone/discovery.py +71 -40
- pizone/types.py +15 -0
- pizone/v2.py +585 -0
- pizone/version.py +2 -2
- pizone/zone.py +11 -1
- {python_izone-1.3.9.dist-info → python_izone-1.3.11.dist-info}/METADATA +3 -3
- python_izone-1.3.11.dist-info/RECORD +15 -0
- {python_izone-1.3.9.dist-info → python_izone-1.3.11.dist-info}/WHEEL +1 -1
- python_izone-1.3.9.dist-info/RECORD +0 -14
- {python_izone-1.3.9.dist-info → python_izone-1.3.11.dist-info}/licenses/licence.txt +0 -0
pizone/controller.py
CHANGED
|
@@ -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.
|
|
@@ -659,9 +708,11 @@ class Controller:
|
|
|
659
708
|
|
|
660
709
|
@property
|
|
661
710
|
def zone_ctrl(self) -> int:
|
|
662
|
-
"""Zone that currently controls the AC unit.
|
|
711
|
+
"""Zone index that currently controls the AC unit.
|
|
663
712
|
|
|
664
713
|
Value is interpreted in combination with :attr:`ras_mode`.
|
|
714
|
+
See :attr:`control_setpoint_owner` for the controller or zone that
|
|
715
|
+
owns the unit target.
|
|
665
716
|
|
|
666
717
|
Raises:
|
|
667
718
|
TypeError: If the cached value is missing or not numeric.
|
|
@@ -669,6 +720,50 @@ class Controller:
|
|
|
669
720
|
"""
|
|
670
721
|
return int(self._get_system_state("CtrlZone"))
|
|
671
722
|
|
|
723
|
+
@property
|
|
724
|
+
def control_setpoint_owner(self) -> Self | Zone | None:
|
|
725
|
+
"""Controller or zone that currently owns the unit setpoint.
|
|
726
|
+
|
|
727
|
+
Returns this controller when RAS mode is ``RAS``, when RAS mode is
|
|
728
|
+
``master`` and :attr:`zone_ctrl` is past :attr:`zones_total` (the
|
|
729
|
+
indoor unit itself), or when any AUTO zone has no room sensor.
|
|
730
|
+
Otherwise the zone matching :attr:`zone_ctrl`, or ``None`` if that
|
|
731
|
+
index does not match a zone.
|
|
732
|
+
|
|
733
|
+
CONST/OPCL zones never have a room sensor; only a missing AUTO CTS
|
|
734
|
+
forces unit control.
|
|
735
|
+
"""
|
|
736
|
+
if (
|
|
737
|
+
self.ras_mode == "RAS"
|
|
738
|
+
or (self.ras_mode == "master" and self.zone_ctrl > self.zones_total)
|
|
739
|
+
or any(
|
|
740
|
+
zone.type is Zone.Type.AUTO and zone.temp_current is None
|
|
741
|
+
for zone in self.zones
|
|
742
|
+
)
|
|
743
|
+
):
|
|
744
|
+
return self
|
|
745
|
+
return next(
|
|
746
|
+
(zone for zone in self.zones if zone.index == self.zone_ctrl),
|
|
747
|
+
None,
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
@property
|
|
751
|
+
def control_setpoint(self) -> float | None:
|
|
752
|
+
"""Setpoint of whoever currently owns the unit target.
|
|
753
|
+
|
|
754
|
+
:attr:`temp_setpoint` when the controller owns the unit target,
|
|
755
|
+
otherwise the controlling AUTO zone's setpoint. ``None`` when there
|
|
756
|
+
is no matching zone or that zone is not AUTO.
|
|
757
|
+
"""
|
|
758
|
+
owner = self.control_setpoint_owner
|
|
759
|
+
if owner is None:
|
|
760
|
+
return None
|
|
761
|
+
if not isinstance(owner, Zone):
|
|
762
|
+
return self.temp_setpoint
|
|
763
|
+
if owner.type is not Zone.Type.AUTO:
|
|
764
|
+
return None
|
|
765
|
+
return owner.temp_setpoint
|
|
766
|
+
|
|
672
767
|
@property
|
|
673
768
|
def zones_total(self) -> int:
|
|
674
769
|
"""Number of zones the system is configured for.
|
|
@@ -729,12 +824,53 @@ class Controller:
|
|
|
729
824
|
raise first_other
|
|
730
825
|
|
|
731
826
|
async def _fetch_system(self, notify: bool = True) -> ControllerData | None:
|
|
732
|
-
"""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)
|
|
733
831
|
values: Controller.ControllerData = await self._request_get("SystemSettings")
|
|
734
832
|
return self._apply_system_settings(values, notify=notify)
|
|
735
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
|
+
|
|
736
870
|
async def _fetch_zone_group(self, group: int, notify: bool = True) -> None:
|
|
737
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")
|
|
738
874
|
if group not in (0, 4, 8, 12):
|
|
739
875
|
raise ValueError(f"Unsupported zone group start index {group}")
|
|
740
876
|
|
|
@@ -746,10 +882,21 @@ class Controller:
|
|
|
746
882
|
self.zones[i + group]._update_zone(zone_data, notify) # noqa: SLF001
|
|
747
883
|
|
|
748
884
|
async def _fetch_zones(self, notify: bool = True) -> None:
|
|
749
|
-
"""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
|
+
"""
|
|
750
890
|
zones = len(self.zones)
|
|
751
891
|
if zones == 0:
|
|
752
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
|
|
753
900
|
await self._gather_refresh(
|
|
754
901
|
*[self._fetch_zone_group(i, notify) for i in range(0, zones, 4)]
|
|
755
902
|
)
|
|
@@ -772,7 +919,12 @@ class Controller:
|
|
|
772
919
|
self._event_coordinator.power_update(self)
|
|
773
920
|
|
|
774
921
|
async def _fetch_all(self, notify: bool = True) -> None:
|
|
775
|
-
"""
|
|
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
|
|
776
928
|
zones = len(self.zones)
|
|
777
929
|
coros: list[Awaitable[Any]] = [self._fetch_system(notify)]
|
|
778
930
|
if power_mod.ENABLE_POWER and self._power is not None and self._power.enabled:
|
|
@@ -783,12 +935,12 @@ class Controller:
|
|
|
783
935
|
|
|
784
936
|
@_refresh_api
|
|
785
937
|
async def refresh_system(self) -> None:
|
|
786
|
-
"""Refresh system settings from the device
|
|
938
|
+
"""Refresh system settings from the device."""
|
|
787
939
|
await self._fetch_system(notify=True)
|
|
788
940
|
|
|
789
941
|
@_refresh_api
|
|
790
942
|
async def refresh_zones(self) -> None:
|
|
791
|
-
"""Refresh all zones from the device
|
|
943
|
+
"""Refresh all zones from the device."""
|
|
792
944
|
await self._fetch_zones(notify=True)
|
|
793
945
|
|
|
794
946
|
@_refresh_api
|
|
@@ -798,7 +950,7 @@ class Controller:
|
|
|
798
950
|
|
|
799
951
|
@_refresh_api
|
|
800
952
|
async def refresh_all(self) -> None:
|
|
801
|
-
"""Refresh system, zones, and power (if enabled)
|
|
953
|
+
"""Refresh system, zones, and power (if enabled)."""
|
|
802
954
|
await self._fetch_all(notify=True)
|
|
803
955
|
|
|
804
956
|
# disposition: deprecate — prefer public refresh_*; used by legacy poll
|
|
@@ -882,9 +1034,9 @@ class Controller:
|
|
|
882
1034
|
|
|
883
1035
|
@staticmethod
|
|
884
1036
|
def _system_settings_valid(values: ControllerData) -> bool:
|
|
885
|
-
"""Return whether *values* look
|
|
1037
|
+
"""Return whether *values* look healthy; unit-only systems have zero zones."""
|
|
886
1038
|
return (
|
|
887
|
-
int(values["NoOfZones"])
|
|
1039
|
+
int(values["NoOfZones"]) >= 0
|
|
888
1040
|
and str(values["SysFan"]) != "error"
|
|
889
1041
|
and str(values["RAS"]) != "error"
|
|
890
1042
|
)
|
|
@@ -925,23 +1077,178 @@ class Controller:
|
|
|
925
1077
|
return
|
|
926
1078
|
|
|
927
1079
|
async with self._sending_lock, self._refresh_scope():
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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)
|
|
932
1102
|
|
|
933
1103
|
async def _execute_zone_command(
|
|
934
1104
|
self, command: str, data: dict[str, Any], zone_index: int
|
|
935
1105
|
) -> None:
|
|
936
|
-
"""POST a zone command; on 1.4 path confirm with
|
|
1106
|
+
"""POST a zone command; on 1.4 path confirm with a zone refresh."""
|
|
937
1107
|
if self._legacy_pathway:
|
|
938
1108
|
await self._send_command_async(command, data)
|
|
939
1109
|
return
|
|
940
1110
|
|
|
941
|
-
group = (zone_index // 4) * 4
|
|
942
1111
|
async with self._sending_lock, self._refresh_scope():
|
|
943
|
-
|
|
944
|
-
|
|
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}")
|
|
945
1252
|
|
|
946
1253
|
def _set_bridge_ok(self, ok: bool, ex: Exception | None = None) -> None:
|
|
947
1254
|
was_connected = self.connected
|
|
@@ -1024,7 +1331,6 @@ class Controller:
|
|
|
1024
1331
|
lock,
|
|
1025
1332
|
session.get(
|
|
1026
1333
|
f"http://{self.device_ip}/{resource}",
|
|
1027
|
-
headers={"Connection": "close"},
|
|
1028
1334
|
timeout=aiohttp.ClientTimeout(total=Controller.REQUEST_TIMEOUT),
|
|
1029
1335
|
) as response,
|
|
1030
1336
|
):
|
|
@@ -1032,10 +1338,11 @@ class Controller:
|
|
|
1032
1338
|
raise ControllerCommandError(
|
|
1033
1339
|
f"HTTP {response.status} for http://{self.device_ip}/{resource}"
|
|
1034
1340
|
)
|
|
1341
|
+
raw = await response.read()
|
|
1342
|
+
text = v2_mod.decode_device_body(raw)
|
|
1035
1343
|
try:
|
|
1036
|
-
return
|
|
1344
|
+
return json.loads(text)
|
|
1037
1345
|
except json.JSONDecodeError as ex:
|
|
1038
|
-
text = await response.text()
|
|
1039
1346
|
if text[-4:] == "{OK}":
|
|
1040
1347
|
return json.loads(text[:-4])
|
|
1041
1348
|
_LOG.error('Decode error for "%s"', text, exc_info=True)
|
|
@@ -1061,7 +1368,7 @@ class Controller:
|
|
|
1061
1368
|
session = self._discovery_service.session
|
|
1062
1369
|
if session is None:
|
|
1063
1370
|
raise ConnectionError("Discovery service is not started")
|
|
1064
|
-
body = json.dumps(data).encode("
|
|
1371
|
+
body = json.dumps(data).encode("utf-8")
|
|
1065
1372
|
lock = self._sending_lock if self._legacy_pathway else nullcontext()
|
|
1066
1373
|
try:
|
|
1067
1374
|
async with (
|
|
@@ -1081,7 +1388,7 @@ class Controller:
|
|
|
1081
1388
|
f"Unable to connect to: http://{self.device_ip}/{command}"
|
|
1082
1389
|
f" response={response.status} message={response.reason}"
|
|
1083
1390
|
)
|
|
1084
|
-
result = await response.
|
|
1391
|
+
result = v2_mod.decode_device_body(await response.read())
|
|
1085
1392
|
except (TimeoutError, aiohttp.ClientError) as ex:
|
|
1086
1393
|
raise ConnectionError("Unable to connect to the controller") from ex
|
|
1087
1394
|
|