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.
- {python_izone-1.3.2 → python_izone-1.3.3}/PKG-INFO +1 -1
- {python_izone-1.3.2 → python_izone-1.3.3}/pizone/controller.py +210 -121
- {python_izone-1.3.2 → python_izone-1.3.3}/pizone/discovery.py +1 -1
- {python_izone-1.3.2 → python_izone-1.3.3}/pizone/power.py +44 -22
- {python_izone-1.3.2 → python_izone-1.3.3}/pizone/version.py +2 -2
- {python_izone-1.3.2 → python_izone-1.3.3}/pizone/zone.py +2 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/tests/conftest.py +11 -24
- {python_izone-1.3.2 → python_izone-1.3.3}/tests/test_controller.py +152 -7
- {python_izone-1.3.2 → python_izone-1.3.3}/tests/test_power.py +65 -5
- {python_izone-1.3.2 → python_izone-1.3.3}/.gitattributes +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/.github/workflows/main.yml +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/.gitignore +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/.pre-commit-config.yaml +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/.pylintrc +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/.python-version +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/.vscode/.ropeproject/config.py +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/.vscode/launch.json +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/.vscode/settings.json +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/README.md +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/api_docs/AC-DOC-1401-11_iZoneEthernetInterface.pdf +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/api_docs/iPower_JSON_datastrings_Out.h +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/api_docs/iZone_JSON_datastrings.h +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/licence.txt +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/pizone/__init__.py +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/pizone/exceptions.py +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/pizone/py.typed +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/pyproject.toml +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/scripts/check +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/scripts/coverage +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/tests/__init__.py +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/tests/http_fakes.py +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/tests/power_data.py +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/tests/resources.py +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/tests/test_discovery.py +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/tests/test_fullstack.py +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/tests/test_http.py +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/tests/test_zone.py +0 -0
- {python_izone-1.3.2 → python_izone-1.3.3}/uv.lock +0 -0
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
|
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
|
-
|
|
131
|
-
|
|
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(
|
|
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.
|
|
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(
|
|
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
|
|
221
|
-
return self.
|
|
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 =
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
if not
|
|
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.
|
|
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
|
-
"""
|
|
597
|
-
|
|
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`
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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.
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
|
|
712
|
-
|
|
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
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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[
|
|
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
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (1, 3,
|
|
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
|
|
66
|
-
"""Mock out the network IO for
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
101
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
250
|
-
await controller._refresh_power(notify=False)
|
|
277
|
+
await controller._refresh_power(notify=False)
|
|
251
278
|
|
|
252
|
-
assert controller.connected is
|
|
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__(
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_izone-1.3.2 → python_izone-1.3.3}/api_docs/AC-DOC-1401-11_iZoneEthernetInterface.pdf
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|