python-izone 1.3.8__tar.gz → 1.3.10__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.8 → python_izone-1.3.10}/PKG-INFO +23 -3
- {python_izone-1.3.8 → python_izone-1.3.10}/README.md +20 -0
- python_izone-1.3.10/docs/release-notes/v1.3.10.md +15 -0
- python_izone-1.3.10/docs/release-notes/v1.3.9.md +17 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/pizone/controller.py +86 -15
- {python_izone-1.3.8 → python_izone-1.3.10}/pizone/discovery.py +72 -7
- {python_izone-1.3.8 → python_izone-1.3.10}/pizone/power.py +7 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/pizone/version.py +2 -2
- {python_izone-1.3.8 → python_izone-1.3.10}/pizone/zone.py +5 -1
- {python_izone-1.3.8 → python_izone-1.3.10}/pyproject.toml +1 -1
- python_izone-1.3.10/scripts/izone_v1_probe.py +502 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/tests/http_fakes.py +11 -3
- python_izone-1.3.10/tests/test_control_zone.py +66 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/tests/test_controller.py +48 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/tests/test_discovery_api.py +63 -0
- python_izone-1.3.10/tests/test_dump_state.py +94 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/uv.lock +44 -44
- {python_izone-1.3.8 → python_izone-1.3.10}/.gitattributes +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/.github/workflows/main.yml +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/.gitignore +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/.pre-commit-config.yaml +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/.python-version +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/.vscode/.ropeproject/config.py +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/.vscode/extensions.json +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/.vscode/launch.json +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/.vscode/settings.json +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/api_docs/AC-DOC-1401-11_iZoneEthernetInterface.pdf +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/api_docs/iPower_JSON_datastrings_Out.h +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/api_docs/iZone_JSON_datastrings.h +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/docs/release-notes/v1.3.4.md +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/docs/release-notes/v1.3.5.md +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/docs/release-notes/v1.3.6.md +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/docs/release-notes/v1.3.7.md +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/docs/release-notes/v1.3.8.md +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/licence.txt +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/pizone/__init__.py +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/pizone/const.py +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/pizone/exceptions.py +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/pizone/py.typed +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/pizone/types.py +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/scripts/check +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/scripts/coverage +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/tests/__init__.py +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/tests/conftest.py +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/tests/power_data.py +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/tests/resources.py +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/tests/test_discovery.py +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/tests/test_fullstack.py +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/tests/test_http.py +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/tests/test_power.py +0 -0
- {python_izone-1.3.8 → python_izone-1.3.10}/tests/test_zone.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: python-izone
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.10
|
|
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>
|
|
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
15
15
|
Classifier: Topic :: Home Automation
|
|
16
16
|
Classifier: Topic :: System :: Hardware
|
|
17
17
|
Requires-Python: >=3.14
|
|
18
|
-
Requires-Dist: aiohttp>=3.14.
|
|
18
|
+
Requires-Dist: aiohttp>=3.14.3
|
|
19
19
|
Requires-Dist: ifaddr>=0.2.0
|
|
20
20
|
Provides-Extra: test
|
|
21
21
|
Requires-Dist: pytest-aio; extra == 'test'
|
|
@@ -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,15 @@
|
|
|
1
|
+
# Release notes — v1.3.10
|
|
2
|
+
|
|
3
|
+
**Compare range:** https://github.com/Swamp-Ig/pizone/compare/v1.3.9...v1.3.10
|
|
4
|
+
|
|
5
|
+
## Since v1.3.9
|
|
6
|
+
|
|
7
|
+
### Control setpoint (new)
|
|
8
|
+
|
|
9
|
+
- `Controller.control_setpoint_owner` — the controller, the matching zone, or `None` for who owns the unit target. RAS mode, master with CtrlZone past the configured zone count, or a missing AUTO CTS selects the controller. CONST/OPCL `Temp:0` does not.
|
|
10
|
+
- `Controller.control_setpoint` — that owner's setpoint (°C)
|
|
11
|
+
|
|
12
|
+
### Fixes / hardening
|
|
13
|
+
|
|
14
|
+
- Drop the `Connection: close` / `force_close` HTTP keepalive workaround (core#176536)
|
|
15
|
+
- Require `aiohttp>=3.14.3` for CVE-2026-69244
|
|
@@ -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.
|
|
@@ -633,9 +659,11 @@ class Controller:
|
|
|
633
659
|
|
|
634
660
|
@property
|
|
635
661
|
def zone_ctrl(self) -> int:
|
|
636
|
-
"""Zone that currently controls the AC unit.
|
|
662
|
+
"""Zone index that currently controls the AC unit.
|
|
637
663
|
|
|
638
664
|
Value is interpreted in combination with :attr:`ras_mode`.
|
|
665
|
+
See :attr:`control_setpoint_owner` for the controller or zone that
|
|
666
|
+
owns the unit target.
|
|
639
667
|
|
|
640
668
|
Raises:
|
|
641
669
|
TypeError: If the cached value is missing or not numeric.
|
|
@@ -643,6 +671,50 @@ class Controller:
|
|
|
643
671
|
"""
|
|
644
672
|
return int(self._get_system_state("CtrlZone"))
|
|
645
673
|
|
|
674
|
+
@property
|
|
675
|
+
def control_setpoint_owner(self) -> Self | Zone | None:
|
|
676
|
+
"""Controller or zone that currently owns the unit setpoint.
|
|
677
|
+
|
|
678
|
+
Returns this controller when RAS mode is ``RAS``, when RAS mode is
|
|
679
|
+
``master`` and :attr:`zone_ctrl` is past :attr:`zones_total` (the
|
|
680
|
+
indoor unit itself), or when any AUTO zone has no room sensor.
|
|
681
|
+
Otherwise the zone matching :attr:`zone_ctrl`, or ``None`` if that
|
|
682
|
+
index does not match a zone.
|
|
683
|
+
|
|
684
|
+
CONST/OPCL zones never have a room sensor; only a missing AUTO CTS
|
|
685
|
+
forces unit control.
|
|
686
|
+
"""
|
|
687
|
+
if (
|
|
688
|
+
self.ras_mode == "RAS"
|
|
689
|
+
or (self.ras_mode == "master" and self.zone_ctrl > self.zones_total)
|
|
690
|
+
or any(
|
|
691
|
+
zone.type is Zone.Type.AUTO and zone.temp_current is None
|
|
692
|
+
for zone in self.zones
|
|
693
|
+
)
|
|
694
|
+
):
|
|
695
|
+
return self
|
|
696
|
+
return next(
|
|
697
|
+
(zone for zone in self.zones if zone.index == self.zone_ctrl),
|
|
698
|
+
None,
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
@property
|
|
702
|
+
def control_setpoint(self) -> float | None:
|
|
703
|
+
"""Setpoint of whoever currently owns the unit target.
|
|
704
|
+
|
|
705
|
+
:attr:`temp_setpoint` when the controller owns the unit target,
|
|
706
|
+
otherwise the controlling AUTO zone's setpoint. ``None`` when there
|
|
707
|
+
is no matching zone or that zone is not AUTO.
|
|
708
|
+
"""
|
|
709
|
+
owner = self.control_setpoint_owner
|
|
710
|
+
if owner is None:
|
|
711
|
+
return None
|
|
712
|
+
if not isinstance(owner, Zone):
|
|
713
|
+
return self.temp_setpoint
|
|
714
|
+
if owner.type is not Zone.Type.AUTO:
|
|
715
|
+
return None
|
|
716
|
+
return owner.temp_setpoint
|
|
717
|
+
|
|
646
718
|
@property
|
|
647
719
|
def zones_total(self) -> int:
|
|
648
720
|
"""Number of zones the system is configured for.
|
|
@@ -998,7 +1070,6 @@ class Controller:
|
|
|
998
1070
|
lock,
|
|
999
1071
|
session.get(
|
|
1000
1072
|
f"http://{self.device_ip}/{resource}",
|
|
1001
|
-
headers={"Connection": "close"},
|
|
1002
1073
|
timeout=aiohttp.ClientTimeout(total=Controller.REQUEST_TIMEOUT),
|
|
1003
1074
|
) as response,
|
|
1004
1075
|
):
|
|
@@ -10,16 +10,19 @@ 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
|
|
|
21
24
|
import aiohttp
|
|
22
|
-
from aiohttp import ClientSession
|
|
25
|
+
from aiohttp import ClientSession
|
|
23
26
|
import ifaddr
|
|
24
27
|
|
|
25
28
|
from .const import PLACEHOLDER_DEVICE_UID
|
|
@@ -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.
|
|
@@ -293,8 +301,7 @@ class DiscoveryService:
|
|
|
293
301
|
|
|
294
302
|
"""
|
|
295
303
|
if self._own_session:
|
|
296
|
-
|
|
297
|
-
self._session = ClientSession(connector=TCPConnector(force_close=True))
|
|
304
|
+
self._session = ClientSession()
|
|
298
305
|
|
|
299
306
|
_svc = self
|
|
300
307
|
|
|
@@ -665,13 +672,13 @@ class DiscoveryService:
|
|
|
665
672
|
async def _probe(
|
|
666
673
|
self, host: str
|
|
667
674
|
) -> tuple[ControllerEndpoint, dict[str, Any]] | None:
|
|
675
|
+
"""HTTP GET /SystemSettings; None on transport or content-shaped failure."""
|
|
668
676
|
session = self._session
|
|
669
677
|
if session is None:
|
|
670
678
|
return None
|
|
671
679
|
try:
|
|
672
680
|
async with session.get(
|
|
673
681
|
f"http://{host}/SystemSettings",
|
|
674
|
-
headers={"Connection": "close"},
|
|
675
682
|
timeout=aiohttp.ClientTimeout(total=Controller.REQUEST_TIMEOUT),
|
|
676
683
|
) as response:
|
|
677
684
|
if response.status >= 400:
|
|
@@ -680,13 +687,17 @@ class DiscoveryService:
|
|
|
680
687
|
data = await response.json(content_type=None)
|
|
681
688
|
except json.JSONDecodeError:
|
|
682
689
|
text = await response.text()
|
|
683
|
-
if len(text)
|
|
690
|
+
if len(text) < 4 or text[-4:] != "{OK}":
|
|
691
|
+
return None
|
|
692
|
+
try:
|
|
684
693
|
data = json.loads(text[:-4])
|
|
685
|
-
|
|
694
|
+
except json.JSONDecodeError:
|
|
686
695
|
return None
|
|
687
|
-
except TimeoutError, aiohttp.ClientError, OSError:
|
|
696
|
+
except TimeoutError, aiohttp.ClientError, OSError, UnicodeDecodeError:
|
|
688
697
|
return None
|
|
689
698
|
|
|
699
|
+
if not isinstance(data, dict):
|
|
700
|
+
return None
|
|
690
701
|
uid = data.get("AirStreamDeviceUId")
|
|
691
702
|
if not isinstance(uid, str) or not uid:
|
|
692
703
|
return None
|
|
@@ -794,6 +805,39 @@ class DiscoveryService:
|
|
|
794
805
|
return self._transport.is_closing()
|
|
795
806
|
return self._close_task is not None
|
|
796
807
|
|
|
808
|
+
def dump_state(self) -> dict[str, Any]:
|
|
809
|
+
"""Return a JSON-friendly snapshot of discovery registry state.
|
|
810
|
+
|
|
811
|
+
Does not send an IASD broadcast. Includes a ring buffer of the most
|
|
812
|
+
recent inbound UDP datagrams (excluding our own IASD echo).
|
|
813
|
+
"""
|
|
814
|
+
|
|
815
|
+
def _endpoints(
|
|
816
|
+
endpoints: dict[str, ControllerEndpoint],
|
|
817
|
+
) -> list[dict[str, str]]:
|
|
818
|
+
return [
|
|
819
|
+
{"uid": endpoint.uid, "host": endpoint.host}
|
|
820
|
+
for endpoint in endpoints.values()
|
|
821
|
+
]
|
|
822
|
+
|
|
823
|
+
recent_udp = [
|
|
824
|
+
{
|
|
825
|
+
**reply,
|
|
826
|
+
"received_at": datetime.fromtimestamp(
|
|
827
|
+
reply["received_at"], tz=UTC
|
|
828
|
+
).isoformat(),
|
|
829
|
+
}
|
|
830
|
+
for reply in self._recent_udp
|
|
831
|
+
]
|
|
832
|
+
|
|
833
|
+
return {
|
|
834
|
+
"closed": self.is_closed,
|
|
835
|
+
"udp_bound": self._transport is not None and not self.is_closed,
|
|
836
|
+
"claimed": _endpoints(self._claimed_endpoints),
|
|
837
|
+
"known": _endpoints(self._known_endpoints),
|
|
838
|
+
"recent_udp": recent_udp,
|
|
839
|
+
}
|
|
840
|
+
|
|
797
841
|
@property
|
|
798
842
|
def session(self) -> ClientSession | None:
|
|
799
843
|
"""Return the aiohttp session used for HTTP requests."""
|
|
@@ -822,6 +866,7 @@ class DiscoveryService:
|
|
|
822
866
|
def _process_datagram(self, data: bytes, addr: tuple[str, int]) -> None:
|
|
823
867
|
if data == DISCOVERY_MSG:
|
|
824
868
|
return
|
|
869
|
+
self._record_udp_datagram(data, addr)
|
|
825
870
|
if data in (CHANGED_SYSTEM, CHANGED_ZONES, CHANGED_SCHEDULES):
|
|
826
871
|
_LOG.debug("Ignoring push notification %s from %s", data, addr)
|
|
827
872
|
return
|
|
@@ -830,6 +875,26 @@ class DiscoveryService:
|
|
|
830
875
|
else:
|
|
831
876
|
self._discovery_received(data)
|
|
832
877
|
|
|
878
|
+
def _record_udp_datagram(self, data: bytes, addr: tuple[str, int]) -> None:
|
|
879
|
+
"""Append an inbound UDP datagram to the diagnostics ring buffer."""
|
|
880
|
+
try:
|
|
881
|
+
message = data.decode()
|
|
882
|
+
except UnicodeDecodeError:
|
|
883
|
+
message = data.decode(errors="replace")
|
|
884
|
+
entry: dict[str, Any] = {
|
|
885
|
+
"source_ip": addr[0],
|
|
886
|
+
"source_port": addr[1],
|
|
887
|
+
"received_at": time.time(),
|
|
888
|
+
"message": message,
|
|
889
|
+
}
|
|
890
|
+
endpoint = self._parse_datagram(data)
|
|
891
|
+
if endpoint is not None:
|
|
892
|
+
entry["uid"] = endpoint.uid
|
|
893
|
+
entry["host"] = endpoint.host
|
|
894
|
+
parts = message.split(",")
|
|
895
|
+
entry["tags"] = parts[3:] if len(parts) > 3 else []
|
|
896
|
+
self._recent_udp.append(entry)
|
|
897
|
+
|
|
833
898
|
# disposition: deprecate
|
|
834
899
|
def _parse_datagram_deprecated(
|
|
835
900
|
self, data: bytes
|
|
@@ -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.10'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 3, 10)
|
|
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."""
|