python-izone 1.3.7__tar.gz → 1.3.9__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 (49) hide show
  1. {python_izone-1.3.7 → python_izone-1.3.9}/PKG-INFO +21 -1
  2. {python_izone-1.3.7 → python_izone-1.3.9}/README.md +20 -0
  3. python_izone-1.3.9/docs/release-notes/v1.3.8.md +9 -0
  4. python_izone-1.3.9/docs/release-notes/v1.3.9.md +17 -0
  5. {python_izone-1.3.7 → python_izone-1.3.9}/pizone/controller.py +39 -13
  6. {python_izone-1.3.7 → python_izone-1.3.9}/pizone/discovery.py +79 -8
  7. {python_izone-1.3.7 → python_izone-1.3.9}/pizone/power.py +7 -0
  8. {python_izone-1.3.7 → python_izone-1.3.9}/pizone/version.py +2 -2
  9. {python_izone-1.3.7 → python_izone-1.3.9}/pizone/zone.py +5 -1
  10. python_izone-1.3.9/scripts/izone_v1_probe.py +502 -0
  11. {python_izone-1.3.7 → python_izone-1.3.9}/tests/http_fakes.py +11 -3
  12. {python_izone-1.3.7 → python_izone-1.3.9}/tests/test_controller.py +48 -0
  13. {python_izone-1.3.7 → python_izone-1.3.9}/tests/test_discovery_api.py +69 -1
  14. python_izone-1.3.9/tests/test_dump_state.py +94 -0
  15. {python_izone-1.3.7 → python_izone-1.3.9}/.gitattributes +0 -0
  16. {python_izone-1.3.7 → python_izone-1.3.9}/.github/workflows/main.yml +0 -0
  17. {python_izone-1.3.7 → python_izone-1.3.9}/.gitignore +0 -0
  18. {python_izone-1.3.7 → python_izone-1.3.9}/.pre-commit-config.yaml +0 -0
  19. {python_izone-1.3.7 → python_izone-1.3.9}/.python-version +0 -0
  20. {python_izone-1.3.7 → python_izone-1.3.9}/.vscode/.ropeproject/config.py +0 -0
  21. {python_izone-1.3.7 → python_izone-1.3.9}/.vscode/extensions.json +0 -0
  22. {python_izone-1.3.7 → python_izone-1.3.9}/.vscode/launch.json +0 -0
  23. {python_izone-1.3.7 → python_izone-1.3.9}/.vscode/settings.json +0 -0
  24. {python_izone-1.3.7 → python_izone-1.3.9}/api_docs/AC-DOC-1401-11_iZoneEthernetInterface.pdf +0 -0
  25. {python_izone-1.3.7 → python_izone-1.3.9}/api_docs/iPower_JSON_datastrings_Out.h +0 -0
  26. {python_izone-1.3.7 → python_izone-1.3.9}/api_docs/iZone_JSON_datastrings.h +0 -0
  27. {python_izone-1.3.7 → python_izone-1.3.9}/docs/release-notes/v1.3.4.md +0 -0
  28. {python_izone-1.3.7 → python_izone-1.3.9}/docs/release-notes/v1.3.5.md +0 -0
  29. {python_izone-1.3.7 → python_izone-1.3.9}/docs/release-notes/v1.3.6.md +0 -0
  30. {python_izone-1.3.7 → python_izone-1.3.9}/docs/release-notes/v1.3.7.md +0 -0
  31. {python_izone-1.3.7 → python_izone-1.3.9}/licence.txt +0 -0
  32. {python_izone-1.3.7 → python_izone-1.3.9}/pizone/__init__.py +0 -0
  33. {python_izone-1.3.7 → python_izone-1.3.9}/pizone/const.py +0 -0
  34. {python_izone-1.3.7 → python_izone-1.3.9}/pizone/exceptions.py +0 -0
  35. {python_izone-1.3.7 → python_izone-1.3.9}/pizone/py.typed +0 -0
  36. {python_izone-1.3.7 → python_izone-1.3.9}/pizone/types.py +0 -0
  37. {python_izone-1.3.7 → python_izone-1.3.9}/pyproject.toml +0 -0
  38. {python_izone-1.3.7 → python_izone-1.3.9}/scripts/check +0 -0
  39. {python_izone-1.3.7 → python_izone-1.3.9}/scripts/coverage +0 -0
  40. {python_izone-1.3.7 → python_izone-1.3.9}/tests/__init__.py +0 -0
  41. {python_izone-1.3.7 → python_izone-1.3.9}/tests/conftest.py +0 -0
  42. {python_izone-1.3.7 → python_izone-1.3.9}/tests/power_data.py +0 -0
  43. {python_izone-1.3.7 → python_izone-1.3.9}/tests/resources.py +0 -0
  44. {python_izone-1.3.7 → python_izone-1.3.9}/tests/test_discovery.py +0 -0
  45. {python_izone-1.3.7 → python_izone-1.3.9}/tests/test_fullstack.py +0 -0
  46. {python_izone-1.3.7 → python_izone-1.3.9}/tests/test_http.py +0 -0
  47. {python_izone-1.3.7 → python_izone-1.3.9}/tests/test_power.py +0 -0
  48. {python_izone-1.3.7 → python_izone-1.3.9}/tests/test_zone.py +0 -0
  49. {python_izone-1.3.7 → python_izone-1.3.9}/uv.lock +0 -0
