python-izone 1.3.2__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 (38) hide show
  1. {python_izone-1.3.2 → python_izone-1.3.3}/PKG-INFO +1 -1
  2. {python_izone-1.3.2 → python_izone-1.3.3}/pizone/controller.py +210 -121
  3. {python_izone-1.3.2 → python_izone-1.3.3}/pizone/discovery.py +1 -1
  4. {python_izone-1.3.2 → python_izone-1.3.3}/pizone/power.py +44 -22
  5. {python_izone-1.3.2 → python_izone-1.3.3}/pizone/version.py +2 -2
  6. {python_izone-1.3.2 → python_izone-1.3.3}/pizone/zone.py +2 -0
  7. {python_izone-1.3.2 → python_izone-1.3.3}/tests/conftest.py +11 -24
  8. {python_izone-1.3.2 → python_izone-1.3.3}/tests/test_controller.py +152 -7
  9. {python_izone-1.3.2 → python_izone-1.3.3}/tests/test_power.py +65 -5
  10. {python_izone-1.3.2 → python_izone-1.3.3}/.gitattributes +0 -0
  11. {python_izone-1.3.2 → python_izone-1.3.3}/.github/workflows/main.yml +0 -0
  12. {python_izone-1.3.2 → python_izone-1.3.3}/.gitignore +0 -0
  13. {python_izone-1.3.2 → python_izone-1.3.3}/.pre-commit-config.yaml +0 -0
  14. {python_izone-1.3.2 → python_izone-1.3.3}/.pylintrc +0 -0
  15. {python_izone-1.3.2 → python_izone-1.3.3}/.python-version +0 -0
  16. {python_izone-1.3.2 → python_izone-1.3.3}/.vscode/.ropeproject/config.py +0 -0
  17. {python_izone-1.3.2 → python_izone-1.3.3}/.vscode/launch.json +0 -0
  18. {python_izone-1.3.2 → python_izone-1.3.3}/.vscode/settings.json +0 -0
  19. {python_izone-1.3.2 → python_izone-1.3.3}/README.md +0 -0
  20. {python_izone-1.3.2 → python_izone-1.3.3}/api_docs/AC-DOC-1401-11_iZoneEthernetInterface.pdf +0 -0
  21. {python_izone-1.3.2 → python_izone-1.3.3}/api_docs/iPower_JSON_datastrings_Out.h +0 -0
  22. {python_izone-1.3.2 → python_izone-1.3.3}/api_docs/iZone_JSON_datastrings.h +0 -0
  23. {python_izone-1.3.2 → python_izone-1.3.3}/licence.txt +0 -0
  24. {python_izone-1.3.2 → python_izone-1.3.3}/pizone/__init__.py +0 -0
  25. {python_izone-1.3.2 → python_izone-1.3.3}/pizone/exceptions.py +0 -0
  26. {python_izone-1.3.2 → python_izone-1.3.3}/pizone/py.typed +0 -0
  27. {python_izone-1.3.2 → python_izone-1.3.3}/pyproject.toml +0 -0
  28. {python_izone-1.3.2 → python_izone-1.3.3}/scripts/check +0 -0
  29. {python_izone-1.3.2 → python_izone-1.3.3}/scripts/coverage +0 -0
  30. {python_izone-1.3.2 → python_izone-1.3.3}/tests/__init__.py +0 -0
  31. {python_izone-1.3.2 → python_izone-1.3.3}/tests/http_fakes.py +0 -0
  32. {python_izone-1.3.2 → python_izone-1.3.3}/tests/power_data.py +0 -0
  33. {python_izone-1.3.2 → python_izone-1.3.3}/tests/resources.py +0 -0
  34. {python_izone-1.3.2 → python_izone-1.3.3}/tests/test_discovery.py +0 -0
  35. {python_izone-1.3.2 → python_izone-1.3.3}/tests/test_fullstack.py +0 -0
  36. {python_izone-1.3.2 → python_izone-1.3.3}/tests/test_http.py +0 -0
  37. {python_izone-1.3.2 → python_izone-1.3.3}/tests/test_zone.py +0 -0
  38. {python_izone-1.3.2 → 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.2
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()
@@ -125,15 +127,20 @@ class Controller:
125
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)
129
-
130
- self.fan_modes = Controller._VALID_FAN_MODES[
131
- str(self._system_settings.get("FanAuto", "disabled"))
132
- ]
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"]
133
140
 
134
141
  await self._probe_v2_api()
135
142
 
136
- zone_count = int(self._system_settings["NoOfZones"])
143
+ zone_count = int(settings["NoOfZones"])
137
144
  self.zones = [Zone(self, i) for i in range(zone_count)]
138
145
 
139
146
  await self._refresh_zones(notify=False)
@@ -141,15 +148,18 @@ class Controller:
141
148
  await self._probe_power()
142
149
 
143
150
  self._initialized = True
151
+ if not self.connected:
152
+ self._event_coordinator.controller_disconnected(
153
+ self, ConnectionError("iZone controller unavailable")
154
+ )
144
155
  self._discovery_service.create_task(self._poll_loop())
145
156
 
146
157
  async def _probe_v2_api(self) -> None:
147
158
  """Detect V2 API support; non-fatal on failure."""
148
159
  try:
149
- response = await self._send_command_async(
160
+ response = await self._http_post(
150
161
  "iZoneRequestV2",
151
162
  {"iZoneV2Request": {"Type": 1, "No": 0, "No1": 0}},
152
- mark_disconnected=False,
153
163
  )
154
164
  data = json.loads(response)
155
165
  uid = data["AirStreamDeviceUId"]
@@ -165,7 +175,7 @@ class Controller:
165
175
 
166
176
  try:
167
177
  power = Power(self)
168
- await power.init(mark_disconnected=False)
178
+ await power.init()
169
179
  if power.enabled:
170
180
  self._power = power
171
181
  return
@@ -215,10 +225,15 @@ class Controller:
215
225
  async with self._scan_condition:
216
226
  self._scan_condition.notify()
217
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
+
218
233
  @property
219
234
  def connected(self) -> bool:
220
- """True while the controller can reach the device over HTTP."""
221
- 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
222
237
 
223
238
  @property
224
239
  def power(self) -> Power | None:
@@ -476,19 +491,25 @@ class Controller:
476
491
  ConnectionError: If any HTTP request fails.
477
492
  KeyError: If a required field is missing from a device response.
478
493
  """
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.
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
483
501
  await asyncio.gather(
484
502
  self._refresh_system(notify),
485
503
  self._refresh_power(notify),
486
504
  *[self._refresh_zone_group(i, notify) for i in range(0, zones, 4)],
487
505
  )
488
506
 
489
- async def _refresh_system(self, notify: bool = True) -> None:
507
+ async def _refresh_system(self, notify: bool = True) -> ControllerData | None:
490
508
  """Refresh the system settings from the device.
491
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
+
492
513
  Raises:
493
514
  ConnectionError: If the HTTP request fails.
494
515
  KeyError: If the response is missing required fields.
@@ -496,25 +517,40 @@ class Controller:
496
517
  values: Controller.ControllerData = await self._get_resource("SystemSettings")
497
518
  if self._device_uid != values["AirStreamDeviceUId"]:
498
519
  _LOG.error("_refresh_system called with non-matching device ID")
499
- return
520
+ return None
500
521
 
501
- 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
502
533
 
534
+ self._system_settings = values
535
+ self._set_izone_ok(True)
503
536
  if notify:
504
537
  self._event_coordinator.controller_update(self)
538
+ return values
505
539
 
506
540
  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
- """
541
+ """Refresh power monitor data when enabled."""
514
542
  if self._power is None or not self._power.enabled:
515
543
  return
516
544
 
517
- 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
518
554
 
519
555
  if updated and notify:
520
556
  self._event_coordinator.power_update(self)
@@ -527,7 +563,9 @@ class Controller:
527
563
  KeyError: If a response is missing required fields.
528
564
  AttributeError: If a zone index in the response does not match.
529
565
  """
530
- zones = int(self._system_settings["NoOfZones"])
566
+ zones = len(self.zones)
567
+ if zones == 0:
568
+ return
531
569
  await asyncio.gather(
532
570
  *[self._refresh_zone_group(i, notify) for i in range(0, zones, 4)]
533
571
  )
@@ -555,10 +593,18 @@ class Controller:
555
593
  def _refresh_address(self, address: str) -> None:
556
594
  """Update the device IP and schedule a reconnect attempt if needed."""
557
595
  self._ip = address
558
- # Signal to the retry connection loop to have another go.
559
- if self._fail_exception:
596
+ if not self._bridge_ok:
560
597
  self._discovery_service.create_task(self._retry_connection())
561
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
+
562
608
  def _get_system_state(self, state: str) -> DictValue:
563
609
  return self._system_settings[state]
564
610
 
@@ -583,33 +629,47 @@ class Controller:
583
629
  self._event_coordinator.controller_update(self)
584
630
  await self.refresh()
585
631
 
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:
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:
592
650
  return
593
- 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)
594
663
 
