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.
- {python_izone-1.3.7 → python_izone-1.3.9}/PKG-INFO +21 -1
- {python_izone-1.3.7 → python_izone-1.3.9}/README.md +20 -0
- python_izone-1.3.9/docs/release-notes/v1.3.8.md +9 -0
- python_izone-1.3.9/docs/release-notes/v1.3.9.md +17 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/pizone/controller.py +39 -13
- {python_izone-1.3.7 → python_izone-1.3.9}/pizone/discovery.py +79 -8
- {python_izone-1.3.7 → python_izone-1.3.9}/pizone/power.py +7 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/pizone/version.py +2 -2
- {python_izone-1.3.7 → python_izone-1.3.9}/pizone/zone.py +5 -1
- python_izone-1.3.9/scripts/izone_v1_probe.py +502 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/tests/http_fakes.py +11 -3
- {python_izone-1.3.7 → python_izone-1.3.9}/tests/test_controller.py +48 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/tests/test_discovery_api.py +69 -1
- python_izone-1.3.9/tests/test_dump_state.py +94 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/.gitattributes +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/.github/workflows/main.yml +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/.gitignore +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/.pre-commit-config.yaml +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/.python-version +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/.vscode/.ropeproject/config.py +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/.vscode/extensions.json +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/.vscode/launch.json +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/.vscode/settings.json +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/api_docs/AC-DOC-1401-11_iZoneEthernetInterface.pdf +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/api_docs/iPower_JSON_datastrings_Out.h +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/api_docs/iZone_JSON_datastrings.h +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/docs/release-notes/v1.3.4.md +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/docs/release-notes/v1.3.5.md +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/docs/release-notes/v1.3.6.md +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/docs/release-notes/v1.3.7.md +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/licence.txt +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/pizone/__init__.py +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/pizone/const.py +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/pizone/exceptions.py +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/pizone/py.typed +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/pizone/types.py +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/pyproject.toml +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/scripts/check +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/scripts/coverage +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/tests/__init__.py +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/tests/conftest.py +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/tests/power_data.py +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/tests/resources.py +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/tests/test_discovery.py +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/tests/test_fullstack.py +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/tests/test_http.py +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/tests/test_power.py +0 -0
- {python_izone-1.3.7 → python_izone-1.3.9}/tests/test_zone.py +0 -0
- {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.
|
|
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
|
-
|
|
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.
|
|
@@ -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
|
|
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)
|
|
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,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,
|
|
803
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
@@ -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:
|
|
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) ->
|
|
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
|
|
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",
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_izone-1.3.7 → python_izone-1.3.9}/api_docs/AC-DOC-1401-11_iZoneEthernetInterface.pdf
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|