python-izone 1.3.3__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.3 → 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.3 → python_izone-1.3.4}/pizone/__init__.py +8 -1
- {python_izone-1.3.3 → python_izone-1.3.4}/pizone/controller.py +48 -23
- {python_izone-1.3.3 → python_izone-1.3.4}/pizone/exceptions.py +8 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/pizone/version.py +2 -2
- {python_izone-1.3.3 → python_izone-1.3.4}/tests/test_controller.py +39 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/tests/test_http.py +3 -2
- {python_izone-1.3.3 → python_izone-1.3.4}/.gitattributes +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/.github/workflows/main.yml +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/.gitignore +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/.pre-commit-config.yaml +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/.pylintrc +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/.python-version +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/.vscode/.ropeproject/config.py +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/.vscode/launch.json +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/.vscode/settings.json +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/README.md +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/api_docs/AC-DOC-1401-11_iZoneEthernetInterface.pdf +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/api_docs/iPower_JSON_datastrings_Out.h +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/api_docs/iZone_JSON_datastrings.h +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/licence.txt +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/pizone/discovery.py +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/pizone/power.py +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/pizone/py.typed +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/pizone/zone.py +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/pyproject.toml +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/scripts/check +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/scripts/coverage +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/tests/__init__.py +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/tests/conftest.py +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/tests/http_fakes.py +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/tests/power_data.py +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/tests/resources.py +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/tests/test_discovery.py +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/tests/test_fullstack.py +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/tests/test_power.py +0 -0
- {python_izone-1.3.3 → python_izone-1.3.4}/tests/test_zone.py +0 -0
- {python_izone-1.3.3 → 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
|
|
|
@@ -79,6 +79,27 @@ class Controller:
|
|
|
79
79
|
"var-speed": [Fan.LOW, Fan.MED, Fan.HIGH, Fan.AUTO],
|
|
80
80
|
}
|
|
81
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
|
+
|
|
82
103
|
def __init__(
|
|
83
104
|
self,
|
|
84
105
|
discovery_service: DiscoveryService,
|
|
@@ -280,14 +301,13 @@ class Controller:
|
|
|
280
301
|
|
|
281
302
|
@property
|
|
282
303
|
def mode(self) -> Mode:
|
|
283
|
-
"""System mode (cooling, heating, etc.).
|
|
284
|
-
|
|
285
|
-
Raises:
|
|
286
|
-
ValueError: If the cached mode is not a valid :class:`Mode` member.
|
|
287
|
-
"""
|
|
304
|
+
"""System mode (cooling, heating, etc.)."""
|
|
288
305
|
if self.free_air:
|
|
289
306
|
return self.Mode.FREE_AIR
|
|
290
|
-
|
|
307
|
+
try:
|
|
308
|
+
return self.Mode(self._get_system_state("SysMode"))
|
|
309
|
+
except ValueError:
|
|
310
|
+
return self.Mode.COOL
|
|
291
311
|
|
|
292
312
|
async def set_mode(self, value: Mode) -> None:
|
|
293
313
|
"""Set the system mode (cooling, heating, etc.).
|
|
@@ -309,12 +329,11 @@ class Controller:
|
|
|
309
329
|
|
|
310
330
|
@property
|
|
311
331
|
def fan(self) -> Fan:
|
|
312
|
-
"""The current fan level.
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
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
|
|
318
337
|
|
|
319
338
|
async def set_fan(self, value: Fan) -> None:
|
|
320
339
|
"""Set the fan level.
|
|
@@ -606,7 +625,13 @@ class Controller:
|
|
|
606
625
|
)
|
|
607
626
|
|
|
608
627
|
def _get_system_state(self, state: str) -> DictValue:
|
|
609
|
-
|
|
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
|
|
610
635
|
|
|
611
636
|
async def _set_system_state(
|
|
612
637
|
self,
|
|
@@ -691,8 +716,8 @@ class Controller:
|
|
|
691
716
|
"""Fetch a JSON resource via HTTP GET without updating connection state.
|
|
692
717
|
|
|
693
718
|
Raises:
|
|
694
|
-
ConnectionError: If the device cannot be reached
|
|
695
|
-
|
|
719
|
+
ConnectionError: If the device cannot be reached.
|
|
720
|
+
ResponseDecodeError: If the response cannot be decoded as JSON.
|
|
696
721
|
ControllerCommandError: If the device returns HTTP 4xx.
|
|
697
722
|
"""
|
|
698
723
|
session = self._discovery_service.session
|
|
@@ -716,7 +741,7 @@ class Controller:
|
|
|
716
741
|
if text[-4:] == "{OK}":
|
|
717
742
|
return json.loads(text[:-4])
|
|
718
743
|
_LOG.error('Decode error for "%s"', text, exc_info=True)
|
|
719
|
-
raise
|
|
744
|
+
raise ResponseDecodeError(
|
|
720
745
|
"Unable to decode response from the controller"
|
|
721
746
|
) from ex
|
|
722
747
|
|
|
@@ -762,8 +787,8 @@ class Controller:
|
|
|
762
787
|
"""Fetch a JSON resource from the device via HTTP GET.
|
|
763
788
|
|
|
764
789
|
Raises:
|
|
765
|
-
ConnectionError: If the device cannot be reached
|
|
766
|
-
|
|
790
|
+
ConnectionError: If the device cannot be reached or the HTTP request fails.
|
|
791
|
+
ResponseDecodeError: If the response cannot be decoded as JSON.
|
|
767
792
|
ControllerCommandError: If the device returns HTTP 4xx.
|
|
768
793
|
"""
|
|
769
794
|
try:
|
|
@@ -776,11 +801,11 @@ class Controller:
|
|
|
776
801
|
False, ConnectionError("Unable to connect to the controller")
|
|
777
802
|
)
|
|
778
803
|
raise ConnectionError("Unable to connect to the controller") from ex
|
|
804
|
+
except ResponseDecodeError:
|
|
805
|
+
self._set_bridge_ok(True)
|
|
806
|
+
raise
|
|
779
807
|
except ConnectionError as ex:
|
|
780
|
-
|
|
781
|
-
self._set_bridge_ok(True)
|
|
782
|
-
else:
|
|
783
|
-
self._set_bridge_ok(False, ex)
|
|
808
|
+
self._set_bridge_ok(False, ex)
|
|
784
809
|
raise
|
|
785
810
|
self._set_bridge_ok(True)
|
|
786
811
|
return result
|
|
@@ -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
|
+
"""
|
|
@@ -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
|
|
@@ -428,6 +428,45 @@ async def test_init_fault_disconnect_listener() -> None:
|
|
|
428
428
|
assert controller.zones == []
|
|
429
429
|
|
|
430
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
|
+
|
|
431
470
|
@pytest.mark.asyncio
|
|
432
471
|
async def test_both_false_on_transport_failure(service: MockDiscoveryService) -> None:
|
|
433
472
|
controller = cast(MockController, service._controllers["000000001"])
|
|
@@ -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
|
|
|
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.3 → 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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|