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.
- {python_izone-1.3.11 → python_izone-1.3.12}/PKG-INFO +1 -1
- python_izone-1.3.12/docs/release-notes/v1.3.12.md +23 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/pizone/controller.py +93 -15
- {python_izone-1.3.11 → python_izone-1.3.12}/pizone/v2.py +44 -15
- {python_izone-1.3.11 → python_izone-1.3.12}/pizone/version.py +2 -2
- {python_izone-1.3.11 → python_izone-1.3.12}/pizone/zone.py +93 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_control_zone.py +23 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_controller.py +76 -1
- {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_discovery_api.py +1 -1
- {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_v2.py +102 -5
- {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_zone.py +78 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/tests/v2_fixtures.py +67 -3
- {python_izone-1.3.11 → python_izone-1.3.12}/uv.lock +245 -119
- {python_izone-1.3.11 → python_izone-1.3.12}/.gitattributes +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/.github/workflows/main.yml +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/.gitignore +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/.pre-commit-config.yaml +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/.python-version +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/.vscode/.ropeproject/config.py +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/.vscode/extensions.json +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/.vscode/launch.json +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/.vscode/settings.json +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/README.md +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/api_docs/AC-DOC-1401-11_iZoneEthernetInterface.pdf +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/api_docs/iPower_JSON_datastrings_Out.h +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/api_docs/iZone_JSON_datastrings.h +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/docs/release-notes/v1.3.10.md +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/docs/release-notes/v1.3.11.md +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/docs/release-notes/v1.3.4.md +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/docs/release-notes/v1.3.5.md +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/docs/release-notes/v1.3.6.md +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/docs/release-notes/v1.3.7.md +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/docs/release-notes/v1.3.8.md +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/docs/release-notes/v1.3.9.md +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/docs/v2-confirmation.md +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/licence.txt +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/pizone/__init__.py +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/pizone/const.py +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/pizone/discovery.py +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/pizone/exceptions.py +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/pizone/power.py +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/pizone/py.typed +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/pizone/types.py +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/pyproject.toml +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/scripts/check +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/scripts/coverage +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/scripts/izone_v1_probe.py +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/tests/__init__.py +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/tests/conftest.py +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/tests/http_fakes.py +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/tests/power_data.py +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/tests/resources.py +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_discovery.py +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_discovery_close_timeout.py +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_dump_state.py +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_fullstack.py +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_http.py +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_power.py +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_v2_confirmation.py +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_v2_fan_capability.py +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_v2_vendor_enums.py +0 -0
- {python_izone-1.3.11 → python_izone-1.3.12}/tests/test_v2_zone_validation.py +0 -0
|
@@ -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
|
|
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
|
-
|
|
782
|
+
ValueError: If the cached value is not numeric.
|
|
719
783
|
|
|
720
784
|
"""
|
|
721
|
-
|
|
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
|
|
728
|
-
``
|
|
729
|
-
indoor unit itself),
|
|
730
|
-
|
|
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
|
|
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 ==
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (1, 3,
|
|
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"] ==
|
|
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"] ==
|
|
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
|
|
146
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|