595
664
  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)
665
+ """Mark the bridge transport as restored."""
666
+ self._set_bridge_ok(True)
607
667
 
608
668
  async def _retry_connection(self) -> None:
609
669
  """Attempt to restore connectivity after a connection failure.
610
670
 
611
671
  Connection errors are logged and not re-raised. A successful refresh
612
- restores :attr:`connected` via :meth:`_restored_connection`.
672
+ restores :attr:`connected` when both bridge and iZone layers recover.
613
673
  """
614
674
  _LOG.info(
615
675
  "Attempting to reconnect to server uid=%s ip=%s",
@@ -627,6 +687,77 @@ class Controller:
627
687
  exc_info=True,
628
688
  )
629
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
+
630
761
  async def _get_resource(self, resource: str) -> Any:
631
762
  """Fetch a JSON resource from the device via HTTP GET.
632
763
 
@@ -636,86 +767,44 @@ class Controller:
636
767
  ControllerCommandError: If the device returns HTTP 4xx.
637
768
  """
638
769
  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
770
+ result = await self._http_get(resource)
771
+ except ControllerCommandError:
772
+ self._set_bridge_ok(True)
773
+ raise
667
774
  except (asyncio.TimeoutError, aiohttp.ClientError) as ex:
668
- self._failed_connection(ex)
775
+ self._set_bridge_ok(
776
+ False, ConnectionError("Unable to connect to the controller")
777
+ )
669
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
670
787
 
671
- async def _send_command_async(
672
- self, command: str, data: dict[str, Any], *, mark_disconnected: bool = True
673
- ) -> str:
788
+ async def _send_command_async(self, command: str, data: dict[str, Any]) -> str:
674
789
  """Send a command to the device via HTTP POST.
675
790
 
676
- Args:
677
- mark_disconnected: When ``False``, transport failures do not mark
678
- the controller disconnected.
679
-
680
791
  Raises:
681
792
  ConnectionError: If the device cannot be reached or the HTTP request fails.
682
793
  ControllerCommandError: If the device returns HTTP 4xx or an
683
794
  ``{ERROR...}`` payload.
684
795
  """
685
796
  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")
797
+ result = await self._http_post(command, data)
798
+ except ControllerCommandError:
799
+ self._set_bridge_ok(True)
800
+ raise
710
801
  except (asyncio.TimeoutError, aiohttp.ClientError) as ex:
711
- if mark_disconnected:
712
- self._failed_connection(ex)
802
+ self._set_bridge_ok(
803
+ False, ConnectionError("Unable to connect to controller")
804
+ )
713
805
  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()
806
+ except ConnectionError as ex:
807
+ self._set_bridge_ok(False, ex)
808
+ raise
809
+ self._set_bridge_ok(True)
721
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,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:
@@ -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.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()
@@ -62,48 +62,35 @@ class MockController(Controller):
62
62
  self._failed_connection(ex)
63
63
  raise ConnectionError("Explicitly Disconnected") from ex
64
64
 
65
- async def _get_resource(self, resource: str) -> Any:
66
- """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."""
67
67
  self._check_discovery_connected()
68
68
  result = self.resources.get(resource)
69
69
  if result:
70
- self._restored_connection()
71
70
  return deepcopy(result)
72
71
  raise ConnectionError(f"Mock resource '{resource}' not available")
73
72
 
74
- async def _send_command_async(
75
- self, command: str, data: dict[str, Any], *, mark_disconnected: bool = True
76
- ) -> str:
77
- """Mock out the network IO for _send_command."""
78
- if self._fail_exception:
79
- raise ConnectionError(
80
- "Unable to connect to the controller"
81
- ) 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")
82
77
  self._check_discovery_connected()
83
78
  self.sent.append((command, data))
84
79
  if command == "iZoneRequestV2":
85
80
  if self.v2_probe_response is not None:
86
- self._restored_connection()
87
81
  return self.v2_probe_response
