python-izone 1.3.7__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 CHANGED
@@ -282,9 +282,10 @@ class Controller:
282
282
  raise ConnectionError("SystemSettings device ID mismatch")
283
283
 
284
284
  if self._system_settings:
285
- self.fan_modes = Controller._VALID_FAN_MODES[
286
- str(self._system_settings.get("FanAuto", "disabled"))
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
- try:
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
- try:
494
- return self.Fan(self._get_system_state("SysFan"))
495
- except ValueError:
496
- return self.Fan.AUTO
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.
@@ -549,7 +557,7 @@ class DiscoveryService:
549
557
  continue
550
558
  try:
551
559
  self._cache_endpoint(endpoint)
552
- except RuntimeError:
560
+ except ControllerAlreadyClaimedError:
553
561
  continue
554
562
  verified.append(endpoint)
555
563
  if endpoint.uid not in notified:
@@ -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) >= 4 and text[-4:] == "{OK}":
692
+ if len(text) < 4 or text[-4:] != "{OK}":
693
+ return None
694
+ try:
684
695
  data = json.loads(text[:-4])
685
- else:
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,13 +807,51 @@ 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."""
800
846
  return self._session
801
847
 
802
- def _on_error_received(self, _: Exception) -> None:
803
- _LOG.warning("Error passed and ignored to error_received", exc_info=True)
848
+ def _on_error_received(self, exc: Exception) -> None:
849
+ # Expected when broadcast is blocked (firewall / no permission) or the
850
+ # interface is down; scan just finds nothing. Keep unexpected errors loud.
851
+ if isinstance(exc, OSError):
852
+ _LOG.debug("UDP error ignored: %s", exc)
853
+ return
854
+ _LOG.warning("Error passed and ignored to error_received: %s", exc)
804
855
 
805
856
  def _find_by_addr(self, addr: tuple[str, int]) -> Controller | None:
806
857
  for ctrl in self._controllers.values():
@@ -817,6 +868,7 @@ class DiscoveryService:
817
868
  def _process_datagram(self, data: bytes, addr: tuple[str, int]) -> None:
818
869
  if data == DISCOVERY_MSG:
819
870
  return
871
+ self._record_udp_datagram(data, addr)
820
872
  if data in (CHANGED_SYSTEM, CHANGED_ZONES, CHANGED_SCHEDULES):
821
873
  _LOG.debug("Ignoring push notification %s from %s", data, addr)
822
874
  return
@@ -825,6 +877,26 @@ class DiscoveryService:
825
877
  else:
826
878
  self._discovery_received(data)
827
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
+
828
900
  # disposition: deprecate
829
901
  def _parse_datagram_deprecated(
830
902
  self, data: bytes
@@ -923,8 +995,7 @@ class DiscoveryService:
923
995
  previous = self._known_endpoints.get(endpoint.uid)
924
996
  newly_seen = previous is None
925
997
  host_changed = previous is not None and previous.host != endpoint.host
926
- with suppress(RuntimeError):
927
- self._cache_endpoint(endpoint)
998
+ self._cache_endpoint(endpoint)
928
999
  if newly_seen or host_changed:
929
1000
  self._emit_endpoint_discovered(endpoint)
930
1001
 
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.7'
22
- __version_tuple__ = version_tuple = (1, 3, 7)
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.7
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=9akqPwsB8gcsmqnT5GQ3CCilo4m3jYL7cA3MWFOU1Uc,37442
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=qggYsfTliS2fgO5WngEhVPgiQ-AREJyDah_xDSuzcIM,520
10
- pizone/zone.py,sha256=tqiLJAJCmp1cehCtVPjawUNY9qyYypC4x3igI5jtDNo,7446
11
- python_izone-1.3.7.dist-info/METADATA,sha256=t1FnN14YONJRs8bYY-sNsbADvzt6VaQbQLfOGlzCzDQ,2703
12
- python_izone-1.3.7.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
13
- python_izone-1.3.7.dist-info/licenses/licence.txt,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
14
- python_izone-1.3.7.dist-info/RECORD,,