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.
- {python_izone-1.3.1 → python_izone-1.3.3}/PKG-INFO +1 -1
- {python_izone-1.3.1 → python_izone-1.3.3}/pizone/controller.py +257 -124
- {python_izone-1.3.1 → python_izone-1.3.3}/pizone/discovery.py +1 -1
- {python_izone-1.3.1 → python_izone-1.3.3}/pizone/power.py +41 -5
- {python_izone-1.3.1 → python_izone-1.3.3}/pizone/version.py +2 -2
- {python_izone-1.3.1 → python_izone-1.3.3}/pizone/zone.py +2 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/tests/conftest.py +19 -13
- python_izone-1.3.3/tests/test_controller.py +440 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/tests/test_discovery.py +4 -3
- {python_izone-1.3.1 → python_izone-1.3.3}/tests/test_power.py +65 -4
- python_izone-1.3.1/tests/test_controller.py +0 -116
- {python_izone-1.3.1 → python_izone-1.3.3}/.gitattributes +0 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/.github/workflows/main.yml +0 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/.gitignore +0 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/.pre-commit-config.yaml +0 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/.pylintrc +0 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/.python-version +0 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/.vscode/.ropeproject/config.py +0 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/.vscode/launch.json +0 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/.vscode/settings.json +0 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/README.md +0 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/api_docs/AC-DOC-1401-11_iZoneEthernetInterface.pdf +0 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/api_docs/iPower_JSON_datastrings_Out.h +0 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/api_docs/iZone_JSON_datastrings.h +0 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/licence.txt +0 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/pizone/__init__.py +0 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/pizone/exceptions.py +0 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/pizone/py.typed +0 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/pyproject.toml +0 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/scripts/check +0 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/scripts/coverage +0 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/tests/__init__.py +0 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/tests/http_fakes.py +0 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/tests/power_data.py +0 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/tests/resources.py +0 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/tests/test_fullstack.py +0 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/tests/test_http.py +0 -0
- {python_izone-1.3.1 → python_izone-1.3.3}/tests/test_zone.py +0 -0
- {python_izone-1.3.1 → 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()
|
|
@@ -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
|
|
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.
|
|
131
|
-
str(self._system_settings.get("FanAuto", "disabled"))
|
|
132
|
-
]
|
|
141
|
+
await self._probe_v2_api()
|
|
133
142
|
|
|
134
|
-
zone_count = int(
|
|
143
|
+
zone_count = int(settings["NoOfZones"])
|
|
135
144
|
self.zones = [Zone(self, i) for i in range(zone_count)]
|
|
136
145
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
187
|
-
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
|
|
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 =
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
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:
|
|
553
650
|
return
|
|
554
|
-
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)
|
|
555
663
|
|
|
556
664
|
def _restored_connection(self) -> None:
|
|
557
|
-
"""
|
|
558
|
-
|
|
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`
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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.
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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.
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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[
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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.
|
|
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()
|
|
@@ -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
|
|
63
|
-
"""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."""
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ""
|