python-izone 1.3.8__py3-none-any.whl → 1.3.9__py3-none-any.whl
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.
- pizone/controller.py +39 -13
- pizone/discovery.py +70 -3
- pizone/power.py +7 -0
- pizone/version.py +2 -2
- pizone/zone.py +5 -1
- {python_izone-1.3.8.dist-info → python_izone-1.3.9.dist-info}/METADATA +21 -1
- python_izone-1.3.9.dist-info/RECORD +14 -0
- python_izone-1.3.8.dist-info/RECORD +0 -14
- {python_izone-1.3.8.dist-info → python_izone-1.3.9.dist-info}/WHEEL +0 -0
- {python_izone-1.3.8.dist-info → python_izone-1.3.9.dist-info}/licenses/licence.txt +0 -0
pizone/controller.py
CHANGED
|
@@ -282,9 +282,10 @@ class Controller:
|
|
|
282
282
|
raise ConnectionError("SystemSettings device ID mismatch")
|
|
283
283
|
|
|
284
284
|
if self._system_settings:
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
285
|
+
fan_auto = str(self._system_settings.get("FanAuto", "disabled"))
|
|
286
|
+
self.fan_modes = Controller._VALID_FAN_MODES.get(
|
|
287
|
+
fan_auto, Controller._VALID_FAN_MODES["unknown"]
|
|
288
|
+
)
|
|
288
289
|
else:
|
|
289
290
|
self.fan_modes = Controller._VALID_FAN_MODES["disabled"]
|
|
290
291
|
|
|
@@ -414,6 +415,25 @@ class Controller:
|
|
|
414
415
|
"""True while the bridge is up and the iZone AC subsystem is available."""
|
|
415
416
|
return self._bridge_ok and self._izone_ok
|
|
416
417
|
|
|
418
|
+
def dump_state(self) -> dict[str, Any]:
|
|
419
|
+
"""Return a JSON-friendly snapshot of cached controller state.
|
|
420
|
+
|
|
421
|
+
Includes connection/capability fields that are not mirrored in
|
|
422
|
+
``SystemSettings``, plus the raw settings and zone payloads.
|
|
423
|
+
"""
|
|
424
|
+
return {
|
|
425
|
+
"device_uid": self.device_uid,
|
|
426
|
+
"device_ip": self.device_ip,
|
|
427
|
+
"connected": self.connected,
|
|
428
|
+
"bridge_connected": self.bridge_connected,
|
|
429
|
+
"is_v2": self.is_v2,
|
|
430
|
+
"is_ipower": self.is_ipower,
|
|
431
|
+
"fan_modes": [fan.value for fan in self.fan_modes],
|
|
432
|
+
"system_settings": dict(self._system_settings),
|
|
433
|
+
"zones": [zone.dump_state() for zone in self.zones],
|
|
434
|
+
"power": self._power.dump_state() if self._power is not None else None,
|
|
435
|
+
}
|
|
436
|
+
|
|
417
437
|
@property
|
|
418
438
|
def power(self) -> Power | None:
|
|
419
439
|
"""Power monitor data, or ``None`` if not configured."""
|
|
@@ -460,13 +480,16 @@ class Controller:
|
|
|
460
480
|
|
|
461
481
|
@property
|
|
462
482
|
def mode(self) -> Mode:
|
|
463
|
-
"""System mode (cooling, heating, etc.).
|
|
483
|
+
"""System mode (cooling, heating, etc.).
|
|
484
|
+
|
|
485
|
+
Raises:
|
|
486
|
+
ValueError: If the cached SysMode is not a known :class:`Mode` member
|
|
487
|
+
(fault ``error`` is remapped by :meth:`_get_system_state`).
|
|
488
|
+
|
|
489
|
+
"""
|
|
464
490
|
if self.free_air:
|
|
465
491
|
return self.Mode.FREE_AIR
|
|
466
|
-
|
|
467
|
-
return self.Mode(self._get_system_state("SysMode"))
|
|
468
|
-
except ValueError:
|
|
469
|
-
return self.Mode.COOL
|
|
492
|
+
return self.Mode(self._get_system_state("SysMode"))
|
|
470
493
|
|
|
471
494
|
async def set_mode(self, value: Mode) -> None:
|
|
472
495
|
"""Set the system mode (cooling, heating, etc.).
|
|
@@ -489,11 +512,14 @@ class Controller:
|
|
|
489
512
|
|
|
490
513
|
@property
|
|
491
514
|
def fan(self) -> Fan:
|
|
492
|
-
"""The current fan level.
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
515
|
+
"""The current fan level.
|
|
516
|
+
|
|
517
|
+
Raises:
|
|
518
|
+
ValueError: If the cached SysFan is not a known :class:`Fan` member
|
|
519
|
+
(fault ``error`` is remapped by :meth:`_get_system_state`).
|
|
520
|
+
|
|
521
|
+
"""
|
|
522
|
+
return self.Fan(self._get_system_state("SysFan"))
|
|
497
523
|
|
|
498
524
|
async def set_fan(self, value: Fan) -> None:
|
|
499
525
|
"""Set the fan level.
|
pizone/discovery.py
CHANGED
|
@@ -10,11 +10,14 @@ from asyncio import (
|
|
|
10
10
|
Lock,
|
|
11
11
|
Task,
|
|
12
12
|
)
|
|
13
|
+
from collections import deque
|
|
13
14
|
from collections.abc import Callable, Coroutine, Iterator
|
|
14
15
|
from contextlib import suppress
|
|
16
|
+
from datetime import UTC, datetime
|
|
15
17
|
from ipaddress import IPv4Interface
|
|
16
18
|
import json
|
|
17
19
|
import logging
|
|
20
|
+
import time
|
|
18
21
|
from types import TracebackType
|
|
19
22
|
from typing import Any, Self, cast
|
|
20
23
|
|
|
@@ -52,6 +55,9 @@ RESCAN_COOLDOWN = 5.0
|
|
|
52
55
|
# disposition: 1.4 — active discover_by_uid / discover_all wait
|
|
53
56
|
SCAN_TIMEOUT = 5.0
|
|
54
57
|
|
|
58
|
+
# Inbound UDP datagrams retained for diagnostics (ASPort, CHANGED_*, garbage).
|
|
59
|
+
UDP_REPLY_BUFFER_SIZE = 20
|
|
60
|
+
|
|
55
61
|
_LOG = logging.getLogger("pizone.discovery")
|
|
56
62
|
|
|
57
63
|
# disposition: 1.4 — create_discovery() singleton guard
|
|
@@ -159,6 +165,8 @@ class DiscoveryService:
|
|
|
159
165
|
self._on_endpoint_discovered = on_endpoint_discovered
|
|
160
166
|
self._known_endpoints: dict[str, ControllerEndpoint] = {}
|
|
161
167
|
self._claimed_endpoints: dict[str, ControllerEndpoint] = {}
|
|
168
|
+
# Ring buffer of recent inbound UDP datagrams (diagnostics).
|
|
169
|
+
self._recent_udp: deque[dict[str, Any]] = deque(maxlen=UDP_REPLY_BUFFER_SIZE)
|
|
162
170
|
self._scan_collector: dict[str, str] | None = None
|
|
163
171
|
self._discover_lock = Lock()
|
|
164
172
|
# Set during discover_all wait so passive notifies dedupe with verify fan-out.
|
|
@@ -665,6 +673,7 @@ class DiscoveryService:
|
|
|
665
673
|
async def _probe(
|
|
666
674
|
self, host: str
|
|
667
675
|
) -> tuple[ControllerEndpoint, dict[str, Any]] | None:
|
|
676
|
+
"""HTTP GET /SystemSettings; None on transport or content-shaped failure."""
|
|
668
677
|
session = self._session
|
|
669
678
|
if session is None:
|
|
670
679
|
return None
|
|
@@ -680,13 +689,17 @@ class DiscoveryService:
|
|
|
680
689
|
data = await response.json(content_type=None)
|
|
681
690
|
except json.JSONDecodeError:
|
|
682
691
|
text = await response.text()
|
|
683
|
-
if len(text)
|
|
692
|
+
if len(text) < 4 or text[-4:] != "{OK}":
|
|
693
|
+
return None
|
|
694
|
+
try:
|
|
684
695
|
data = json.loads(text[:-4])
|
|
685
|
-
|
|
696
|
+
except json.JSONDecodeError:
|
|
686
697
|
return None
|
|
687
|
-
except TimeoutError, aiohttp.ClientError, OSError:
|
|
698
|
+
except TimeoutError, aiohttp.ClientError, OSError, UnicodeDecodeError:
|
|
688
699
|
return None
|
|
689
700
|
|
|
701
|
+
if not isinstance(data, dict):
|
|
702
|
+
return None
|
|
690
703
|
uid = data.get("AirStreamDeviceUId")
|
|
691
704
|
if not isinstance(uid, str) or not uid:
|
|
692
705
|
return None
|
|
@@ -794,6 +807,39 @@ class DiscoveryService:
|
|
|
794
807
|
return self._transport.is_closing()
|
|
795
808
|
return self._close_task is not None
|
|
796
809
|
|
|
810
|
+
def dump_state(self) -> dict[str, Any]:
|
|
811
|
+
"""Return a JSON-friendly snapshot of discovery registry state.
|
|
812
|
+
|
|
813
|
+
Does not send an IASD broadcast. Includes a ring buffer of the most
|
|
814
|
+
recent inbound UDP datagrams (excluding our own IASD echo).
|
|
815
|
+
"""
|
|
816
|
+
|
|
817
|
+
def _endpoints(
|
|
818
|
+
endpoints: dict[str, ControllerEndpoint],
|
|
819
|
+
) -> list[dict[str, str]]:
|
|
820
|
+
return [
|
|
821
|
+
{"uid": endpoint.uid, "host": endpoint.host}
|
|
822
|
+
for endpoint in endpoints.values()
|
|
823
|
+
]
|
|
824
|
+
|
|
825
|
+
recent_udp = [
|
|
826
|
+
{
|
|
827
|
+
**reply,
|
|
828
|
+
"received_at": datetime.fromtimestamp(
|
|
829
|
+
reply["received_at"], tz=UTC
|
|
830
|
+
).isoformat(),
|
|
831
|
+
}
|
|
832
|
+
for reply in self._recent_udp
|
|
833
|
+
]
|
|
834
|
+
|
|
835
|
+
return {
|
|
836
|
+
"closed": self.is_closed,
|
|
837
|
+
"udp_bound": self._transport is not None and not self.is_closed,
|
|
838
|
+
"claimed": _endpoints(self._claimed_endpoints),
|
|
839
|
+
"known": _endpoints(self._known_endpoints),
|
|
840
|
+
"recent_udp": recent_udp,
|
|
841
|
+
}
|
|
842
|
+
|
|
797
843
|
@property
|
|
798
844
|
def session(self) -> ClientSession | None:
|
|
799
845
|
"""Return the aiohttp session used for HTTP requests."""
|
|
@@ -822,6 +868,7 @@ class DiscoveryService:
|
|
|
822
868
|
def _process_datagram(self, data: bytes, addr: tuple[str, int]) -> None:
|
|
823
869
|
if data == DISCOVERY_MSG:
|
|
824
870
|
return
|
|
871
|
+
self._record_udp_datagram(data, addr)
|
|
825
872
|
if data in (CHANGED_SYSTEM, CHANGED_ZONES, CHANGED_SCHEDULES):
|
|
826
873
|
_LOG.debug("Ignoring push notification %s from %s", data, addr)
|
|
827
874
|
return
|
|
@@ -830,6 +877,26 @@ class DiscoveryService:
|
|
|
830
877
|
else:
|
|
831
878
|
self._discovery_received(data)
|
|
832
879
|
|
|
880
|
+
def _record_udp_datagram(self, data: bytes, addr: tuple[str, int]) -> None:
|
|
881
|
+
"""Append an inbound UDP datagram to the diagnostics ring buffer."""
|
|
882
|
+
try:
|
|
883
|
+
message = data.decode()
|
|
884
|
+
except UnicodeDecodeError:
|
|
885
|
+
message = data.decode(errors="replace")
|
|
886
|
+
entry: dict[str, Any] = {
|
|
887
|
+
"source_ip": addr[0],
|
|
888
|
+
"source_port": addr[1],
|
|
889
|
+
"received_at": time.time(),
|
|
890
|
+
"message": message,
|
|
891
|
+
}
|
|
892
|
+
endpoint = self._parse_datagram(data)
|
|
893
|
+
if endpoint is not None:
|
|
894
|
+
entry["uid"] = endpoint.uid
|
|
895
|
+
entry["host"] = endpoint.host
|
|
896
|
+
parts = message.split(",")
|
|
897
|
+
entry["tags"] = parts[3:] if len(parts) > 3 else []
|
|
898
|
+
self._recent_udp.append(entry)
|
|
899
|
+
|
|
833
900
|
# disposition: deprecate
|
|
834
901
|
def _parse_datagram_deprecated(
|
|
835
902
|
self, data: bytes
|
pizone/power.py
CHANGED
|
@@ -282,6 +282,13 @@ class Power:
|
|
|
282
282
|
self._power_ok = ok
|
|
283
283
|
self._controller._event_coordinator.power_update(self._controller) # noqa: SLF001
|
|
284
284
|
|
|
285
|
+
def dump_state(self) -> dict[str, Any]:
|
|
286
|
+
"""Return a JSON-friendly snapshot of cached power monitor state."""
|
|
287
|
+
return {
|
|
288
|
+
"config": dict(self._config),
|
|
289
|
+
"status": dict(self._status),
|
|
290
|
+
}
|
|
291
|
+
|
|
285
292
|
@property
|
|
286
293
|
def connected(self) -> bool:
|
|
287
294
|
"""True while the bridge is up and power monitor I/O is healthy."""
|
pizone/version.py
CHANGED
|
@@ -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.9'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 3, 9)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
pizone/zone.py
CHANGED
|
@@ -4,7 +4,7 @@ Properties for reading and setting zone data.
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
from enum import Enum
|
|
7
|
-
from typing import TYPE_CHECKING, cast
|
|
7
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
8
8
|
|
|
9
9
|
if TYPE_CHECKING:
|
|
10
10
|
from .controller import Controller
|
|
@@ -68,6 +68,10 @@ class Zone:
|
|
|
68
68
|
"""The index of the zone."""
|
|
69
69
|
return self._index
|
|
70
70
|
|
|
71
|
+
def dump_state(self) -> dict[str, Any]:
|
|
72
|
+
"""Return a JSON-friendly snapshot of cached zone state."""
|
|
73
|
+
return dict(self._zone_data)
|
|
74
|
+
|
|
71
75
|
@property
|
|
72
76
|
def name(self) -> str:
|
|
73
77
|
"""Zone name."""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-izone
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.9
|
|
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>
|
|
@@ -58,6 +58,26 @@ needed for iZone controllers that read the request in a single operation.
|
|
|
58
58
|
The iZone Ethernet interface is documented in
|
|
59
59
|
[AC-DOC-1401-11_iZoneEthernetInterface.pdf](./AC-DOC-1401-11_iZoneEthernetInterface.pdf).
|
|
60
60
|
|
|
61
|
+
## Troubleshooting / issue reports
|
|
62
|
+
|
|
63
|
+
If Home Assistant can load the iZone integration, prefer **Download diagnostics**
|
|
64
|
+
from the integration page.
|
|
65
|
+
|
|
66
|
+
When that is not possible (or you need a live HTTP/UDP survey of the bridge),
|
|
67
|
+
run the stdlib probe from a machine on the same LAN:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
curl -fsSL -o izone_v1_probe.py \
|
|
71
|
+
https://raw.githubusercontent.com/Swamp-Ig/pizone/main/scripts/izone_v1_probe.py
|
|
72
|
+
python3 izone_v1_probe.py <bridge-ip> --with-content-type > izone-v1-probe.log
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Attach `izone-v1-probe.log` to the GitHub issue. The CLI host (IP or hostname)
|
|
76
|
+
and IPv4/IPv6 literals in the log are redacted to `**REDACTED**` by default
|
|
77
|
+
(including UDP `IP_…` payloads); use `--show-ips` only for local debugging.
|
|
78
|
+
Stop Home Assistant (or other listeners on UDP 7005) first if the UDP listen
|
|
79
|
+
step fails to bind.
|
|
80
|
+
|
|
61
81
|
## Development
|
|
62
82
|
|
|
63
83
|
1. Install [uv](https://docs.astral.sh/uv/).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
pizone/__init__.py,sha256=9EbQzqD1XfS5fSE-zgFieuzxAfZcQb9a3wNXsMp0JKc,2644
|
|
2
|
+
pizone/const.py,sha256=0skTh6zBe61AJ_57fqdBrxz3aVBM3T4Ak_cPSkOx2Bo,153
|
|
3
|
+
pizone/controller.py,sha256=l0qLAhSEF71kMLc9pG7EuQ3jaAfo0kdSBr0QgbJvfKo,42225
|
|
4
|
+
pizone/discovery.py,sha256=aiXZ6c57Z66usjwFMcQZ9ioNkw3_S2i1KQTpIEjquEQ,40199
|
|
5
|
+
pizone/exceptions.py,sha256=qzYNHzQBMDcuvLfxlvOsk6J2Lp0gtzlHqJWpzyEunvU,1603
|
|
6
|
+
pizone/power.py,sha256=ZWj_RLxvcQT3WxdAyUHzPAl7DtQGtT4FPIIdGVxatO0,10576
|
|
7
|
+
pizone/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
pizone/types.py,sha256=fZrnec0Q815HyG2sFciLqNX0QDbTq1_0wTF92GXQh78,200
|
|
9
|
+
pizone/version.py,sha256=g62BHujLgGdP6cB1oucUZ89PYnKUKXkd5IR17viSOVM,520
|
|
10
|
+
pizone/zone.py,sha256=LoiUFHcHLAmsVEkS1YcA6lKAOzu7A6RGnV_5mC1vV-I,7601
|
|
11
|
+
python_izone-1.3.9.dist-info/METADATA,sha256=3uBQnmINRhPCzENIcDiNGjTPoCfycDO7gUZhb_cy2yM,3521
|
|
12
|
+
python_izone-1.3.9.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
13
|
+
python_izone-1.3.9.dist-info/licenses/licence.txt,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
14
|
+
python_izone-1.3.9.dist-info/RECORD,,
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
pizone/__init__.py,sha256=9EbQzqD1XfS5fSE-zgFieuzxAfZcQb9a3wNXsMp0JKc,2644
|
|
2
|
-
pizone/const.py,sha256=0skTh6zBe61AJ_57fqdBrxz3aVBM3T4Ak_cPSkOx2Bo,153
|
|
3
|
-
pizone/controller.py,sha256=nctzAF8Y75T0_Ue9Hxxv9OfEex4zoNseOMjGfI6wHFE,41109
|
|
4
|
-
pizone/discovery.py,sha256=ZEXlzMgEcK2qYCU7UXCdgOrgv6vChOCgN_ddq5rACg0,37685
|
|
5
|
-
pizone/exceptions.py,sha256=qzYNHzQBMDcuvLfxlvOsk6J2Lp0gtzlHqJWpzyEunvU,1603
|
|
6
|
-
pizone/power.py,sha256=bXoDRVD6gCfq0kJv132sjfy13DHGd6p2uBg9HcxRSxA,10343
|
|
7
|
-
pizone/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
-
pizone/types.py,sha256=fZrnec0Q815HyG2sFciLqNX0QDbTq1_0wTF92GXQh78,200
|
|
9
|
-
pizone/version.py,sha256=FXUTXirc7-JCTXzXG9H84JqQTySUJIh6ktR1ZkEoISc,520
|
|
10
|
-
pizone/zone.py,sha256=tqiLJAJCmp1cehCtVPjawUNY9qyYypC4x3igI5jtDNo,7446
|
|
11
|
-
python_izone-1.3.8.dist-info/METADATA,sha256=S5q-Hty056t4AYv8gUgXUhvRbfgpuUButQxLBXAxpi0,2703
|
|
12
|
-
python_izone-1.3.8.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
13
|
-
python_izone-1.3.8.dist-info/licenses/licence.txt,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
14
|
-
python_izone-1.3.8.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|