88
- ex = ConnectionError("V2 probe failed")
89
- if mark_disconnected:
90
- self._failed_connection(ex)
91
- raise ConnectionError("Unable to connect to controller") from ex
82
+ raise ConnectionError("Unable to connect to controller")
92
83
  if command == "PowerRequest":
93
84
  req_type: int = data["PowerRequest"]["Type"]
94
85
  if req_type in self.fail_power_types:
95
- ex = TimeoutError("Power request failed")
96
- if mark_disconnected:
97
- self._failed_connection(ex)
98
- raise ConnectionError("Unable to connect to controller") from ex
86
+ raise ConnectionError("Unable to connect to controller")
99
87
  if req_type == 1:
100
- config = self.power_config if self.power_config is not None else POWER_CONFIG
101
- self._restored_connection()
88
+ config = (
89
+ self.power_config if self.power_config is not None else POWER_CONFIG
90
+ )
102
91
  return json.dumps({"PowerMonitorConfig": config})
103
92
  if req_type == 2:
104
- self._restored_connection()
105
93
  return json.dumps({"PowerMonitorStatus": POWER_STATUS})
106
- self._restored_connection()
107
94
  return ""
108
95
 
109
96
 
@@ -7,10 +7,35 @@ from typing import cast
7
7
  import pytest
8
8
  from pytest import raises
9
9
 
10
- from pizone import Controller
10
+ from pizone import Controller, Listener
11
11
 
12
12
  from .conftest import MockController, MockDiscoveryService, _register_mock_service
13
13
  from .power_data import POWER_CONFIG
14
+ from .resources import SYSTEMS
15
+
16
+
17
+ class _DisconnectListener(Listener):
18
+ def __init__(self) -> None:
19
+ self.disconnected = 0
20
+ self.reconnected = 0
21
+ self.last_exception: Exception | None = None
22
+
23
+ def controller_disconnected(self, _ctrl: Controller, ex: Exception) -> None:
24
+ self.disconnected += 1
25
+ self.last_exception = ex
26
+
27
+ def controller_reconnected(self, _ctrl: Controller) -> None:
28
+ self.reconnected += 1
29
+
30
+
31
+ def _fault_system_settings(device_uid: str) -> dict[str, object]:
32
+ settings = deepcopy(SYSTEMS["000000001"]["SystemSettings"])
33
+ settings["AirStreamDeviceUId"] = device_uid
34
+ settings["NoOfZones"] = 0
35
+ settings["SysFan"] = "error"
36
+ settings["RAS"] = "error"
37
+ settings["UnitType"] = "No Unit Type Configured!"
38
+ return settings
14
39
 
15
40
 
16
41
  @pytest.mark.asyncio
@@ -213,7 +238,7 @@ async def test_initialize_clears_ipower_when_config_disabled() -> None:
213
238
 
214
239
 
215
240
  @pytest.mark.asyncio
216
- async def test_power_init_probe_timeout_does_not_mark_disconnected() -> None:
241
+ async def test_power_init_probe_failure_leaves_controller_connected() -> None:
217
242
  svc = MockDiscoveryService()
218
243
  original_create = svc._create_controller
219
244
 
@@ -234,22 +259,25 @@ async def test_power_init_probe_timeout_does_not_mark_disconnected() -> None:
234
259
  controller = cast(MockController, svc._controllers["000000003"])
235
260
 
236
261
  assert controller.connected is True
237
- assert controller._fail_exception is None
262
+ assert controller.bridge_connected is True
263
+ assert controller._bridge_ok is True
264
+ assert controller._izone_ok is True
238
265
  finally:
239
266
  await svc.close()
240
267
 
241
268
 
242
269
  @pytest.mark.asyncio
