python-izone 1.3.11__tar.gz → 1.3.12__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 (62) hide show
  1. {python_izone-1.3.11 → python_izone-1.3.12}/PKG-INFO +1 -1
  2. python_izone-1.3.12/docs/release-notes/v1.3.12.md +23 -0
  3. {python_izone-1.3.11 → python_izone-1.3.12}/pizone/controller.py +93 -15
  4. {python_izone-1.3.11 → python_izone-1.3.12}/pizone/v2.py +44 -15
  5. {python_izone-1.3.11 → python_izone-1.3.12}/pizone/version.py +2 -2
  6. {python_izone-1.3.11 → python_izone-1.3.12}/pizone/zone.py +93 -0
  7. {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_control_zone.py +23 -0
  8. {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_controller.py +76 -1
  9. {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_discovery_api.py +1 -1
  10. {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_v2.py +102 -5
  11. {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_zone.py +78 -0
  12. {python_izone-1.3.11 → python_izone-1.3.12}/tests/v2_fixtures.py +67 -3
  13. {python_izone-1.3.11 → python_izone-1.3.12}/uv.lock +245 -119
  14. {python_izone-1.3.11 → python_izone-1.3.12}/.gitattributes +0 -0
  15. {python_izone-1.3.11 → python_izone-1.3.12}/.github/workflows/main.yml +0 -0
  16. {python_izone-1.3.11 → python_izone-1.3.12}/.gitignore +0 -0
  17. {python_izone-1.3.11 → python_izone-1.3.12}/.pre-commit-config.yaml +0 -0
  18. {python_izone-1.3.11 → python_izone-1.3.12}/.python-version +0 -0
  19. {python_izone-1.3.11 → python_izone-1.3.12}/.vscode/.ropeproject/config.py +0 -0
  20. {python_izone-1.3.11 → python_izone-1.3.12}/.vscode/extensions.json +0 -0
  21. {python_izone-1.3.11 → python_izone-1.3.12}/.vscode/launch.json +0 -0
  22. {python_izone-1.3.11 → python_izone-1.3.12}/.vscode/settings.json +0 -0
  23. {python_izone-1.3.11 → python_izone-1.3.12}/README.md +0 -0
  24. {python_izone-1.3.11 → python_izone-1.3.12}/api_docs/AC-DOC-1401-11_iZoneEthernetInterface.pdf +0 -0
  25. {python_izone-1.3.11 → python_izone-1.3.12}/api_docs/iPower_JSON_datastrings_Out.h +0 -0
  26. {python_izone-1.3.11 → python_izone-1.3.12}/api_docs/iZone_JSON_datastrings.h +0 -0
  27. {python_izone-1.3.11 → python_izone-1.3.12}/docs/release-notes/v1.3.10.md +0 -0
  28. {python_izone-1.3.11 → python_izone-1.3.12}/docs/release-notes/v1.3.11.md +0 -0
  29. {python_izone-1.3.11 → python_izone-1.3.12}/docs/release-notes/v1.3.4.md +0 -0
  30. {python_izone-1.3.11 → python_izone-1.3.12}/docs/release-notes/v1.3.5.md +0 -0
  31. {python_izone-1.3.11 → python_izone-1.3.12}/docs/release-notes/v1.3.6.md +0 -0
  32. {python_izone-1.3.11 → python_izone-1.3.12}/docs/release-notes/v1.3.7.md +0 -0
  33. {python_izone-1.3.11 → python_izone-1.3.12}/docs/release-notes/v1.3.8.md +0 -0
  34. {python_izone-1.3.11 → python_izone-1.3.12}/docs/release-notes/v1.3.9.md +0 -0
  35. {python_izone-1.3.11 → python_izone-1.3.12}/docs/v2-confirmation.md +0 -0
  36. {python_izone-1.3.11 → python_izone-1.3.12}/licence.txt +0 -0
  37. {python_izone-1.3.11 → python_izone-1.3.12}/pizone/__init__.py +0 -0
  38. {python_izone-1.3.11 → python_izone-1.3.12}/pizone/const.py +0 -0
  39. {python_izone-1.3.11 → python_izone-1.3.12}/pizone/discovery.py +0 -0
  40. {python_izone-1.3.11 → python_izone-1.3.12}/pizone/exceptions.py +0 -0
  41. {python_izone-1.3.11 → python_izone-1.3.12}/pizone/power.py +0 -0
  42. {python_izone-1.3.11 → python_izone-1.3.12}/pizone/py.typed +0 -0
  43. {python_izone-1.3.11 → python_izone-1.3.12}/pizone/types.py +0 -0
  44. {python_izone-1.3.11 → python_izone-1.3.12}/pyproject.toml +0 -0
  45. {python_izone-1.3.11 → python_izone-1.3.12}/scripts/check +0 -0
  46. {python_izone-1.3.11 → python_izone-1.3.12}/scripts/coverage +0 -0
  47. {python_izone-1.3.11 → python_izone-1.3.12}/scripts/izone_v1_probe.py +0 -0
  48. {python_izone-1.3.11 → python_izone-1.3.12}/tests/__init__.py +0 -0
  49. {python_izone-1.3.11 → python_izone-1.3.12}/tests/conftest.py +0 -0
  50. {python_izone-1.3.11 → python_izone-1.3.12}/tests/http_fakes.py +0 -0
  51. {python_izone-1.3.11 → python_izone-1.3.12}/tests/power_data.py +0 -0
  52. {python_izone-1.3.11 → python_izone-1.3.12}/tests/resources.py +0 -0
  53. {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_discovery.py +0 -0
  54. {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_discovery_close_timeout.py +0 -0
  55. {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_dump_state.py +0 -0
  56. {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_fullstack.py +0 -0
  57. {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_http.py +0 -0
  58. {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_power.py +0 -0
  59. {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_v2_confirmation.py +0 -0
  60. {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_v2_fan_capability.py +0 -0
  61. {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_v2_vendor_enums.py +0 -0
  62. {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_v2_zone_validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-izone
3
- Version: 1.3.11
3
+ Version: 1.3.12
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,23 @@
1
+ # Release notes — v1.3.12
2
+
3
+ **Compare range:** https://github.com/Swamp-Ig/pizone/compare/v1.3.11...v1.3.12
4
+
5
+ ## Since v1.3.11
6
+
7
+ ### V2 status data (new)
8
+
9
+ - Every V2 zone and system key now reaches the cache, so `dump_state()` and diagnostics include the full payload. Nested system groups (`UnitOpt`, `Temperzone`, `GasHeat`) are flattened to dotted keys such as `UnitOpt.RA`. The `LockCode` and `Pass` secrets are dropped and never cached
10
+ - New zone properties: `damper_position`, `damper_fault`, `sensor_fault`, `rf_signal`, `battery_level` and `sensor_type`, plus the `Zone.SensorType`, `Zone.RfSignal` and `Zone.BatteryLevel` enums. Each returns `None` when the bridge doesn't report it. `damper_fault` also works on V1
11
+ - `BattVolt` is decoded as the vendor's battery-level enum (full / half / empty), not volts
12
+ - Bridges without damper position feedback report a constant `DmpPos` of 50, so `damper_position` reads 50 on those units
13
+ - New controller properties: `airflow_lock`, `airflow_min_lock` and `system_locked`, which work on V1 and V2. V2 lock flags are normalized to the V1 `AirflowLock` / `UnitLocked` values
14
+ - New controller properties `indoor_humidity`, `indoor_tvoc` and `indoor_eco2`. These come from undocumented V2 keys (`InRh`, `InTVOC`, `IneCO2`), so their meaning and units are unverified
15
+
16
+ ### Fixes
17
+
18
+ - A missing `CtrlZone` no longer falls back to zone 0. `zone_ctrl` is now `int | None`, and `control_setpoint_owner` returns `None` when it is unknown
19
+ - `CtrlZone` 15 (unit setpoint, per the vendor header) now gives the unit ownership in every RAS mode, not only master
20
+
21
+ ### Maintenance
22
+
23
+ - Dependency lock bumps: aiohttp 3.14.4, multidict 6.9.1
@@ -133,12 +133,13 @@ class Controller:
133
133
  "EcoMin": "15.0",
134
134
  "EcoMax": "30.0",
135
135
  "RAS": "zones",
136
- "CtrlZone": 0,
137
136
  "NoOfZones": 0,
138
137
  "NoOfConst": 0,
139
138
  "SysType": "0",
140
139
  "FanAuto": "disabled",
141
140
  }
141
+ # Vendor header: CtrlZone 15 means the unit setpoint, not a zone.
142
+ _CTRL_ZONE_UNIT = 15
142
143
 
143
144
  def __init__(
144
145
  self,
@@ -693,6 +694,69 @@ class Controller:
693
694
  """Maximum temperature from eco lock, or 30 if eco lock is off."""
694
695
  return float(self._get_system_state("EcoMax")) if self.eco_lock else 30.0
695
696
 
697
+ @property
698
+ def airflow_lock(self) -> bool | None:
699
+ """True if zone maximum and minimum airflow are locked by the installer.
700
+
701
+ ``None`` if not reported.
702
+ """
703
+ match self._system_settings.get("AirflowLock"):
704
+ case "on":
705
+ return True
706
+ case "onMin" | "off":
707
+ return False
708
+ case _:
709
+ return None
710
+
711
+ @property
712
+ def airflow_min_lock(self) -> bool | None:
713
+ """True if zone minimum airflow is locked by the installer.
714
+
715
+ Also true when :attr:`airflow_lock` locks both. ``None`` if not reported.
716
+ """
717
+ match self._system_settings.get("AirflowLock"):
718
+ case "on" | "onMin":
719
+ return True
720
+ case "off":
721
+ return False
722
+ case _:
723
+ return None
724
+
725
+ @property
726
+ def system_locked(self) -> bool | None:
727
+ """True if the system is keycode locked, or ``None`` if not reported."""
728
+ match self._system_settings.get("UnitLocked"):
729
+ case "true":
730
+ return True
731
+ case "false":
732
+ return False
733
+ case _:
734
+ return None
735
+
736
+ @property
737
+ def indoor_humidity(self) -> int | None:
738
+ """Indoor relative humidity (V2 ``InRh``), or ``None`` if not reported.
739
+
740
+ Not in the vendor API documentation; meaning and units are unverified.
741
+ """
742
+ return self._int_system_state("InRh")
743
+
744
+ @property
745
+ def indoor_tvoc(self) -> int | None:
746
+ """Indoor TVOC (V2 ``InTVOC``), or ``None`` if not reported.
747
+
748
+ Not in the vendor API documentation; meaning and units are unverified.
749
+ """
750
+ return self._int_system_state("InTVOC")
751
+
752
+ @property
753
+ def indoor_eco2(self) -> int | None:
754
+ """Indoor equivalent CO2 (V2 ``IneCO2``), or ``None`` if not reported.
755
+
756
+ Not in the vendor API documentation; meaning and units are unverified.
757
+ """
758
+ return self._int_system_state("IneCO2")
759
+
696
760
  @property
697
761
  def ras_mode(self) -> str:
698
762
  """Return air sensor selection mode.
@@ -707,35 +771,43 @@ class Controller:
707
771
  return cast(str, self._get_system_state("RAS"))
708
772
 
709
773
  @property
710
- def zone_ctrl(self) -> int:
711
- """Zone index that currently controls the AC unit.
774
+ def zone_ctrl(self) -> int | None:
775
+ """Zone index that currently controls the AC unit, or ``None`` if not reported.
712
776
 
713
- Value is interpreted in combination with :attr:`ras_mode`.
714
- See :attr:`control_setpoint_owner` for the controller or zone that
715
- owns the unit target.
777
+ Value is interpreted in combination with :attr:`ras_mode`; ``15`` means
778
+ the unit setpoint. See :attr:`control_setpoint_owner` for the controller
779
+ or zone that owns the unit target.
716
780
 
717
781
  Raises:
718
- TypeError: If the cached value is missing or not numeric.
782
+ ValueError: If the cached value is not numeric.
719
783
 
720
784
  """
721
- return int(self._get_system_state("CtrlZone"))
785
+ value = self._system_settings.get("CtrlZone")
786
+ return None if value is None else int(value)
722
787
 
723
788
  @property
724
789
  def control_setpoint_owner(self) -> Self | Zone | None:
725
790
  """Controller or zone that currently owns the unit setpoint.
726
791
 
727
- Returns this controller when RAS mode is ``RAS``, when RAS mode is
728
- ``master`` and :attr:`zone_ctrl` is past :attr:`zones_total` (the
729
- indoor unit itself), or when any AUTO zone has no room sensor.
730
- Otherwise the zone matching :attr:`zone_ctrl`, or ``None`` if that
731
- index does not match a zone.
792
+ Returns this controller when RAS mode is ``RAS``, when :attr:`zone_ctrl`
793
+ is ``15`` (the unit setpoint), when RAS mode is ``master`` and
794
+ :attr:`zone_ctrl` is past :attr:`zones_total` (the indoor unit itself),
795
+ or when any AUTO zone has no room sensor. Otherwise the zone matching
796
+ :attr:`zone_ctrl`, or ``None`` if that index does not match a zone or
797
+ :attr:`zone_ctrl` is not reported.
732
798
 
733
799
  CONST/OPCL zones never have a room sensor; only a missing AUTO CTS
734
800
  forces unit control.
735
801
  """
802
+ zone_ctrl = self.zone_ctrl
736
803
  if (
737
804
  self.ras_mode == "RAS"
738
- or (self.ras_mode == "master" and self.zone_ctrl > self.zones_total)
805
+ or zone_ctrl == self._CTRL_ZONE_UNIT
806
+ or (
807
+ self.ras_mode == "master"
808
+ and zone_ctrl is not None
809
+ and zone_ctrl > self.zones_total
810
+ )
739
811
  or any(
740
812
  zone.type is Zone.Type.AUTO and zone.temp_current is None
741
813
  for zone in self.zones
@@ -743,7 +815,7 @@ class Controller:
743
815
  ):
744
816
  return self
745
817
  return next(
746
- (zone for zone in self.zones if zone.index == self.zone_ctrl),
818
+ (zone for zone in self.zones if zone.index == zone_ctrl),
747
819
  None,
748
820
  )
749
821
 
@@ -1041,6 +1113,12 @@ class Controller:
1041
1113
  and str(values["RAS"]) != "error"
1042
1114
  )
1043
1115
 
1116
+ def _int_system_state(self, state: str) -> int | None:
1117
+ value = self._system_settings.get(state)
1118
+ if isinstance(value, bool) or not isinstance(value, int):
1119
+ return None
1120
+ return value
1121
+
1044
1122
  def _get_system_state(self, state: str) -> DictValue:
1045
1123
  default = self._SYSTEM_STATE_DEFAULTS.get(state, "")
1046
1124
  value = self._system_settings.get(state)
@@ -53,6 +53,12 @@ _FAN_AUTO_TYPE = {
53
53
  3: "4-speed",
54
54
  }
55
55
 
56
+ # V2 keys consumed under a different V1 name; everything else passes through.
57
+ _ZONE_V2_NORMALIZED = frozenset({"ZoneType", "Setpoint"})
58
+ _SYSTEM_V2_NORMALIZED = frozenset({"AirflowLock", "AirflowMinLock", "LockStatus"})
59
+ # Never cache or dump these.
60
+ _SYSTEM_V2_SECRETS = frozenset({"LockCode", "Pass"})
61
+
56
62
 
57
63
  class SysOnWire(IntEnum):
58
64
  """ASH SysOn_e."""
@@ -261,6 +267,39 @@ def _free_air_from_system(system_v2: dict[str, Any]) -> str:
261
267
  return "on" if int(system_v2.get("iSaveOn") or 0) else "off"
262
268
 
263
269
 
270
+ def _locks_from_system(system_v2: dict[str, Any]) -> dict[str, str]:
271
+ """Translate V2 lock flags into V1 AirflowLock / UnitLocked values."""
272
+ locks: dict[str, str] = {}
273
+ if "AirflowLock" in system_v2 or "AirflowMinLock" in system_v2:
274
+ if int(system_v2.get("AirflowLock") or 0):
275
+ locks["AirflowLock"] = "on"
276
+ elif int(system_v2.get("AirflowMinLock") or 0):
277
+ locks["AirflowLock"] = "onMin"
278
+ else:
279
+ locks["AirflowLock"] = "off"
280
+ if "LockStatus" in system_v2:
281
+ locks["UnitLocked"] = "true" if int(system_v2["LockStatus"]) else "false"
282
+ return locks
283
+
284
+
285
+ def _pass_through(
286
+ data: dict[str, str | int | float],
287
+ source: dict[str, Any],
288
+ *,
289
+ skip: frozenset[str],
290
+ prefix: str = "",
291
+ ) -> None:
292
+ """Copy scalar keys not already in *data*; flatten nested dicts to ``A.B``."""
293
+ for key, value in source.items():
294
+ name = f"{prefix}{key}"
295
+ if key in skip or name in data:
296
+ continue
297
+ if isinstance(value, dict):
298
+ _pass_through(data, value, skip=frozenset(), prefix=f"{name}.")
299
+ elif isinstance(value, (str, int, float)):
300
+ data[name] = value
301
+
302
+
264
303
  def system_v2_to_settings(
265
304
  system_v2: dict[str, Any], uid: str
266
305
  ) -> dict[str, str | int | float]:
@@ -309,7 +348,7 @@ def system_v2_to_settings(
309
348
 
310
349
  free_air = _free_air_from_system(system_v2)
311
350
 
312
- return {
351
+ data: dict[str, str | int | float] = {
313
352
  "AirStreamDeviceUId": uid,
314
353
  "SysOn": on_val,
315
354
  "SysMode": mode_val,
@@ -319,7 +358,6 @@ def system_v2_to_settings(
319
358
  "Setpoint": temp_to_v1_str(system_v2.get("Setpoint")),
320
359
  "Temp": temp_to_v1_str(system_v2.get("Temp")),
321
360
  "RAS": ras_val,
322
- "CtrlZone": int(system_v2.get("CtrlZone") or 0),
323
361
  "Tag1": str(system_v2.get("Tag1") or ""),
324
362
  "Tag2": str(system_v2.get("Tag2") or ""),
325
363
  "Warnings": str(system_v2.get("Warnings") or "none"),
@@ -333,6 +371,9 @@ def system_v2_to_settings(
333
371
  "FreeAir": free_air,
334
372
  "FanAuto": fan_auto,
335
373
  }
374
+ data.update(_locks_from_system(system_v2))
375
+ _pass_through(data, system_v2, skip=_SYSTEM_V2_NORMALIZED | _SYSTEM_V2_SECRETS)
376
+ return data
336
377
 
337
378
 
338
379
  def zones_v2_to_zone_data(zone_v2: dict[str, Any]) -> dict[str, str | int | float]:
@@ -368,19 +409,7 @@ def zones_v2_to_zone_data(zone_v2: dict[str, Any]) -> dict[str, str | int | floa
368
409
  "ConstA": int(zone_v2.get("ConstA") or 0),
369
410
  "Master": int(zone_v2.get("Master") or 0),
370
411
  }
371
- for key in (
372
- "DmpPos",
373
- "RfSignal",
374
- "BattVolt",
375
- "SensorFault",
376
- "DmpFlt",
377
- "Bypass",
378
- "SensType",
379
- "Area",
380
- "Rh",
381
- ):
382
- if key in zone_v2:
383
- data[key] = zone_v2[key]
412
+ _pass_through(data, zone_v2, skip=_ZONE_V2_NORMALIZED)
384
413
  return data
385
414
 
386
415
 
@@ -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.11'
22
- __version_tuple__ = version_tuple = (1, 3, 11)
21
+ __version__ = version = '1.3.12'
22
+ __version_tuple__ = version_tuple = (1, 3, 12)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -3,6 +3,7 @@
3
3
  Properties for reading and setting zone data.
4
4
  """
5
5
 
6
+ import builtins
6
7
  from enum import Enum
7
8
  from typing import TYPE_CHECKING, Any, cast
8
9
 
@@ -54,6 +55,31 @@ class Zone:
54
55
  CLOSE = "close"
55
56
  AUTO = "auto"
56
57
 
58
+ class SensorType(Enum):
59
+ """Zone temperature sensor type (V2 ``SensType``)."""
60
+
61
+ CCTS = 0
62
+ CSM = 1
63
+ ISENSE = 2
64
+ WIRELESS = 3
65
+ WIRED = 4
66
+ NONE = 255
67
+
68
+ class RfSignal(Enum):
69
+ """Wireless zone sensor RF signal level (V2 ``RfSignal``)."""
70
+
71
+ FULL = 0
72
+ HALF = 1
73
+ QUARTER = 2
74
+ NONE = 3
75
+
76
+ class BatteryLevel(Enum):
77
+ """Wireless zone sensor battery level (V2 ``BattVolt``)."""
78
+
79
+ FULL = 0
80
+ HALF = 1
81
+ EMPTY = 2
82
+
57
83
  DictValue = str | int | float
58
84
  ZoneData = dict[str, DictValue]
59
85
 
@@ -117,6 +143,51 @@ class Zone:
117
143
  """Minimum allowed airflow for the zone as a percent."""
118
144
  return cast(int, self._get_zone_state("MinAir"))
119
145
 
146
+ @property
147
+ def damper_position(self) -> int | None:
148
+ """Current damper position as a percent, or ``None`` if not reported.
149
+
150
+ V2 only. Bridges without damper position feedback report a constant 50.
151
+ """
152
+ return self._int_state("DmpPos")
153
+
154
+ @property
155
+ def damper_fault(self) -> bool | None:
156
+ """True if the zone damper motor reports a fault, or ``None`` if not reported."""
157
+ return self._bool_state("DmpFlt")
158
+
159
+ @property
160
+ def sensor_fault(self) -> bool | None:
161
+ """True if the zone sensor reports a fault, or ``None`` if not reported.
162
+
163
+ V2 only.
164
+ """
165
+ return self._bool_state("SensorFault")
166
+
167
+ @property
168
+ def rf_signal(self) -> Zone.RfSignal | None:
169
+ """Wireless sensor RF signal level, or ``None`` if not reported.
170
+
171
+ V2 only. Only meaningful for wireless :attr:`sensor_type` zones.
172
+ """
173
+ return self._enum_state(Zone.RfSignal, "RfSignal")
174
+
175
+ @property
176
+ def battery_level(self) -> Zone.BatteryLevel | None:
177
+ """Wireless sensor battery level, or ``None`` if not reported.
178
+
179
+ V2 only. Only meaningful for wireless :attr:`sensor_type` zones.
180
+ """
181
+ return self._enum_state(Zone.BatteryLevel, "BattVolt")
182
+
183
+ @property
184
+ def sensor_type(self) -> Zone.SensorType | None:
185
+ """Zone temperature sensor type, or ``None`` if not reported.
186
+
187
+ V2 only.
188
+ """
189
+ return self._enum_state(Zone.SensorType, "SensType")
190
+
120
191
  async def set_airflow_min(self, value: int) -> None:
121
192
  """Change the zone minimum airflow in 5% increments.
122
193
 
@@ -224,6 +295,28 @@ class Zone:
224
295
  def _get_zone_state(self, state: str) -> DictValue:
225
296
  return self._zone_data[state]
226
297
 
298
+ def _int_state(self, state: str) -> int | None:
299
+ value = self._zone_data.get(state)
300
+ if isinstance(value, bool) or not isinstance(value, int):
301
+ return None
302
+ return value
303
+
304
+ def _bool_state(self, state: str) -> bool | None:
305
+ # V1 sends "true"/"false"; V2 sends 0/1.
306
+ match self._zone_data.get(state):
307
+ case "true" | 1:
308
+ return True
309
+ case "false" | 0:
310
+ return False
311
+ case _:
312
+ return None
313
+
314
+ def _enum_state[E: Enum](self, enum: builtins.type[E], state: str) -> E | None:
315
+ try:
316
+ return enum(self._zone_data[state])
317
+ except KeyError, ValueError:
318
+ return None
319
+
227
320
  async def _send_command(self, command: str, data: str | float) -> None:
228
321
  """Send a zone command via the parent controller.
229
322
 
@@ -31,6 +31,7 @@ async def test_zones_mode_uses_ctrlzone_despite_const_temp_zero(
31
31
  [
32
32
  pytest.param("RAS", 1, False, "self", 23.5, id="ras"),
33
33
  pytest.param("master", 13, False, "self", 23.5, id="master_unit"),
34
+ pytest.param("zones", 15, False, "self", 23.5, id="unit_sentinel"),
34
35
  pytest.param("master", 1, False, 1, 23.5, id="master_zone"),
35
36
  pytest.param("zones", 1, True, "self", 23.5, id="auto_missing_cts"),
36
37
  pytest.param("zones", 99, False, None, None, id="unmatched_zone"),
@@ -64,3 +65,25 @@ async def test_control_zone_ownership(
64
65
  assert controller.control_setpoint is None
65
66
  else:
66
67
  assert controller.control_setpoint == pytest.approx(setpoint)
68
+
69
+
70
+ @pytest.mark.parametrize(
71
+ ("ras_mode", "owns"),
72
+ [
73
+ pytest.param("RAS", True, id="ras"),
74
+ pytest.param("master", False, id="master"),
75
+ pytest.param("zones", False, id="zones"),
76
+ ],
77
+ )
78
+ @pytest.mark.asyncio
79
+ async def test_missing_ctrlzone_has_no_zone_owner(
80
+ service: MockDiscoveryService, ras_mode: str, owns: bool
81
+ ) -> None:
82
+ """A missing CtrlZone must not fall back to zone 0."""
83
+ controller = cast(MockController, service._controllers["000000001"])
84
+ controller._system_settings["RAS"] = ras_mode
85
+ del controller._system_settings["CtrlZone"]
86
+
87
+ assert controller.zone_ctrl is None
88
+ assert (controller.control_setpoint_owner is controller) is owns
89
+ assert controller.control_setpoint_owner is not controller.zones[0]
@@ -905,7 +905,7 @@ async def test_v2_refresh_sequential_zones_keeps_battery() -> None:
905
905
 
906
906
  assert controller.is_v2 is True
907
907
  assert controller._read_api == "v2"
908
- assert controller.zones[0].dump_state()["BattVolt"] == 310
908
+ assert controller.zones[0].dump_state()["BattVolt"] == 1
909
909
 
910
910
  controller.sent.clear()
911
911
  controller.v2_zones = {
@@ -1084,3 +1084,78 @@ async def test_v2_other_commands_do_not_fall_back_on_command_error() -> None:
1084
1084
  await controller.set_sleep_timer(30)
1085
1085
  assert not any(cmd == "SleepTimer" for cmd, _ in controller.sent)
1086
1086
  await svc.close()
1087
+
1088
+
1089
+ # disposition: 1.4
1090
+ @pytest.mark.asyncio
1091
+ async def test_v1_status_properties(service_14: MockDiscoveryService) -> None:
1092
+ controller = cast(MockController, service_14._controllers["000000001"])
1093
+
1094
+ assert controller.airflow_lock is False
1095
+ assert controller.airflow_min_lock is True
1096
+ assert controller.system_locked is False
1097
+ assert controller.indoor_humidity is None
1098
+ assert controller.indoor_tvoc is None
1099
+ assert controller.indoor_eco2 is None
1100
+
1101
+
1102
+ # disposition: 1.4
1103
+ @pytest.mark.asyncio
1104
+ async def test_v2_status_properties(service_14: MockDiscoveryService) -> None:
1105
+ controller = cast(MockController, service_14._controllers["000000001"])
1106
+ controller._system_settings = v2_mod.system_v2_to_settings(
1107
+ SAMPLE_SYSTEM_V2["SystemV2"], "000000001"
1108
+ )
1109
+
1110
+ assert controller.airflow_lock is False
1111
+ assert controller.airflow_min_lock is False
1112
+ assert controller.system_locked is False
1113
+ assert controller.indoor_humidity == 50
1114
+ assert controller.indoor_tvoc == 0
1115
+ assert controller.indoor_eco2 == 400
1116
+
1117
+
1118
+ # disposition: 1.4
1119
+ @pytest.mark.parametrize(
1120
+ ("cached", "airflow_lock", "airflow_min_lock"),
1121
+ [
1122
+ pytest.param({"AirflowLock": "on"}, True, True, id="on"),
1123
+ pytest.param({"AirflowLock": "onMin"}, False, True, id="on-min"),
1124
+ pytest.param({"AirflowLock": "off"}, False, False, id="off"),
1125
+ pytest.param({"AirflowLock": "weird"}, None, None, id="unknown"),
1126
+ pytest.param({}, None, None, id="absent"),
1127
+ ],
1128
+ )
1129
+ @pytest.mark.asyncio
1130
+ async def test_airflow_lock_decoding(
1131
+ service_14: MockDiscoveryService,
1132
+ cached: dict[str, str],
1133
+ airflow_lock: bool | None,
1134
+ airflow_min_lock: bool | None,
1135
+ ) -> None:
1136
+ controller = cast(MockController, service_14._controllers["000000001"])
1137
+ controller._system_settings.pop("AirflowLock")
1138
+ controller._system_settings.update(cached)
1139
+
1140
+ assert controller.airflow_lock is airflow_lock
1141
+ assert controller.airflow_min_lock is airflow_min_lock
1142
+
1143
+
1144
+ # disposition: 1.4
1145
+ @pytest.mark.parametrize(
1146
+ ("cached", "expected"),
1147
+ [
1148
+ pytest.param({"UnitLocked": "true"}, True, id="locked"),
1149
+ pytest.param({"UnitLocked": "false"}, False, id="unlocked"),
1150
+ pytest.param({}, None, id="absent"),
1151
+ ],
1152
+ )
1153
+ @pytest.mark.asyncio
1154
+ async def test_system_locked_decoding(
1155
+ service_14: MockDiscoveryService, cached: dict[str, str], expected: bool | None
1156
+ ) -> None:
1157
+ controller = cast(MockController, service_14._controllers["000000001"])
1158
+ controller._system_settings.pop("UnitLocked")
1159
+ controller._system_settings.update(cached)
1160
+
1161
+ assert controller.system_locked is expected
@@ -960,7 +960,7 @@ async def test_create_controller_prefers_useful_v2() -> None:
960
960
  assert controller.mode == Controller.Mode.HEAT
961
961
  assert controller.fan == Controller.Fan.LOW
962
962
  assert len(controller.zones) == 2
963
- assert controller.zones[0].dump_state()["BattVolt"] == 310
963
+ assert controller.zones[0].dump_state()["BattVolt"] == 1
964
964
  assert controller.zones[0].name == "Kitchen"
965
965
  # Type=1 system + Type=2 x2 zones (no V1 GET).
966
966
  assert session.get_calls == 0
@@ -142,17 +142,114 @@ def test_free_air_reads_from_isave_fields(
142
142
  assert settings["FreeAir"] == expected
143
143
 
144
144
 
145
- def test_zones_v2_to_zone_data_preserves_battery() -> None:
146
- zone = v2_mod.zones_v2_to_zone_data(cast(dict, SAMPLE_ZONES_V2[0]["ZonesV2"]))
145
+ def test_zones_v2_to_zone_data_passes_through_v2_keys() -> None:
146
+ zone_v2 = SAMPLE_ZONES_V2[0]["ZonesV2"]
147
+ zone = v2_mod.zones_v2_to_zone_data(cast(dict, zone_v2))
147
148
  assert zone["Index"] == 0
148
149
  assert zone["Name"] == "Kitchen"
149
150
  assert zone["Type"] == "auto"
150
151
  assert zone["Mode"] == "auto"
151
152
  assert zone["SetPoint"] == 22.5
152
153
  assert abs(float(zone["Temp"]) - 21.08) < 0.001
153
- assert zone["BattVolt"] == 310
154
- assert zone["DmpPos"] == 45
155
- assert zone["RfSignal"] == 80
154
+ for key in (
155
+ "SensType",
156
+ "DmpFlt",
157
+ "iSense",
158
+ "Area",
159
+ "Calibration",
160
+ "Bypass",
161
+ "DmpPos",
162
+ "RfSignal",
163
+ "BattVolt",
164
+ "SensorFault",
165
+ "BalanceMax",
166
+ "BalanceMin",
167
+ "DamperSkip",
168
+ ):
169
+ assert zone[key] == zone_v2[key]
170
+ # Normalized under V1 names, not duplicated raw.
171
+ assert "ZoneType" not in zone
172
+ assert "Setpoint" not in zone
173
+
174
+
175
+ def test_system_v2_to_settings_passes_through_v2_keys() -> None:
176
+ settings = v2_mod.system_v2_to_settings(
177
+ cast(dict, SAMPLE_SYSTEM_V2["SystemV2"]), "000025841"
178
+ )
179
+ assert settings["FilterWarn"] == 3
180
+ assert settings["InRh"] == 50
181
+ assert settings["IneCO2"] == 400
182
+ assert settings["UnitOpt.RA"] == 1
183
+ assert settings["Temperzone.HeatSetpoint"] == 3500
184
+ assert settings["GasHeat.CycleFanHeat"] == 0
185
+ assert "UnitOpt" not in settings
186
+ # V1-shaped values win over raw V2 keys of the same name.
187
+ assert settings["Setpoint"] == "22.5"
188
+ assert settings["AirflowLock"] == "off"
189
+ assert settings["UnitLocked"] == "false"
190
+ assert "AirflowMinLock" not in settings
191
+ assert "LockStatus" not in settings
192
+
193
+
194
+ def test_system_v2_missing_ctrlzone_stays_missing() -> None:
195
+ system = {
196
+ key: value
197
+ for key, value in SAMPLE_SYSTEM_V2["SystemV2"].items()
198
+ if key != "CtrlZone"
199
+ }
200
+ settings = v2_mod.system_v2_to_settings(cast(dict, system), "000025841")
201
+ assert "CtrlZone" not in settings
202
+
203
+
204
+ def test_system_v2_to_settings_drops_secrets() -> None:
205
+ settings = v2_mod.system_v2_to_settings(
206
+ cast(dict, SAMPLE_SYSTEM_V2["SystemV2"]), "000025841"
207
+ )
208
+ assert "LockCode" not in settings
209
+ assert "Pass" not in settings
210
+ assert "123456" not in settings.values()
211
+ assert "secret" not in settings.values()
212
+
213
+
214
+ @pytest.mark.parametrize(
215
+ ("airflow_lock", "airflow_min_lock", "expected"),
216
+ [
217
+ (0, 0, "off"),
218
+ (0, 1, "onMin"),
219
+ (1, 0, "on"),
220
+ (1, 1, "on"),
221
+ ],
222
+ )
223
+ def test_system_v2_airflow_lock_translates_to_v1(
224
+ airflow_lock: int, airflow_min_lock: int, expected: str
225
+ ) -> None:
226
+ system = {
227
+ **SAMPLE_SYSTEM_V2["SystemV2"],
228
+ "AirflowLock": airflow_lock,
229
+ "AirflowMinLock": airflow_min_lock,
230
+ }
231
+ settings = v2_mod.system_v2_to_settings(cast(dict, system), "000025841")
232
+ assert settings["AirflowLock"] == expected
233
+
234
+
235
+ @pytest.mark.parametrize(("lock_status", "expected"), [(0, "false"), (1, "true")])
236
+ def test_system_v2_lock_status_translates_to_v1(
237
+ lock_status: int, expected: str
238
+ ) -> None:
239
+ system = {**SAMPLE_SYSTEM_V2["SystemV2"], "LockStatus": lock_status}
240
+ settings = v2_mod.system_v2_to_settings(cast(dict, system), "000025841")
241
+ assert settings["UnitLocked"] == expected
242
+
243
+
244
+ def test_system_v2_without_lock_keys_omits_v1_lock_keys() -> None:
245
+ system = {
246
+ key: value
247
+ for key, value in SAMPLE_SYSTEM_V2["SystemV2"].items()
248
+ if key not in ("AirflowLock", "AirflowMinLock", "LockStatus")
249
+ }
250
+ settings = v2_mod.system_v2_to_settings(cast(dict, system), "000025841")
251
+ assert "AirflowLock" not in settings
252
+ assert "UnitLocked" not in settings
156
253
 
157
254
 
158
255
  @pytest.mark.asyncio