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.
Files changed (39) hide show
  1. {python_izone-1.3.3 → python_izone-1.3.4}/PKG-INFO +1 -1
  2. python_izone-1.3.4/docs/release-notes/v1.3.4.md +55 -0
  3. {python_izone-1.3.3 → python_izone-1.3.4}/pizone/__init__.py +8 -1
  4. {python_izone-1.3.3 → python_izone-1.3.4}/pizone/controller.py +48 -23
  5. {python_izone-1.3.3 → python_izone-1.3.4}/pizone/exceptions.py +8 -0
  6. {python_izone-1.3.3 → python_izone-1.3.4}/pizone/version.py +2 -2
  7. {python_izone-1.3.3 → python_izone-1.3.4}/tests/test_controller.py +39 -0
  8. {python_izone-1.3.3 → python_izone-1.3.4}/tests/test_http.py +3 -2
  9. {python_izone-1.3.3 → python_izone-1.3.4}/.gitattributes +0 -0
  10. {python_izone-1.3.3 → python_izone-1.3.4}/.github/workflows/main.yml +0 -0
  11. {python_izone-1.3.3 → python_izone-1.3.4}/.gitignore +0 -0
  12. {python_izone-1.3.3 → python_izone-1.3.4}/.pre-commit-config.yaml +0 -0
  13. {python_izone-1.3.3 → python_izone-1.3.4}/.pylintrc +0 -0
  14. {python_izone-1.3.3 → python_izone-1.3.4}/.python-version +0 -0
  15. {python_izone-1.3.3 → python_izone-1.3.4}/.vscode/.ropeproject/config.py +0 -0
  16. {python_izone-1.3.3 → python_izone-1.3.4}/.vscode/launch.json +0 -0
  17. {python_izone-1.3.3 → python_izone-1.3.4}/.vscode/settings.json +0 -0
  18. {python_izone-1.3.3 → python_izone-1.3.4}/README.md +0 -0
  19. {python_izone-1.3.3 → python_izone-1.3.4}/api_docs/AC-DOC-1401-11_iZoneEthernetInterface.pdf +0 -0
  20. {python_izone-1.3.3 → python_izone-1.3.4}/api_docs/iPower_JSON_datastrings_Out.h +0 -0
  21. {python_izone-1.3.3 → python_izone-1.3.4}/api_docs/iZone_JSON_datastrings.h +0 -0
  22. {python_izone-1.3.3 → python_izone-1.3.4}/licence.txt +0 -0
  23. {python_izone-1.3.3 → python_izone-1.3.4}/pizone/discovery.py +0 -0
  24. {python_izone-1.3.3 → python_izone-1.3.4}/pizone/power.py +0 -0
  25. {python_izone-1.3.3 → python_izone-1.3.4}/pizone/py.typed +0 -0
  26. {python_izone-1.3.3 → python_izone-1.3.4}/pizone/zone.py +0 -0
  27. {python_izone-1.3.3 → python_izone-1.3.4}/pyproject.toml +0 -0
  28. {python_izone-1.3.3 → python_izone-1.3.4}/scripts/check +0 -0
  29. {python_izone-1.3.3 → python_izone-1.3.4}/scripts/coverage +0 -0
  30. {python_izone-1.3.3 → python_izone-1.3.4}/tests/__init__.py +0 -0
  31. {python_izone-1.3.3 → python_izone-1.3.4}/tests/conftest.py +0 -0
  32. {python_izone-1.3.3 → python_izone-1.3.4}/tests/http_fakes.py +0 -0
  33. {python_izone-1.3.3 → python_izone-1.3.4}/tests/power_data.py +0 -0
  34. {python_izone-1.3.3 → python_izone-1.3.4}/tests/resources.py +0 -0
  35. {python_izone-1.3.3 → python_izone-1.3.4}/tests/test_discovery.py +0 -0
  36. {python_izone-1.3.3 → python_izone-1.3.4}/tests/test_fullstack.py +0 -0
  37. {python_izone-1.3.3 → python_izone-1.3.4}/tests/test_power.py +0 -0
  38. {python_izone-1.3.3 → python_izone-1.3.4}/tests/test_zone.py +0 -0
  39. {python_izone-1.3.3 → python_izone-1.3.4}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-izone
3
- Version: 1.3.3
3
+ Version: 1.3.4
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,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
- return self.Mode(self._get_system_state("SysMode"))
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
- Raises:
315
- ValueError: If the cached fan mode is not a valid :class:`Fan` member.
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
- return self._system_settings[state]
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 or the response
695
- cannot be decoded.
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 ConnectionError(
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, the response
766
- cannot be decoded, or the HTTP request fails.
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
- if str(ex) == "Unable to decode response from the controller":
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.3'
22
- __version_tuple__ = version_tuple = (1, 3, 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(ConnectionError):
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