python-izone 1.3.1__tar.gz → 1.3.3__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 (39) hide show
  1. {python_izone-1.3.1 → python_izone-1.3.3}/PKG-INFO +1 -1
  2. {python_izone-1.3.1 → python_izone-1.3.3}/pizone/controller.py +257 -124
  3. {python_izone-1.3.1 → python_izone-1.3.3}/pizone/discovery.py +1 -1
  4. {python_izone-1.3.1 → python_izone-1.3.3}/pizone/power.py +41 -5
  5. {python_izone-1.3.1 → python_izone-1.3.3}/pizone/version.py +2 -2
  6. {python_izone-1.3.1 → python_izone-1.3.3}/pizone/zone.py +2 -0
  7. {python_izone-1.3.1 → python_izone-1.3.3}/tests/conftest.py +19 -13
  8. python_izone-1.3.3/tests/test_controller.py +440 -0
  9. {python_izone-1.3.1 → python_izone-1.3.3}/tests/test_discovery.py +4 -3
  10. {python_izone-1.3.1 → python_izone-1.3.3}/tests/test_power.py +65 -4
  11. python_izone-1.3.1/tests/test_controller.py +0 -116
  12. {python_izone-1.3.1 → python_izone-1.3.3}/.gitattributes +0 -0
  13. {python_izone-1.3.1 → python_izone-1.3.3}/.github/workflows/main.yml +0 -0
  14. {python_izone-1.3.1 → python_izone-1.3.3}/.gitignore +0 -0
  15. {python_izone-1.3.1 → python_izone-1.3.3}/.pre-commit-config.yaml +0 -0
  16. {python_izone-1.3.1 → python_izone-1.3.3}/.pylintrc +0 -0
  17. {python_izone-1.3.1 → python_izone-1.3.3}/.python-version +0 -0
  18. {python_izone-1.3.1 → python_izone-1.3.3}/.vscode/.ropeproject/config.py +0 -0
  19. {python_izone-1.3.1 → python_izone-1.3.3}/.vscode/launch.json +0 -0
  20. {python_izone-1.3.1 → python_izone-1.3.3}/.vscode/settings.json +0 -0
  21. {python_izone-1.3.1 → python_izone-1.3.3}/README.md +0 -0
  22. {python_izone-1.3.1 → python_izone-1.3.3}/api_docs/AC-DOC-1401-11_iZoneEthernetInterface.pdf +0 -0
  23. {python_izone-1.3.1 → python_izone-1.3.3}/api_docs/iPower_JSON_datastrings_Out.h +0 -0
  24. {python_izone-1.3.1 → python_izone-1.3.3}/api_docs/iZone_JSON_datastrings.h +0 -0
  25. {python_izone-1.3.1 → python_izone-1.3.3}/licence.txt +0 -0
  26. {python_izone-1.3.1 → python_izone-1.3.3}/pizone/__init__.py +0 -0
  27. {python_izone-1.3.1 → python_izone-1.3.3}/pizone/exceptions.py +0 -0
  28. {python_izone-1.3.1 → python_izone-1.3.3}/pizone/py.typed +0 -0
  29. {python_izone-1.3.1 → python_izone-1.3.3}/pyproject.toml +0 -0
  30. {python_izone-1.3.1 → python_izone-1.3.3}/scripts/check +0 -0
  31. {python_izone-1.3.1 → python_izone-1.3.3}/scripts/coverage +0 -0
  32. {python_izone-1.3.1 → python_izone-1.3.3}/tests/__init__.py +0 -0
  33. {python_izone-1.3.1 → python_izone-1.3.3}/tests/http_fakes.py +0 -0
  34. {python_izone-1.3.1 → python_izone-1.3.3}/tests/power_data.py +0 -0
  35. {python_izone-1.3.1 → python_izone-1.3.3}/tests/resources.py +0 -0
  36. {python_izone-1.3.1 → python_izone-1.3.3}/tests/test_fullstack.py +0 -0
  37. {python_izone-1.3.1 → python_izone-1.3.3}/tests/test_http.py +0 -0
  38. {python_izone-1.3.1 → python_izone-1.3.3}/tests/test_zone.py +0 -0
  39. {python_izone-1.3.1 → python_izone-1.3.3}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-izone