243
- async def test_power_poll_timeout_marks_disconnected(
270
+ async def test_power_poll_failure_does_not_mark_controller_disconnected(
244
271
  ipower_service: MockDiscoveryService,
245
272
  ) -> None:
246
273
  controller = cast(MockController, ipower_service._controllers["000000003"])
274
+ assert controller.power is not None
247
275
  controller.fail_power_types.add(2)
248
276
 
249
- with raises(ConnectionError):
250
- await controller._refresh_power(notify=False)
277
+ await controller._refresh_power(notify=False)
251
278
 
252
- assert controller.connected is False
279
+ assert controller.connected is True
280
+ assert controller.power.connected is False
253
281
 
254
282
 
255
283
  @pytest.mark.asyncio
@@ -293,3 +321,120 @@ async def test_v2_probe_clears_is_v2_on_http_error() -> None:
293
321
  assert controller.is_v2 is False
294
322
  finally:
295
323
  await svc.close()
324
+
325
+
326
+ @pytest.mark.asyncio
327
+ async def test_bridge_ok_true_when_izone_fault(service: MockDiscoveryService) -> None:
328
+ controller = cast(MockController, service._controllers["000000001"])
329
+ healthy_fan = controller.fan
330
+
331
+ controller.resources["SystemSettings"] = _fault_system_settings("000000001")
332
+ await controller._refresh_system(notify=False)
333
+
334
+ assert controller.bridge_connected is True
335
+ assert controller.connected is False
336
+ assert controller.fan == healthy_fan
337
+
338
+
339
+ @pytest.mark.asyncio
340
+ async def test_cache_preserved_on_izone_fault(service: MockDiscoveryService) -> None:
341
+ controller = cast(MockController, service._controllers["000000001"])
342
+ healthy_settings = deepcopy(controller.resources["SystemSettings"])
343
+
344
+ controller.resources["SystemSettings"] = _fault_system_settings("000000001")
345
+ await controller._refresh_system(notify=False)
346
+
347
+ assert controller._system_settings == healthy_settings
348
+
349
+
350
+ @pytest.mark.asyncio
351
+ async def test_connected_restored_on_izone_recovery(
352
+ service: MockDiscoveryService,
353
+ ) -> None:
354
+ listener = _DisconnectListener()
355
+ controller = cast(MockController, service._controllers["000000001"])
356
+ controller._event_coordinator = listener
357
+ healthy_settings = deepcopy(controller.resources["SystemSettings"])
358
+
359
+ controller.resources["SystemSettings"] = _fault_system_settings("000000001")
360
+ await controller._refresh_system(notify=True)
361
+ assert listener.disconnected == 1
362
+
363
+ controller.resources["SystemSettings"] = healthy_settings
364
+ await controller._refresh_system(notify=True)
365
+ assert controller.connected is True
366
+ assert listener.reconnected == 1
367
+
368
+
369
+ @pytest.mark.asyncio
370
+ async def test_no_listener_flutter_repeated_fault_polls(
371
+ service: MockDiscoveryService,
372
+ ) -> None:
373
+ listener = _DisconnectListener()
374
+ controller = cast(MockController, service._controllers["000000001"])
375
+ controller._event_coordinator = listener
376
+
377
+ controller.resources["SystemSettings"] = _fault_system_settings("000000001")
378
+ await controller._refresh_system(notify=True)
379
+ await controller._refresh_system(notify=True)
380
+
381
+ assert listener.disconnected == 1
382
+ assert listener.reconnected == 0
383
+
384
+
385
+ @pytest.mark.asyncio
386
+ async def test_izone_fault_disconnect_uses_connection_error(
387
+ service: MockDiscoveryService,
388
+ ) -> None:
389
+ listener = _DisconnectListener()
390
+ controller = cast(MockController, service._controllers["000000001"])
391
+ controller._event_coordinator = listener
392
+
393
+ controller.resources["SystemSettings"] = _fault_system_settings("000000001")
394
+ await controller._refresh_system(notify=True)
395
+
396
+ assert isinstance(listener.last_exception, ConnectionError)
397
+
398
+
399
+ @pytest.mark.asyncio
400
+ async def test_v2_probe_failure_leaves_bridge_ok(service: MockDiscoveryService) -> None:
401
+ controller = cast(MockController, service._controllers["000000001"])
402
+ controller.v2_probe_response = None
403
+
404
+ await controller._probe_v2_api()
405
+
406
+ assert controller._bridge_ok is True
407
+ assert controller.bridge_connected is True
408
+
409
+
410
+ @pytest.mark.asyncio
411
+ async def test_init_fault_disconnect_listener() -> None:
412
+ listener = _DisconnectListener()
413
+ svc = MockDiscoveryService()
414
+ controller = MockController(
415
+ svc,
416
+ listener,
417
+ device_uid="000000004",
418
+ device_ip="10.0.0.4",
419
+ is_v2=False,
420
+ is_ipower=False,
421
+ )
422
+ controller.resources["SystemSettings"] = _fault_system_settings("000000004")
423
+
424
+ await controller._initialize()
425
+
426
+ assert controller.connected is False
427
+ assert listener.disconnected == 1
428
+ assert controller.zones == []
429
+
430
+
431
+ @pytest.mark.asyncio
432
+ async def test_both_false_on_transport_failure(service: MockDiscoveryService) -> None:
433
+ controller = cast(MockController, service._controllers["000000001"])
434
+ controller._connected = False
435
+
436
+ with raises(ConnectionError):
437
+ await controller._get_resource("SystemSettings")
438
+
439
+ assert controller.bridge_connected is False
440
+ assert controller.connected is False
@@ -7,21 +7,39 @@ from typing import Any, cast
7
7
  import pytest
8
8
 
9
9
  from pizone import BatteryLevel, Controller, Power
10
+ from pizone.discovery import Listener
10
11
 
11
12
  from .power_data import POWER_CONFIG, POWER_STATUS
12
13
 
13
14
 
15
+ class _PowerListener(Listener):
16
+ def __init__(self) -> None:
17
+ self.power_updates = 0
18
+
19
+ def power_update(self, _controller: Controller) -> None:
20
+ self.power_updates += 1
21
+
22
+
14
23
  class MockPowerController:
15
24
  """Minimal controller stub for Power unit tests."""
16
25
 
17
- def __init__(self, responses: dict[int, dict[str, object]]) -> None:
26
+ def __init__(
27
+ self,
28
+ responses: dict[int, dict[str, object]],
29
+ *,
30
+ bridge_connected: bool = True,
31
+ listener: _PowerListener | None = None,
32
+ ) -> None:
18
33
  self._responses = responses
19
34
  self.sent: list[tuple[str, dict[str, Any]]] = []
35
+ self._bridge_ok = bridge_connected
36
+ self._event_coordinator = listener or _PowerListener()
37
+
38
+ @property
39
+ def bridge_connected(self) -> bool:
40
+ return self._bridge_ok
20
41
 
21
- async def _send_command_async(
22
- self, command: str, data: dict[str, Any], *, mark_disconnected: bool = True
23
- ) -> str:
24
- del mark_disconnected
42
+ async def _http_post(self, command: str, data: dict[str, Any]) -> str:
25
43
  self.sent.append((command, data))
26
44
  req_type: int = data["PowerRequest"]["Type"]
27
45
  return json.dumps(self._responses[req_type])
@@ -39,6 +57,7 @@ async def test_power_init_and_status_last_reading() -> None:
39
57
 
40
58
  await power.init()
41
59
  assert power.enabled is True
60
+ assert power.connected is True
42
61
  assert power.voltage == 240
43
62
  assert power.groups is not None
44
63
  assert len(power.groups) == 1
@@ -105,3 +124,44 @@ async def test_power_nested_property_reads() -> None:
105
124
  assert group.name == "Grid"
106
125
  assert group.status_ok is False
107
126
  assert group.status_power == 1500
127
+
128
+
129
+ @pytest.mark.asyncio
130
+ async def test_power_connected_false_when_bridge_down() -> None:
131
+ controller = MockPowerController(
132
+ {1: {"PowerMonitorConfig": POWER_CONFIG}},
133
+ bridge_connected=False,
134
+ )
135
+ power = Power(cast(Controller, controller))
136
+
137
+ with pytest.raises(ConnectionError, match="Bridge not connected"):
138
+ await power.init()
139
+
140
+ assert power.connected is False
141
+
142
+
143
+ @pytest.mark.asyncio
144
+ async def test_power_connected_restored_on_recovery() -> None:
145
+ listener = _PowerListener()
146
+ controller = MockPowerController(
147
+ {
148
+ 1: {"PowerMonitorConfig": POWER_CONFIG},
149
+ 2: {"PowerMonitorStatus": POWER_STATUS},
150
+ },
151
+ listener=listener,
152
+ )
153
+ power = Power(cast(Controller, controller))
154
+ await power.init()
155
+
156
+ controller._bridge_ok = False
157
+ controller._responses = {}
158
+ with pytest.raises(ConnectionError):
159
+ await power.refresh()
160
+ assert power.connected is False
161
+ assert listener.power_updates == 1
162
+
163
+ controller._bridge_ok = True
164
+ controller._responses = {2: {"PowerMonitorStatus": POWER_STATUS}}
165
+ await power.refresh()
166
+ assert power.connected is True
167
+ assert listener.power_updates == 2
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes