python-izone 1.3.2__tar.gz → 1.3.4__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 (39) hide show
  1. {python_izone-1.3.2 → python_izone-1.3.4}/PKG-INFO +1 -1
  2. python_izone-1.3.4/docs/release-notes/v1.3.4.md +55 -0
  3. {python_izone-1.3.2 → python_izone-1.3.4}/pizone/__init__.py +8 -1
  4. {python_izone-1.3.2 → python_izone-1.3.4}/pizone/controller.py +251 -137
  5. {python_izone-1.3.2 → python_izone-1.3.4}/pizone/discovery.py +1 -1
  6. {python_izone-1.3.2 → python_izone-1.3.4}/pizone/exceptions.py +8 -0
  7. {python_izone-1.3.2 → python_izone-1.3.4}/pizone/power.py +44 -22
  8. {python_izone-1.3.2 → python_izone-1.3.4}/pizone/version.py +2 -2
  9. {python_izone-1.3.2 → python_izone-1.3.4}/pizone/zone.py +2 -0
  10. {python_izone-1.3.2 → python_izone-1.3.4}/tests/conftest.py +11 -24
  11. {python_izone-1.3.2 → python_izone-1.3.4}/tests/test_controller.py +191 -7
  12. {python_izone-1.3.2 → python_izone-1.3.4}/tests/test_http.py +3 -2
  13. {python_izone-1.3.2 → python_izone-1.3.4}/tests/test_power.py +65 -5
  14. {python_izone-1.3.2 → python_izone-1.3.4}/.gitattributes +0 -0
  15. {python_izone-1.3.2 → python_izone-1.3.4}/.github/workflows/main.yml +0 -0
  16. {python_izone-1.3.2 → python_izone-1.3.4}/.gitignore +0 -0
  17. {python_izone-1.3.2 → python_izone-1.3.4}/.pre-commit-config.yaml +0 -0
  18. {python_izone-1.3.2 → python_izone-1.3.4}/.pylintrc +0 -0
  19. {python_izone-1.3.2 → python_izone-1.3.4}/.python-version +0 -0
  20. {python_izone-1.3.2 → python_izone-1.3.4}/.vscode/.ropeproject/config.py +0 -0
  21. {python_izone-1.3.2 → python_izone-1.3.4}/.vscode/launch.json +0 -0
  22. {python_izone-1.3.2 → python_izone-1.3.4}/.vscode/settings.json +0 -0
  23. {python_izone-1.3.2 → python_izone-1.3.4}/README.md +0 -0
  24. {python_izone-1.3.2 → python_izone-1.3.4}/api_docs/AC-DOC-1401-11_iZoneEthernetInterface.pdf +0 -0
  25. {python_izone-1.3.2 → python_izone-1.3.4}/api_docs/iPower_JSON_datastrings_Out.h +0 -0
  26. {python_izone-1.3.2 → python_izone-1.3.4}/api_docs/iZone_JSON_datastrings.h +0 -0
  27. {python_izone-1.3.2 → python_izone-1.3.4}/licence.txt +0 -0
  28. {python_izone-1.3.2 → python_izone-1.3.4}/pizone/py.typed +0 -0
  29. {python_izone-1.3.2 → python_izone-1.3.4}/pyproject.toml +0 -0
  30. {python_izone-1.3.2 → python_izone-1.3.4}/scripts/check +0 -0
  31. {python_izone-1.3.2 → python_izone-1.3.4}/scripts/coverage +0 -0
  32. {python_izone-1.3.2 → python_izone-1.3.4}/tests/__init__.py +0 -0
  33. {python_izone-1.3.2 → python_izone-1.3.4}/tests/http_fakes.py +0 -0
  34. {python_izone-1.3.2 → python_izone-1.3.4}/tests/power_data.py +0 -0
  35. {python_izone-1.3.2 → python_izone-1.3.4}/tests/resources.py +0 -0
  36. {python_izone-1.3.2 → python_izone-1.3.4}/tests/test_discovery.py +0 -0
  37. {python_izone-1.3.2 → python_izone-1.3.4}/tests/test_fullstack.py +0 -0
  38. {python_izone-1.3.2 → python_izone-1.3.4}/tests/test_zone.py +0 -0
  39. {python_izone-1.3.2 → python_izone-1.3.4}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-izone
3
- Version: 1.3.2
3
+ Version: 1.3.4
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>
@@ -0,0 +1,55 @@
1
+ # Release notes — v1.3.4
2
+
3
+ Released 2026-07-12. Maintenance and modernization release for the Home Assistant
4
+ iZone integration.
5
+
6
+ Shipped as **1.3.4** because PyPI versions `1.3.0`–`1.3.3` from an earlier
7
+ premature release line remain yanked and cannot be replaced.
8
+
9
+ **Compare range:** https://github.com/Swamp-Ig/pizone/compare/v1.2.10...v1.3.4
10
+
11
+ ## Summary
12
+
13
+ Since `v1.2.10`, the library has been brought up to current Python packaging
14
+ and typing practice, moved its HTTP stack to aiohttp, clarified how connection
15
+ and command failures are reported, expanded test coverage, and tightened
16
+ behaviour around partial device outages.
17
+
18
+ ## Packaging, dependencies, and development
19
+
20
+ - **Python 3.14** minimum
21
+ - Development and releases migrated to **uv** and **Hatchling** (VCS versioning, `py.typed`)
22
+ - **aiohttp** (`>=3.14.1`) replaces the custom HTTP client; request timeout raised to **10 seconds**
23
+ - **`ifaddr`** replaces `netifaces` for broadcast discovery
24
+ - Stricter **mypy** configuration; typing and style cleanup across the codebase
25
+ - CI updated (current GitHub Actions, **PyPI trusted publishing**); hardware tests excluded from default CI runs
26
+ - Unit test coverage raised to **~92%** with dedicated power, HTTP, and discovery tests
27
+
28
+ ## Error handling and connection behaviour
29
+
30
+ - **Cached property reads** no longer raise when disconnected; async I/O methods raise **`ConnectionError`** for transport failures
31
+ - **`ControllerCommandError`** for reachable devices that reject a request (`{ERROR}` POST body or HTTP 4xx)
32
+ - **`ResponseDecodeError`** for bridge-reachable HTTP responses that cannot be decoded as JSON (keeps `bridge_connected` true without string-matching exception messages)
33
+ - **Layered connection state:** `bridge_connected` (ASH bridge HTTP) and `connected` (bridge plus valid iZone AC data). Fault `SystemSettings` no longer overwrites cached state; Home Assistant can mark entities unavailable through existing disconnect listeners during partial outages (bridge up, AC subsystem down)
34
+ - **Fault-safe property reads:** empty or fault-shaped system settings return safe defaults so polling and entity bootstrap do not crash on `KeyError` or invalid enum values
35
+ - **`Power.connected`** tracks power monitor I/O separately and does not clear `Controller.connected`
36
+ - Optional subsystem I/O (power, V2 probe) uses untracked HTTP helpers so init-time probe failures do not poison controller connection state
37
+ - Connection restore and reconnect notification fixes, including deduplication of failed init attempts and a `DiscoveryService` shutdown race
38
+
39
+ ## Protocol and performance
40
+
41
+ - **Zones 13–14** supported via the zone extender endpoint (`Zones13_14`)
42
+ - Controller resources refreshed **concurrently** (`asyncio.gather` with serialized HTTP via `_sending_lock`)
43
+ - Runtime **V2** and **iPower** capability detection via non-fatal init probes (overrides or supplements discovery datagram hints)
44
+
45
+ ## Documentation
46
+
47
+ - Public API and I/O method docstrings document exception behaviour and cached-read semantics
48
+ - Package docstring documents layered connection properties (`bridge_connected`, `connected`, `Power.connected`)
49
+ - README and inline docs updated for aiohttp requirements and development workflow
50
+
51
+ ## Other fixes
52
+
53
+ - Discovery accepts `X` in the AC capability slot (per iZone docs, means subsystem unavailable in that slot — not an invalid bridge)
54
+ - Power `status_last_reading` key correction
55
+ - `controller_reconnected` KeyError when UID missing from internal disconnect tracking
@@ -8,17 +8,24 @@ Synchronous property reads return cached device data and do not raise
8
8
  raise :exc:`ConnectionError` when the device cannot be reached. They raise