3
- Version: 1.3.1
3
+ Version: 1.3.3
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>
@@ -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):
@@ -113,7 +114,8 @@ class Controller:
113
114
  self._power: Power | None = None
114
115
 
115
116
  self._initialized: bool = False
116
- self._fail_exception: Exception | None = None
117
+ self._bridge_ok: bool = True
118
+ self._izone_ok: bool = True
117
119
 
118
120
  self._sending_lock = Lock()
119
121
  self._scan_condition = Condition()
@@ -122,33 +124,75 @@ class Controller:
122
124
  """Load system, zone, and optional power data from the device.
123
125
 
124
126
  Raises:
125
- ConnectionError: If any initial HTTP request fails.
127
+ ConnectionError: If a required HTTP request fails.
126
128
  KeyError: If a required field is missing from a device response.
127
129
  """
128
- await self._refresh_system(notify=False)
130
+ settings = await self._refresh_system(notify=False)
131
+ if settings is None:
132
+ raise ConnectionError("SystemSettings device ID mismatch")
133
+
134
+ if self._system_settings:
135
+ self.fan_modes = Controller._VALID_FAN_MODES[
136
+ str(self._system_settings.get("FanAuto", "disabled"))
137
+ ]
138
+ else:
139
+ self.fan_modes = Controller._VALID_FAN_MODES["disabled"]
129
140
 
130
- self.fan_modes = Controller._VALID_FAN_MODES[
131
- str(self._system_settings.get("FanAuto", "disabled"))
132
- ]
141
+ await self._probe_v2_api()
133
142
 
134
- zone_count = int(self._system_settings["NoOfZones"])
143
+ zone_count = int(settings["NoOfZones"])
135
144
  self.zones = [Zone(self, i) for i in range(zone_count)]
136
145
 
137
- if self._is_ipower:
138
- self._power = Power(self)
139
- await asyncio.gather(
140
- self._refresh_zones(notify=False),
141
- self._power.init(),
142
- )
143
- if self._power.enabled:
144
- await self._refresh_power(notify=False)
145
- else:
146
- self._power = None
147
- await self._refresh_zones(notify=False)
146
+ await self._refresh_zones(notify=False)
147
+
148
+ await self._probe_power()
148
149
 
149
150
  self._initialized = True
151
+ if not self.connected:
152
+ self._event_coordinator.controller_disconnected(
153
+ self, ConnectionError("iZone controller unavailable")
154
+ )
150
155
  self._discovery_service.create_task(self._poll_loop())
151
156
 
157
+ async def _probe_v2_api(self) -> None:
158
+ """Detect V2 API support; non-fatal on failure."""
159
+ try:
160
+ response = await self._http_post(
161
+ "iZoneRequestV2",
162
+ {"iZoneV2Request": {"Type": 1, "No": 0, "No1": 0}},
163
+ )
164
+ data = json.loads(response)
165
+ uid = data["AirStreamDeviceUId"]
166
+ self._is_v2 = uid == self._device_uid and "SystemV2" in data
167
+ except (ConnectionError, ControllerCommandError, json.JSONDecodeError, KeyError):
168
+ self._is_v2 = False
169
+
170
+ async def _probe_power(self) -> None:
171
+ """Probe power monitor endpoint when discovery hinted iPower; non-fatal."""
172
+ if not self._is_ipower:
173
+ self._power = None
174
+ return
175
+
176
+ try:
177
+ power = Power(self)
178
+ await power.init()
179
+ if power.enabled:
180
+ self._power = power
181
+ return
182
+ _LOG.warning(
183
+ "Power monitor disabled on uid=%s; skipping power support",
184
+ self._device_uid,
185
+ )
186
+ except (ConnectionError, ControllerCommandError, json.JSONDecodeError, KeyError):
187
+ _LOG.warning(
188
+ "Power monitor probe failed for uid=%s",
189
+ self._device_uid,
190
+ exc_info=True,
191
+ )
192
+
193
+ self._is_ipower = False
194
+ self._power = None
195
+
152
196
  async def _poll_loop(self) -> None:
