python-izone 1.3.2__tar.gz → 1.3.4__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.4}/PKG-INFO +1 -1
- python_izone-1.3.4/docs/release-notes/v1.3.4.md +55 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/pizone/__init__.py +8 -1
- {python_izone-1.3.2 → python_izone-1.3.4}/pizone/controller.py +251 -137
- {python_izone-1.3.2 → python_izone-1.3.4}/pizone/discovery.py +1 -1
- {python_izone-1.3.2 → python_izone-1.3.4}/pizone/exceptions.py +8 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/pizone/power.py +44 -22
- {python_izone-1.3.2 → python_izone-1.3.4}/pizone/version.py +2 -2
- {python_izone-1.3.2 → python_izone-1.3.4}/pizone/zone.py +2 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/tests/conftest.py +11 -24
- {python_izone-1.3.2 → python_izone-1.3.4}/tests/test_controller.py +191 -7
- {python_izone-1.3.2 → python_izone-1.3.4}/tests/test_http.py +3 -2
- {python_izone-1.3.2 → python_izone-1.3.4}/tests/test_power.py +65 -5
- {python_izone-1.3.2 → python_izone-1.3.4}/.gitattributes +0 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/.github/workflows/main.yml +0 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/.gitignore +0 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/.pre-commit-config.yaml +0 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/.pylintrc +0 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/.python-version +0 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/.vscode/.ropeproject/config.py +0 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/.vscode/launch.json +0 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/.vscode/settings.json +0 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/README.md +0 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/api_docs/AC-DOC-1401-11_iZoneEthernetInterface.pdf +0 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/api_docs/iPower_JSON_datastrings_Out.h +0 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/api_docs/iZone_JSON_datastrings.h +0 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/licence.txt +0 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/pizone/py.typed +0 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/pyproject.toml +0 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/scripts/check +0 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/scripts/coverage +0 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/tests/__init__.py +0 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/tests/http_fakes.py +0 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/tests/power_data.py +0 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/tests/resources.py +0 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/tests/test_discovery.py +0 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/tests/test_fullstack.py +0 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/tests/test_zone.py +0 -0
- {python_izone-1.3.2 → python_izone-1.3.4}/uv.lock +0 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Release notes — v1.3.4
|
|
2
|
+
|
|
3
|
+
Released 2026-07-12. Maintenance and modernization release for the Home Assistant
|
|
4
|
+
iZone integration.
|
|
5
|
+
|
|
6
|
+
Shipped as **1.3.4** because PyPI versions `1.3.0`–`1.3.3` from an earlier
|
|
7
|
+
premature release line remain yanked and cannot be replaced.
|
|
8
|
+
|
|
9
|
+
**Compare range:** https://github.com/Swamp-Ig/pizone/compare/v1.2.10...v1.3.4
|
|
10
|
+
|
|
11
|
+
## Summary
|
|
12
|
+
|
|
13
|
+
Since `v1.2.10`, the library has been brought up to current Python packaging
|
|
14
|
+
and typing practice, moved its HTTP stack to aiohttp, clarified how connection
|
|
15
|
+
and command failures are reported, expanded test coverage, and tightened
|
|
16
|
+
behaviour around partial device outages.
|
|
17
|
+
|
|
18
|
+
## Packaging, dependencies, and development
|
|
19
|
+
|
|
20
|
+
- **Python 3.14** minimum
|
|
21
|
+
- Development and releases migrated to **uv** and **Hatchling** (VCS versioning, `py.typed`)
|
|
22
|
+
- **aiohttp** (`>=3.14.1`) replaces the custom HTTP client; request timeout raised to **10 seconds**
|
|
23
|
+
- **`ifaddr`** replaces `netifaces` for broadcast discovery
|
|
24
|
+
- Stricter **mypy** configuration; typing and style cleanup across the codebase
|
|
25
|
+
- CI updated (current GitHub Actions, **PyPI trusted publishing**); hardware tests excluded from default CI runs
|
|
26
|
+
- Unit test coverage raised to **~92%** with dedicated power, HTTP, and discovery tests
|
|
27
|
+
|
|
28
|
+
## Error handling and connection behaviour
|
|
29
|
+
|
|
30
|
+
- **Cached property reads** no longer raise when disconnected; async I/O methods raise **`ConnectionError`** for transport failures
|
|
31
|
+
- **`ControllerCommandError`** for reachable devices that reject a request (`{ERROR}` POST body or HTTP 4xx)
|
|
32
|
+
- **`ResponseDecodeError`** for bridge-reachable HTTP responses that cannot be decoded as JSON (keeps `bridge_connected` true without string-matching exception messages)
|
|
33
|
+
- **Layered connection state:** `bridge_connected` (ASH bridge HTTP) and `connected` (bridge plus valid iZone AC data). Fault `SystemSettings` no longer overwrites cached state; Home Assistant can mark entities unavailable through existing disconnect listeners during partial outages (bridge up, AC subsystem down)
|
|
34
|
+
- **Fault-safe property reads:** empty or fault-shaped system settings return safe defaults so polling and entity bootstrap do not crash on `KeyError` or invalid enum values
|
|
35
|
+
- **`Power.connected`** tracks power monitor I/O separately and does not clear `Controller.connected`
|
|
36
|
+
- Optional subsystem I/O (power, V2 probe) uses untracked HTTP helpers so init-time probe failures do not poison controller connection state
|
|
37
|
+
- Connection restore and reconnect notification fixes, including deduplication of failed init attempts and a `DiscoveryService` shutdown race
|
|
38
|
+
|
|
39
|
+
## Protocol and performance
|
|
40
|
+
|
|
41
|
+
- **Zones 13–14** supported via the zone extender endpoint (`Zones13_14`)
|
|
42
|
+
- Controller resources refreshed **concurrently** (`asyncio.gather` with serialized HTTP via `_sending_lock`)
|
|
43
|
+
- Runtime **V2** and **iPower** capability detection via non-fatal init probes (overrides or supplements discovery datagram hints)
|
|
44
|
+
|
|
45
|
+
## Documentation
|
|
46
|
+
|
|
47
|
+
- Public API and I/O method docstrings document exception behaviour and cached-read semantics
|
|
48
|
+
- Package docstring documents layered connection properties (`bridge_connected`, `connected`, `Power.connected`)
|
|
49
|
+
- README and inline docs updated for aiohttp requirements and development workflow
|
|
50
|
+
|
|
51
|
+
## Other fixes
|
|
52
|
+
|
|
53
|
+
- Discovery accepts `X` in the AC capability slot (per iZone docs, means subsystem unavailable in that slot — not an invalid bridge)
|
|
54
|
+
- Power `status_last_reading` key correction
|
|
55
|
+
- `controller_reconnected` KeyError when UID missing from internal disconnect tracking
|
|
@@ -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",
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
+
"""
|
|
@@ -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.4'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 3, 4)
|
|
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,159 @@ 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_init_fault_property_reads_no_crash() -> None:
|
|
433
|
+
listener = _DisconnectListener()
|
|
434
|
+
svc = MockDiscoveryService()
|
|
435
|
+
controller = MockController(
|
|
436
|
+
svc,
|
|
437
|
+
listener,
|
|
438
|
+
device_uid="000000005",
|
|
439
|
+
device_ip="10.0.0.5",
|
|
440
|
+
is_v2=False,
|
|
441
|
+
is_ipower=False,
|
|
442
|
+
)
|
|
443
|
+
controller.resources["SystemSettings"] = _fault_system_settings("000000005")
|
|
444
|
+
|
|
445
|
+
await controller._initialize()
|
|
446
|
+
|
|
447
|
+
assert controller.connected is False
|
|
448
|
+
assert controller.ras_mode == "zones"
|
|
449
|
+
assert controller.sys_type == "0"
|
|
450
|
+
assert controller.fan == Controller.Fan.AUTO
|
|
451
|
+
assert controller.mode == Controller.Mode.COOL
|
|
452
|
+
assert controller.is_on is False
|
|
453
|
+
assert controller.zones_total == 0
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
@pytest.mark.asyncio
|
|
457
|
+
async def test_fault_placeholder_values_in_cache_use_defaults(
|
|
458
|
+
service: MockDiscoveryService,
|
|
459
|
+
) -> None:
|
|
460
|
+
controller = cast(MockController, service._controllers["000000001"])
|
|
461
|
+
controller._system_settings["SysFan"] = "error"
|
|
462
|
+
controller._system_settings["RAS"] = "error"
|
|
463
|
+
controller._system_settings["SysMode"] = "error"
|
|
464
|
+
|
|
465
|
+
assert controller.fan == Controller.Fan.AUTO
|
|
466
|
+
assert controller.ras_mode == "zones"
|
|
467
|
+
assert controller.mode == Controller.Mode.COOL
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
@pytest.mark.asyncio
|
|
471
|
+
async def test_both_false_on_transport_failure(service: MockDiscoveryService) -> None:
|
|
472
|
+
controller = cast(MockController, service._controllers["000000001"])
|
|
473
|
+
controller._connected = False
|
|
474
|
+
|
|
475
|
+
with raises(ConnectionError):
|
|
476
|
+
await controller._get_resource("SystemSettings")
|
|
477
|
+
|
|
478
|
+
assert controller.bridge_connected is False
|
|
479
|
+
assert controller.connected is False
|
|
@@ -8,7 +8,7 @@ import pytest
|
|
|
8
8
|
from aiohttp import ClientSession
|
|
9
9
|
from pytest import raises
|
|
10
10
|
|
|
11
|
-
from pizone import Controller, ControllerCommandError
|
|
11
|
+
from pizone import Controller, ControllerCommandError, ResponseDecodeError
|
|
12
12
|
|
|
13
13
|
from .conftest import MockDiscoveryService
|
|
14
14
|
from .http_fakes import FakeHttpResponse, FakeHttpSession
|
|
@@ -98,11 +98,12 @@ async def test_get_resource_decode_failure(service: MockDiscoveryService) -> Non
|
|
|
98
98
|
),
|
|
99
99
|
)
|
|
100
100
|
try:
|
|
101
|
-
with raises(
|
|
101
|
+
with raises(ResponseDecodeError):
|
|
102
102
|
await controller._get_resource("SystemSettings")
|
|
103
103
|
finally:
|
|
104
104
|
service._session = original_session
|
|
105
105
|
|
|
106
|
+
assert controller.bridge_connected is True
|
|
106
107
|
assert controller.connected is True
|
|
107
108
|
|
|
108
109
|
|
|
@@ -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.4}/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
|