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.
Files changed (46) hide show
  1. {python_izone-1.3.5 → python_izone-1.3.7}/PKG-INFO +1 -1
  2. python_izone-1.3.7/docs/release-notes/v1.3.5.md +85 -0
  3. python_izone-1.3.7/docs/release-notes/v1.3.6.md +70 -0
  4. python_izone-1.3.7/docs/release-notes/v1.3.7.md +7 -0
  5. {python_izone-1.3.5 → python_izone-1.3.7}/pizone/__init__.py +7 -3
  6. {python_izone-1.3.5 → python_izone-1.3.7}/pizone/discovery.py +26 -9
  7. {python_izone-1.3.5 → python_izone-1.3.7}/pizone/exceptions.py +8 -0
  8. {python_izone-1.3.5 → python_izone-1.3.7}/pizone/version.py +2 -2
  9. {python_izone-1.3.5 → python_izone-1.3.7}/tests/test_discovery_api.py +166 -6
  10. python_izone-1.3.5/docs/release-notes/v1.3.5.md +0 -92
  11. {python_izone-1.3.5 → python_izone-1.3.7}/.gitattributes +0 -0
  12. {python_izone-1.3.5 → python_izone-1.3.7}/.github/workflows/main.yml +0 -0
  13. {python_izone-1.3.5 → python_izone-1.3.7}/.gitignore +0 -0
  14. {python_izone-1.3.5 → python_izone-1.3.7}/.pre-commit-config.yaml +0 -0
  15. {python_izone-1.3.5 → python_izone-1.3.7}/.python-version +0 -0
  16. {python_izone-1.3.5 → python_izone-1.3.7}/.vscode/.ropeproject/config.py +0 -0
  17. {python_izone-1.3.5 → python_izone-1.3.7}/.vscode/extensions.json +0 -0
  18. {python_izone-1.3.5 → python_izone-1.3.7}/.vscode/launch.json +0 -0
  19. {python_izone-1.3.5 → python_izone-1.3.7}/.vscode/settings.json +0 -0
  20. {python_izone-1.3.5 → python_izone-1.3.7}/README.md +0 -0
  21. {python_izone-1.3.5 → python_izone-1.3.7}/api_docs/AC-DOC-1401-11_iZoneEthernetInterface.pdf +0 -0
  22. {python_izone-1.3.5 → python_izone-1.3.7}/api_docs/iPower_JSON_datastrings_Out.h +0 -0
  23. {python_izone-1.3.5 → python_izone-1.3.7}/api_docs/iZone_JSON_datastrings.h +0 -0
  24. {python_izone-1.3.5 → python_izone-1.3.7}/docs/release-notes/v1.3.4.md +0 -0
  25. {python_izone-1.3.5 → python_izone-1.3.7}/licence.txt +0 -0
  26. {python_izone-1.3.5 → python_izone-1.3.7}/pizone/const.py +0 -0
  27. {python_izone-1.3.5 → python_izone-1.3.7}/pizone/controller.py +0 -0
  28. {python_izone-1.3.5 → python_izone-1.3.7}/pizone/power.py +0 -0
  29. {python_izone-1.3.5 → python_izone-1.3.7}/pizone/py.typed +0 -0
  30. {python_izone-1.3.5 → python_izone-1.3.7}/pizone/types.py +0 -0
  31. {python_izone-1.3.5 → python_izone-1.3.7}/pizone/zone.py +0 -0
  32. {python_izone-1.3.5 → python_izone-1.3.7}/pyproject.toml +0 -0
  33. {python_izone-1.3.5 → python_izone-1.3.7}/scripts/check +0 -0
  34. {python_izone-1.3.5 → python_izone-1.3.7}/scripts/coverage +0 -0
  35. {python_izone-1.3.5 → python_izone-1.3.7}/tests/__init__.py +0 -0
  36. {python_izone-1.3.5 → python_izone-1.3.7}/tests/conftest.py +0 -0
  37. {python_izone-1.3.5 → python_izone-1.3.7}/tests/http_fakes.py +0 -0
  38. {python_izone-1.3.5 → python_izone-1.3.7}/tests/power_data.py +0 -0
  39. {python_izone-1.3.5 → python_izone-1.3.7}/tests/resources.py +0 -0
  40. {python_izone-1.3.5 → python_izone-1.3.7}/tests/test_controller.py +0 -0
  41. {python_izone-1.3.5 → python_izone-1.3.7}/tests/test_discovery.py +0 -0
  42. {python_izone-1.3.5 → python_izone-1.3.7}/tests/test_fullstack.py +0 -0
  43. {python_izone-1.3.5 → python_izone-1.3.7}/tests/test_http.py +0 -0
  44. {python_izone-1.3.5 → python_izone-1.3.7}/tests/test_power.py +0 -0
  45. {python_izone-1.3.5 → python_izone-1.3.7}/tests/test_zone.py +0 -0
  46. {python_izone-1.3.5 → python_izone-1.3.7}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-izone
3
- Version: 1.3.5
3
+ Version: 1.3.7
4
4
  Summary: A python interface to the iZone airconditioner controller
5
5
  Project-URL: Homepage, https://github.com/Swamp-Ig/pizone
6
6
  Author-email: Penny Wood <pypl@ninjateaparty.com>
@@ -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 ControllerCommandError, ResponseDecodeError, UnpairedBridgeError
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 = 20.0
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 RuntimeError(f"Controller {endpoint.uid} already created")
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 RuntimeError(f"Controller {claimed.uid} already created")
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 RuntimeError(f"Controller {uid} already created")
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
- RuntimeError: If a controller with *uid* already exists.
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 RuntimeError(f"Controller {uid} already created")
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
- await service.start_discovery()
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.5'
22
- __version_tuple__ = version_tuple = (1, 3, 5)
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, MagicMock, patch
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(RuntimeError, match="already created"):
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(RuntimeError, match="already created"),
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._transport = MagicMock()
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(RuntimeError, match="already created"):
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