153
197
  while True:
154
198
  try:
@@ -181,10 +225,15 @@ class Controller:
181
225
  async with self._scan_condition:
182
226
  self._scan_condition.notify()
183
227
 
228
+ @property
229
+ def bridge_connected(self) -> bool:
230
+ """True while the ASH bridge is reachable over HTTP."""
231
+ return self._bridge_ok
232
+
184
233
  @property
185
234
  def connected(self) -> bool:
186
- """True while the controller can reach the device over HTTP."""
187
- return self._fail_exception is None
235
+ """True while the bridge is up and the iZone AC subsystem is available."""
236
+ return self._bridge_ok and self._izone_ok
188
237
 
189
238
  @property
190
239
  def power(self) -> Power | None:
@@ -206,6 +255,11 @@ class Controller:
206
255
  """Return whether this is a v2 controller."""
207
256
  return self._is_v2
208
257
 
258
+ @property
259
+ def is_ipower(self) -> bool:
260
+ """Return whether power monitoring is available."""
261
+ return self._is_ipower
262
+
209
263
  @property
210
264
  def discovery(self) -> DiscoveryService:
211
265
  """Discovery service for this controller."""
@@ -437,19 +491,25 @@ class Controller:
437
491
  ConnectionError: If any HTTP request fails.
438
492
  KeyError: If a required field is missing from a device response.
439
493
  """
440
- zones = int(self._system_settings["NoOfZones"])
441
- # gather schedules all refresh coroutines together; _sending_lock serializes
442
- # HTTP to one in-flight request per controller. Revisit the lock if firmware
443
- # supports concurrent requests — gather can stay.
494
+ zones = len(self.zones)
495
+ if zones == 0:
496
+ await asyncio.gather(
497
+ self._refresh_system(notify),
498
+ self._refresh_power(notify),
499
+ )
500
+ return
444
501
  await asyncio.gather(
445
502
  self._refresh_system(notify),
446
503
  self._refresh_power(notify),
447
504
  *[self._refresh_zone_group(i, notify) for i in range(0, zones, 4)],
448
505
  )
449
506
 
450
- async def _refresh_system(self, notify: bool = True) -> None:
507
+ async def _refresh_system(self, notify: bool = True) -> ControllerData | None:
451
508
  """Refresh the system settings from the device.
452
509
 
510
+ Returns the device response even when the payload is a fault placeholder,
511
+ so callers can read fields like ``NoOfZones`` during initialization.
512
+
453
513
  Raises:
454
514
  ConnectionError: If the HTTP request fails.
455
515
  KeyError: If the response is missing required fields.
@@ -457,25 +517,40 @@ class Controller:
457
517
  values: Controller.ControllerData = await self._get_resource("SystemSettings")
458
518
  if self._device_uid != values["AirStreamDeviceUId"]:
459
519
  _LOG.error("_refresh_system called with non-matching device ID")
460
- return
520
+ return None
461
521
 
462
- self._system_settings = values
522
+ if not self._system_settings_valid(values):
523
+ _LOG.warning(
524
+ "iZone subsystem fault uid=%s; retaining cache",
525
+ self._device_uid,
526
+ )
527
+ self._set_izone_ok(
528
+ False, ConnectionError("iZone controller unavailable")
529
+ )
530
+ if notify:
531
+ self._event_coordinator.controller_update(self)
532
+ return values
463
533
 
534
+ self._system_settings = values
535
+ self._set_izone_ok(True)
464
536
  if notify:
465
537
  self._event_coordinator.controller_update(self)
538
+ return values
466
539
 
467
540
  async def _refresh_power(self, notify: bool = True) -> None:
468
- """Refresh power monitor data when enabled.
469
-
470
- Raises:
471
- ConnectionError: If the HTTP request fails.
472
- json.JSONDecodeError: If the device response is not valid JSON.
473
- KeyError: If the response is missing required fields.
474
- """
541
+ """Refresh power monitor data when enabled."""
475
542
  if self._power is None or not self._power.enabled:
476
543
  return
477
544
 
478
- updated = await self._power.refresh()
545
+ try:
546
+ updated = await self._power.refresh()
547
+ except (ConnectionError, ControllerCommandError, json.JSONDecodeError, KeyError):
548
+ _LOG.warning(
549
+ "Power refresh failed for uid=%s",
550
+ self._device_uid,
551
+ exc_info=True,
552
+ )
553
+ return
479
554
 
480
555
  if updated and notify:
481
556
  self._event_coordinator.power_update(self)
@@ -488,7 +563,9 @@ class Controller:
488
563
  KeyError: If a response is missing required fields.
489
564
  AttributeError: If a zone index in the response does not match.
490
565
  """
491
- zones = int(self._system_settings["NoOfZones"])
566
+ zones = len(self.zones)
567
+ if zones == 0:
568
+ return
492
569
  await asyncio.gather(
493
570
  *[self._refresh_zone_group(i, notify) for i in range(0, zones, 4)]
494
571
  )
@@ -516,10 +593,18 @@ class Controller:
516
593
  def _refresh_address(self, address: str) -> None:
517
594
  """Update the device IP and schedule a reconnect attempt if needed."""
518
595
  self._ip = address
519
- # Signal to the retry connection loop to have another go.
520
- if self._fail_exception:
596
+ if not self._bridge_ok:
521
597
  self._discovery_service.create_task(self._retry_connection())
522
598
 
599
+ @staticmethod
600
+ def _system_settings_valid(values: ControllerData) -> bool:
601
+ """Return whether *values* look like healthy AC subsystem data."""
602
+ return (
603
+ int(values["NoOfZones"]) > 0
604
+ and str(values["SysFan"]) != "error"
605
+ and str(values["RAS"]) != "error"
606
+ )
607
+
523
608
  def _get_system_state(self, state: str) -> DictValue:
524
609
  return self._system_settings[state]
525
610
 
@@ -544,33 +629,47 @@ class Controller:
544
629
  self._event_coordinator.controller_update(self)
545
630
  await self.refresh()
546
631
 
547
- def _failed_connection(self, ex: Exception) -> None:
548
- if self._fail_exception:
549
- self._fail_exception = ex
550
- return
551
- self._fail_exception = ex
552
- if not self._initialized:
632
+ def _set_bridge_ok(self, ok: bool, ex: Exception | None = None) -> None:
633
+ was_connected = self.connected
634
+ self._bridge_ok = ok
635
+ if not ok and ex is None:
636
+ ex = ConnectionError("Unable to connect to the controller")
637
+ self._notify_connected_changed(was_connected, ex if not ok else None)
638
+
639
+ def _set_izone_ok(self, ok: bool, ex: Exception | None = None) -> None:
640
+ was_connected = self.connected
641
+ self._izone_ok = ok
642
+ if not ok and ex is None:
643
+ ex = ConnectionError("iZone controller unavailable")
644
+ self._notify_connected_changed(was_connected, ex if not ok else None)
645
+
646
+ def _notify_connected_changed(
647
+ self, was_connected: bool, ex: Exception | None
648
+ ) -> None:
649
+ if was_connected == self.connected or not self._initialized:
553
650
  return
554
- self._event_coordinator.controller_disconnected(self, ex)
651
+ if self.connected:
652
+ self._event_coordinator.controller_update(self)
653
+ for zone in self.zones:
654
+ self._event_coordinator.zone_update(self, zone)
655
+ self._event_coordinator.power_update(self)
656
+ self._event_coordinator.controller_reconnected(self)
657
+ elif ex is not None:
658
+ self._event_coordinator.controller_disconnected(self, ex)
659
+
660
+ def _failed_connection(self, ex: Exception) -> None:
661
+ """Mark the bridge transport as failed."""
662
+ self._set_bridge_ok(False, ex)
555
663
 
556
664
  def _restored_connection(self) -> None:
557
- """Clear a prior connection failure after successful I/O."""
558
- if self._fail_exception is None:
559
- return
560
- self._fail_exception = None
561
- if not self._initialized:
562
- return
563
- self._event_coordinator.controller_update(self)
564
- for zone in self.zones:
565
- self._event_coordinator.zone_update(self, zone)
566
- self._event_coordinator.power_update(self)
567
- self._event_coordinator.controller_reconnected(self)
665
+ """Mark the bridge transport as restored."""
666
+ self._set_bridge_ok(True)
568
667
 
569
668
  async def _retry_connection(self) -> None:
570
669
  """Attempt to restore connectivity after a connection failure.
