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.
Files changed (51) hide show
  1. {python_izone-1.3.8 → python_izone-1.3.10}/PKG-INFO +23 -3
  2. {python_izone-1.3.8 → python_izone-1.3.10}/README.md +20 -0
  3. python_izone-1.3.10/docs/release-notes/v1.3.10.md +15 -0
  4. python_izone-1.3.10/docs/release-notes/v1.3.9.md +17 -0
  5. {python_izone-1.3.8 → python_izone-1.3.10}/pizone/controller.py +86 -15
  6. {python_izone-1.3.8 → python_izone-1.3.10}/pizone/discovery.py +72 -7
  7. {python_izone-1.3.8 → python_izone-1.3.10}/pizone/power.py +7 -0
  8. {python_izone-1.3.8 → python_izone-1.3.10}/pizone/version.py +2 -2
  9. {python_izone-1.3.8 → python_izone-1.3.10}/pizone/zone.py +5 -1
  10. {python_izone-1.3.8 → python_izone-1.3.10}/pyproject.toml +1 -1
  11. python_izone-1.3.10/scripts/izone_v1_probe.py +502 -0
  12. {python_izone-1.3.8 → python_izone-1.3.10}/tests/http_fakes.py +11 -3
  13. python_izone-1.3.10/tests/test_control_zone.py +66 -0
  14. {python_izone-1.3.8 → python_izone-1.3.10}/tests/test_controller.py +48 -0
  15. {python_izone-1.3.8 → python_izone-1.3.10}/tests/test_discovery_api.py +63 -0
  16. python_izone-1.3.10/tests/test_dump_state.py +94 -0
  17. {python_izone-1.3.8 → python_izone-1.3.10}/uv.lock +44 -44
  18. {python_izone-1.3.8 → python_izone-1.3.10}/.gitattributes +0 -0
  19. {python_izone-1.3.8 → python_izone-1.3.10}/.github/workflows/main.yml +0 -0
  20. {python_izone-1.3.8 → python_izone-1.3.10}/.gitignore +0 -0
  21. {python_izone-1.3.8 → python_izone-1.3.10}/.pre-commit-config.yaml +0 -0
  22. {python_izone-1.3.8 → python_izone-1.3.10}/.python-version +0 -0
  23. {python_izone-1.3.8 → python_izone-1.3.10}/.vscode/.ropeproject/config.py +0 -0
  24. {python_izone-1.3.8 → python_izone-1.3.10}/.vscode/extensions.json +0 -0
  25. {python_izone-1.3.8 → python_izone-1.3.10}/.vscode/launch.json +0 -0
  26. {python_izone-1.3.8 → python_izone-1.3.10}/.vscode/settings.json +0 -0
  27. {python_izone-1.3.8 → python_izone-1.3.10}/api_docs/AC-DOC-1401-11_iZoneEthernetInterface.pdf +0 -0
  28. {python_izone-1.3.8 → python_izone-1.3.10}/api_docs/iPower_JSON_datastrings_Out.h +0 -0
  29. {python_izone-1.3.8 → python_izone-1.3.10}/api_docs/iZone_JSON_datastrings.h +0 -0
  30. {python_izone-1.3.8 → python_izone-1.3.10}/docs/release-notes/v1.3.4.md +0 -0
  31. {python_izone-1.3.8 → python_izone-1.3.10}/docs/release-notes/v1.3.5.md +0 -0
  32. {python_izone-1.3.8 → python_izone-1.3.10}/docs/release-notes/v1.3.6.md +0 -0
  33. {python_izone-1.3.8 → python_izone-1.3.10}/docs/release-notes/v1.3.7.md +0 -0
  34. {python_izone-1.3.8 → python_izone-1.3.10}/docs/release-notes/v1.3.8.md +0 -0
  35. {python_izone-1.3.8 → python_izone-1.3.10}/licence.txt +0 -0
  36. {python_izone-1.3.8 → python_izone-1.3.10}/pizone/__init__.py +0 -0
  37. {python_izone-1.3.8 → python_izone-1.3.10}/pizone/const.py +0 -0
  38. {python_izone-1.3.8 → python_izone-1.3.10}/pizone/exceptions.py +0 -0
  39. {python_izone-1.3.8 → python_izone-1.3.10}/pizone/py.typed +0 -0
  40. {python_izone-1.3.8 → python_izone-1.3.10}/pizone/types.py +0 -0
  41. {python_izone-1.3.8 → python_izone-1.3.10}/scripts/check +0 -0
  42. {python_izone-1.3.8 → python_izone-1.3.10}/scripts/coverage +0 -0
  43. {python_izone-1.3.8 → python_izone-1.3.10}/tests/__init__.py +0 -0
  44. {python_izone-1.3.8 → python_izone-1.3.10}/tests/conftest.py +0 -0
  45. {python_izone-1.3.8 → python_izone-1.3.10}/tests/power_data.py +0 -0
  46. {python_izone-1.3.8 → python_izone-1.3.10}/tests/resources.py +0 -0
  47. {python_izone-1.3.8 → python_izone-1.3.10}/tests/test_discovery.py +0 -0
  48. {python_izone-1.3.8 → python_izone-1.3.10}/tests/test_fullstack.py +0 -0
  49. {python_izone-1.3.8 → python_izone-1.3.10}/tests/test_http.py +0 -0
  50. {python_izone-1.3.8 → python_izone-1.3.10}/tests/test_power.py +0 -0
  51. {python_izone-1.3.8 → python_izone-1.3.10}/tests/test_zone.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: python-izone
3
- Version: 1.3.8
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.1
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
- 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.
@@ -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, TCPConnector
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
- # Mitigation for hubs that stall under HTTP reuse (core#176536).
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) >= 4 and text[-4:] == "{OK}":
690
+ if len(text) < 4 or text[-4:] != "{OK}":
691
+ return None
692
+ try:
684
693
  data = json.loads(text[:-4])
685
- else:
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.8'
22
- __version_tuple__ = version_tuple = (1, 3, 8)
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."""
@@ -21,7 +21,7 @@ classifiers = [
21
21
  "Topic :: System :: Hardware",
22
22
  ]
23
23
  dependencies = [
24
- "aiohttp>=3.14.1",
24
+ "aiohttp>=3.14.3",
25
25
  "ifaddr>=0.2.0",
26
26
  ]
27
27
  dynamic = ["version"]