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