python-izone 1.3.2__py3-none-any.whl → 1.3.4__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/__init__.py CHANGED
@@ -8,17 +8,24 @@ Synchronous property reads return cached device data and do not raise
8
8
  raise :exc:`ConnectionError` when the device cannot be reached. They raise
9
9
  :exc:`~pizone.exceptions.ControllerCommandError` when the device responds but
10
10
  rejects the request (``{ERROR...}`` body or HTTP 4xx).
11
+
12
+ Layered connection state on :class:`~pizone.controller.Controller`:
13
+
14
+ - :attr:`~pizone.controller.Controller.bridge_connected` — ASH bridge HTTP transport
15
+ - :attr:`~pizone.controller.Controller.connected` — bridge plus valid iZone AC data
16
+ - :attr:`~pizone.power.Power.connected` — power monitor I/O (when enabled)
11
17
  """
12
18
 
13
19
  from .controller import Controller
14
20
  from .discovery import DiscoveryService, Listener, discovery
15
- from .exceptions import ControllerCommandError
21
+ from .exceptions import ControllerCommandError, ResponseDecodeError
16
22
  from .power import BatteryLevel, Power, PowerChannel, PowerDevice, PowerGroup
17
23
  from .zone import Zone
18
24
 
19
25
  __all__ = [
20
26
  "Controller",
21
27
  "ControllerCommandError",
28
+ "ResponseDecodeError",
22
29
  "Zone",
23
30
  "DiscoveryService",
24
31
  "Listener",
pizone/controller.py CHANGED
@@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any, cast
11
11
 
12
12
  import aiohttp
13
13
 
14
- from .exceptions import ControllerCommandError
14
+ from .exceptions import ControllerCommandError, ResponseDecodeError
15
15
  from .power import Power
16
16
  from .zone import Zone
17
17
 
@@ -33,9 +33,10 @@ class Controller:
33
33
  They raise :exc:`ConnectionError` when the device cannot be reached.
34
34
  They raise :exc:`~pizone.exceptions.ControllerCommandError` when the
35
35
  device responds but rejects the request (``{ERROR...}`` body or HTTP 4xx).
36
- A successful transport request clears a prior connection failure and
37
- notifies listeners via
38
- :meth:`~pizone.discovery.Listener.controller_reconnected`.
36
+
37
+ :attr:`connected` is ``True`` when the ASH bridge is reachable over HTTP
38
+ and the iZone AC subsystem last returned valid system data. Check
39
+ :attr:`bridge_connected` for bridge transport health alone.
39
40
  """
40
41
 
41
42
  class Mode(Enum):
@@ -78,6 +79,27 @@ class Controller:
78
79
  "var-speed": [Fan.LOW, Fan.MED, Fan.HIGH, Fan.AUTO],
79
80
  }
80
81
 