@@ -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/).
@@ -32,6 +32,26 @@ needed for iZone controllers that read the request in a single operation.
32
32
  The iZone Ethernet interface is documented in
33
33
  [AC-DOC-1401-11_iZoneEthernetInterface.pdf](./AC-DOC-1401-11_iZoneEthernetInterface.pdf).
34
34
 
35
+ ## Troubleshooting / issue reports
36
+
37
+ If Home Assistant can load the iZone integration, prefer **Download diagnostics**
38
+ from the integration page.
39
+
40
+ When that is not possible (or you need a live HTTP/UDP survey of the bridge),
41
+ run the stdlib probe from a machine on the same LAN:
42
+
43
+ ```bash
44
+ curl -fsSL -o izone_v1_probe.py \
45
+ https://raw.githubusercontent.com/Swamp-Ig/pizone/main/scripts/izone_v1_probe.py
46
+ python3 izone_v1_probe.py <bridge-ip> --with-content-type > izone-v1-probe.log
47
+ ```
48
+
49
+ Attach `izone-v1-probe.log` to the GitHub issue. The CLI host (IP or hostname)
50
+ and IPv4/IPv6 literals in the log are redacted to `**REDACTED**` by default
51
+ (including UDP `IP_…` payloads); use `--show-ips` only for local debugging.
52
+ Stop Home Assistant (or other listeners on UDP 7005) first if the UDP listen
53
+ step fails to bind.
54
+
35
55
  ## Development
36
56
 
