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 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
- 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.
@@ -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 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)
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 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
+ """
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
- """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
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 via V1 HTTP."""
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 via V1 HTTP."""
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) via overlapping V1 GETs."""
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 like healthy AC subsystem data."""
1037
+ """Return whether *values* look healthy; unit-only systems have zero zones."""
886
1038
  return (
887
- int(values["NoOfZones"]) > 0
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
- await self._http_post(command, {command: send})
929
- self._system_settings[state] = value
930
- self._event_coordinator.controller_update(self)
931
- 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)
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 that zone group's GET."""
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
- await self._http_post(command, data)
944
- 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}")
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 await response.json(content_type=None)
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("latin_1")
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.text(encoding="latin_1")
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