571
670
 
572
671
  Connection errors are logged and not re-raised. A successful refresh
573
- restores :attr:`connected` via :meth:`_restored_connection`.
672
+ restores :attr:`connected` when both bridge and iZone layers recover.
574
673
  """
575
674
  _LOG.info(
576
675
  "Attempting to reconnect to server uid=%s ip=%s",
@@ -588,6 +687,77 @@ class Controller:
588
687
  exc_info=True,
589
688
  )
590
689
 
690
+ async def _http_get(self, resource: str) -> Any:
691
+ """Fetch a JSON resource via HTTP GET without updating connection state.
692
+
693
+ Raises:
694
+ ConnectionError: If the device cannot be reached or the response
695
+ cannot be decoded.
696
+ ControllerCommandError: If the device returns HTTP 4xx.
697
+ """
698
+ session = self._discovery_service.session
699
+ if session is None:
700
+ raise ConnectionError("Discovery service is not started")
701
+ async with (
702
+ self._sending_lock,
703
+ session.get(
704
+ f"http://{self.device_ip}/{resource}",
705
+ timeout=aiohttp.ClientTimeout(total=Controller.REQUEST_TIMEOUT),
706
+ ) as response,
707
+ ):
708
+ if response.status >= 400 and response.status < 500:
709
+ raise ControllerCommandError(
710
+ f"HTTP {response.status} for http://{self.device_ip}/{resource}"
711
+ )
712
+ try:
713
+ return await response.json(content_type=None)
714
+ except json.JSONDecodeError as ex:
715
+ text = await response.text()
716
+ if text[-4:] == "{OK}":
717
+ return json.loads(text[:-4])
718
+ _LOG.error('Decode error for "%s"', text, exc_info=True)
719
+ raise ConnectionError(
720
+ "Unable to decode response from the controller"
721
+ ) from ex
722
+
723
+ async def _http_post(self, command: str, data: dict[str, Any]) -> str:
724
+ """Send a command via HTTP POST without updating connection state.
725
+
726
+ Raises:
727
+ ConnectionError: If the device cannot be reached or the HTTP request fails.
728
+ ControllerCommandError: If the device returns HTTP 4xx or an
729
+ ``{ERROR...}`` payload.
730
+ """
731
+ session = self._discovery_service.session
732
+ if session is None:
733
+ raise ConnectionError("Discovery service is not started")
734
+ body = json.dumps(data).encode("latin_1")
735
+ async with (
736
+ self._sending_lock,
737
+ session.post(
738
+ f"http://{self.device_ip}/{command}",
739
+ data=body,
740
+ headers={"Connection": "close"},
741
+ timeout=aiohttp.ClientTimeout(total=Controller.REQUEST_TIMEOUT),
742
+ ) as response,
743
+ ):
744
+ if response.status >= 400 and response.status < 500:
745
+ raise ControllerCommandError(
746
+ f"HTTP {response.status} for http://{self.device_ip}/{command}"
747
+ )
748
+ if response.status != 200:
749
+ raise ConnectionError(
750
+ f"Unable to connect to: http://{self.device_ip}/{command}"
751
+ f" response={response.status} message={response.reason}"
752
+ )
753
+ result = await response.text(encoding="latin_1")
754
+
755
+ if len(result) >= 7 and result[:6] == "{ERROR":
756
+ raise ControllerCommandError(f"Server returned error state {result}")
757
+ if len(result) >= 4 and result[-4:] == "{OK}":
758
+ result = result[:-4]
759
+ return result
760
+
591
761
  async def _get_resource(self, resource: str) -> Any:
592
762
  """Fetch a JSON resource from the device via HTTP GET.
593
763
 
@@ -597,41 +767,25 @@ class Controller:
597
767
  ControllerCommandError: If the device returns HTTP 4xx.
598
768
  """
599
769
  try:
600
- session = self._discovery_service.session
601
- if session is None:
602
- raise ConnectionError("Discovery service is not started")
603
- async with (
604
- self._sending_lock,
605
- session.get(
606
- f"http://{self.device_ip}/{resource}",
607
- timeout=aiohttp.ClientTimeout(total=Controller.REQUEST_TIMEOUT),
608
- ) as response,
609
- ):
610
- if response.status >= 400 and response.status < 500:
611
- self._restored_connection()
612
- raise ControllerCommandError(
613
- f"HTTP {response.status} for http://{self.device_ip}/{resource}"
614
- )
615
- try:
616
- result = await response.json(content_type=None)
617
- except json.JSONDecodeError as ex:
618
- text = await response.text()
619
- if text[-4:] == "{OK}":
620
- result = json.loads(text[:-4])
621
- else:
622
- _LOG.error('Decode error for "%s"', text, exc_info=True)
623
- raise ConnectionError(
624
- "Unable to decode response from the controller"
625
- ) from ex
626
- self._restored_connection()
627
- return result
770
+ result = await self._http_get(resource)
771
+ except ControllerCommandError:
772
+ self._set_bridge_ok(True)
773
+ raise
628
774
  except (asyncio.TimeoutError, aiohttp.ClientError) as ex:
629
- self._failed_connection(ex)
775
+ self._set_bridge_ok(
776
+ False, ConnectionError("Unable to connect to the controller")
777
+ )
630
778
  raise ConnectionError("Unable to connect to the controller") from ex
779
+ except ConnectionError as ex:
780
+ if str(ex) == "Unable to decode response from the controller":
781
+ self._set_bridge_ok(True)
782
+ else:
783
+ self._set_bridge_ok(False, ex)
784
+ raise
785
+ self._set_bridge_ok(True)
786
+ return result
631
787
 
632
- async def _send_command_async(
633
- self, command: str, data: dict[str, Any]
634
- ) -> str:
788
+ async def _send_command_async(self, command: str, data: dict[str, Any]) -> str:
635
789
  """Send a command to the device via HTTP POST.
636
790
 
637
791
  Raises:
@@ -640,38 +794,17 @@ class Controller:
640
794
  ``{ERROR...}`` payload.
641
795
  """
642
796
  try:
643
- session = self._discovery_service.session
644
- if session is None:
645
- raise ConnectionError("Discovery service is not started")
646
- body = json.dumps(data).encode("latin_1")
647
- async with (
648
- self._sending_lock,
649
- session.post(
650
- f"http://{self.device_ip}/{command}",
651
- data=body,
652
- headers={"Connection": "close"},
653
- timeout=aiohttp.ClientTimeout(total=Controller.REQUEST_TIMEOUT),
654
- ) as response,
655
- ):
656
- if response.status >= 400 and response.status < 500:
657
- self._restored_connection()
658
- raise ControllerCommandError(
659
- f"HTTP {response.status} for http://{self.device_ip}/{command}"
660
- )
661
- if response.status != 200:
662
- raise aiohttp.ClientError(
663
- f"Unable to connect to: http://{self.device_ip}/{command}"
664
- f" response={response.status} message={response.reason}"
665
- )
666
- result = await response.text(encoding="latin_1")
797
+ result = await self._http_post(command, data)
798
+ except ControllerCommandError:
799
+ self._set_bridge_ok(True)
800
+ raise
667
801
  except (asyncio.TimeoutError, aiohttp.ClientError) as ex:
668
- self._failed_connection(ex)
802
+ self._set_bridge_ok(
803
+ False, ConnectionError("Unable to connect to controller")
804
+ )
669
805
  raise ConnectionError("Unable to connect to controller") from ex
670
-
671
- if len(result) >= 7 and result[:6] == "{ERROR":
672
- self._restored_connection()
673
- raise ControllerCommandError(f"Server returned error state {result}")
674
- if len(result) >= 4 and result[-4:] == "{OK}":
675
- result = result[:-4]
676
- self._restored_connection()
806
+ except ConnectionError as ex:
807
+ self._set_bridge_ok(False, ex)
808
+ raise
809
+ self._set_bridge_ok(True)
677
810
  return result
@@ -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
@@ -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,6 +192,7 @@ 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
197
  async def init(self) -> None:
195
198
  """Load power monitor configuration from the device.
@@ -234,12 +237,45 @@ class Power:
234
237
  json.JSONDecodeError: If the device response is not valid JSON.
235
238
  KeyError: If the response is missing *result*.
236
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
237
272
  # pylint: disable=protected-access
238
- datas = await self._controller._send_command_async(
239
- "PowerRequest", {"PowerRequest": {"Type": req_type, "No": 0, "No1": 0}}
240
- )
241
- data = json.loads(datas)
242
- 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
243
279
 
244
280
  @property
245
281
  def enabled(self) -> bool:
@@ -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.1'
22
- __version_tuple__ = version_tuple = (1, 3, 1)
21
+ __version__ = version = '1.3.3'
22
+ __version_tuple__ = version_tuple = (1, 3, 3)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -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()
@@ -51,6 +51,9 @@ class MockController(Controller):
51
51
  self.resources = _system_data(device_uid)
52
52
  self.sent: list[tuple[str, Any]] = []
53
53
  self._connected = True
54
+ self.v2_probe_response: str | None = None
55
+ self.power_config: dict[str, Any] | None = None
56
+ self.fail_power_types: set[int] = set()
54
57
 
55
58
  def _check_discovery_connected(self) -> None:
56
59
  service = cast(MockDiscoveryService, self.discovery)
@@ -59,30 +62,33 @@ class MockController(Controller):
59
62
  self._failed_connection(ex)
60
63
  raise ConnectionError("Explicitly Disconnected") from ex
61
64
 
62
- async def _get_resource(self, resource: str) -> Any:
63
- """Mock out the network IO for _get_resource."""
65
+ async def _http_get(self, resource: str) -> Any:
66
+ """Mock out the network IO for tracked GET requests."""
64
67
  self._check_discovery_connected()
65
68
  result = self.resources.get(resource)
66
69
  if result:
67
- self._restored_connection()
68
70
  return deepcopy(result)
69
71
  raise ConnectionError(f"Mock resource '{resource}' not available")
70
72
 
71
- async def _send_command_async(
72
- self, command: str, data: dict[str, Any]
73
- ) -> str:
74
- """Mock out the network IO for _send_command."""
75
- if self._fail_exception:
76
- raise ConnectionError(
77
- "Unable to connect to the controller"
78
- ) from self._fail_exception
73
+ async def _http_post(self, command: str, data: dict[str, Any]) -> str:
74
+ """Mock out the network IO for untracked POST requests."""
75
+ if not self._bridge_ok:
76
+ raise ConnectionError("Unable to connect to the controller")
79
77
  self._check_discovery_connected()
80
78
  self.sent.append((command, data))
81
- self._restored_connection()
79
+ if command == "iZoneRequestV2":
80
+ if self.v2_probe_response is not None:
81
+ return self.v2_probe_response
82
+ raise ConnectionError("Unable to connect to controller")
82
83
  if command == "PowerRequest":
83
84
  req_type: int = data["PowerRequest"]["Type"]
85
+ if req_type in self.fail_power_types:
86
+ raise ConnectionError("Unable to connect to controller")
84
87
  if req_type == 1:
85
- return json.dumps({"PowerMonitorConfig": POWER_CONFIG})
88
+ config = (
89
+ self.power_config if self.power_config is not None else POWER_CONFIG
90
+ )
91
+ return json.dumps({"PowerMonitorConfig": config})
86
92
  if req_type == 2:
87
93
  return json.dumps({"PowerMonitorStatus": POWER_STATUS})
88
94
  return ""