python-izone 1.3.5__tar.gz → 1.3.7__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.5 → python_izone-1.3.7}/PKG-INFO +1 -1
- python_izone-1.3.7/docs/release-notes/v1.3.5.md +85 -0
- python_izone-1.3.7/docs/release-notes/v1.3.6.md +70 -0
- python_izone-1.3.7/docs/release-notes/v1.3.7.md +7 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/pizone/__init__.py +7 -3
- {python_izone-1.3.5 → python_izone-1.3.7}/pizone/discovery.py +26 -9
- {python_izone-1.3.5 → python_izone-1.3.7}/pizone/exceptions.py +8 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/pizone/version.py +2 -2
- {python_izone-1.3.5 → python_izone-1.3.7}/tests/test_discovery_api.py +166 -6
- python_izone-1.3.5/docs/release-notes/v1.3.5.md +0 -92
- {python_izone-1.3.5 → python_izone-1.3.7}/.gitattributes +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/.github/workflows/main.yml +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/.gitignore +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/.pre-commit-config.yaml +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/.python-version +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/.vscode/.ropeproject/config.py +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/.vscode/extensions.json +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/.vscode/launch.json +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/.vscode/settings.json +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/README.md +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/api_docs/AC-DOC-1401-11_iZoneEthernetInterface.pdf +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/api_docs/iPower_JSON_datastrings_Out.h +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/api_docs/iZone_JSON_datastrings.h +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/docs/release-notes/v1.3.4.md +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/licence.txt +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/pizone/const.py +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/pizone/controller.py +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/pizone/power.py +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/pizone/py.typed +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/pizone/types.py +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/pizone/zone.py +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/pyproject.toml +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/scripts/check +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/scripts/coverage +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/tests/__init__.py +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/tests/conftest.py +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/tests/http_fakes.py +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/tests/power_data.py +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/tests/resources.py +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/tests/test_controller.py +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/tests/test_discovery.py +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/tests/test_fullstack.py +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/tests/test_http.py +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/tests/test_power.py +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/tests/test_zone.py +0 -0
- {python_izone-1.3.5 → python_izone-1.3.7}/uv.lock +0 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Release notes — v1.3.5
|
|
2
|
+
|
|
3
|
+
Released 2026-07-17.
|
|
4
|
+
|
|
5
|
+
**Compare range:** https://github.com/Swamp-Ig/pizone/compare/v1.3.4...v1.3.5
|
|
6
|
+
|
|
7
|
+
This is a **pre-release**. It includes work toward a better Home Assistant
|
|
8
|
+
integration (stored bridge IP, improved discovery, and cleaner refresh
|
|
9
|
+
timing), plus reliability fixes you can use today. Home Assistant still uses
|
|
10
|
+
the same library entry points as 1.3.4. The older API remains available; a
|
|
11
|
+
future major release will remove it in favour of the new discovery and refresh
|
|
12
|
+
API below.
|
|
13
|
+
|
|
14
|
+
## Summary
|
|
15
|
+
|
|
16
|
+
Since v1.3.4:
|
|
17
|
+
|
|
18
|
+
- HTTP connections are closed after each GET, which greatly reduces stalls on
|
|
19
|
+
some iZone bridges ([home-assistant/core#176536](https://github.com/home-assistant/core/issues/176536))
|
|
20
|
+
- A new discovery and refresh API is available for integrators (the older API
|
|
21
|
+
still works for now)
|
|
22
|
+
- iPower monitoring is off unless you opt in with `ENABLE_POWER = True`
|
|
23
|
+
- Bridges that are not paired to an air conditioner (UID `000000000`) are
|
|
24
|
+
ignored or rejected instead of showing up as broken controllers
|
|
25
|
+
|
|
26
|
+
## Bridge connection reliability
|
|
27
|
+
|
|
28
|
+
Some ASH bridges misbehave when HTTP connections are reused. This release:
|
|
29
|
+
|
|
30
|
+
- Sends `Connection: close` on every HTTP GET (polling and discovery probes)
|
|
31
|
+
- Closes TCP connections after each request when the library creates its own
|
|
32
|
+
aiohttp session
|
|
33
|
+
|
|
34
|
+
This reduces how often the bridge stops responding. It does not fix every
|
|
35
|
+
hub-side stall; replacing faulty hardware may still be needed.
|
|
36
|
+
|
|
37
|
+
## New discovery and refresh API (optional for now)
|
|
38
|
+
|
|
39
|
+
Integrators can adopt a clearer model without background poll loops inside the
|
|
40
|
+
library:
|
|
41
|
+
|
|
42
|
+
- `create_discovery()` with callbacks when a controller is found or its IP
|
|
43
|
+
changes
|
|
44
|
+
- `discover_by_host`, `discover_by_uid`, `discover_all` to find bridges on the
|
|
45
|
+
network
|
|
46
|
+
- `create_controller(uid, host)` to connect to a known bridge
|
|
47
|
+
- `refresh_all`, `refresh_system`, `refresh_zones`, and related methods so the
|
|
48
|
+
caller controls when to poll
|
|
49
|
+
|
|
50
|
+
The previous API (`discovery()`, `fetch_controller`, `Listener`, and so on)
|
|
51
|
+
still works and is what Home Assistant uses today.
|
|
52
|
+
|
|
53
|
+
## iPower opt-in
|
|
54
|
+
|
|
55
|
+
`ENABLE_POWER` now defaults to `False`. Power monitor HTTP traffic only runs
|
|
56
|
+
after you set `pizone.ENABLE_POWER = True` before creating or refreshing a
|
|
57
|
+
controller. This avoids extra requests on bridges that advertise iPower but are
|
|
58
|
+
not set up for it. The default may change if more field data shows power
|
|
59
|
+
monitoring is reliable enough to enable by default.
|
|
60
|
+
|
|
61
|
+
## Unpaired bridges
|
|
62
|
+
|
|
63
|
+
Wireless ASH bridges with no AC paired report device UID `000000000`. The
|
|
64
|
+
library now:
|
|
65
|
+
|
|
66
|
+
- Ignores them during passive UDP discovery
|
|
67
|
+
- Raises `UnpairedBridgeError` if you try to connect to that UID on purpose
|
|
68
|
+
- Refuses to cache them after an IP lookup
|
|
69
|
+
|
|
70
|
+
Paired bridges that temporarily report fault-shaped settings (AC off, reboot,
|
|
71
|
+
and so on) are unchanged: they keep their real UID and show as disconnected
|
|
72
|
+
until the AC data recovers.
|
|
73
|
+
|
|
74
|
+
## Other changes
|
|
75
|
+
|
|
76
|
+
- UDP `CHANGED_*` notifications are recognized and ignored (no spurious refresh)
|
|
77
|
+
- Discovery verifies bridge identity over HTTP before trusting UDP replies
|
|
78
|
+
- Development tooling moved to Ruff for lint and format
|
|
79
|
+
|
|
80
|
+
## Home Assistant
|
|
81
|
+
|
|
82
|
+
If you maintain the iZone integration or test against this library, pin
|
|
83
|
+
`python-izone==1.3.5` for the connection reuse fix on the current integration
|
|
84
|
+
code path. The new discovery API is included for upcoming integration work but
|
|
85
|
+
is not required for a version bump alone.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Release notes — v1.3.6
|
|
2
|
+
|
|
3
|
+
Released 2026-07-19.
|
|
4
|
+
|
|
5
|
+
**Compare range:** https://github.com/Swamp-Ig/pizone/compare/v1.3.5...v1.3.6
|
|
6
|
+
|
|
7
|
+
This is a **pre-release** for integrators adopting the newer discovery API. It is not aimed at end users as a standalone upgrade. Prefer waiting for a future stable line unless you are developing against that API.
|
|
8
|
+
|
|
9
|
+
## Since v1.3.5
|
|
10
|
+
|
|
11
|
+
- Discovery scan wait (`SCAN_TIMEOUT`) reduced from 20s to 5s
|
|
12
|
+
- `close()` waits until the UDP transport has finished tearing down before returning, so a following `create_discovery()` can rebind `:7005` immediately (fixes delete → HomeKit rediscovery `EADDRINUSE` races)
|
|
13
|
+
- Failed `create_discovery` / `start_discovery` no longer leaves a sticky process singleton that blocked retries until process restart
|
|
14
|
+
- `PLACEHOLDER_DEVICE_UID` is no longer exported from the public package API (`UnpairedBridgeError` remains the supported signal)
|
|
15
|
+
|
|
16
|
+
Everything from v1.3.5 below still applies.
|
|
17
|
+
|
|
18
|
+
## Summary (since v1.3.4)
|
|
19
|
+
|
|
20
|
+
- HTTP connections are closed after each GET, which greatly reduces stalls on some iZone bridges ([home-assistant/core#176536](https://github.com/home-assistant/core/issues/176536))
|
|
21
|
+
- A new discovery and refresh API is available for integrators (the older API still works for now)
|
|
22
|
+
- iPower monitoring is off unless you opt in with `ENABLE_POWER = True`
|
|
23
|
+
- Bridges that are not paired to an air conditioner (UID `000000000`) are ignored or rejected instead of showing up as broken controllers
|
|
24
|
+
- Faster discovery scans and reliable UDP close → recreate (this release)
|
|
25
|
+
|
|
26
|
+
## Bridge connection reliability
|
|
27
|
+
|
|
28
|
+
Some ASH bridges misbehave when HTTP connections are reused. This release line:
|
|
29
|
+
|
|
30
|
+
- Sends `Connection: close` on every HTTP GET (polling and discovery probes)
|
|
31
|
+
- Closes TCP connections after each request when the library creates its own aiohttp session
|
|
32
|
+
|
|
33
|
+
This reduces how often the bridge stops responding. It does not fix every hub-side stall; replacing faulty hardware may still be needed.
|
|
34
|
+
|
|
35
|
+
## New discovery and refresh API (optional for now)
|
|
36
|
+
|
|
37
|
+
Integrators can adopt a clearer model without background poll loops inside the library:
|
|
38
|
+
|
|
39
|
+
- `create_discovery()` with callbacks when a controller is found or its IP changes
|
|
40
|
+
- `discover_by_host`, `discover_by_uid`, `discover_all` to find bridges on the network
|
|
41
|
+
- `create_controller(uid, host)` to connect to a known bridge
|
|
42
|
+
- `refresh_all`, `refresh_system`, `refresh_zones`, and related methods so the caller controls when to poll
|
|
43
|
+
|
|
44
|
+
`close()` on the discovery service now waits for UDP teardown so the next `create_discovery()` can bind the discovery port again without racing the OS socket release.
|
|
45
|
+
|
|
46
|
+
The previous API (`discovery()`, `fetch_controller`, `Listener`, and so on) still works.
|
|
47
|
+
|
|
48
|
+
## iPower opt-in
|
|
49
|
+
|
|
50
|
+
`ENABLE_POWER` now defaults to `False`. Power monitor HTTP traffic only runs after you set `pizone.ENABLE_POWER = True` before creating or refreshing a controller. This avoids extra requests on bridges that advertise iPower but are not set up for it. The default may change if more field data shows power monitoring is reliable enough to enable by default.
|
|
51
|
+
|
|
52
|
+
## Unpaired bridges
|
|
53
|
+
|
|
54
|
+
Wireless ASH bridges with no AC paired report device UID `000000000`. The library now:
|
|
55
|
+
|
|
56
|
+
- Ignores them during passive UDP discovery
|
|
57
|
+
- Raises `UnpairedBridgeError` if you try to connect to that UID on purpose
|
|
58
|
+
- Refuses to cache them after an IP lookup
|
|
59
|
+
|
|
60
|
+
Paired bridges that temporarily report fault-shaped settings (AC off, reboot, and so on) are unchanged: they keep their real UID and show as disconnected until the AC data recovers.
|
|
61
|
+
|
|
62
|
+
## Other changes
|
|
63
|
+
|
|
64
|
+
- UDP `CHANGED_*` notifications are recognized and ignored (no spurious refresh)
|
|
65
|
+
- Discovery verifies bridge identity over HTTP before trusting UDP replies
|
|
66
|
+
- Development tooling moved to Ruff for lint and format
|
|
67
|
+
|
|
68
|
+
## Home Assistant
|
|
69
|
+
|
|
70
|
+
Pin `python-izone==1.3.6` when moving the iZone integration onto the newer discovery and coordinator path. This pre-release is intended for that integration work, not as a general end-user library upgrade on the legacy path alone.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Release notes — v1.3.7
|
|
2
|
+
|
|
3
|
+
**Compare range:** https://github.com/Swamp-Ig/pizone/compare/v1.3.6...v1.3.7
|
|
4
|
+
|
|
5
|
+
## Since v1.3.6
|
|
6
|
+
|
|
7
|
+
- Add `ControllerAlreadyClaimedError` for duplicate controller claim/discovery attempts (replaces bare `RuntimeError` with message `already created` on controller paths; discovery-service singleton errors unchanged)
|
|
@@ -29,10 +29,14 @@ from types import ModuleType
|
|
|
29
29
|
from typing import Any
|
|
30
30
|
|
|
31
31
|
from . import power as power_mod
|
|
32
|
-
from .const import PLACEHOLDER_DEVICE_UID
|
|
33
32
|
from .controller import Controller
|
|
34
33
|
from .discovery import DiscoveryService, Listener, create_discovery, discovery
|
|
35
|
-
from .exceptions import
|
|
34
|
+
from .exceptions import (
|
|
35
|
+
ControllerAlreadyClaimedError,
|
|
36
|
+
ControllerCommandError,
|
|
37
|
+
ResponseDecodeError,
|
|
38
|
+
UnpairedBridgeError,
|
|
39
|
+
)
|
|
36
40
|
from .power import BatteryLevel, Power, PowerChannel, PowerDevice, PowerGroup
|
|
37
41
|
from .types import ControllerEndpoint
|
|
38
42
|
from .zone import Zone
|
|
@@ -60,9 +64,9 @@ sys.modules[__name__].__class__ = _PizoneModule
|
|
|
60
64
|
|
|
61
65
|
__all__ = [
|
|
62
66
|
"ENABLE_POWER",
|
|
63
|
-
"PLACEHOLDER_DEVICE_UID",
|
|
64
67
|
"BatteryLevel",
|
|
65
68
|
"Controller",
|
|
69
|
+
"ControllerAlreadyClaimedError",
|
|
66
70
|
"ControllerCommandError",
|
|
67
71
|
"ControllerEndpoint",
|
|
68
72
|
"DiscoveryService",
|
|
@@ -24,7 +24,7 @@ import ifaddr
|
|
|
24
24
|
|
|
25
25
|
from .const import PLACEHOLDER_DEVICE_UID
|
|
26
26
|
from .controller import Controller
|
|
27
|
-
from .exceptions import raise_if_placeholder_uid
|
|
27
|
+
from .exceptions import ControllerAlreadyClaimedError, raise_if_placeholder_uid
|
|
28
28
|
from .types import ControllerEndpoint
|
|
29
29
|
from .zone import Zone
|
|
30
30
|
|
|
@@ -50,7 +50,7 @@ DISCOVERY_RESCAN = 20.0
|
|
|
50
50
|
RESCAN_COOLDOWN = 5.0
|
|
51
51
|
|
|
52
52
|
# disposition: 1.4 — active discover_by_uid / discover_all wait
|
|
53
|
-
SCAN_TIMEOUT =
|
|
53
|
+
SCAN_TIMEOUT = 5.0
|
|
54
54
|
|
|
55
55
|
_LOG = logging.getLogger("pizone.discovery")
|
|
56
56
|
|
|
@@ -405,7 +405,9 @@ class DiscoveryService:
|
|
|
405
405
|
def _ensure_endpoint_available(self, endpoint: ControllerEndpoint) -> None:
|
|
406
406
|
"""Raise if *endpoint* is already claimed."""
|
|
407
407
|
if endpoint.uid in self._claimed_endpoints:
|
|
408
|
-
raise
|
|
408
|
+
raise ControllerAlreadyClaimedError(
|
|
409
|
+
f"Controller {endpoint.uid} already created"
|
|
410
|
+
)
|
|
409
411
|
|
|
410
412
|
# disposition: 1.4
|
|
411
413
|
def _claim_endpoint(self, endpoint: ControllerEndpoint) -> None:
|
|
@@ -451,7 +453,9 @@ class DiscoveryService:
|
|
|
451
453
|
"""
|
|
452
454
|
claimed = self._endpoint_by_host(self._claimed_endpoints, host)
|
|
453
455
|
if claimed is not None:
|
|
454
|
-
raise
|
|
456
|
+
raise ControllerAlreadyClaimedError(
|
|
457
|
+
f"Controller {claimed.uid} already created"
|
|
458
|
+
)
|
|
455
459
|
known = self._endpoint_by_host(self._known_endpoints, host)
|
|
456
460
|
if known is not None:
|
|
457
461
|
return known
|
|
@@ -476,7 +480,7 @@ class DiscoveryService:
|
|
|
476
480
|
"""
|
|
477
481
|
raise_if_placeholder_uid(uid)
|
|
478
482
|
if uid in self._claimed_endpoints:
|
|
479
|
-
raise
|
|
483
|
+
raise ControllerAlreadyClaimedError(f"Controller {uid} already created")
|
|
480
484
|
endpoint = self._known_endpoints.get(uid)
|
|
481
485
|
if endpoint is not None:
|
|
482
486
|
return endpoint
|
|
@@ -569,7 +573,7 @@ class DiscoveryService:
|
|
|
569
573
|
|
|
570
574
|
Raises:
|
|
571
575
|
UnpairedBridgeError: If *uid* is the unpaired placeholder (no I/O).
|
|
572
|
-
|
|
576
|
+
ControllerAlreadyClaimedError: If a controller with *uid* already exists.
|
|
573
577
|
ConnectionError: If the controller cannot be reached after
|
|
574
578
|
address fallback.
|
|
575
579
|
ControllerCommandError: If the device rejects the protocol request.
|
|
@@ -579,7 +583,7 @@ class DiscoveryService:
|
|
|
579
583
|
"""
|
|
580
584
|
raise_if_placeholder_uid(uid)
|
|
581
585
|
if uid in self._claimed_endpoints:
|
|
582
|
-
raise
|
|
586
|
+
raise ControllerAlreadyClaimedError(f"Controller {uid} already created")
|
|
583
587
|
pending_address_changed: ControllerEndpoint | None = None
|
|
584
588
|
|
|
585
589
|
try:
|
|
@@ -756,8 +760,13 @@ class DiscoveryService:
|
|
|
756
760
|
return
|
|
757
761
|
_LOG.info("Close called on discovery service.")
|
|
758
762
|
self._close_task = current
|
|
759
|
-
if self._transport:
|
|
763
|
+
if self._transport is not None:
|
|
764
|
+
# DatagramTransport.close() only schedules sock release via
|
|
765
|
+
# call_soon(_call_connection_lost). Wait until connection_lost
|
|
766
|
+
# clears _transport so a following create_discovery can bind :7005.
|
|
760
767
|
self._transport.close()
|
|
768
|
+
while self._transport is not None:
|
|
769
|
+
await asyncio.sleep(0)
|
|
761
770
|
|
|
762
771
|
pending = [task for task in list(self._tasks) if task is not current]
|
|
763
772
|
for task in pending:
|
|
@@ -773,6 +782,7 @@ class DiscoveryService:
|
|
|
773
782
|
|
|
774
783
|
def _on_connection_lost(self, exc: Exception | None) -> None:
|
|
775
784
|
_LOG.debug("Connection Lost")
|
|
785
|
+
self._transport = None
|
|
776
786
|
if not self._close_task:
|
|
777
787
|
_LOG.error("Connection Lost unexpectedly: %s", repr(exc))
|
|
778
788
|
self.create_task(self.close())
|
|
@@ -1002,5 +1012,12 @@ async def create_discovery(
|
|
|
1002
1012
|
on_endpoint_discovered=on_endpoint_discovered,
|
|
1003
1013
|
)
|
|
1004
1014
|
_active_discovery = service
|
|
1005
|
-
|
|
1015
|
+
try:
|
|
1016
|
+
await service.start_discovery()
|
|
1017
|
+
except BaseException:
|
|
1018
|
+
# close() clears the global when it finishes; clear again if close never ran.
|
|
1019
|
+
with suppress(BaseException):
|
|
1020
|
+
await service.close()
|
|
1021
|
+
_clear_discovery_global(service)
|
|
1022
|
+
raise
|
|
1006
1023
|
return service
|
|
@@ -28,6 +28,14 @@ class UnpairedBridgeError(ValueError):
|
|
|
28
28
|
"""
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
class ControllerAlreadyClaimedError(Exception):
|
|
32
|
+
"""A controller UID or host is already claimed on this discovery service.
|
|
33
|
+
|
|
34
|
+
Raised when ``discover_by_host``, ``discover_by_uid``, or ``create_controller``
|
|
35
|
+
would duplicate an active controller registration.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
31
39
|
def raise_if_placeholder_uid(uid: str) -> None:
|
|
32
40
|
"""Raise :exc:`UnpairedBridgeError` if *uid* is the unpaired placeholder."""
|
|
33
41
|
if uid == PLACEHOLDER_DEVICE_UID:
|
|
@@ -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.7'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 3, 7)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -7,21 +7,23 @@
|
|
|
7
7
|
# (sticky within a function until the next disposition tag).
|
|
8
8
|
|
|
9
9
|
import asyncio
|
|
10
|
+
import errno
|
|
10
11
|
import sys
|
|
11
12
|
from typing import cast
|
|
12
|
-
from unittest.mock import AsyncMock,
|
|
13
|
+
from unittest.mock import AsyncMock, patch
|
|
13
14
|
|
|
14
15
|
from aiohttp import ClientSession
|
|
15
16
|
import pytest
|
|
16
17
|
|
|
17
18
|
from pizone import (
|
|
18
|
-
PLACEHOLDER_DEVICE_UID,
|
|
19
19
|
Controller,
|
|
20
|
+
ControllerAlreadyClaimedError,
|
|
20
21
|
ControllerCommandError,
|
|
21
22
|
ControllerEndpoint,
|
|
22
23
|
UnpairedBridgeError,
|
|
23
24
|
create_discovery,
|
|
24
25
|
)
|
|
26
|
+
from pizone.const import PLACEHOLDER_DEVICE_UID
|
|
25
27
|
from pizone.discovery import DiscoveryService
|
|
26
28
|
|
|
27
29
|
from .conftest import MockController, MockDiscoveryService
|
|
@@ -66,6 +68,164 @@ async def test_create_discovery_singleton() -> None:
|
|
|
66
68
|
assert discovery_module._active_discovery is None
|
|
67
69
|
|
|
68
70
|
|
|
71
|
+
# disposition: 1.4
|
|
72
|
+
@pytest.mark.asyncio
|
|
73
|
+
async def test_close_then_immediate_create_discovery_rebinds() -> None:
|
|
74
|
+
"""close() must release UDP :7005 before returning so a new create can bind.
|
|
75
|
+
|
|
76
|
+
Reproduces the delete→HomeKit race: stop discovery then immediately start
|
|
77
|
+
again on the same event loop without awaiting an extra sleep.
|
|
78
|
+
|
|
79
|
+
Use an injected session (as Home Assistant does) so close() does not await
|
|
80
|
+
an owned ClientSession — that await can mask the teardown race.
|
|
81
|
+
"""
|
|
82
|
+
session = ClientSession()
|
|
83
|
+
try:
|
|
84
|
+
first = await create_discovery(session=session)
|
|
85
|
+
await first.close()
|
|
86
|
+
assert discovery_module._active_discovery is None
|
|
87
|
+
|
|
88
|
+
second = await create_discovery(session=session)
|
|
89
|
+
try:
|
|
90
|
+
assert second is not first
|
|
91
|
+
assert discovery_module._active_discovery is second
|
|
92
|
+
finally:
|
|
93
|
+
await second.close()
|
|
94
|
+
assert discovery_module._active_discovery is None
|
|
95
|
+
finally:
|
|
96
|
+
leftover = discovery_module._active_discovery
|
|
97
|
+
if leftover is not None:
|
|
98
|
+
await leftover.close()
|
|
99
|
+
await session.close()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# disposition: 1.4
|
|
103
|
+
@pytest.mark.asyncio
|
|
104
|
+
async def test_owned_session_close_still_needs_udp_teardown_wait() -> None:
|
|
105
|
+
"""Owned-session await must not be treated as sufficient UDP teardown.
|
|
106
|
+
|
|
107
|
+
CPython asyncio schedules ``DatagramTransport`` sock close via
|
|
108
|
+
``call_soon(_call_connection_lost)``. The next ``await`` (owned
|
|
109
|
+
``ClientSession.close``) usually runs that callback first — FIFO
|
|
110
|
+
``call_soon`` behaviour, not a language/compiler guarantee. Defer the
|
|
111
|
+
callback so only waiting on ``connection_lost`` (Fix A) makes rebind safe.
|
|
112
|
+
"""
|
|
113
|
+
loop = asyncio.get_running_loop()
|
|
114
|
+
real_call_soon = loop.call_soon
|
|
115
|
+
teardown_delay = 0.05
|
|
116
|
+
|
|
117
|
+
def call_soon(callback: object, *args: object, context: object = None) -> object:
|
|
118
|
+
if getattr(callback, "__name__", None) == "_call_connection_lost":
|
|
119
|
+
return loop.call_later(teardown_delay, callback, *args)
|
|
120
|
+
if context is not None:
|
|
121
|
+
return real_call_soon(callback, *args, context=context)
|
|
122
|
+
return real_call_soon(callback, *args)
|
|
123
|
+
|
|
124
|
+
first = await create_discovery()
|
|
125
|
+
try:
|
|
126
|
+
with patch.object(loop, "call_soon", call_soon):
|
|
127
|
+
await first.close()
|
|
128
|
+
assert discovery_module._active_discovery is None
|
|
129
|
+
|
|
130
|
+
second = await create_discovery()
|
|
131
|
+
try:
|
|
132
|
+
assert second is not first
|
|
133
|
+
assert discovery_module._active_discovery is second
|
|
134
|
+
finally:
|
|
135
|
+
await second.close()
|
|
136
|
+
assert discovery_module._active_discovery is None
|
|
137
|
+
finally:
|
|
138
|
+
await asyncio.sleep(teardown_delay + 0.01)
|
|
139
|
+
leftover = discovery_module._active_discovery
|
|
140
|
+
if leftover is not None:
|
|
141
|
+
await leftover.close()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# disposition: 1.4
|
|
145
|
+
@pytest.mark.asyncio
|
|
146
|
+
async def test_create_discovery_failed_start_allows_retry() -> None:
|
|
147
|
+
"""Failed start_discovery must not leave the process singleton set.
|
|
148
|
+
|
|
149
|
+
Otherwise later create_discovery calls raise RuntimeError until process
|
|
150
|
+
restart (HomeKit rediscovery / SETUP_RETRY poisoned after EADDRINUSE).
|
|
151
|
+
"""
|
|
152
|
+
try:
|
|
153
|
+
with (
|
|
154
|
+
patch.object(
|
|
155
|
+
DiscoveryService,
|
|
156
|
+
"start_discovery",
|
|
157
|
+
AsyncMock(
|
|
158
|
+
side_effect=OSError(errno.EADDRINUSE, "Address already in use")
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
pytest.raises(OSError, match="Address already in use"),
|
|
162
|
+
):
|
|
163
|
+
await create_discovery()
|
|
164
|
+
|
|
165
|
+
assert discovery_module._active_discovery is None
|
|
166
|
+
|
|
167
|
+
with patch.object(DiscoveryService, "start_discovery", AsyncMock()):
|
|
168
|
+
disco = await create_discovery()
|
|
169
|
+
try:
|
|
170
|
+
assert disco is discovery_module._active_discovery
|
|
171
|
+
finally:
|
|
172
|
+
await disco.close()
|
|
173
|
+
assert discovery_module._active_discovery is None
|
|
174
|
+
finally:
|
|
175
|
+
leftover = discovery_module._active_discovery
|
|
176
|
+
if leftover is not None:
|
|
177
|
+
await leftover.close()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# disposition: 1.4
|
|
181
|
+
@pytest.mark.asyncio
|
|
182
|
+
async def test_failed_start_holds_singleton_until_close_finishes() -> None:
|
|
183
|
+
"""Overlapping create during failed-start cleanup must hit already-created.
|
|
184
|
+
|
|
185
|
+
``create_discovery`` must not clear the process singleton before ``close()``
|
|
186
|
+
finishes, so a client that serializes create→close→create cannot bind
|
|
187
|
+
against a service still tearing down.
|
|
188
|
+
"""
|
|
189
|
+
original_close = DiscoveryService.close
|
|
190
|
+
close_entered = asyncio.Event()
|
|
191
|
+
finish_close = asyncio.Event()
|
|
192
|
+
held_during_close: list[bool] = []
|
|
193
|
+
|
|
194
|
+
async def gated_close(self: DiscoveryService) -> None:
|
|
195
|
+
held_during_close.append(discovery_module._active_discovery is self)
|
|
196
|
+
close_entered.set()
|
|
197
|
+
await finish_close.wait()
|
|
198
|
+
await original_close(self)
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
with (
|
|
202
|
+
patch.object(
|
|
203
|
+
DiscoveryService,
|
|
204
|
+
"start_discovery",
|
|
205
|
+
AsyncMock(
|
|
206
|
+
side_effect=OSError(errno.EADDRINUSE, "Address already in use")
|
|
207
|
+
),
|
|
208
|
+
),
|
|
209
|
+
patch.object(DiscoveryService, "close", gated_close),
|
|
210
|
+
):
|
|
211
|
+
create_task = asyncio.create_task(create_discovery())
|
|
212
|
+
await asyncio.wait_for(close_entered.wait(), timeout=1)
|
|
213
|
+
assert discovery_module._active_discovery is not None
|
|
214
|
+
with pytest.raises(RuntimeError, match="already created"):
|
|
215
|
+
await create_discovery()
|
|
216
|
+
finish_close.set()
|
|
217
|
+
with pytest.raises(OSError, match="Address already in use"):
|
|
218
|
+
await create_task
|
|
219
|
+
|
|
220
|
+
assert held_during_close == [True]
|
|
221
|
+
assert discovery_module._active_discovery is None
|
|
222
|
+
finally:
|
|
223
|
+
finish_close.set()
|
|
224
|
+
leftover = discovery_module._active_discovery
|
|
225
|
+
if leftover is not None:
|
|
226
|
+
await leftover.close()
|
|
227
|
+
|
|
228
|
+
|
|
69
229
|
# disposition: 1.4
|
|
70
230
|
@pytest.mark.asyncio
|
|
71
231
|
async def test_discover_by_host() -> None:
|
|
@@ -123,7 +283,7 @@ async def test_discover_by_host_raises_if_controller_exists() -> None:
|
|
|
123
283
|
ClientSession,
|
|
124
284
|
FakeHttpSession(get_error=OSError("should not probe")),
|
|
125
285
|
)
|
|
126
|
-
with pytest.raises(
|
|
286
|
+
with pytest.raises(ControllerAlreadyClaimedError, match="already created"):
|
|
127
287
|
await service.discover_by_host("10.0.0.90")
|
|
128
288
|
await service.close()
|
|
129
289
|
|
|
@@ -165,7 +325,7 @@ async def test_discover_by_uid_raises_if_controller_exists() -> None:
|
|
|
165
325
|
scan = AsyncMock()
|
|
166
326
|
with (
|
|
167
327
|
patch.object(service, "scan", scan),
|
|
168
|
-
pytest.raises(
|
|
328
|
+
pytest.raises(ControllerAlreadyClaimedError, match="already created"),
|
|
169
329
|
):
|
|
170
330
|
await service.discover_by_uid("000025841")
|
|
171
331
|
scan.assert_not_awaited()
|
|
@@ -464,7 +624,7 @@ async def test_discover_all_includes_closed_controllers() -> None:
|
|
|
464
624
|
@pytest.mark.asyncio
|
|
465
625
|
async def test_scan_sends_broadcast() -> None:
|
|
466
626
|
service = MockDiscoveryService(legacy_pathway=False)
|
|
467
|
-
service.
|
|
627
|
+
await service.start_discovery()
|
|
468
628
|
with patch.object(service, "_send_broadcasts") as send_broadcasts:
|
|
469
629
|
await service.scan()
|
|
470
630
|
send_broadcasts.assert_called_once()
|
|
@@ -497,7 +657,7 @@ async def test_create_controller_raises_if_uid_exists() -> None:
|
|
|
497
657
|
FakeHttpSession(get_response=_system_settings_response("000025841")),
|
|
498
658
|
)
|
|
499
659
|
await service.create_controller("000025841", "10.0.0.90")
|
|
500
|
-
with pytest.raises(
|
|
660
|
+
with pytest.raises(ControllerAlreadyClaimedError, match="already created"):
|
|
501
661
|
await service.create_controller("000025841", "10.0.0.90")
|
|
502
662
|
await service.close()
|
|
503
663
|
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
# Release notes — v1.3.5
|
|
2
|
-
|
|
3
|
-
Released 2026-07-17. **Beta release** for the upcoming Home Assistant iZone
|
|
4
|
-
Phase 2 integration work (izone 1.4 program). The version number stays on the
|
|
5
|
-
**1.3.x** line for now; the program name is “1.4” internally.
|
|
6
|
-
|
|
7
|
-
**Compare range:** https://github.com/Swamp-Ig/pizone/compare/v1.3.4...v1.3.5
|
|
8
|
-
|
|
9
|
-
## Beta status
|
|
10
|
-
|
|
11
|
-
This release ships the full dual-track library on `main` so Home Assistant can
|
|
12
|
-
pin a real PyPI version while Phase 2a/2b land. The **current HA integration
|
|
13
|
-
still uses the legacy API** (`discovery()`, `fetch_*`, `Listener`). The new
|
|
14
|
-
consumer-driven API is available for new integration code but is not required
|
|
15
|
-
for a pin-only HA bump.
|
|
16
|
-
|
|
17
|
-
**`1.4.0`** (planned after HA Phase 2 merges) will remove the legacy surface
|
|
18
|
-
(`discovery()`, `fetch_*`, `Listener`, and related poll/scan loops).
|
|
19
|
-
|
|
20
|
-
## Summary
|
|
21
|
-
|
|
22
|
-
Since `v1.3.4`, this release adds the izone 1.4 discovery and refresh model
|
|
23
|
-
alongside the legacy path, gates iPower behind an opt-in flag, rejects
|
|
24
|
-
unpaired-bridge placeholder UIDs, and mitigates HTTP keepalive stalls on some
|
|
25
|
-
ASH bridges.
|
|
26
|
-
|
|
27
|
-
## Connection: close (core#176536)
|
|
28
|
-
|
|
29
|
-
- HTTP **GET** requests send `Connection: close` (controller reads and
|
|
30
|
-
discovery probes)
|
|
31
|
-
- When pizone owns the aiohttp session, `TCPConnector(force_close=True)` is used
|
|
32
|
-
- Mitigation for hubs that stall under connection reuse; does not fix hub-side
|
|
33
|
-
stalls reported in [home-assistant/core#176536](https://github.com/home-assistant/core/issues/176536)
|
|
34
|
-
|
|
35
|
-
## New 1.4 API (dual-track — beta)
|
|
36
|
-
|
|
37
|
-
Available now; legacy path remains for existing consumers:
|
|
38
|
-
|
|
39
|
-
- **`create_discovery()`** — one-shot discovery service with callbacks
|
|
40
|
-
- **`discover_by_host`**, **`discover_by_uid`**, **`discover_all`** — ephemeral
|
|
41
|
-
or service-scoped endpoint discovery with HTTP verify
|
|
42
|
-
- **`create_controller(uid, host)`** — explicit controller factory after
|
|
43
|
-
discovery
|
|
44
|
-
- **`refresh_all`**, **`refresh_system`**, **`refresh_zones`**, etc. —
|
|
45
|
-
consumer-owned polling on the 1.4 path (no library debounce loops)
|
|
46
|
-
- **`ControllerEndpoint`**, **`on_endpoint_discovered`**, **`on_address_changed`**
|
|
47
|
-
callbacks for integration discovery flows
|
|
48
|
-
|
|
49
|
-
Legacy **`discovery()`**, **`fetch_controller`**, **`fetch_controllers`**, and
|
|
50
|
-
**`Listener`** continue to work unchanged on the deprecated pathway.
|
|
51
|
-
|
|
52
|
-
## Power gate
|
|
53
|
-
|
|
54
|
-
- **`ENABLE_POWER`** defaults to **`False`**; no iPower HTTP I/O until set at
|
|
55
|
-
runtime (for example `pizone.ENABLE_POWER = True` before create/refresh)
|
|
56
|
-
- Home Assistant does not expose power entities yet; this avoids surprise I/O on
|
|
57
|
-
bridges that advertise iPower in UDP
|
|
58
|
-
|
|
59
|
-
## Unpaired bridge UID
|
|
60
|
-
|
|
61
|
-
- **`PLACEHOLDER_DEVICE_UID`** (`"000000000"`) — exact match for ASH bridges
|
|
62
|
-
with no paired AC
|
|
63
|
-
- Passive UDP discovery ignores this UID (no cache, no notify)
|
|
64
|
-
- **`discover_by_uid`** / **`create_controller`** reject immediately with
|
|
65
|
-
**`UnpairedBridgeError`** (no network I/O)
|
|
66
|
-
- **`discover_by_host`** raises after HTTP probe if the UID is the placeholder
|
|
67
|
-
(no cache)
|
|
68
|
-
|
|
69
|
-
## UDP and discovery (1.4 path)
|
|
70
|
-
|
|
71
|
-
- **`CHANGED_*`** push datagrams recognized and ignored (no refresh)
|
|
72
|
-
- ASPort passive discovery caches unclaimed endpoints and fans out
|
|
73
|
-
**`on_endpoint_discovered`** for new or host-changed endpoints
|
|
74
|
-
- **`discover_all`** HTTP-verifies collected hosts and dedupes notifications
|
|
75
|
-
|
|
76
|
-
## Other
|
|
77
|
-
|
|
78
|
-
- Live fault-shaped **SystemSettings** fixtures for unpaired and paired cold-restart
|
|
79
|
-
specimens (tests)
|
|
80
|
-
- Test suite labelled with disposition comments (`1.4` vs `deprecate`) for
|
|
81
|
-
dual-track cleanup
|
|
82
|
-
- Ruff replaces Black and pylint for lint and format
|
|
83
|
-
|
|
84
|
-
## For Home Assistant integrators
|
|
85
|
-
|
|
86
|
-
Pin **`python-izone==1.3.5`** for:
|
|
87
|
-
|
|
88
|
-
1. **Immediate:** Connection: close mitigation on the existing legacy integration
|
|
89
|
-
path (pin-only bump)
|
|
90
|
-
2. **Upcoming:** Access to the 1.4 API when Phase 2 runtime and config flow land
|
|
91
|
-
|
|
92
|
-
Do not expect breaking changes on the legacy API until **`1.4.0`**.
|
|
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.5 → python_izone-1.3.7}/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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|