37
57
  1. Install [uv](https://docs.astral.sh/uv/).
@@ -0,0 +1,9 @@
1
+ # Release notes — v1.3.8
2
+
3
+ **Compare range:** https://github.com/Swamp-Ig/pizone/compare/v1.3.7...v1.3.8
4
+
5
+ ## Since v1.3.7
6
+
7
+ - `discover_all` skips controllers that become claimed mid-scan (`ControllerAlreadyClaimedError`) instead of failing the scan
8
+ - Expected UDP `sendto` failures (blocked broadcast / interface down) log at debug instead of warning with a traceback
9
+ - Drop dead `RuntimeError` suppress around UDP `_cache_endpoint` (claimed endpoints already return earlier; that path raises `ControllerAlreadyClaimedError`)
@@ -0,0 +1,17 @@
1
+ # Release notes — v1.3.9
2
+
3
+ **Compare range:** https://github.com/Swamp-Ig/pizone/compare/v1.3.8...v1.3.9
4
+
5
+ ## Since v1.3.8
6
+
7
+ ### Diagnostics (new)
8
+
9
+ - `DiscoveryService.dump_state()` / `Controller.dump_state()` / `Zone.dump_state()` — JSON-friendly snapshots for Home Assistant config-entry diagnostics (includes a recent UDP datagram ring buffer on the discovery service)
10
+ - Stdlib bridge probe for issue triage when HA diagnostics are not available: [`scripts/izone_v1_probe.py`](../../scripts/izone_v1_probe.py)
11
+ See **Troubleshooting / issue reports** in the [README](../../README.md) for the one-liner download + run instructions. Probe logs redact CLI host and IP literals by default.
12
+
13
+ ### Fixes / hardening
14
+
15
+ - Harden `DiscoveryService._probe` for manual-host / non-iZone HTTP bodies: require a JSON object, catch broken `{OK}` `json.loads`, and treat `UnicodeDecodeError` as a miss (return `None` instead of raising)
16
+ - `Controller.fan` / `Controller.mode` raise `ValueError` on unknown non-fault `SysFan` / `SysMode` strings instead of silently mapping to `AUTO` / `COOL` (fault `"error"` still uses `_get_system_state` defaults)
17
+ - Unknown `FanAuto` values fall back to the `"unknown"` fan-mode list instead of crashing `_initialize`
@@ -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.
@@ -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
 
@@ -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."""
@@ -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
@@ -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."""
@@ -0,0 +1,502 @@
1
+ #!/usr/bin/env python3
2
+ """Probe iZone bridge: V1 vs V2 HTTP + UDP discovery inventory.
3
+
4
+ Stdlib only. Safe by default: write probes re-send the *current* SystemON /
5
+ zone command (no-op). Pass --mutate only if you intentionally want a toggle.
6
+
7
+ Network identifiers in stdout are redacted to **REDACTED** (same idea as
8
+ Home Assistant iZone diagnostics): the CLI host argument (IP or hostname),
9
+ IPv4/IPv6 literals (including UDP ``IP_…`` payloads), and socket peer
10
+ addresses. Pass ``--show-ips`` only for local debugging — do not attach that
11
+ log to a public issue.
12
+
13
+ Usage:
14
+ python3 izone_v1_probe.py <bridge-ip> --with-content-type > izone-v1-probe.log
15
+ python3 izone_v1_probe.py 10.0.0.90 --zone 1
16
+ python3 izone_v1_probe.py 10.0.0.90 --udp-seconds 5
17
+
18
+ Canonical copy: scripts/izone_v1_probe.py on the pizone main branch.
19
+ Attach the log on the GitHub issue.
20
+ Stop Home Assistant / other iZone listeners first if UDP bind on :7005 fails.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import ipaddress
27
+ import json
28
+ import re
29
+ import socket
30
+ import sys
31
+ import time
32
+ import urllib.error
33
+ import urllib.request
34
+ from typing import Any
35
+
36
+
37
+ TIMEOUT = 10.0
38
+ DISCOVERY_PORT = 12107
39
+ LISTEN_PORT = 7005
40
+ IASD = b"IASD"
41
+ REDACTED = "**REDACTED**"
42
+ _IPV4 = re.compile(r"(?<!\d)(?:\d{1,3}\.){3}\d{1,3}(?!\d)")
43
+ # Candidates only; confirmed with ipaddress.IPv6Address (handles ``::``, zone ids).
44
+ _IPV6_CANDIDATE = re.compile(
45
+ r"\[?(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\]?"
46
+ r"|\[?[0-9a-fA-F:]*::[0-9a-fA-F:]*(?:%[0-9A-Za-z._-]+)?\]?"
47
+ )
48
+
49
+ # Set from ``main``; when False, ``_print`` strips network ids from every line.
50
+ _SHOW_IPS = False
51
+ # Exact CLI host (IP or hostname), redacted as a substring wherever it appears.
52
+ _HOST_TOKEN: str | None = None
53
+
54
+
55
+ def _redact_ipv6_match(match: re.Match[str]) -> str:
56
+ raw = match.group(0)
57
+ core = raw.strip("[]").split("%", 1)[0]
58
+ try:
59
+ ipaddress.IPv6Address(core)
60
+ except ValueError:
61
+ return raw
62
+ if raw.startswith("[") and raw.endswith("]"):
63
+ return f"[{REDACTED}]"
64
+ return REDACTED
65
+
66
+
67
+ def _redact_network(text: str) -> str:
68
+ """Redact CLI host, IPv4/IPv6 literals (e.g. UDP ``IP_…`` payloads)."""
69
+ if _HOST_TOKEN:
70
+ text = re.sub(re.escape(_HOST_TOKEN), REDACTED, text, flags=re.IGNORECASE)
71
+ # urllib IPv6 URL form http://[fe80::1]/...
72
+ text = re.sub(
73
+ re.escape(f"[{_HOST_TOKEN}]"),
74
+ f"[{REDACTED}]",
75
+ text,
76
+ flags=re.IGNORECASE,
77
+ )
78
+ text = _IPV4.sub(REDACTED, text)
79
+ return _IPV6_CANDIDATE.sub(_redact_ipv6_match, text)
80
+
81
+
82
+ def _fmt_addr(addr: str) -> str:
83
+ """Format a socket address for logs (always redacted unless ``--show-ips``)."""
84
+ return addr if _SHOW_IPS else REDACTED
85
+
86
+
87
+ def _print(*args: object, **kwargs: Any) -> None:
88
+ """Print to stdout, redacting network ids unless ``--show-ips``."""
89
+ if _SHOW_IPS:
90
+ print(*args, **kwargs)
91
+ return
92
+ print(
93
+ *(_redact_network(a) if isinstance(a, str) else a for a in args),
94
+ **kwargs,
95
+ )
96
+
97
+
98
+ def _req(
99
+ method: str,
100
+ url: str,
101
+ body: dict[str, Any] | None = None,
102
+ *,
103
+ content_type: bool,
104
+ ) -> tuple[int | None, str, float, str | None]:
105
+ data = None
106
+ headers: dict[str, str] = {"Connection": "close"}
107
+ if body is not None:
108
+ data = json.dumps(body).encode("latin_1")
109
+ headers["Content-Length"] = str(len(data))
110
+ if content_type:
111
+ headers["Content-Type"] = "application/json"
112
+ request = urllib.request.Request(url, data=data, headers=headers, method=method)
113
+ t0 = time.monotonic()
114
+ try:
115
+ with urllib.request.urlopen(request, timeout=TIMEOUT) as resp:
116
+ raw = resp.read()
117
+ text = raw.decode("latin_1", errors="replace")
118
+ return resp.status, text, time.monotonic() - t0, None
119
+ except urllib.error.HTTPError as ex:
120
+ text = ex.read().decode("latin_1", errors="replace")
121
+ return ex.code, text, time.monotonic() - t0, f"HTTPError: {ex.reason}"
122
+ except Exception as ex: # noqa: BLE001 — probe script; report anything
123
+ return None, "", time.monotonic() - t0, f"{type(ex).__name__}: {ex}"
124
+
125
+
126
+ def _show(
127
+ label: str,
128
+ status: int | None,
129
+ body: str,
130
+ dt: float,
131
+ err: str | None,
132
+ *,
133
+ full: bool = False,
134
+ ) -> None:
135
+ _print(f"\n=== {label} ===")
136
+ _print(f"status={status} time={dt:.3f}s" + (f" error={err}" if err else ""))
137
+ if full:
138
+ parsed = _parse_jsonish(body)
139
+ if parsed is not None:
140
+ _print(json.dumps(parsed, indent=2, ensure_ascii=False))
141
+ return
142
+ snippet = body if len(body) <= 1200 else body[:1200] + f"… ({len(body)} chars)"
143
+ _print(snippet if snippet else "(empty body)")
144
+
145
+
146
+ def _parse_jsonish(body: str) -> Any:
147
+ text = body
148
+ if text.endswith("{OK}"):
149
+ text = text[:-4]
150
+ if not text.strip():
151
+ return None
152
+ try:
153
+ return json.loads(text)
154
+ except json.JSONDecodeError:
155
+ return None
156
+
157
+
158
+ def _zones_as_list(parsed: Any) -> list[dict[str, Any]]:
159
+ if isinstance(parsed, list):
160
+ return [z for z in parsed if isinstance(z, dict)]
161
+ if isinstance(parsed, dict):
162
+ inner = parsed.get("Zones")
163
+ if isinstance(inner, list):
164
+ return [z for z in inner if isinstance(z, dict)]
165
+ return []
166
+
167
+
168
+ def _v1_zone_groups(zone_count: int) -> list[str]:
169
+ """V1 grouped GET paths covering zone_count zones (incl. Zones13_14)."""
170
+ n = max(zone_count, 1)
171
+ paths = ["Zones1_4"]
172
+ if n > 4:
173
+ paths.append("Zones5_8")
174
+ if n > 8:
175
+ paths.append("Zones9_12")
176
+ if n > 12:
177
+ paths.append("Zones13_14")
178
+ return paths
179
+
180
+
181
+ def _udp_listen(
182
+ host: str,
183
+ listen_seconds: float,
184
+ *,
185
+ send_iasd: bool,
186
+ label: str,
187
+ ) -> None:
188
+ _print(
189
+ f"\n=== {label} (bind :{LISTEN_PORT}, "
190
+ f"{'IASD → :' + str(DISCOVERY_PORT) + ', ' if send_iasd else ''}"
191
+ f"{listen_seconds:.1f}s) ==="
192
+ )
193
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
194
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
195
+ try:
196
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
197
+ except OSError as ex:
198
+ _print(f"SO_BROADCAST failed: {ex}")
199
+ try:
200
+ sock.bind(("0.0.0.0", LISTEN_PORT))
201
+ except OSError as ex:
202
+ _print(
203
+ f"bind :{LISTEN_PORT} failed: {ex}\n"
204
+ " (Stop HA / izone-v2 / another probe using that port, then retry.)"
205
+ )
206
+ return
207
+
208
+ sock.settimeout(0.5)
209
+ if send_iasd:
210
+ for addr in (("255.255.255.255", DISCOVERY_PORT), (host, DISCOVERY_PORT)):
211
+ try:
212
+ sock.sendto(IASD, addr)
213
+ _print(f"sent IASD → {_fmt_addr(addr[0])}:{addr[1]}")
214
+ except OSError as ex:
215
+ _print(f"send IASD → {_fmt_addr(addr[0])}:{addr[1]} failed: {ex}")
216
+
217
+ deadline = time.monotonic() + listen_seconds
218
+ count = 0
219
+ while time.monotonic() < deadline:
220
+ try:
221
+ data, addr = sock.recvfrom(4096)
222
+ except socket.timeout:
223
+ continue
224
+ except OSError as ex:
225
+ _print(f"recv failed: {ex}")
226
+ break
227
+ text = data.decode("latin_1", errors="replace")
228
+ count += 1
229
+ kind = (
230
+ "ASPort"
231
+ if text.startswith("ASPort_")
232
+ else "iZoneChanged"
233
+ if text.startswith("iZoneChanged")
234
+ else "other"
235
+ )
236
+ _print(f" [{kind}] from {_fmt_addr(addr[0])}:{addr[1]} {text!r}")
237
+
238
+ _print(f"UDP packets received: {count}")
239
+ sock.close()
240
+
241
+
242
+ def main() -> int:
243
+ parser = argparse.ArgumentParser(description=__doc__)
244
+ parser.add_argument("host", help="Bridge IP address")
245
+ parser.add_argument(
246
+ "--zone",
247
+ type=int,
248
+ default=1,
249
+ help="1-based zone number for ZoneCommand no-op (default: 1)",
250
+ )
251
+ parser.add_argument(
252
+ "--with-content-type",
253
+ action="store_true",
254
+ help="Also retry POSTs with Content-Type: application/json",
255
+ )
256
+ parser.add_argument(
257
+ "--mutate",
258
+ action="store_true",
259
+ help="Allow a SystemON off→on or on→off toggle (default: no-op only)",
260
+ )
261
+ parser.add_argument(
262
+ "--udp-seconds",
263
+ type=float,
264
+ default=3.0,
265
+ help="How long to listen after IASD (default: 3)",
266
+ )
267
+ parser.add_argument(
268
+ "--skip-udp",
269
+ action="store_true",
270
+ help="Skip UDP discovery / listen",
271
+ )
272
+ parser.add_argument(
273
+ "--skip-writes",
274
+ action="store_true",
275
+ help="Inventory + UDP only (no SystemON/ZoneCommand/FAN probes)",
276
+ )
277
+ parser.add_argument(
278
+ "--show-ips",
279
+ action="store_true",
280
+ help="Leave host/IP addresses in stdout (local debug only; do not attach)",
281
+ )
282
+ args = parser.parse_args()
283
+ global _SHOW_IPS, _HOST_TOKEN
284
+ _SHOW_IPS = args.show_ips
285
+ host = args.host.strip().strip("[]")
286
+ _HOST_TOKEN = host or None
287
+ base = f"http://{host}" if ":" not in host else f"http://[{host}]"
288
+ ct_modes = [False]
289
+ if args.with_content_type:
290
+ ct_modes.append(True)
291
+
292
+ _print(f"iZone V1/V2 probe against {host}")
293
+ _print(f"python={sys.version.split()[0]} timeout={TIMEOUT}s")
294
+
295
+ if not args.skip_udp:
296
+ _udp_listen(
297
+ host,
298
+ args.udp_seconds,
299
+ send_iasd=True,
300
+ label="UDP discovery ping",
301
+ )
302
+
303
+ # --- V1 system inventory ---
304
+ status, body, dt, err = _req("GET", f"{base}/SystemSettings", content_type=False)
305
+ _show("GET /SystemSettings (V1 inventory)", status, body, dt, err, full=True)
306
+ settings = _parse_jsonish(body) if status == 200 else None
307
+ zone_count = 0
308
+ if isinstance(settings, dict):
309
+ try:
310
+ zone_count = int(settings.get("NoOfZones") or 0)
311
+ except (TypeError, ValueError):
312
+ zone_count = 0
313
+ _print(
314
+ f"\nsummary: UID={settings.get('AirStreamDeviceUId')!r} "
315
+ f"SysType={settings.get('SysType')!r} "
316
+ f"NoOfZones={zone_count} SysOn={settings.get('SysOn')!r} "
317
+ f"SysMode={settings.get('SysMode')!r} SysFan={settings.get('SysFan')!r}"
318
+ )
319
+
320
+ # --- V1 zone inventory ---
321
+ all_v1_zones: list[dict[str, Any]] = []
322
+ for path in _v1_zone_groups(zone_count or 8):
323
+ status, body, dt, err = _req("GET", f"{base}/{path}", content_type=False)
324
+ _show(f"GET /{path} (V1 inventory)", status, body, dt, err, full=True)
325
+ if status == 200:
326
+ all_v1_zones.extend(_zones_as_list(_parse_jsonish(body)))
327
+
328
+ if all_v1_zones:
329
+ _print("\n=== V1 zone summary ===")
330
+ for z in all_v1_zones:
331
+ _print(
332
+ f" idx={z.get('Index')} name={z.get('Name')!r} "
333
+ f"type={z.get('Type')!r} mode={z.get('Mode')!r} "
334
+ f"setpoint={z.get('SetPoint')!r} temp={z.get('Temp')!r}"
335
+ )
336
+
337
+ # --- V2 system + zones inventory ---
338
+ status, body, dt, err = _req(
339
+ "POST",
340
+ f"{base}/iZoneRequestV2",
341
+ {"iZoneV2Request": {"Type": 1, "No": 0, "No1": 0}},
342
+ content_type=False,
343
+ )
344
+ _show(
345
+ "POST /iZoneRequestV2 Type=1 SystemV2 (inventory)",
346
+ status,
347
+ body,
348
+ dt,
349
+ err,
350
+ full=True,
351
+ )
352
+ parsed = _parse_jsonish(body) if status == 200 else None
353
+ if isinstance(parsed, dict):
354
+ sys_v2 = parsed.get("SystemV2")
355
+ if isinstance(sys_v2, dict):
356
+ _print(
357
+ f"\nV2 summary: UID={parsed.get('AirStreamDeviceUId')!r} "
358
+ f"SysOn={sys_v2.get('SysOn')!r} SysMode={sys_v2.get('SysMode')!r} "
359
+ f"SysFan={sys_v2.get('SysFan')!r} NoOfZones={sys_v2.get('NoOfZones')!r}"
360
+ )
361
+ try:
362
+ zone_count = max(zone_count, int(sys_v2.get("NoOfZones") or 0))
363
+ except (TypeError, ValueError):
364
+ pass
365
+
366
+ # Prefer V2 zone count when present; fall back to V1 / 8
367
+ v2_zones = zone_count or 8
368
+ for idx in range(v2_zones):
369
+ status, body, dt, err = _req(
370
+ "POST",
371
+ f"{base}/iZoneRequestV2",
372
+ {"iZoneV2Request": {"Type": 2, "No": idx, "No1": 0}},
373
+ content_type=False,
374
+ )
375
+ _show(
376
+ f"POST /iZoneRequestV2 Type=2 ZonesV2 No={idx} (inventory)",
377
+ status,
378
+ body,
379
+ dt,
380
+ err,
381
+ full=True,
382
+ )
383
+
384
+ if args.skip_writes:
385
+ _print("\n(--skip-writes) Done. Please attach the log on the issue. Thanks!")
386
+ return 0
387
+
388
+ # --- Content-Type comparison on V2 request (shape probe) ---
389
+ if args.with_content_type:
390
+ status, body, dt, err = _req(
391
+ "POST",
392
+ f"{base}/iZoneRequestV2",
393
+ {"iZoneV2Request": {"Type": 1, "No": 0, "No1": 0}},
394
+ content_type=True,
395
+ )
396
+ _show(
397
+ "POST /iZoneRequestV2 Type=1 + Content-Type (shape probe)",
398
+ status,
399
+ body,
400
+ dt,
401
+ err,
402
+ )
403
+
404
+ # --- safe writes ---
405
+ if not isinstance(settings, dict):
406
+ _print("\nSkipping V1 write probes — SystemSettings missing/unparsed.")
407
+ _print("\nDone. Please attach the log on the issue. Thanks!")
408
+ return 0
409
+
410
+ sys_on = settings.get("SysOn")
411
+ if sys_on in ("on", "off"):
412
+ target = sys_on
413
+ if args.mutate:
414
+ target = "off" if sys_on == "on" else "on"
415
+ _print(f"\n--mutate: toggling SystemON {sys_on!r} → {target!r}")
416
+ payload = {"SystemON": target}
417
+ for use_ct in ct_modes:
418
+ label = (
419
+ f"POST /SystemON {{{target!r}}} (no-op)"
420
+ if target == sys_on
421
+ else f"POST /SystemON {{{target!r}}} (mutate)"
422
+ )
423
+ if use_ct:
424
+ label += " + Content-Type"
425
+ status, body, dt, err = _req(
426
+ "POST", f"{base}/SystemON", payload, content_type=use_ct
427
+ )
428
+ _show(label, status, body, dt, err)
429
+ if args.mutate and target != sys_on and status == 200:
430
+ status, body, dt, err = _req(
431
+ "POST",
432
+ f"{base}/SystemON",
433
+ {"SystemON": sys_on},
434
+ content_type=use_ct,
435
+ )
436
+ _show(
437
+ f"POST /SystemON restore {{{sys_on!r}}}"
438
+ + (" + Content-Type" if use_ct else ""),
439
+ status,
440
+ body,
441
+ dt,
442
+ err,
443
+ )
444
+ else:
445
+ _print(f"\nSkipping SystemON write — unexpected SysOn={sys_on!r}")
446
+
447
+ # Zone no-op from inventoried V1 zones
448
+ z_match = next(
449
+ (z for z in all_v1_zones if z.get("Index") == args.zone - 1),
450
+ all_v1_zones[args.zone - 1] if len(all_v1_zones) >= args.zone else None,
451
+ )
452
+ if z_match is not None:
453
+ mode = z_match.get("Mode")
454
+ setpoint = z_match.get("SetPoint")
455
+ if mode == "auto" and setpoint is not None:
456
+ command: Any = setpoint
457
+ elif mode in ("open", "close"):
458
+ command = mode
459
+ else:
460
+ command = mode if mode is not None else setpoint
461
+ payload = {
462
+ "ZoneCommand": {"ZoneNo": str(args.zone), "Command": str(command)}
463
+ }
464
+ for use_ct in ct_modes:
465
+ label = f"POST /ZoneCommand zone={args.zone} Command={command!r} (no-op)"
466
+ if use_ct:
467
+ label += " + Content-Type"
468
+ status, body, dt, err = _req(
469
+ "POST", f"{base}/ZoneCommand", payload, content_type=use_ct
470
+ )
471
+ _show(label, status, body, dt, err)
472
+ else:
473
+ _print(f"\nSkipping ZoneCommand — no V1 zone data for zone {args.zone}")
474
+
475
+ for use_ct in ct_modes:
476
+ label = 'POST /SystemFAN {"SystemFAN":"top"} (V1 out-of-spec probe)'
477
+ if use_ct:
478
+ label += " + Content-Type"
479
+ status, body, dt, err = _req(
480
+ "POST",
481
+ f"{base}/SystemFAN",
482
+ {"SystemFAN": "top"},
483
+ content_type=use_ct,
484
+ )
485
+ _show(label, status, body, dt, err)
486
+
487
+ # Brief post-write listen for iZoneChanged_* (no second IASD)
488
+ if not args.skip_udp:
489
+ _udp_listen(
490
+ host,
491
+ min(args.udp_seconds, 3.0),
492
+ send_iasd=False,
493
+ label="UDP post-write listen (iZoneChanged_*?)",
494
+ )
495
+
496
+ _print("\nDone. Please attach the log on the issue. Thanks!")
497
+ return 0
498
+
499
+
500
+ if __name__ == "__main__":
501
+ raise SystemExit(main())
502
+
@@ -4,6 +4,8 @@ import json
4
4
  from types import TracebackType
5
5
  from typing import Self
6
6
 
7
+ _UNSET: object = object()
8
+
7
9
 
8
10
  class FakeHttpResponse:
9
11
  """Minimal async HTTP response double."""
@@ -13,8 +15,9 @@ class FakeHttpResponse:
13
15
  status: int,
14
16
  body: str = "",
15
17
  *,
16
- json_data: dict[str, object] | None = None,
18
+ json_data: object = _UNSET,
17
19
  json_error: bool = False,
20
+ unicode_error: bool = False,
18
21
  ) -> None:
19
22
  self.status = status
20
23
  self.reason = (
@@ -27,17 +30,22 @@ class FakeHttpResponse:
27
30
  self._body = body
28
31
  self._json_data = json_data
29
32
  self._json_error = json_error
33
+ self._unicode_error = unicode_error
30
34
 
31
- async def json(self, content_type: str | None = None) -> dict[str, object]:
35
+ async def json(self, content_type: str | None = None) -> object:
32
36
  del content_type
37
+ if self._unicode_error:
38
+ raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte")
33
39
  if self._json_error:
34
40
  raise json.JSONDecodeError("invalid", self._body, 0)
35
- if self._json_data is not None:
41
+ if self._json_data is not _UNSET:
36
42
  return self._json_data
37
43
  return json.loads(self._body)
38
44
 
39
45
  async def text(self, encoding: str | None = None) -> str:
40
46
  del encoding
47
+ if self._unicode_error:
48
+ raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte")
41
49
  return self._body
42
50
 
43
51
  async def __aenter__(self) -> Self:
@@ -561,6 +561,54 @@ async def test_fault_placeholder_values_in_cache_use_defaults(
561
561
  assert controller.mode == Controller.Mode.COOL
562
562
 
563
563
 
564
+ # disposition: deprecate
565
+ @pytest.mark.asyncio
566
+ @pytest.mark.parametrize(
567
+ ("field", "value", "prop"),
568
+ [
569
+ ("SysFan", "quiet", "fan"),
570
+ ("SysFan", "weird", "fan"),
571
+ ("SysMode", "quiet", "mode"),
572
+ ("SysMode", "turbo", "mode"),
573
+ ],
574
+ )
575
+ async def test_unknown_sys_enum_raises(
576
+ service: MockDiscoveryService,
577
+ field: str,
578
+ value: str,
579
+ prop: str,
580
+ ) -> None:
581
+ """Unknown non-fault SysFan/SysMode must not silently map to AUTO/COOL."""
582
+ controller = cast(MockController, service._controllers["000000001"])
583
+ controller._system_settings[field] = value
584
+
585
+ with pytest.raises(ValueError):
586
+ getattr(controller, prop)
587
+
588
+
589
+ # disposition: deprecate
590
+ @pytest.mark.asyncio
591
+ async def test_unknown_fan_auto_falls_back_to_unknown_list() -> None:
592
+ service = MockDiscoveryService()
593
+ controller = MockController(
594
+ service,
595
+ service._event_coordinator,
596
+ device_uid="000000099",
597
+ device_ip="10.0.0.99",
598
+ is_v2=False,
599
+ is_ipower=False,
600
+ )
601
+ settings = deepcopy(controller.resources["SystemSettings"])
602
+ settings["FanAuto"] = "brand-new-speed-table"
603
+ controller.resources["SystemSettings"] = settings
604
+ service._controllers["000000099"] = controller
605
+
606
+ await controller._initialize(system_settings=settings)
607
+
608
+ assert controller.fan_modes == Controller._VALID_FAN_MODES["unknown"]
609
+ await service.close()
610
+
611
+
564
612
  # disposition: deprecate
565
613
  @pytest.mark.asyncio
566
614
  async def test_both_false_on_transport_failure(service: MockDiscoveryService) -> None:
@@ -252,6 +252,69 @@ async def test_discover_by_host_unreachable() -> None:
252
252
  await service.close()
253
253
 
254
254
 
255
+ # disposition: 1.4
256
+ @pytest.mark.asyncio
257
+ @pytest.mark.parametrize(
258
+ "response",
259
+ [
260
+ pytest.param(
261
+ FakeHttpResponse(200, "<html>not a bridge</html>", json_error=True),
262
+ id="html",
263
+ ),
264
+ pytest.param(
265
+ FakeHttpResponse(200, json_data={"SysOn": "on"}),
266
+ id="json-missing-uid",
267
+ ),
268
+ pytest.param(
269
+ FakeHttpResponse(200, json_data=None),
270
+ id="empty-null-body",
271
+ ),
272
+ pytest.param(
273
+ FakeHttpResponse(200, json_data=["not", "an", "object"]),
274
+ id="json-array",
275
+ ),
276
+ pytest.param(
277
+ FakeHttpResponse(200, json_data="not-an-object"),
278
+ id="json-string",
279
+ ),
280
+ pytest.param(
281
+ FakeHttpResponse(200, "garbage{OK}", json_error=True),
282
+ id="ok-suffix-non-json",
283
+ ),
284
+ pytest.param(
285
+ FakeHttpResponse(200, unicode_error=True),
286
+ id="non-utf8",
287
+ ),
288
+ ],
289
+ )
290
+ async def test_discover_by_host_malformed_body_returns_none(
291
+ response: FakeHttpResponse,
292
+ ) -> None:
293
+ """Manual host widens _probe input; content failures must not raise."""
294
+ service = MockDiscoveryService(legacy_pathway=False)
295
+ service._session = cast(ClientSession, FakeHttpSession(get_response=response))
296
+ endpoint = await service.discover_by_host("10.0.0.90")
297
+ assert endpoint is None
298
+ await service.close()
299
+
300
+
301
+ # disposition: 1.4
302
+ @pytest.mark.asyncio
303
+ async def test_discover_by_host_ok_suffix_json() -> None:
304
+ """Bridge quirk: JSON body with a trailing {OK} still probes successfully."""
305
+ body = '{"AirStreamDeviceUId":"000025841","SysOn":"on"}{OK}'
306
+ service = MockDiscoveryService(legacy_pathway=False)
307
+ service._session = cast(
308
+ ClientSession,
309
+ FakeHttpSession(
310
+ get_response=FakeHttpResponse(200, body, json_error=True),
311
+ ),
312
+ )
313
+ endpoint = await service.discover_by_host("10.0.0.90")
314
+ assert endpoint == ControllerEndpoint(uid="000025841", host="10.0.0.90")
315
+ await service.close()
316
+
317
+
255
318
  # disposition: 1.4
256
319
  @pytest.mark.asyncio
257
320
  async def test_discover_by_host_uses_known_cache() -> None:
@@ -588,9 +651,14 @@ async def test_discover_all_excludes_created_controllers() -> None:
588
651
  FakeHttpSession(get_response=_system_settings_response("000025841")),
589
652
  )
590
653
  await service.create_controller("000025841", "10.0.0.90")
654
+
655
+ async def _scan_reports_claimed() -> None:
656
+ assert service._scan_collector is not None
657
+ service._scan_collector["000025841"] = "10.0.0.90"
658
+
591
659
  with (
592
660
  patch("pizone.discovery.asyncio.sleep", AsyncMock()),
593
- patch.object(service, "scan", AsyncMock()),
661
+ patch.object(service, "scan", side_effect=_scan_reports_claimed),
594
662
  ):
595
663
  endpoints = await service.discover_all()
596
664
  assert endpoints == []
@@ -0,0 +1,94 @@
1
+ """Tests for dump_state snapshots on discovery, controller, and zone."""
2
+
3
+ from typing import cast
4
+
5
+ import pytest
6
+
7
+ from pizone import ControllerEndpoint
8
+ from pizone.discovery import UDP_REPLY_BUFFER_SIZE
9
+
10
+ from .conftest import MockController, MockDiscoveryService
11
+
12
+
13
+ @pytest.mark.asyncio
14
+ async def test_zone_dump_state(service: MockDiscoveryService) -> None:
15
+ controller = cast(MockController, service._controllers["000000001"])
16
+ zone = controller.zones[1]
17
+
18
+ dumped = zone.dump_state()
19
+
20
+ assert dumped["Name"] == "LOUNGE"
21
+ assert dumped["Index"] == 1
22
+
23
+
24
+ @pytest.mark.asyncio
25
+ async def test_controller_dump_state(service: MockDiscoveryService) -> None:
26
+ controller = cast(MockController, service._controllers["000000001"])
27
+
28
+ dumped = controller.dump_state()
29
+
30
+ assert dumped["device_uid"] == "000000001"
31
+ assert dumped["device_ip"] == "192.0.2.1" or isinstance(dumped["device_ip"], str)
32
+ assert dumped["connected"] is True
33
+ assert dumped["bridge_connected"] is True
34
+ assert "system_settings" in dumped
35
+ assert dumped["system_settings"]["AirStreamDeviceUId"] == "000000001"
36
+ assert isinstance(dumped["fan_modes"], list)
37
+ assert len(dumped["zones"]) == len(controller.zones)
38
+ assert dumped["zones"][1]["Index"] == 1
39
+ assert dumped["power"] is None or isinstance(dumped["power"], dict)
40
+
41
+
42
+ @pytest.mark.asyncio
43
+ async def test_discovery_dump_state(service: MockDiscoveryService) -> None:
44
+ service._recent_udp.clear()
45
+ service._claimed_endpoints["000000001"] = ControllerEndpoint(
46
+ uid="000000001", host="192.0.2.1"
47
+ )
48
+ service._known_endpoints["000000002"] = ControllerEndpoint(
49
+ uid="000000002", host="192.0.2.2"
50
+ )
51
+
52
+ dumped = service.dump_state()
53
+
54
+ assert dumped["closed"] is False or isinstance(dumped["closed"], bool)
55
+ assert "udp_bound" in dumped
56
+ assert {"uid": "000000001", "host": "192.0.2.1"} in dumped["claimed"]
57
+ assert {"uid": "000000002", "host": "192.0.2.2"} in dumped["known"]
58
+ assert dumped["recent_udp"] == []
59
+
60
+
61
+ @pytest.mark.asyncio
62
+ async def test_discovery_udp_ring_buffer() -> None:
63
+ service = MockDiscoveryService(legacy_pathway=False)
64
+ service._recent_udp.clear()
65
+
66
+ service._process_datagram(
67
+ b"ASPort_12107,Mac_000025841,IP_10.0.0.90,iZone,iPower",
68
+ ("10.0.0.90", 12107),
69
+ )
70
+ service._process_datagram(
71
+ b"ASPort_12107,Mac_000025841,IP_10.0.0.91,iZone",
72
+ ("10.0.0.91", 12107),
73
+ )
74
+ service._process_datagram(b"iZoneChanged_System", ("10.0.0.91", 12107))
75
+ service._process_datagram(b"not-a-discovery-message", ("10.0.0.5", 12107))
76
+
77
+ dumped = service.dump_state()
78
+ assert len(dumped["recent_udp"]) == 4
79
+ assert dumped["recent_udp"][0]["host"] == "10.0.0.90"
80
+ assert dumped["recent_udp"][0]["tags"] == ["iZone", "iPower"]
81
+ assert dumped["recent_udp"][1]["host"] == "10.0.0.91"
82
+ assert dumped["recent_udp"][2]["message"] == "iZoneChanged_System"
83
+ assert "uid" not in dumped["recent_udp"][2]
84
+ assert dumped["recent_udp"][3]["message"] == "not-a-discovery-message"
85
+ assert "T" in dumped["recent_udp"][0]["received_at"]
86
+
87
+ for i in range(UDP_REPLY_BUFFER_SIZE + 5):
88
+ service._process_datagram(
89
+ f"ASPort_12107,Mac_000025841,IP_10.0.0.{i % 250},iZone".encode(),
90
+ ("10.0.0.1", 12107),
91
+ )
92
+ assert len(service.dump_state()["recent_udp"]) == UDP_REPLY_BUFFER_SIZE
93
+
94
+ await service.close()
File without changes
File without changes
File without changes
File without changes