82
+ _FAULT_STATE_VALUES = frozenset({"error"})
83
+ _SYSTEM_STATE_DEFAULTS: ControllerData = {
84
+ "SysOn": "off",
85
+ "SysMode": "cool",
86
+ "SysFan": "auto",
87
+ "SleepTimer": 0,
88
+ "FreeAir": "disabled",
89
+ "Supply": "0.0",
90
+ "Setpoint": "0.0",
91
+ "Temp": "0.0",
92
+ "EcoLock": "false",
93
+ "EcoMin": "15.0",
94
+ "EcoMax": "30.0",
95
+ "RAS": "zones",
96
+ "CtrlZone": 0,
97
+ "NoOfZones": 0,
98
+ "NoOfConst": 0,
99
+ "SysType": "0",
100
+ "FanAuto": "disabled",
101
+ }
102
+
81
103
  def __init__(
82
104
  self,
83
105
  discovery_service: DiscoveryService,
@@ -113,7 +135,8 @@ class Controller:
113
135
  self._power: Power | None = None
114
136
 
115
137
  self._initialized: bool = False
116
- self._fail_exception: Exception | None = None
138
+ self._bridge_ok: bool = True
139
+ self._izone_ok: bool = True
117
140
 
118
141
  self._sending_lock = Lock()
119
142
  self._scan_condition = Condition()
@@ -125,15 +148,20 @@ class Controller:
125
148
  ConnectionError: If a required HTTP request fails.
126
149
  KeyError: If a required field is missing from a device response.
127
150
  """
128
- await self._refresh_system(notify=False)
129
-
130
- self.fan_modes = Controller._VALID_FAN_MODES[
131
- str(self._system_settings.get("FanAuto", "disabled"))
132
- ]
151
+ settings = await self._refresh_system(notify=False)
152
+ if settings is None:
153
+ raise ConnectionError("SystemSettings device ID mismatch")
154
+
155
+ if self._system_settings:
156
+ self.fan_modes = Controller._VALID_FAN_MODES[
157
+ str(self._system_settings.get("FanAuto", "disabled"))
158
+ ]
159
+ else:
160
+ self.fan_modes = Controller._VALID_FAN_MODES["disabled"]
133
161
 
134
162
  await self._probe_v2_api()
135
163
 
136
- zone_count = int(self._system_settings["NoOfZones"])
164
+ zone_count = int(settings["NoOfZones"])
137
165
  self.zones = [Zone(self, i) for i in range(zone_count)]
138
166
 
139
167
  await self._refresh_zones(notify=False)
@@ -141,15 +169,18 @@ class Controller:
141
169
  await self._probe_power()
142
170
 
143
171
  self._initialized = True
172
+ if not self.connected:
173
+ self._event_coordinator.controller_disconnected(
174
+ self, ConnectionError("iZone controller unavailable")
175
+ )
144
176
  self._discovery_service.create_task(self._poll_loop())
145
177
 
146
178
  async def _probe_v2_api(self) -> None:
147
179
  """Detect V2 API support; non-fatal on failure."""
148
180
  try:
149
- response = await self._send_command_async(
181
+ response = await self._http_post(
150
182
  "iZoneRequestV2",
151
183
  {"iZoneV2Request": {"Type": 1, "No": 0, "No1": 0}},
152
- mark_disconnected=False,
153
184
  )
154
185
  data = json.loads(response)
155
186
  uid = data["AirStreamDeviceUId"]
@@ -165,7 +196,7 @@ class Controller:
165
196
 
166
197
  try:
167
198
  power = Power(self)
168
- await power.init(mark_disconnected=False)
199
+ await power.init()
169
200
  if power.enabled:
170
201
  self._power = power
171
202
  return
@@ -215,10 +246,15 @@ class Controller:
215
246
  async with self._scan_condition:
216
247
  self._scan_condition.notify()
217
248
 
249
+ @property
250
+ def bridge_connected(self) -> bool:
251
+ """True while the ASH bridge is reachable over HTTP."""
252
+ return self._bridge_ok
253
+
218
254
  @property
219
255
  def connected(self) -> bool:
220
- """True while the controller can reach the device over HTTP."""
221
- return self._fail_exception is None
256
+ """True while the bridge is up and the iZone AC subsystem is available."""
257
+ return self._bridge_ok and self._izone_ok
222
258
 
223
259
  @property
224
260
  def power(self) -> Power | None:
@@ -265,14 +301,13 @@ class Controller:
265
301
 
266
302
  @property
267
303
  def mode(self) -> Mode:
268
- """System mode (cooling, heating, etc.).
269
-
270
- Raises:
271
- ValueError: If the cached mode is not a valid :class:`Mode` member.
272
- """
304
+ """System mode (cooling, heating, etc.)."""
273
305
  if self.free_air:
274
306
  return self.Mode.FREE_AIR
275
- return self.Mode(self._get_system_state("SysMode"))
307
+ try:
308
+ return self.Mode(self._get_system_state("SysMode"))
309
+ except ValueError:
310
+ return self.Mode.COOL
276
311
 
277
312
  async def set_mode(self, value: Mode) -> None:
278
313
  """Set the system mode (cooling, heating, etc.).
@@ -294,12 +329,11 @@ class Controller:
294
329
 
295
330
  @property
296
331
  def fan(self) -> Fan:
297
- """The current fan level.
298
-
299
- Raises:
300
- ValueError: If the cached fan mode is not a valid :class:`Fan` member.
301
- """
302
- return self.Fan(self._get_system_state("SysFan"))
332
+ """The current fan level."""
333
+ try:
334
+ return self.Fan(self._get_system_state("SysFan"))
335
+ except ValueError:
336
+ return self.Fan.AUTO
303
337
 
304
338
  async def set_fan(self, value: Fan) -> None:
305
339
  """Set the fan level.
@@ -476,19 +510,25 @@ class Controller:
476
510
  ConnectionError: If any HTTP request fails.
477
511
  KeyError: If a required field is missing from a device response.
478
512
  """
479
- zones = int(self._system_settings["NoOfZones"])
480
- # gather schedules all refresh coroutines together; _sending_lock serializes
481
- # HTTP to one in-flight request per controller. Revisit the lock if firmware
482
- # supports concurrent requests — gather can stay.
513
+ zones = len(self.zones)
514
+ if zones == 0:
515
+ await asyncio.gather(
516
+ self._refresh_system(notify),
517
+ self._refresh_power(notify),
518
+ )
519
+ return
483
520
  await asyncio.gather(
484
521
  self._refresh_system(notify),
485
522
  self._refresh_power(notify),
486
523
  *[self._refresh_zone_group(i, notify) for i in range(0, zones, 4)],
487
524
  )
488
525
 
489
- async def _refresh_system(self, notify: bool = True) -> None:
526
+ async def _refresh_system(self, notify: bool = True) -> ControllerData | None:
490
527
  """Refresh the system settings from the device.
491
528
 
529
+ Returns the device response even when the payload is a fault placeholder,
530
+ so callers can read fields like ``NoOfZones`` during initialization.
531
+
492
532
  Raises:
493
533
  ConnectionError: If the HTTP request fails.
494
534
  KeyError: If the response is missing required fields.
@@ -496,25 +536,40 @@ class Controller:
496
536
  values: Controller.ControllerData = await self._get_resource("SystemSettings")
497
537
  if self._device_uid != values["AirStreamDeviceUId"]:
498
538
  _LOG.error("_refresh_system called with non-matching device ID")
499
- return
539
+ return None
500
540
 
501
- self._system_settings = values
541
+ if not self._system_settings_valid(values):
542
+ _LOG.warning(
543
+ "iZone subsystem fault uid=%s; retaining cache",
544
+ self._device_uid,
545
+ )
546
+ self._set_izone_ok(
547
+ False, ConnectionError("iZone controller unavailable")
548
+ )
549
+ if notify:
550
+ self._event_coordinator.controller_update(self)
551
+ return values
502
552
 
553
+ self._system_settings = values
554
+ self._set_izone_ok(True)
503
555
  if notify:
504
556
  self._event_coordinator.controller_update(self)
557
+ return values
505
558
 
506
559
  async def _refresh_power(self, notify: bool = True) -> None:
507
- """Refresh power monitor data when enabled.
508
-
509
- Raises:
510
- ConnectionError: If the HTTP request fails.
511
- json.JSONDecodeError: If the device response is not valid JSON.
512
- KeyError: If the response is missing required fields.
513
- """
560
+ """Refresh power monitor data when enabled."""
514
561
  if self._power is None or not self._power.enabled:
515
562
  return
516
563
 
517
- updated = await self._power.refresh()
564
+ try:
565
+ updated = await self._power.refresh()
566
+ except (ConnectionError, ControllerCommandError, json.JSONDecodeError, KeyError):
567
+ _LOG.warning(
568
+ "Power refresh failed for uid=%s",
569
+ self._device_uid,
570
+ exc_info=True,
571
+ )
572
+ return
518
573
 
519
574
  if updated and notify:
520
575
  self._event_coordinator.power_update(self)
@@ -527,7 +582,9 @@ class Controller:
527
582
  KeyError: If a response is missing required fields.
528
583
  AttributeError: If a zone index in the response does not match.
529
584
  """
530
- zones = int(self._system_settings["NoOfZones"])
585
+ zones = len(self.zones)
586
+ if zones == 0:
587
+ return
531
588
  await asyncio.gather(
532
589
  *[self._refresh_zone_group(i, notify) for i in range(0, zones, 4)]
533
590
  )
@@ -555,12 +612,26 @@ class Controller:
555
612
  def _refresh_address(self, address: str) -> None:
556
613
  """Update the device IP and schedule a reconnect attempt if needed."""
557
614
  self._ip = address
558
- # Signal to the retry connection loop to have another go.
559
- if self._fail_exception:
615
+ if not self._bridge_ok:
560
616
  self._discovery_service.create_task(self._retry_connection())
561
617
 
618
+ @staticmethod
619
+ def _system_settings_valid(values: ControllerData) -> bool:
620
+ """Return whether *values* look like healthy AC subsystem data."""
621
+ return (
622
+ int(values["NoOfZones"]) > 0
623
+ and str(values["SysFan"]) != "error"
624
+ and str(values["RAS"]) != "error"
625
+ )
626
+
562
627
  def _get_system_state(self, state: str) -> DictValue:
563
- return self._system_settings[state]
628
+ default = self._SYSTEM_STATE_DEFAULTS.get(state, "")
629
+ value = self._system_settings.get(state)
630
+ if value is None:
631
+ return default
632
+ if state in ("SysFan", "RAS", "SysMode") and str(value) in self._FAULT_STATE_VALUES:
633
+ return default
634
+ return value
564
635
 
565
636
  async def _set_system_state(
566
637
  self,
@@ -583,33 +654,47 @@ class Controller:
583
654
  self._event_coordinator.controller_update(self)
584
655
  await self.refresh()
585
656
 
586
- def _failed_connection(self, ex: Exception) -> None:
587
- if self._fail_exception:
588
- self._fail_exception = ex
589
- return
590
- self._fail_exception = ex
591
- if not self._initialized:
657
+ def _set_bridge_ok(self, ok: bool, ex: Exception | None = None) -> None:
658
+ was_connected = self.connected
659
+ self._bridge_ok = ok
660
+ if not ok and ex is None:
661
+ ex = ConnectionError("Unable to connect to the controller")
662
+ self._notify_connected_changed(was_connected, ex if not ok else None)
663
+
664
+ def _set_izone_ok(self, ok: bool, ex: Exception | None = None) -> None:
665
+ was_connected = self.connected
666
+ self._izone_ok = ok
667
+ if not ok and ex is None:
668
+ ex = ConnectionError("iZone controller unavailable")
669
+ self._notify_connected_changed(was_connected, ex if not ok else None)
670
+
671
+ def _notify_connected_changed(
672
+ self, was_connected: bool, ex: Exception | None
673
+ ) -> None:
674
+ if was_connected == self.connected or not self._initialized:
592
675
  return
593
- self._event_coordinator.controller_disconnected(self, ex)
676
+ if self.connected:
677
+ self._event_coordinator.controller_update(self)
678
+ for zone in self.zones:
679
+ self._event_coordinator.zone_update(self, zone)
680
+ self._event_coordinator.power_update(self)
681
+ self._event_coordinator.controller_reconnected(self)
682
+ elif ex is not None:
683
+ self._event_coordinator.controller_disconnected(self, ex)
684
+
685
+ def _failed_connection(self, ex: Exception) -> None:
686
+ """Mark the bridge transport as failed."""
687
+ self._set_bridge_ok(False, ex)
594
688
 
595
689
  def _restored_connection(self) -> None:
596
- """Clear a prior connection failure after successful I/O."""
597
- if self._fail_exception is None:
598
- return
599
- self._fail_exception = None
600
- if not self._initialized:
601
- return
602
- self._event_coordinator.controller_update(self)
603
- for zone in self.zones:
604
- self._event_coordinator.zone_update(self, zone)
605
- self._event_coordinator.power_update(self)
606
- self._event_coordinator.controller_reconnected(self)
690
+ """Mark the bridge transport as restored."""
691
+ self._set_bridge_ok(True)
607
692
 
608
693
  async def _retry_connection(self) -> None:
609
694
  """Attempt to restore connectivity after a connection failure.
610
695
 
611
696
  Connection errors are logged and not re-raised. A successful refresh
612
- restores :attr:`connected` via :meth:`_restored_connection`.
697
+ restores :attr:`connected` when both bridge and iZone layers recover.
613
698
  """
614
699
  _LOG.info(
615
700
  "Attempting to reconnect to server uid=%s ip=%s",
@@ -627,95 +712,124 @@ class Controller:
627
712
  exc_info=True,
628
713
  )
629
714
 
715
+ async def _http_get(self, resource: str) -> Any:
716
+ """Fetch a JSON resource via HTTP GET without updating connection state.
717
+
718
+ Raises:
719
+ ConnectionError: If the device cannot be reached.
720
+ ResponseDecodeError: If the response cannot be decoded as JSON.
721
+ ControllerCommandError: If the device returns HTTP 4xx.
722
+ """
723
+ session = self._discovery_service.session
724
+ if session is None:
725
+ raise ConnectionError("Discovery service is not started")
726
+ async with (
727
+ self._sending_lock,
728
+ session.get(
729
+ f"http://{self.device_ip}/{resource}",
730
+ timeout=aiohttp.ClientTimeout(total=Controller.REQUEST_TIMEOUT),
731
+ ) as response,
732
+ ):
733
+ if response.status >= 400 and response.status < 500:
734
+ raise ControllerCommandError(
735
+ f"HTTP {response.status} for http://{self.device_ip}/{resource}"
736
+ )
737
+ try:
738
+ return await response.json(content_type=None)
739
+ except json.JSONDecodeError as ex:
740
+ text = await response.text()
741
+ if text[-4:] == "{OK}":
742
+ return json.loads(text[:-4])
743
+ _LOG.error('Decode error for "%s"', text, exc_info=True)
744
+ raise ResponseDecodeError(
745
+ "Unable to decode response from the controller"
746
+ ) from ex
747
+
748
+ async def _http_post(self, command: str, data: dict[str, Any]) -> str:
749
+ """Send a command via HTTP POST without updating connection state.
750
+
751
+ Raises:
752
+ ConnectionError: If the device cannot be reached or the HTTP request fails.
753
+ ControllerCommandError: If the device returns HTTP 4xx or an
754
+ ``{ERROR...}`` payload.
755
+ """
756
+ session = self._discovery_service.session
757
+ if session is None:
758
+ raise ConnectionError("Discovery service is not started")
759
+ body = json.dumps(data).encode("latin_1")
760
+ async with (
761
+ self._sending_lock,
762
+ session.post(
763
+ f"http://{self.device_ip}/{command}",
764
+ data=body,
765
+ headers={"Connection": "close"},
766
+ timeout=aiohttp.ClientTimeout(total=Controller.REQUEST_TIMEOUT),
767
+ ) as response,
768
+ ):
769
+ if response.status >= 400 and response.status < 500:
770
+ raise ControllerCommandError(
771
+ f"HTTP {response.status} for http://{self.device_ip}/{command}"
772
+ )
773
+ if response.status != 200:
774
+ raise ConnectionError(
775
+ f"Unable to connect to: http://{self.device_ip}/{command}"
776
+ f" response={response.status} message={response.reason}"
777
+ )
778
+ result = await response.text(encoding="latin_1")
779
+
780
+ if len(result) >= 7 and result[:6] == "{ERROR":
781
+ raise ControllerCommandError(f"Server returned error state {result}")
782
+ if len(result) >= 4 and result[-4:] == "{OK}":
783
+ result = result[:-4]
784
+ return result
785
+
630
786
  async def _get_resource(self, resource: str) -> Any:
631
787
  """Fetch a JSON resource from the device via HTTP GET.
632
788
 
633
789
  Raises:
634
- ConnectionError: If the device cannot be reached, the response
635
- cannot be decoded, or the HTTP request fails.
790
+ ConnectionError: If the device cannot be reached or the HTTP request fails.
791
+ ResponseDecodeError: If the response cannot be decoded as JSON.
636
792
  ControllerCommandError: If the device returns HTTP 4xx.
637
793
  """
638
794
  try:
639
- session = self._discovery_service.session
640
- if session is None:
641
- raise ConnectionError("Discovery service is not started")
642
- async with (
643
- self._sending_lock,
644
- session.get(
645
- f"http://{self.device_ip}/{resource}",
646
- timeout=aiohttp.ClientTimeout(total=Controller.REQUEST_TIMEOUT),
647
- ) as response,
648
- ):
649
- if response.status >= 400 and response.status < 500:
650
- self._restored_connection()
651
- raise ControllerCommandError(
652
- f"HTTP {response.status} for http://{self.device_ip}/{resource}"
653
- )
654
- try:
655
- result = await response.json(content_type=None)
656
- except json.JSONDecodeError as ex:
657
- text = await response.text()
658
- if text[-4:] == "{OK}":
659
- result = json.loads(text[:-4])
660
- else:
661
- _LOG.error('Decode error for "%s"', text, exc_info=True)
662
- raise ConnectionError(
663
- "Unable to decode response from the controller"
664
- ) from ex
665
- self._restored_connection()
666
- return result
795
+ result = await self._http_get(resource)
796
+ except ControllerCommandError:
797
+ self._set_bridge_ok(True)
798
+ raise
667
799
  except (asyncio.TimeoutError, aiohttp.ClientError) as ex:
668
- self._failed_connection(ex)
800
+ self._set_bridge_ok(
801
+ False, ConnectionError("Unable to connect to the controller")
802
+ )
669
803
  raise ConnectionError("Unable to connect to the controller") from ex
804
+ except ResponseDecodeError:
805
+ self._set_bridge_ok(True)
806
+ raise
807
+ except ConnectionError as ex:
808
+ self._set_bridge_ok(False, ex)
809
+ raise
810
+ self._set_bridge_ok(True)
811
+ return result
670
812
 
671
- async def _send_command_async(
672
- self, command: str, data: dict[str, Any], *, mark_disconnected: bool = True
673
- ) -> str:
813
+ async def _send_command_async(self, command: str, data: dict[str, Any]) -> str:
674
814
  """Send a command to the device via HTTP POST.
675
815
 
676
- Args:
677
- mark_disconnected: When ``False``, transport failures do not mark
678
- the controller disconnected.
679
-
680
816
  Raises:
681
817
  ConnectionError: If the device cannot be reached or the HTTP request fails.
682
818
  ControllerCommandError: If the device returns HTTP 4xx or an
683
819
  ``{ERROR...}`` payload.
684
820
  """
685
821
  try:
686
- session = self._discovery_service.session
687
- if session is None:
688
- raise ConnectionError("Discovery service is not started")
689
- body = json.dumps(data).encode("latin_1")
690
- async with (
691
- self._sending_lock,
692
- session.post(
693
- f"http://{self.device_ip}/{command}",
694
- data=body,
695
- headers={"Connection": "close"},
696
- timeout=aiohttp.ClientTimeout(total=Controller.REQUEST_TIMEOUT),
697
- ) as response,
698
- ):
699
- if response.status >= 400 and response.status < 500:
700
- self._restored_connection()
701
- raise ControllerCommandError(
702
- f"HTTP {response.status} for http://{self.device_ip}/{command}"
703
- )
704
- if response.status != 200:
705
- raise aiohttp.ClientError(
706
- f"Unable to connect to: http://{self.device_ip}/{command}"
707
- f" response={response.status} message={response.reason}"
708
- )
709
- result = await response.text(encoding="latin_1")
822
+ result = await self._http_post(command, data)
823
+ except ControllerCommandError:
824
+ self._set_bridge_ok(True)
825
+ raise
710
826
  except (asyncio.TimeoutError, aiohttp.ClientError) as ex:
711
- if mark_disconnected:
712
- self._failed_connection(ex)
827
+ self._set_bridge_ok(
828
+ False, ConnectionError("Unable to connect to controller")
829
+ )
713
830
  raise ConnectionError("Unable to connect to controller") from ex
714
-
715
- if len(result) >= 7 and result[:6] == "{ERROR":
716
- self._restored_connection()
717
- raise ControllerCommandError(f"Server returned error state {result}")
718
- if len(result) >= 4 and result[-4:] == "{OK}":
719
- result = result[:-4]
720
- self._restored_connection()
831
+ except ConnectionError as ex:
832
+ self._set_bridge_ok(False, ex)
833
+ raise
834
+ self._set_bridge_ok(True)
721
835
  return result
pizone/discovery.py CHANGED
@@ -415,7 +415,7 @@ class DiscoveryService:
415
415
  return ctrl
416
416
  return None
417
417
 
418
- async def _wrap_update(self, coro: Awaitable[None]) -> None:
418
+ async def _wrap_update(self, coro: Awaitable[object]) -> None:
419
419
  """Run a controller refresh coroutine and log connection failures."""
420
420
  try:
421
421
  await coro
pizone/exceptions.py CHANGED
@@ -8,3 +8,11 @@ class ControllerCommandError(Exception):
8
8
  device is reachable. Not used for transport failures (timeouts, connection
9
9
  refused, etc.) — those remain :exc:`ConnectionError`.
10
10
  """
11
+
12
+
13
+ class ResponseDecodeError(ConnectionError):
14
+ """The bridge responded over HTTP but the body could not be decoded as JSON.
15
+
16
+ Raised when transport succeeds and the response is not a transport failure,
17
+ but JSON parsing fails (and the ``{OK}`` suffix workaround does not apply).
18
+ """
pizone/power.py CHANGED
@@ -12,6 +12,8 @@ from enum import IntEnum, unique
12
12
  from collections.abc import Iterable
13
13
  from typing import TYPE_CHECKING, Any, cast
14
14
 
15
+ from .exceptions import ControllerCommandError
16
+
15
17
  if TYPE_CHECKING:
16
18
  from .controller import Controller
17
19
 
@@ -190,22 +192,17 @@ class Power:
190
192
  self._status: dict[str, Any] = {"LastReadingNo": -1}
191
193
  self._devices = tuple(PowerDevice(self, i) for i in range(5))
192
194
  self._groups: tuple[PowerGroup, ...] | None = None
195
+ self._power_ok: bool = True
193
196
 
194
- async def init(self, *, mark_disconnected: bool = True) -> None:
197
+ async def init(self) -> None:
195
198
  """Load power monitor configuration from the device.
196
199
 
197
- Args:
198
- mark_disconnected: When ``False``, connection failures during this
199
- request do not mark the controller disconnected.
200
-
201
200
  Raises:
202
201
  ConnectionError: If the HTTP request fails.
203
202
  json.JSONDecodeError: If the device response is not valid JSON.
204
203
  KeyError: If the response is missing required fields.
205
204
  """
206
- self._config = await self._do_request(
207
- 1, "PowerMonitorConfig", mark_disconnected=mark_disconnected
208
- )
205
+ self._config = await self._do_request(1, "PowerMonitorConfig")
209
206
  gdict: dict[int, list[PowerChannel]] = {}
210
207
  for dev in self.devices:
211
208
  for chan in dev.channels:
@@ -232,28 +229,53 @@ class Power:
232
229
  self._status = status
233
230
  return True
234
231
 
235
- async def _do_request(
236
- self, req_type: int, result: str, *, mark_disconnected: bool = True
237
- ) -> dict[str, Any]:
232
+ async def _do_request(self, req_type: int, result: str) -> dict[str, Any]:
238
233
  """Send a power monitor request to the device.
239
234
 
240
- Args:
241
- mark_disconnected: When ``False``, connection failures do not mark
242
- the controller disconnected.
243
-
244
235
  Raises:
245
236
  ConnectionError: If the HTTP request fails.
246
237
  json.JSONDecodeError: If the device response is not valid JSON.
247
238
  KeyError: If the response is missing *result*.
248
239
  """
240
+ if not self._controller.bridge_connected:
241
+ self._set_power_ok(False)
242
+ raise ConnectionError("Bridge not connected")
243
+ try:
244
+ # pylint: disable=protected-access
245
+ datas = await self._controller._http_post(
246
+ "PowerRequest",
247
+ {"PowerRequest": {"Type": req_type, "No": 0, "No1": 0}},
248
+ )
249
+ data = json.loads(datas)
250
+ payload = cast(dict[str, Any], data[result])
251
+ except (
252
+ ConnectionError,
253
+ ControllerCommandError,
254
+ json.JSONDecodeError,
255
+ KeyError,
256
+ ) as ex:
257
+ self._set_power_ok(False)
258
+ if isinstance(ex, ConnectionError):
259
+ raise
260
+ if isinstance(ex, json.JSONDecodeError):
261
+ raise ConnectionError("Invalid power monitor response") from ex
262
+ if isinstance(ex, KeyError):
263
+ raise ConnectionError("Invalid power monitor response") from ex
264
+ raise ConnectionError("Power monitor request failed") from ex
265
+ self._set_power_ok(True)
266
+ return payload
267
+
268
+ def _set_power_ok(self, ok: bool) -> None:
269
+ if self._power_ok == ok:
270
+ return
271
+ self._power_ok = ok
249
272
  # pylint: disable=protected-access
250
- datas = await self._controller._send_command_async(
251
- "PowerRequest",
252
- {"PowerRequest": {"Type": req_type, "No": 0, "No1": 0}},
253
- mark_disconnected=mark_disconnected,
254
- )
255
- data = json.loads(datas)
256
- return cast(dict[str, Any], data[result])
273
+ self._controller._event_coordinator.power_update(self._controller)
274
+
275
+ @property
276
+ def connected(self) -> bool:
277
+ """True while the bridge is up and power monitor I/O is healthy."""
278
+ return self._controller.bridge_connected and self._power_ok
257
279
 
258
280
  @property
259
281
  def enabled(self) -> bool:
pizone/version.py CHANGED
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.3.2'
22
- __version_tuple__ = version_tuple = (1, 3, 2)
21
+ __version__ = version = '1.3.4'
22
+ __version_tuple__ = version_tuple = (1, 3, 4)
23
23
 
24
24
  __commit_id__ = commit_id = None
pizone/zone.py CHANGED
@@ -187,6 +187,8 @@ class Zone:
187
187
  """
188
188
  if zone_data["Index"] != self._index:
189
189
  raise AttributeError("Can't change index of existing zone.")
190
+ if str(zone_data.get("Type")) == "error":
191
+ return
190
192
  self._zone_data = zone_data
191
193
  if notify:
192
194
  self._fire_listeners()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-izone
3
- Version: 1.3.2
3
+ Version: 1.3.4
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,12 @@
1
+ pizone/__init__.py,sha256=F1D0SKGns5TUvAH4iZOovrE4ZQH3GXHWW3bUPcovDrw,1365
2
+ pizone/controller.py,sha256=K0cscWcwHe4OFaVI_F-wV9J-Da3QGNfCFDpKgdVFkCM,30012
3
+ pizone/discovery.py,sha256=8sBl1QlMlkGs_gwt-neGouwpYahwrlkAH37KEw9SbH0,18135
4
+ pizone/exceptions.py,sha256=8rqSGIrQV5KQeOx_Lc6Mh7FVAbSl4-Q6qyW-cwrqlZ0,658
5
+ pizone/power.py,sha256=FsKS8LhyHS2cUDKnDX6Vc1bWhpXSZABIpPLNQERqlLk,9899
6
+ pizone/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ pizone/version.py,sha256=J1PLKOPRRvOkHG-jR78Tmen2I1NkbGLw4y0NdfCA_Ts,520
8
+ pizone/zone.py,sha256=0rVYIiqGXUJeT3i_EBLPOTEsFIY6nNSheMuI7Up8GzU,7232
9
+ python_izone-1.3.4.dist-info/METADATA,sha256=mOBTQOsYuJe2T_NaTDB_G-CrBofSrGJoqs9-3qyKFOI,2657
10
+ python_izone-1.3.4.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
11
+ python_izone-1.3.4.dist-info/licenses/licence.txt,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
12
+ python_izone-1.3.4.dist-info/RECORD,,
@@ -1,12 +0,0 @@
1
- pizone/__init__.py,sha256=xRQgtXzCtsGhJDBOzDxV105Trv2NCFe8Em6fZEqZ4bc,997
2
- pizone/controller.py,sha256=hO2B7sIZt8Djt_Nq0_ov7ocNxBy9Nk_DtkcEmixFt2s,26114
3
- pizone/discovery.py,sha256=tvZs5npvL2y41vyYxn5V6EDCI9v27_Z-vy_ezSN-RA4,18133
4
- pizone/exceptions.py,sha256=mWsvXzBNh6mKOPs0QFLqgO6ipa6Se6PEGhFTwr9_ehA,362
5
- pizone/power.py,sha256=4kWS_XilZki7dLzlJ0cKYoz8s6pS76bAtrHhUXTZ_eg,9039
6
- pizone/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
- pizone/version.py,sha256=3UteJz_pk76FGRZGb0537LBHxRlaYKkTw4QYKVpfrjs,520
8
- pizone/zone.py,sha256=rDv-Q9k9HGIzK3arBxpKoAa-xRGETQkgBOTulMdOhqc,7163
9
- python_izone-1.3.2.dist-info/METADATA,sha256=pEMLJ6CTDnghdy4FJ8wIducmpD-ZxAnVPv26t347FuQ,2657
10
- python_izone-1.3.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
11
- python_izone-1.3.2.dist-info/licenses/licence.txt,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
12
- python_izone-1.3.2.dist-info/RECORD,,