9
9
  :exc:`~pizone.exceptions.ControllerCommandError` when the device responds but
10
10
  rejects the request (``{ERROR...}`` body or HTTP 4xx).
11
+
12
+ Layered connection state on :class:`~pizone.controller.Controller`:
13
+
14
+ - :attr:`~pizone.controller.Controller.bridge_connected` — ASH bridge HTTP transport
15
+ - :attr:`~pizone.controller.Controller.connected` — bridge plus valid iZone AC data
16
+ - :attr:`~pizone.power.Power.connected` — power monitor I/O (when enabled)
11
17
  """
12
18
 
13
19
  from .controller import Controller
14
20
  from .discovery import DiscoveryService, Listener, discovery
15
- from .exceptions import ControllerCommandError
21
+ from .exceptions import ControllerCommandError, ResponseDecodeError
16
22
  from .power import BatteryLevel, Power, PowerChannel, PowerDevice, PowerGroup
17
23
  from .zone import Zone
18
24
 
19
25
  __all__ = [
20
26
  "Controller",
21
27
  "ControllerCommandError",
28
+ "ResponseDecodeError",
22
29
  "Zone",
23
30
  "DiscoveryService",
24
31
  "Listener",
@@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any, cast
11
11
 
12
12
  import aiohttp
13
13
 
14
- from .exceptions import ControllerCommandError
14
+ from .exceptions import ControllerCommandError, ResponseDecodeError
15
15
  from .power import Power
16
16
  from .zone import Zone
17
17
 
@@ -33,9 +33,10 @@ class Controller:
33
33
  They raise :exc:`ConnectionError` when the device cannot be reached.
34
34
  They raise :exc:`~pizone.exceptions.ControllerCommandError` when the
35
35
  device responds but rejects the request (``{ERROR...}`` body or HTTP 4xx).
36
- A successful transport request clears a prior connection failure and
37
- notifies listeners via
38
- :meth:`~pizone.discovery.Listener.controller_reconnected`.
36
+
37
+ :attr:`connected` is ``True`` when the ASH bridge is reachable over HTTP
38
+ and the iZone AC subsystem last returned valid system data. Check
39
+ :attr:`bridge_connected` for bridge transport health alone.
39
40
  """
40
41
 
41
42
  class Mode(Enum):
@@ -78,6 +79,27 @@ class Controller:
78
79
  "var-speed": [Fan.LOW, Fan.MED, Fan.HIGH, Fan.AUTO],
79
80
  }
80
81
 
82
+ _FAULT_STATE_VALUES = frozenset({"error"})
83
+ _SYSTEM_STATE_DEFAULTS: ControllerData = {
84
+ "SysOn": "off",
85
+ "SysMode": "cool",
86
+ "SysFan": "auto",
87
+ "SleepTimer": 0,
88
+ "FreeAir": "disabled",
89
+ "Supply": "0.0",
90
+ "Setpoint": "0.0",
91
+ "Temp": "0.0",
92
+ "EcoLock": "false",
93
+ "EcoMin": "15.0",
94
+ "EcoMax": "30.0",
95
+ "RAS": "zones",
96
+ "CtrlZone": 0,
97
+ "NoOfZones": 0,
98
+ "NoOfConst": 0,
99
+ "SysType": "0",
100
+ "FanAuto": "disabled",
101
+ }
102
+
81
103
  def __init__(
82
104
  self,
83
105
  discovery_service: DiscoveryService,
@@ -113,7 +135,8 @@ class Controller:
113
135
  self._power: Power | None = None
114
136
 
115
137
  self._initialized: bool = False
116
- self._fail_exception: Exception | None = None
138
+ self._bridge_ok: bool = True
139
+ self._izone_ok: bool = True
117
140
 
118
141
  self._sending_lock = Lock()
119
142
  self._scan_condition = Condition()
@@ -125,15 +148,20 @@ class Controller:
125
148
  ConnectionError: If a required HTTP request fails.
126
149
  KeyError: If a required field is missing from a device response.
127
150
  """
128
- await self._refresh_system(notify=False)
129
-
130
- self.fan_modes = Controller._VALID_FAN_MODES[
131
- str(self._system_settings.get("FanAuto", "disabled"))
132
- ]
151
+ settings = await self._refresh_system(notify=False)
152
+ if settings is None:
153
+ raise ConnectionError("SystemSettings device ID mismatch")
154
+
155
+ if self._system_settings:
156
+ self.fan_modes = Controller._VALID_FAN_MODES[
157
+ str(self._system_settings.get("FanAuto", "disabled"))
158
+ ]
159
+ else:
160
+ self.fan_modes = Controller._VALID_FAN_MODES["disabled"]
133
161
 
134
162
  await self._probe_v2_api()
135
163
 
136
- zone_count = int(self._system_settings["NoOfZones"])
164
+ zone_count = int(settings["NoOfZones"])
137
165
  self.zones = [Zone(self, i) for i in range(zone_count)]
138
166
 
139
167
  await self._refresh_zones(notify=False)
@@ -141,15 +169,18 @@ class Controller:
141
169
  await self._probe_power()
142
170
 
143
171
  self._initialized = True
172
+ if not self.connected:
173
+ self._event_coordinator.controller_disconnected(
174
+ self, ConnectionError("iZone controller unavailable")
175
+ )
144
176
  self._discovery_service.create_task(self._poll_loop())
145
177
 
146
178
  async def _probe_v2_api(self) -> None:
147
179
  """Detect V2 API support; non-fatal on failure."""
148
180
  try:
149
- response = await self._send_command_async(
181
+ response = await self._http_post(
150
182
  "iZoneRequestV2",
151
183
  {"iZoneV2Request": {"Type": 1, "No": 0, "No1": 0}},
152
- mark_disconnected=False,
153
184
  )
154
185
  data = json.loads(response)
155
186
  uid = data["AirStreamDeviceUId"]
@@ -165,7 +196,7 @@ class Controller:
165
196
 
166
197
  try:
167
198
  power = Power(self)
168
- await power.init(mark_disconnected=False)
199
+ await power.init()
169
200
  if power.enabled:
170
201
  self._power = power
171
202
  return
@@ -215,10 +246,15 @@ class Controller:
215
246
  async with self._scan_condition:
216
247
  self._scan_condition.notify()
217
248
 
249
+ @property
250
+ def bridge_connected(self) -> bool:
251
+ """True while the ASH bridge is reachable over HTTP."""
252
+ return self._bridge_ok
253
+
218
254
  @property
219
255
  def connected(self) -> bool:
220
- """True while the controller can reach the device over HTTP."""
221
- return self._fail_exception is None
256
+ """True while the bridge is up and the iZone AC subsystem is available."""
257
+ return self._bridge_ok and self._izone_ok
222
258
 
223
259
  @property
224
260
  def power(self) -> Power | None:
@@ -265,14 +301,13 @@ class Controller:
265
301
 
266
302
  @property
267
303
  def mode(self) -> Mode:
268
- """System mode (cooling, heating, etc.).
269
-
270
- Raises:
271
- ValueError: If the cached mode is not a valid :class:`Mode` member.
272
- """
304
+ """System mode (cooling, heating, etc.)."""
273
305
  if self.free_air:
274
306
  return self.Mode.FREE_AIR
275
- return self.Mode(self._get_system_state("SysMode"))
307
+ try:
308
+ return self.Mode(self._get_system_state("SysMode"))
309
+ except ValueError:
310
+ return self.Mode.COOL
276
311
 
277
312
  async def set_mode(self, value: Mode) -> None:
278
313
  """Set the system mode (cooling, heating, etc.).
@@ -294,12 +329,11 @@ class Controller:
294
329
 
295
330
  @property
296
331
  def fan(self) -> Fan:
297
- """The current fan level.
298
-
299
- Raises:
300
- ValueError: If the cached fan mode is not a valid :class:`Fan` member.
301
- """
302
- return self.Fan(self._get_system_state("SysFan"))
332
+ """The current fan level."""
333
+ try:
334
+ return self.Fan(self._get_system_state("SysFan"))
335
+ except ValueError:
336
+ return self.Fan.AUTO
303
337
 
304
338
  async def set_fan(self, value: Fan) -> None:
305
339
  """Set the fan level.
@@ -476,19 +510,25 @@ class Controller:
476
510
  ConnectionError: If any HTTP request fails.
477
511
  KeyError: If a required field is missing from a device response.
478
512
  """
479
- zones = int(self._system_settings["NoOfZones"])
480
- # gather schedules all refresh coroutines together; _sending_lock serializes
481
- # HTTP to one in-flight request per controller. Revisit the lock if firmware
482
- # supports concurrent requests — gather can stay.
513
+ zones = len(self.zones)
514
+ if zones == 0:
515
+ await asyncio.gather(
516
+ self._refresh_system(notify),
517
+ self._refresh_power(notify),
518
+ )
519
+ return
483
520
  await asyncio.gather(
484
521
  self._refresh_system(notify),
485
522
  self._refresh_power(notify),
486
523
  *[self._refresh_zone_group(i, notify) for i in range(0, zones, 4)],
487
524
  )
488
525
 
489
- async def _refresh_system(self, notify: bool = True) -> None:
526
+ async def _refresh_system(self, notify: bool = True) -> ControllerData | None:
490
527
  """Refresh the system settings from the device.
491
528
 
529
+ Returns the device response even when the payload is a fault placeholder,
530
+ so callers can read fields like ``NoOfZones`` during initialization.
531
+
492
532
  Raises:
493
533
  ConnectionError: If the HTTP request fails.
494
534
  KeyError: If the response is missing required fields.
@@ -496,25 +536,40 @@ class Controller:
496
536
  values: Controller.ControllerData = await self._get_resource("SystemSettings")
497
537
  if self._device_uid != values["AirStreamDeviceUId"]:
498
538
  _LOG.error("_refresh_system called with non-matching device ID")
499
- return
539
+ return None
500
540
 
501
- self._system_settings = values
541
+ if not self._system_settings_valid(values):
542
+ _LOG.warning(
543
+ "iZone subsystem fault uid=%s; retaining cache",
544
+ self._device_uid,
545
+ )
546
+ self._set_izone_ok(
547
+ False, ConnectionError("iZone controller unavailable")
548
+ )
549
+ if notify:
550
+ self._event_coordinator.controller_update(self)
551
+ return values
502
552
 
553
+ self._system_settings = values
554
+ self._set_izone_ok(True)
503
555
  if notify:
504
556
  self._event_coordinator.controller_update(self)
557
+ return values
505
558
 
506
559
  async def _refresh_power(self, notify: bool = True) -> None:
507
- """Refresh power monitor data when enabled.
508
-
509
- Raises:
510
- ConnectionError: If the HTTP request fails.
511
- json.JSONDecodeError: If the device response is not valid JSON.
512
- KeyError: If the response is missing required fields.
513
- """
560
+ """Refresh power monitor data when enabled."""
514
561
  if self._power is None or not self._power.enabled:
515
562
  return
516
563
 
517
- updated = await self._power.refresh()
564
+ try:
565
+ updated = await self._power.refresh()
566
+ except (ConnectionError, ControllerCommandError, json.JSONDecodeError, KeyError):
567
+ _LOG.warning(
568
+ "Power refresh failed for uid=%s",
569
+ self._device_uid,
570
+ exc_info=True,
571
+ )
572
+ return
518
573
 
519
574
  if updated and notify:
520
575
  self._event_coordinator.power_update(self)
@@ -527,7 +582,9 @@ class Controller:
527
582
  KeyError: If a response is missing required fields.
528
583
  AttributeError: If a zone index in the response does not match.
529
584
  """
530
- zones = int(self._system_settings["NoOfZones"])
585
+ zones = len(self.zones)
586
+ if zones == 0:
587
+ return
531
588
  await asyncio.gather(
532
589
  *[self._refresh_zone_group(i, notify) for i in range(0, zones, 4)]
533
590
  )
@@ -555,12 +612,26 @@ class Controller:
555
612
  def _refresh_address(self, address: str) -> None:
556
613
  """Update the device IP and schedule a reconnect attempt if needed."""
557
614
  self._ip = address
558
- # Signal to the retry connection loop to have another go.
559
- if self._fail_exception:
615
+ if not self._bridge_ok:
560
616
  self._discovery_service.create_task(self._retry_connection())
561
617
 
618
+ @staticmethod
619
+ def _system_settings_valid(values: ControllerData) -> bool:
620
+ """Return whether *values* look like healthy AC subsystem data."""
621
+ return (
622
+ int(values["NoOfZones"]) > 0
623
+ and str(values["SysFan"]) != "error"
624
+ and str(values["RAS"]) != "error"
625
+ )
626
+
562
627
  def _get_system_state(self, state: str) -> DictValue:
563
- return self._system_settings[state]
628
+ default = self._SYSTEM_STATE_DEFAULTS.get(state, "")
629
+ value = self._system_settings.get(state)
630
+ if value is None:
631
+ return default
632
+ if state in ("SysFan", "RAS", "SysMode") and str(value) in self._FAULT_STATE_VALUES:
633
+ return default
634
+ return value
564
635
 
565
636
  async def _set_system_state(
566
637
  self,
@@ -583,33 +654,47 @@ class Controller:
583
654
  self._event_coordinator.controller_update(self)
584
655
  await self.refresh()
585
656
 
586
- def _failed_connection(self, ex: Exception) -> None:
587
- if self._fail_exception:
588
- self._fail_exception = ex
589
- return
590
- self._fail_exception = ex
591
- if not self._initialized:
657
+ def _set_bridge_ok(self, ok: bool, ex: Exception | None = None) -> None:
658
+ was_connected = self.connected
659
+ self._bridge_ok = ok
660
+ if not ok and ex is None:
661
+ ex = ConnectionError("Unable to connect to the controller")
662
+ self._notify_connected_changed(was_connected, ex if not ok else None)
663
+
664
+ def _set_izone_ok(self, ok: bool, ex: Exception | None = None) -> None:
665
+ was_connected = self.connected
666
+ self._izone_ok = ok
667
+ if not ok and ex is None:
668
+ ex = ConnectionError("iZone controller unavailable")
669
+ self._notify_connected_changed(was_connected, ex if not ok else None)
670
+
671
+ def _notify_connected_changed(
672
+ self, was_connected: bool, ex: Exception | None
673
+ ) -> None:
674
+ if was_connected == self.connected or not self._initialized:
592
675
  return
593
- self._event_coordinator.controller_disconnected(self, ex)
676
+ if self.connected:
677
+ self._event_coordinator.controller_update(self)
678
+ for zone in self.zones:
679
+ self._event_coordinator.zone_update(self, zone)
680
+ self._event_coordinator.power_update(self)
681
+ self._event_coordinator.controller_reconnected(self)
682
+ elif ex is not None:
683
+ self._event_coordinator.controller_disconnected(self, ex)
684
+
685
+ def _failed_connection(self, ex: Exception) -> None:
686
+ """Mark the bridge transport as failed."""
687
+ self._set_bridge_ok(False, ex)
594
688
 
595
689
  def _restored_connection(self) -> None:
596
- """Clear a prior connection failure after successful I/O."""
597
- if self._fail_exception is None:
598
- return
599
- self._fail_exception = None
600
- if not self._initialized:
601
- return
602
- self._event_coordinator.controller_update(self)
603
- for zone in self.zones:
604
- self._event_coordinator.zone_update(self, zone)
605
- self._event_coordinator.power_update(self)
606
- self._event_coordinator.controller_reconnected(self)
690
+ """Mark the bridge transport as restored."""
691
+ self._set_bridge_ok(True)
607
692
 
608
693
  async def _retry_connection(self) -> None:
609
694
  """Attempt to restore connectivity after a connection failure.
610
695
 
611
696
  Connection errors are logged and not re-raised. A successful refresh
612
- restores :attr:`connected` via :meth:`_restored_connection`.
697
+ restores :attr:`connected` when both bridge and iZone layers recover.
613
698
  """
614
699
  _LOG.info(
615
700
  "Attempting to reconnect to server uid=%s ip=%s",
@@ -627,95 +712,124 @@ class Controller:
627
712
  exc_info=True,
628
713
  )
629
714
 
715
+ async def _http_get(self, resource: str) -> Any:
716
+ """Fetch a JSON resource via HTTP GET without updating connection state.
717
+
718
+ Raises:
719
+ ConnectionError: If the device cannot be reached.
720
+ ResponseDecodeError: If the response cannot be decoded as JSON.
721
+ ControllerCommandError: If the device returns HTTP 4xx.
722
+ """
723
+ session = self._discovery_service.session
724
+ if session is None:
725
+ raise ConnectionError("Discovery service is not started")
726
+ async with (
727
+ self._sending_lock,
728
+ session.get(
729
+ f"http://{self.device_ip}/{resource}",
730
+ timeout=aiohttp.ClientTimeout(total=Controller.REQUEST_TIMEOUT),
731
+ ) as response,
732
+ ):
733
+ if response.status >= 400 and response.status < 500:
734
+ raise ControllerCommandError(
735
+ f"HTTP {response.status} for http://{self.device_ip}/{resource}"
736
+ )
737
+ try:
738
+ return await response.json(content_type=None)
739
+ except json.JSONDecodeError as ex:
740
+ text = await response.text()
741
+ if text[-4:] == "{OK}":
742
+ return json.loads(text[:-4])
743
+ _LOG.error('Decode error for "%s"', text, exc_info=True)
744
+ raise ResponseDecodeError(
745
+ "Unable to decode response from the controller"
746
+ ) from ex
747
+
748
+ async def _http_post(self, command: str, data: dict[str, Any]) -> str:
749
+ """Send a command via HTTP POST without updating connection state.
750
+
751
+ Raises:
752
+ ConnectionError: If the device cannot be reached or the HTTP request fails.
753
+ ControllerCommandError: If the device returns HTTP 4xx or an
754
+ ``{ERROR...}`` payload.
755
+ """
756
+ session = self._discovery_service.session
757
+ if session is None:
758
+ raise ConnectionError("Discovery service is not started")
759
+ body = json.dumps(data).encode("latin_1")
760
+ async with (
761
+ self._sending_lock,
762
+ session.post(
763
+ f"http://{self.device_ip}/{command}",
764
+ data=body,
765
+ headers={"Connection": "close"},
766
+ timeout=aiohttp.ClientTimeout(total=Controller.REQUEST_TIMEOUT),
767
+ ) as response,
768
+ ):
769
+ if response.status >= 400 and response.status < 500:
770
+ raise ControllerCommandError(
771
+ f"HTTP {response.status} for http://{self.device_ip}/{command}"
772
+ )
773
+ if response.status != 200:
774
+ raise ConnectionError(
775
+ f"Unable to connect to: http://{self.device_ip}/{command}"
776
+ f" response={response.status} message={response.reason}"
777
+ )
778
+ result = await response.text(encoding="latin_1")
779
+
780
+ if len(result) >= 7 and result[:6] == "{ERROR":
781
+ raise ControllerCommandError(f"Server returned error state {result}")
782
+ if len(result) >= 4 and result[-4:] == "{OK}":
783
+ result = result[:-4]
784
+ return result
785
+
630
786
  async def _get_resource(self, resource: str) -> Any:
631
787
  """Fetch a JSON resource from the device via HTTP GET.
632
788
 
633
789
  Raises:
634
- ConnectionError: If the device cannot be reached, the response
635
- cannot be decoded, or the HTTP request fails.
790
+ ConnectionError: If the device cannot be reached or the HTTP request fails.
791
+ ResponseDecodeError: If the response cannot be decoded as JSON.
636
792
  ControllerCommandError: If the device returns HTTP 4xx.
637
793
  """
638
794
  try:
639
- session = self._discovery_service.session
640
- if session is None:
641
- raise ConnectionError("Discovery service is not started")
642
- async with (
643
- self._sending_lock,
644
- session.get(
645
- f"http://{self.device_ip}/{resource}",
646
- timeout=aiohttp.ClientTimeout(total=Controller.REQUEST_TIMEOUT),
647
- ) as response,
648
- ):
649
- if response.status >= 400 and response.status < 500:
650
- self._restored_connection()
651
- raise ControllerCommandError(
652
- f"HTTP {response.status} for http://{self.device_ip}/{resource}"
653
- )
654
- try:
655
- result = await response.json(content_type=None)
656
- except json.JSONDecodeError as ex:
657
- text = await response.text()
658
- if text[-4:] == "{OK}":
659
- result = json.loads(text[:-4])
660
- else:
661
- _LOG.error('Decode error for "%s"', text, exc_info=True)
662
- raise ConnectionError(
663
- "Unable to decode response from the controller"
664
- ) from ex
665
- self._restored_connection()
666
- return result
795
+ result = await self._http_get(resource)
796
+ except ControllerCommandError:
797
+ self._set_bridge_ok(True)
798
+ raise
667
799
  except (asyncio.TimeoutError, aiohttp.ClientError) as ex:
668
- self._failed_connection(ex)
800
+ self._set_bridge_ok(
801
+ False, ConnectionError("Unable to connect to the controller")
802
+ )
669
803
  raise ConnectionError("Unable to connect to the controller") from ex
804
+ except ResponseDecodeError:
805
+ self._set_bridge_ok(True)
806
+ raise
807
+ except ConnectionError as ex:
808
+ self._set_bridge_ok(False, ex)
809
+ raise
810
+ self._set_bridge_ok(True)
811
+ return result
670
812
 
671
- async def _send_command_async(
672
- self, command: str, data: dict[str, Any], *, mark_disconnected: bool = True
673
- ) -> str:
813
+ async def _send_command_async(self, command: str, data: dict[str, Any]) -> str:
674
814
  """Send a command to the device via HTTP POST.
675
815
 
676
- Args:
677
- mark_disconnected: When ``False``, transport failures do not mark
678
- the controller disconnected.
679
-
680
816
  Raises:
681
817
  ConnectionError: If the device cannot be reached or the HTTP request fails.
682
818
  ControllerCommandError: If the device returns HTTP 4xx or an
683
819
  ``{ERROR...}`` payload.
684
820
  """
685
821
  try:
686
- session = self._discovery_service.session
687
- if session is None:
688
- raise ConnectionError("Discovery service is not started")
689
- body = json.dumps(data).encode("latin_1")
690
- async with (
691
- self._sending_lock,
692
- session.post(
693
- f"http://{self.device_ip}/{command}",
694
- data=body,
695
- headers={"Connection": "close"},
696
- timeout=aiohttp.ClientTimeout(total=Controller.REQUEST_TIMEOUT),
697
- ) as response,
698
- ):
699
- if response.status >= 400 and response.status < 500:
700
- self._restored_connection()
701
- raise ControllerCommandError(
702
- f"HTTP {response.status} for http://{self.device_ip}/{command}"
703
- )
704
- if response.status != 200:
705
- raise aiohttp.ClientError(
706
- f"Unable to connect to: http://{self.device_ip}/{command}"
707
- f" response={response.status} message={response.reason}"
708
- )
709
- result = await response.text(encoding="latin_1")
822
+ result = await self._http_post(command, data)
823
+ except ControllerCommandError:
824
+ self._set_bridge_ok(True)
825
+ raise
710
826
  except (asyncio.TimeoutError, aiohttp.ClientError) as ex:
711
- if mark_disconnected:
712
- self._failed_connection(ex)
827
+ self._set_bridge_ok(
828
+ False, ConnectionError("Unable to connect to controller")
829
+ )
713
830
  raise ConnectionError("Unable to connect to controller") from ex
714
-
715
- if len(result) >= 7 and result[:6] == "{ERROR":
716
- self._restored_connection()
717
- raise ControllerCommandError(f"Server returned error state {result}")
718
- if len(result) >= 4 and result[-4:] == "{OK}":
719
- result = result[:-4]
720
- self._restored_connection()
831
+ except ConnectionError as ex:
832
+ self._set_bridge_ok(False, ex)
833
+ raise
834
+ self._set_bridge_ok(True)
721
835
  return result
@@ -415,7 +415,7 @@ class DiscoveryService:
415
415
  return ctrl
416
416
  return None
417
417
 
418
- async def _wrap_update(self, coro: Awaitable[None]) -> None:
418
+ async def _wrap_update(self, coro: Awaitable[object]) -> None:
419
419
  """Run a controller refresh coroutine and log connection failures."""
420
420
  try:
421
421
  await coro
@@ -8,3 +8,11 @@ class ControllerCommandError(Exception):
8
8
  device is reachable. Not used for transport failures (timeouts, connection
9
9
  refused, etc.) — those remain :exc:`ConnectionError`.
10
10
  """
11
+
12
+
13
+ class ResponseDecodeError(ConnectionError):
14
+ """The bridge responded over HTTP but the body could not be decoded as JSON.
15
+
16
+ Raised when transport succeeds and the response is not a transport failure,
17
+ but JSON parsing fails (and the ``{OK}`` suffix workaround does not apply).
18
+ """
@@ -12,6 +12,8 @@ from enum import IntEnum, unique
12
12
  from collections.abc import Iterable
13
13
  from typing import TYPE_CHECKING, Any, cast
14
14
 
15
+ from .exceptions import ControllerCommandError
16
+
15
17
  if TYPE_CHECKING:
16
18
  from .controller import Controller
17
19
 
@@ -190,22 +192,17 @@ class Power:
190
192
  self._status: dict[str, Any] = {"LastReadingNo": -1}
191
193
  self._devices = tuple(PowerDevice(self, i) for i in range(5))
192
194
  self._groups: tuple[PowerGroup, ...] | None = None
195
+ self._power_ok: bool = True
193
196
 
194
- async def init(self, *, mark_disconnected: bool = True) -> None:
197
+ async def init(self) -> None:
195
198
  """Load power monitor configuration from the device.
196
199
 
197
- Args:
198
- mark_disconnected: When ``False``, connection failures during this
199
- request do not mark the controller disconnected.
200
-
201
200
  Raises:
202
201
  ConnectionError: If the HTTP request fails.
203
202
  json.JSONDecodeError: If the device response is not valid JSON.
204
203
  KeyError: If the response is missing required fields.
205
204
  """
206
- self._config = await self._do_request(
207
- 1, "PowerMonitorConfig", mark_disconnected=mark_disconnected
208
- )
205
+ self._config = await self._do_request(1, "PowerMonitorConfig")
209
206
  gdict: dict[int, list[PowerChannel]] = {}
210
207
  for dev in self.devices:
211
208
  for chan in dev.channels:
@@ -232,28 +229,53 @@ class Power:
232
229
  self._status = status
233
230
  return True
234
231
 
235
- async def _do_request(
236
- self, req_type: int, result: str, *, mark_disconnected: bool = True
237
- ) -> dict[str, Any]:
232
+ async def _do_request(self, req_type: int, result: str) -> dict[str, Any]:
238
233
  """Send a power monitor request to the device.
239
234
 
240
- Args:
241
- mark_disconnected: When ``False``, connection failures do not mark
242
- the controller disconnected.
243
-
244
235
  Raises:
245
236
  ConnectionError: If the HTTP request fails.
246
237
  json.JSONDecodeError: If the device response is not valid JSON.
247
238
  KeyError: If the response is missing *result*.
248
239
  """
240
+ if not self._controller.bridge_connected:
241
+ self._set_power_ok(False)
242
+ raise ConnectionError("Bridge not connected")
243
+ try:
244
+ # pylint: disable=protected-access
245
+ datas = await self._controller._http_post(
246
+ "PowerRequest",
247
+ {"PowerRequest": {"Type": req_type, "No": 0, "No1": 0}},
248
+ )
249
+ data = json.loads(datas)
250
+ payload = cast(dict[str, Any], data[result])
251
+ except (
252
+ ConnectionError,
253
+ ControllerCommandError,
254
+ json.JSONDecodeError,
255
+ KeyError,
256
+ ) as ex:
257
+ self._set_power_ok(False)
258
+ if isinstance(ex, ConnectionError):
259
+ raise
260
+ if isinstance(ex, json.JSONDecodeError):
261
+ raise ConnectionError("Invalid power monitor response") from ex
262
+ if isinstance(ex, KeyError):
263
+ raise ConnectionError("Invalid power monitor response") from ex
264
+ raise ConnectionError("Power monitor request failed") from ex
265
+ self._set_power_ok(True)
266
+ return payload
267
+
268
+ def _set_power_ok(self, ok: bool) -> None:
269
+ if self._power_ok == ok:
270
+ return
271
+ self._power_ok = ok
249
272
  # pylint: disable=protected-access
250
- datas = await self._controller._send_command_async(
251
- "PowerRequest",
252
- {"PowerRequest": {"Type": req_type, "No": 0, "No1": 0}},
253
- mark_disconnected=mark_disconnected,
254
- )
255
- data = json.loads(datas)
256
- return cast(dict[str, Any], data[result])
273
+ self._controller._event_coordinator.power_update(self._controller)
274
+
275
+ @property
276
+ def connected(self) -> bool:
277
+ """True while the bridge is up and power monitor I/O is healthy."""
278
+ return self._controller.bridge_connected and self._power_ok
257
279
 
258
280
  @property
259
281
  def enabled(self) -> bool:
@@ -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.2'
22
- __version_tuple__ = version_tuple = (1, 3, 2)
21
+ __version__ = version = '1.3.4'
22
+ __version_tuple__ = version_tuple = (1, 3, 4)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -187,6 +187,8 @@ class Zone:
187
187
  """
188
188
  if zone_data["Index"] != self._index:
189
189
  raise AttributeError("Can't change index of existing zone.")
190
+ if str(zone_data.get("Type")) == "error":
191
+ return
190
192
  self._zone_data = zone_data
191
193
  if notify:
192
194
  self._fire_listeners()
@@ -62,48 +62,35 @@ class MockController(Controller):
62
62
  self._failed_connection(ex)
63
63
  raise ConnectionError("Explicitly Disconnected") from ex
64
64
 
65
- async def _get_resource(self, resource: str) -> Any:
66
- """Mock out the network IO for _get_resource."""
65
+ async def _http_get(self, resource: str) -> Any:
66
+ """Mock out the network IO for tracked GET requests."""
67
67
  self._check_discovery_connected()
68
68
  result = self.resources.get(resource)
69
69
  if result:
70
- self._restored_connection()
71
70
  return deepcopy(result)
72
71
  raise ConnectionError(f"Mock resource '{resource}' not available")
73
72
 
74
- async def _send_command_async(
75
- self, command: str, data: dict[str, Any], *, mark_disconnected: bool = True
76
- ) -> str:
77
- """Mock out the network IO for _send_command."""
78
- if self._fail_exception:
79
- raise ConnectionError(
80
- "Unable to connect to the controller"
81
- ) from self._fail_exception
73
+ async def _http_post(self, command: str, data: dict[str, Any]) -> str:
74
+ """Mock out the network IO for untracked POST requests."""
75
+ if not self._bridge_ok:
76
+ raise ConnectionError("Unable to connect to the controller")
82
77
  self._check_discovery_connected()
83
78
  self.sent.append((command, data))
84
79
  if command == "iZoneRequestV2":
85
80
  if self.v2_probe_response is not None:
86
- self._restored_connection()
87
81
  return self.v2_probe_response
88
- ex = ConnectionError("V2 probe failed")
89
- if mark_disconnected:
90
- self._failed_connection(ex)
91
- raise ConnectionError("Unable to connect to controller") from ex
82
+ raise ConnectionError("Unable to connect to controller")
92
83
  if command == "PowerRequest":
93
84
  req_type: int = data["PowerRequest"]["Type"]
94
85
  if req_type in self.fail_power_types:
95
- ex = TimeoutError("Power request failed")
96
- if mark_disconnected:
97
- self._failed_connection(ex)
98
- raise ConnectionError("Unable to connect to controller") from ex
86
+ raise ConnectionError("Unable to connect to controller")
99
87
  if req_type == 1:
100
- config = self.power_config if self.power_config is not None else POWER_CONFIG
101
- self._restored_connection()
88
+ config = (
89
+ self.power_config if self.power_config is not None else POWER_CONFIG
90
+ )
102
91
  return json.dumps({"PowerMonitorConfig": config})
103
92
  if req_type == 2:
104
- self._restored_connection()
105
93
  return json.dumps({"PowerMonitorStatus": POWER_STATUS})
106
- self._restored_connection()
107
94
  return ""
108
95
 
109
96
 
@@ -7,10 +7,35 @@ from typing import cast
7
7
  import pytest
8
8
  from pytest import raises
9
9
 
10
- from pizone import Controller
10
+ from pizone import Controller, Listener
11
11
 
12
12
  from .conftest import MockController, MockDiscoveryService, _register_mock_service
13
13
  from .power_data import POWER_CONFIG
14
+ from .resources import SYSTEMS
15
+
16
+
17
+ class _DisconnectListener(Listener):
18
+ def __init__(self) -> None:
19
+ self.disconnected = 0
20
+ self.reconnected = 0
21
+ self.last_exception: Exception | None = None
22
+
23
+ def controller_disconnected(self, _ctrl: Controller, ex: Exception) -> None:
24
+ self.disconnected += 1
25
+ self.last_exception = ex
26
+
27
+ def controller_reconnected(self, _ctrl: Controller) -> None:
28
+ self.reconnected += 1
29
+
30
+
31
+ def _fault_system_settings(device_uid: str) -> dict[str, object]:
32
+ settings = deepcopy(SYSTEMS["000000001"]["SystemSettings"])
33
+ settings["AirStreamDeviceUId"] = device_uid
34
+ settings["NoOfZones"] = 0
35
+ settings["SysFan"] = "error"
36
+ settings["RAS"] = "error"
37
+ settings["UnitType"] = "No Unit Type Configured!"
38
+ return settings
14
39
 
15
40
 
16
41
  @pytest.mark.asyncio
@@ -213,7 +238,7 @@ async def test_initialize_clears_ipower_when_config_disabled() -> None:
213
238
 
214
239
 
215
240
  @pytest.mark.asyncio
216
- async def test_power_init_probe_timeout_does_not_mark_disconnected() -> None:
241
+ async def test_power_init_probe_failure_leaves_controller_connected() -> None:
217
242
  svc = MockDiscoveryService()
218
243
  original_create = svc._create_controller
219
244
 
@@ -234,22 +259,25 @@ async def test_power_init_probe_timeout_does_not_mark_disconnected() -> None:
234
259
  controller = cast(MockController, svc._controllers["000000003"])
235
260
 
236
261
  assert controller.connected is True
237
- assert controller._fail_exception is None
262
+ assert controller.bridge_connected is True
263
+ assert controller._bridge_ok is True
264
+ assert controller._izone_ok is True
238
265
  finally:
239
266
  await svc.close()
240
267
 
241
268
 
242
269
  @pytest.mark.asyncio
243
- async def test_power_poll_timeout_marks_disconnected(
270
+ async def test_power_poll_failure_does_not_mark_controller_disconnected(
244
271
  ipower_service: MockDiscoveryService,
245
272
  ) -> None:
246
273
  controller = cast(MockController, ipower_service._controllers["000000003"])
274
+ assert controller.power is not None
247
275
  controller.fail_power_types.add(2)
248
276
 
249
- with raises(ConnectionError):
250
- await controller._refresh_power(notify=False)
277
+ await controller._refresh_power(notify=False)
251
278
 
252
- assert controller.connected is False
279
+ assert controller.connected is True
280
+ assert controller.power.connected is False
253
281
 
254
282
 
255
283
  @pytest.mark.asyncio
@@ -293,3 +321,159 @@ async def test_v2_probe_clears_is_v2_on_http_error() -> None:
293
321
  assert controller.is_v2 is False
294
322
  finally:
295
323
  await svc.close()
324
+
325
+
326
+ @pytest.mark.asyncio
327
+ async def test_bridge_ok_true_when_izone_fault(service: MockDiscoveryService) -> None:
328
+ controller = cast(MockController, service._controllers["000000001"])
329
+ healthy_fan = controller.fan
330
+
331
+ controller.resources["SystemSettings"] = _fault_system_settings("000000001")
332
+ await controller._refresh_system(notify=False)
333
+
334
+ assert controller.bridge_connected is True
335
+ assert controller.connected is False
336
+ assert controller.fan == healthy_fan
337
+
338
+
339
+ @pytest.mark.asyncio
340
+ async def test_cache_preserved_on_izone_fault(service: MockDiscoveryService) -> None:
341
+ controller = cast(MockController, service._controllers["000000001"])
342
+ healthy_settings = deepcopy(controller.resources["SystemSettings"])
343
+
344
+ controller.resources["SystemSettings"] = _fault_system_settings("000000001")
345
+ await controller._refresh_system(notify=False)
346
+
347
+ assert controller._system_settings == healthy_settings
348
+
349
+
350
+ @pytest.mark.asyncio
351
+ async def test_connected_restored_on_izone_recovery(
352
+ service: MockDiscoveryService,
353
+ ) -> None:
354
+ listener = _DisconnectListener()
355
+ controller = cast(MockController, service._controllers["000000001"])
356
+ controller._event_coordinator = listener
357
+ healthy_settings = deepcopy(controller.resources["SystemSettings"])
358
+
359
+ controller.resources["SystemSettings"] = _fault_system_settings("000000001")
360
+ await controller._refresh_system(notify=True)
361
+ assert listener.disconnected == 1
362
+
363
+ controller.resources["SystemSettings"] = healthy_settings
364
+ await controller._refresh_system(notify=True)
365
+ assert controller.connected is True
366
+ assert listener.reconnected == 1
367
+
368
+
369
+ @pytest.mark.asyncio
370
+ async def test_no_listener_flutter_repeated_fault_polls(
371
+ service: MockDiscoveryService,
372
+ ) -> None:
373
+ listener = _DisconnectListener()
374
+ controller = cast(MockController, service._controllers["000000001"])
375
+ controller._event_coordinator = listener
376
+
377
+ controller.resources["SystemSettings"] = _fault_system_settings("000000001")
378
+ await controller._refresh_system(notify=True)
379
+ await controller._refresh_system(notify=True)
380
+
381
+ assert listener.disconnected == 1
382
+ assert listener.reconnected == 0
383
+
384
+
385
+ @pytest.mark.asyncio
386
+ async def test_izone_fault_disconnect_uses_connection_error(
387
+ service: MockDiscoveryService,
388
+ ) -> None:
389
+ listener = _DisconnectListener()
390
+ controller = cast(MockController, service._controllers["000000001"])
391
+ controller._event_coordinator = listener
392
+
393
+ controller.resources["SystemSettings"] = _fault_system_settings("000000001")
394
+ await controller._refresh_system(notify=True)
395
+
396
+ assert isinstance(listener.last_exception, ConnectionError)
397
+
398
+
399
+ @pytest.mark.asyncio
400
+ async def test_v2_probe_failure_leaves_bridge_ok(service: MockDiscoveryService) -> None:
401
+ controller = cast(MockController, service._controllers["000000001"])
402
+ controller.v2_probe_response = None
403
+
404
+ await controller._probe_v2_api()
405
+
406
+ assert controller._bridge_ok is True
407
+ assert controller.bridge_connected is True
408
+
409
+
410
+ @pytest.mark.asyncio
411
+ async def test_init_fault_disconnect_listener() -> None:
412
+ listener = _DisconnectListener()
413
+ svc = MockDiscoveryService()
414
+ controller = MockController(
415
+ svc,
416
+ listener,
417
+ device_uid="000000004",
418
+ device_ip="10.0.0.4",
419
+ is_v2=False,
420
+ is_ipower=False,
421
+ )
422
+ controller.resources["SystemSettings"] = _fault_system_settings("000000004")
423
+
424
+ await controller._initialize()
425
+
426
+ assert controller.connected is False
427
+ assert listener.disconnected == 1
428
+ assert controller.zones == []
429
+
430
+
431
+ @pytest.mark.asyncio
432
+ async def test_init_fault_property_reads_no_crash() -> None:
433
+ listener = _DisconnectListener()
434
+ svc = MockDiscoveryService()
435
+ controller = MockController(
436
+ svc,
437
+ listener,
438
+ device_uid="000000005",
439
+ device_ip="10.0.0.5",
440
+ is_v2=False,
441
+ is_ipower=False,
442
+ )
443
+ controller.resources["SystemSettings"] = _fault_system_settings("000000005")
444
+
445
+ await controller._initialize()
446
+
447
+ assert controller.connected is False
448
+ assert controller.ras_mode == "zones"
449
+ assert controller.sys_type == "0"
450
+ assert controller.fan == Controller.Fan.AUTO
451
+ assert controller.mode == Controller.Mode.COOL
452
+ assert controller.is_on is False
453
+ assert controller.zones_total == 0
454
+
455
+
456
+ @pytest.mark.asyncio
457
+ async def test_fault_placeholder_values_in_cache_use_defaults(
458
+ service: MockDiscoveryService,
459
+ ) -> None:
460
+ controller = cast(MockController, service._controllers["000000001"])
461
+ controller._system_settings["SysFan"] = "error"
462
+ controller._system_settings["RAS"] = "error"
463
+ controller._system_settings["SysMode"] = "error"
464
+
465
+ assert controller.fan == Controller.Fan.AUTO
466
+ assert controller.ras_mode == "zones"
467
+ assert controller.mode == Controller.Mode.COOL
468
+
469
+
470
+ @pytest.mark.asyncio
471
+ async def test_both_false_on_transport_failure(service: MockDiscoveryService) -> None:
472
+ controller = cast(MockController, service._controllers["000000001"])
473
+ controller._connected = False
474
+
475
+ with raises(ConnectionError):
476
+ await controller._get_resource("SystemSettings")
477
+
478
+ assert controller.bridge_connected is False
479
+ assert controller.connected is False
@@ -8,7 +8,7 @@ import pytest
8
8
  from aiohttp import ClientSession
9
9
  from pytest import raises
10
10
 
11
- from pizone import Controller, ControllerCommandError
11
+ from pizone import Controller, ControllerCommandError, ResponseDecodeError
12
12
 
13
13
  from .conftest import MockDiscoveryService
14
14
  from .http_fakes import FakeHttpResponse, FakeHttpSession
@@ -98,11 +98,12 @@ async def test_get_resource_decode_failure(service: MockDiscoveryService) -> Non
98
98
  ),
99
99
  )
100
100
  try:
101
- with raises(ConnectionError):
101
+ with raises(ResponseDecodeError):
102
102
  await controller._get_resource("SystemSettings")
103
103
  finally:
104
104
  service._session = original_session
105
105
 
106
+ assert controller.bridge_connected is True
106
107
  assert controller.connected is True
107
108
 
108
109
 
@@ -7,21 +7,39 @@ from typing import Any, cast
7
7
  import pytest
8
8
 
9
9
  from pizone import BatteryLevel, Controller, Power
10
+ from pizone.discovery import Listener
10
11
 
11
12
  from .power_data import POWER_CONFIG, POWER_STATUS
12
13
 
13
14
 
15
+ class _PowerListener(Listener):
16
+ def __init__(self) -> None:
17
+ self.power_updates = 0
18
+
19
+ def power_update(self, _controller: Controller) -> None:
20
+ self.power_updates += 1
21
+
22
+
14
23
  class MockPowerController:
15
24
  """Minimal controller stub for Power unit tests."""
16
25
 
17
- def __init__(self, responses: dict[int, dict[str, object]]) -> None:
26
+ def __init__(
27
+ self,
28
+ responses: dict[int, dict[str, object]],
29
+ *,
30
+ bridge_connected: bool = True,
31
+ listener: _PowerListener | None = None,
32
+ ) -> None:
18
33
  self._responses = responses
19
34
  self.sent: list[tuple[str, dict[str, Any]]] = []
35
+ self._bridge_ok = bridge_connected
36
+ self._event_coordinator = listener or _PowerListener()
37
+
38
+ @property
39
+ def bridge_connected(self) -> bool:
40
+ return self._bridge_ok
20
41
 
21
- async def _send_command_async(
22
- self, command: str, data: dict[str, Any], *, mark_disconnected: bool = True
23
- ) -> str:
24
- del mark_disconnected
42
+ async def _http_post(self, command: str, data: dict[str, Any]) -> str:
25
43
  self.sent.append((command, data))
26
44
  req_type: int = data["PowerRequest"]["Type"]
27
45
  return json.dumps(self._responses[req_type])
@@ -39,6 +57,7 @@ async def test_power_init_and_status_last_reading() -> None:
39
57
 
40
58
  await power.init()
41
59
  assert power.enabled is True
60
+ assert power.connected is True
42
61
  assert power.voltage == 240
43
62
  assert power.groups is not None
44
63
  assert len(power.groups) == 1
@@ -105,3 +124,44 @@ async def test_power_nested_property_reads() -> None:
105
124
  assert group.name == "Grid"
106
125
  assert group.status_ok is False
107
126
  assert group.status_power == 1500
127
+
128
+
129
+ @pytest.mark.asyncio
130
+ async def test_power_connected_false_when_bridge_down() -> None:
131
+ controller = MockPowerController(
132
+ {1: {"PowerMonitorConfig": POWER_CONFIG}},
133
+ bridge_connected=False,
134
+ )
135
+ power = Power(cast(Controller, controller))
136
+
137
+ with pytest.raises(ConnectionError, match="Bridge not connected"):
138
+ await power.init()
139
+
140
+ assert power.connected is False
141
+
142
+
143
+ @pytest.mark.asyncio
144
+ async def test_power_connected_restored_on_recovery() -> None:
145
+ listener = _PowerListener()
146
+ controller = MockPowerController(
147
+ {
148
+ 1: {"PowerMonitorConfig": POWER_CONFIG},
149
+ 2: {"PowerMonitorStatus": POWER_STATUS},
150
+ },
151
+ listener=listener,
152
+ )
153
+ power = Power(cast(Controller, controller))
154
+ await power.init()
155
+
156
+ controller._bridge_ok = False
157
+ controller._responses = {}
158
+ with pytest.raises(ConnectionError):
159
+ await power.refresh()
160
+ assert power.connected is False
161
+ assert listener.power_updates == 1
162
+
163
+ controller._bridge_ok = True
164
+ controller._responses = {2: {"PowerMonitorStatus": POWER_STATUS}}
165
+ await power.refresh()
166
+ assert power.connected is True
167
+ assert listener.power_updates == 2
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes