python-izone 1.3.1__tar.gz → 1.3.2__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.1 → python_izone-1.3.2}/PKG-INFO +1 -1
- {python_izone-1.3.1 → python_izone-1.3.2}/pizone/controller.py +58 -14
- {python_izone-1.3.1 → python_izone-1.3.2}/pizone/power.py +18 -4
- {python_izone-1.3.1 → python_izone-1.3.2}/pizone/version.py +2 -2
- {python_izone-1.3.1 → python_izone-1.3.2}/tests/conftest.py +22 -3
- python_izone-1.3.2/tests/test_controller.py +295 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/tests/test_discovery.py +4 -3
- {python_izone-1.3.1 → python_izone-1.3.2}/tests/test_power.py +2 -1
- python_izone-1.3.1/tests/test_controller.py +0 -116
- {python_izone-1.3.1 → python_izone-1.3.2}/.gitattributes +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/.github/workflows/main.yml +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/.gitignore +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/.pre-commit-config.yaml +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/.pylintrc +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/.python-version +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/.vscode/.ropeproject/config.py +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/.vscode/launch.json +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/.vscode/settings.json +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/README.md +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/api_docs/AC-DOC-1401-11_iZoneEthernetInterface.pdf +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/api_docs/iPower_JSON_datastrings_Out.h +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/api_docs/iZone_JSON_datastrings.h +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/licence.txt +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/pizone/__init__.py +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/pizone/discovery.py +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/pizone/exceptions.py +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/pizone/py.typed +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/pizone/zone.py +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/pyproject.toml +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/scripts/check +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/scripts/coverage +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/tests/__init__.py +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/tests/http_fakes.py +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/tests/power_data.py +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/tests/resources.py +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/tests/test_fullstack.py +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/tests/test_http.py +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/tests/test_zone.py +0 -0
- {python_izone-1.3.1 → python_izone-1.3.2}/uv.lock +0 -0
|
@@ -122,7 +122,7 @@ class Controller:
|
|
|
122
122
|
"""Load system, zone, and optional power data from the device.
|
|
123
123
|
|
|
124
124
|
Raises:
|
|
125
|
-
ConnectionError: If
|
|
125
|
+
ConnectionError: If a required HTTP request fails.
|
|
126
126
|
KeyError: If a required field is missing from a device response.
|
|
127
127
|
"""
|
|
128
128
|
await self._refresh_system(notify=False)
|
|
@@ -131,24 +131,58 @@ class Controller:
|
|
|
131
131
|
str(self._system_settings.get("FanAuto", "disabled"))
|
|
132
132
|
]
|
|
133
133
|
|
|
134
|
+
await self._probe_v2_api()
|
|
135
|
+
|
|
134
136
|
zone_count = int(self._system_settings["NoOfZones"])
|
|
135
137
|
self.zones = [Zone(self, i) for i in range(zone_count)]
|
|
136
138
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
self._refresh_zones(notify=False),
|
|
141
|
-
self._power.init(),
|
|
142
|
-
)
|
|
143
|
-
if self._power.enabled:
|
|
144
|
-
await self._refresh_power(notify=False)
|
|
145
|
-
else:
|
|
146
|
-
self._power = None
|
|
147
|
-
await self._refresh_zones(notify=False)
|
|
139
|
+
await self._refresh_zones(notify=False)
|
|
140
|
+
|
|
141
|
+
await self._probe_power()
|
|
148
142
|
|
|
149
143
|
self._initialized = True
|
|
150
144
|
self._discovery_service.create_task(self._poll_loop())
|
|
151
145
|
|
|
146
|
+
async def _probe_v2_api(self) -> None:
|
|
147
|
+
"""Detect V2 API support; non-fatal on failure."""
|
|
148
|
+
try:
|
|
149
|
+
response = await self._send_command_async(
|
|
150
|
+
"iZoneRequestV2",
|
|
151
|
+
{"iZoneV2Request": {"Type": 1, "No": 0, "No1": 0}},
|
|
152
|
+
mark_disconnected=False,
|
|
153
|
+
)
|
|
154
|
+
data = json.loads(response)
|
|
155
|
+
uid = data["AirStreamDeviceUId"]
|
|
156
|
+
self._is_v2 = uid == self._device_uid and "SystemV2" in data
|
|
157
|
+
except (ConnectionError, ControllerCommandError, json.JSONDecodeError, KeyError):
|
|
158
|
+
self._is_v2 = False
|
|
159
|
+
|
|
160
|
+
async def _probe_power(self) -> None:
|
|
161
|
+
"""Probe power monitor endpoint when discovery hinted iPower; non-fatal."""
|
|
162
|
+
if not self._is_ipower:
|
|
163
|
+
self._power = None
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
power = Power(self)
|
|
168
|
+
await power.init(mark_disconnected=False)
|
|
169
|
+
if power.enabled:
|
|
170
|
+
self._power = power
|
|
171
|
+
return
|
|
172
|
+
_LOG.warning(
|
|
173
|
+
"Power monitor disabled on uid=%s; skipping power support",
|
|
174
|
+
self._device_uid,
|
|
175
|
+
)
|
|
176
|
+
except (ConnectionError, ControllerCommandError, json.JSONDecodeError, KeyError):
|
|
177
|
+
_LOG.warning(
|
|
178
|
+
"Power monitor probe failed for uid=%s",
|
|
179
|
+
self._device_uid,
|
|
180
|
+
exc_info=True,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
self._is_ipower = False
|
|
184
|
+
self._power = None
|
|
185
|
+
|
|
152
186
|
async def _poll_loop(self) -> None:
|
|
153
187
|
while True:
|
|
154
188
|
try:
|
|
@@ -206,6 +240,11 @@ class Controller:
|
|
|
206
240
|
"""Return whether this is a v2 controller."""
|
|
207
241
|
return self._is_v2
|
|
208
242
|
|
|
243
|
+
@property
|
|
244
|
+
def is_ipower(self) -> bool:
|
|
245
|
+
"""Return whether power monitoring is available."""
|
|
246
|
+
return self._is_ipower
|
|
247
|
+
|
|
209
248
|
@property
|
|
210
249
|
def discovery(self) -> DiscoveryService:
|
|
211
250
|
"""Discovery service for this controller."""
|
|
@@ -630,10 +669,14 @@ class Controller:
|
|
|
630
669
|
raise ConnectionError("Unable to connect to the controller") from ex
|
|
631
670
|
|
|
632
671
|
async def _send_command_async(
|
|
633
|
-
self, command: str, data: dict[str, Any]
|
|
672
|
+
self, command: str, data: dict[str, Any], *, mark_disconnected: bool = True
|
|
634
673
|
) -> str:
|
|
635
674
|
"""Send a command to the device via HTTP POST.
|
|
636
675
|
|
|
676
|
+
Args:
|
|
677
|
+
mark_disconnected: When ``False``, transport failures do not mark
|
|
678
|
+
the controller disconnected.
|
|
679
|
+
|
|
637
680
|
Raises:
|
|
638
681
|
ConnectionError: If the device cannot be reached or the HTTP request fails.
|
|
639
682
|
ControllerCommandError: If the device returns HTTP 4xx or an
|
|
@@ -665,7 +708,8 @@ class Controller:
|
|
|
665
708
|
)
|
|
666
709
|
result = await response.text(encoding="latin_1")
|
|
667
710
|
except (asyncio.TimeoutError, aiohttp.ClientError) as ex:
|
|
668
|
-
|
|
711
|
+
if mark_disconnected:
|
|
712
|
+
self._failed_connection(ex)
|
|
669
713
|
raise ConnectionError("Unable to connect to controller") from ex
|
|
670
714
|
|
|
671
715
|
if len(result) >= 7 and result[:6] == "{ERROR":
|
|
@@ -191,15 +191,21 @@ class Power:
|
|
|
191
191
|
self._devices = tuple(PowerDevice(self, i) for i in range(5))
|
|
192
192
|
self._groups: tuple[PowerGroup, ...] | None = None
|
|
193
193
|
|
|
194
|
-
async def init(self) -> None:
|
|
194
|
+
async def init(self, *, mark_disconnected: bool = True) -> None:
|
|
195
195
|
"""Load power monitor configuration from the device.
|
|
196
196
|
|
|
197
|
+
Args:
|
|
198
|
+
mark_disconnected: When ``False``, connection failures during this
|
|
199
|
+
request do not mark the controller disconnected.
|
|
200
|
+
|
|
197
201
|
Raises:
|
|
198
202
|
ConnectionError: If the HTTP request fails.
|
|
199
203
|
json.JSONDecodeError: If the device response is not valid JSON.
|
|
200
204
|
KeyError: If the response is missing required fields.
|
|
201
205
|
"""
|
|
202
|
-
self._config = await self._do_request(
|
|
206
|
+
self._config = await self._do_request(
|
|
207
|
+
1, "PowerMonitorConfig", mark_disconnected=mark_disconnected
|
|
208
|
+
)
|
|
203
209
|
gdict: dict[int, list[PowerChannel]] = {}
|
|
204
210
|
for dev in self.devices:
|
|
205
211
|
for chan in dev.channels:
|
|
@@ -226,9 +232,15 @@ class Power:
|
|
|
226
232
|
self._status = status
|
|
227
233
|
return True
|
|
228
234
|
|
|
229
|
-
async def _do_request(
|
|
235
|
+
async def _do_request(
|
|
236
|
+
self, req_type: int, result: str, *, mark_disconnected: bool = True
|
|
237
|
+
) -> dict[str, Any]:
|
|
230
238
|
"""Send a power monitor request to the device.
|
|
231
239
|
|
|
240
|
+
Args:
|
|
241
|
+
mark_disconnected: When ``False``, connection failures do not mark
|
|
242
|
+
the controller disconnected.
|
|
243
|
+
|
|
232
244
|
Raises:
|
|
233
245
|
ConnectionError: If the HTTP request fails.
|
|
234
246
|
json.JSONDecodeError: If the device response is not valid JSON.
|
|
@@ -236,7 +248,9 @@ class Power:
|
|
|
236
248
|
"""
|
|
237
249
|
# pylint: disable=protected-access
|
|
238
250
|
datas = await self._controller._send_command_async(
|
|
239
|
-
"PowerRequest",
|
|
251
|
+
"PowerRequest",
|
|
252
|
+
{"PowerRequest": {"Type": req_type, "No": 0, "No1": 0}},
|
|
253
|
+
mark_disconnected=mark_disconnected,
|
|
240
254
|
)
|
|
241
255
|
data = json.loads(datas)
|
|
242
256
|
return cast(dict[str, Any], data[result])
|
|
@@ -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.2'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 3, 2)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -51,6 +51,9 @@ class MockController(Controller):
|
|
|
51
51
|
self.resources = _system_data(device_uid)
|
|
52
52
|
self.sent: list[tuple[str, Any]] = []
|
|
53
53
|
self._connected = True
|
|
54
|
+
self.v2_probe_response: str | None = None
|
|
55
|
+
self.power_config: dict[str, Any] | None = None
|
|
56
|
+
self.fail_power_types: set[int] = set()
|
|
54
57
|
|
|
55
58
|
def _check_discovery_connected(self) -> None:
|
|
56
59
|
service = cast(MockDiscoveryService, self.discovery)
|
|
@@ -69,7 +72,7 @@ class MockController(Controller):
|
|
|
69
72
|
raise ConnectionError(f"Mock resource '{resource}' not available")
|
|
70
73
|
|
|
71
74
|
async def _send_command_async(
|
|
72
|
-
self, command: str, data: dict[str, Any]
|
|
75
|
+
self, command: str, data: dict[str, Any], *, mark_disconnected: bool = True
|
|
73
76
|
) -> str:
|
|
74
77
|
"""Mock out the network IO for _send_command."""
|
|
75
78
|
if self._fail_exception:
|
|
@@ -78,13 +81,29 @@ class MockController(Controller):
|
|
|
78
81
|
) from self._fail_exception
|
|
79
82
|
self._check_discovery_connected()
|
|
80
83
|
self.sent.append((command, data))
|
|
81
|
-
|
|
84
|
+
if command == "iZoneRequestV2":
|
|
85
|
+
if self.v2_probe_response is not None:
|
|
86
|
+
self._restored_connection()
|
|
87
|
+
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
92
|
if command == "PowerRequest":
|
|
83
93
|
req_type: int = data["PowerRequest"]["Type"]
|
|
94
|
+
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
|
|
84
99
|
if req_type == 1:
|
|
85
|
-
|
|
100
|
+
config = self.power_config if self.power_config is not None else POWER_CONFIG
|
|
101
|
+
self._restored_connection()
|
|
102
|
+
return json.dumps({"PowerMonitorConfig": config})
|
|
86
103
|
if req_type == 2:
|
|
104
|
+
self._restored_connection()
|
|
87
105
|
return json.dumps({"PowerMonitorStatus": POWER_STATUS})
|
|
106
|
+
self._restored_connection()
|
|
88
107
|
return ""
|
|
89
108
|
|
|
90
109
|
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Tests for controller property reads and command paths."""
|
|
2
|
+
|
|
3
|
+
# pylint: disable=protected-access
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from pytest import raises
|
|
9
|
+
|
|
10
|
+
from pizone import Controller
|
|
11
|
+
|
|
12
|
+
from .conftest import MockController, MockDiscoveryService, _register_mock_service
|
|
13
|
+
from .power_data import POWER_CONFIG
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.mark.asyncio
|
|
17
|
+
async def test_controller_property_reads(service: MockDiscoveryService) -> None:
|
|
18
|
+
controller = cast(MockController, service._controllers["000000001"])
|
|
19
|
+
|
|
20
|
+
assert controller.connected is True
|
|
21
|
+
assert controller.power is None
|
|
22
|
+
assert controller.device_ip == "8.8.8.8"
|
|
23
|
+
assert controller.device_uid == "000000001"
|
|
24
|
+
assert controller.is_v2 is False
|
|
25
|
+
assert controller.is_on is True
|
|
26
|
+
assert controller.mode == Controller.Mode.HEAT
|
|
27
|
+
assert controller.fan == Controller.Fan.AUTO
|
|
28
|
+
assert controller.sleep_timer == 0
|
|
29
|
+
assert controller.free_air_enabled is True
|
|
30
|
+
assert controller.free_air is False
|
|
31
|
+
assert controller.temp_supply == pytest.approx(25.1)
|
|
32
|
+
assert controller.temp_setpoint == pytest.approx(23.5)
|
|
33
|
+
assert controller.temp_return == pytest.approx(23.6)
|
|
34
|
+
assert controller.eco_lock is True
|
|
35
|
+
assert controller.temp_min == pytest.approx(15.0)
|
|
36
|
+
assert controller.temp_max == pytest.approx(30.0)
|
|
37
|
+
assert controller.ras_mode == "zones"
|
|
38
|
+
assert controller.zone_ctrl == 1
|
|
39
|
+
assert controller.zones_total == 8
|
|
40
|
+
assert controller.zones_const == 1
|
|
41
|
+
assert controller.sys_type == "320"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.asyncio
|
|
45
|
+
async def test_set_on(service: MockDiscoveryService) -> None:
|
|
46
|
+
controller = cast(MockController, service._controllers["000000001"])
|
|
47
|
+
|
|
48
|
+
await controller.set_on(False)
|
|
49
|
+
assert controller.is_on is False
|
|
50
|
+
assert controller.sent[-1] == ("SystemON", {"SystemON": "off"})
|
|
51
|
+
|
|
52
|
+
await controller.set_on(True)
|
|
53
|
+
assert controller.is_on is True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.mark.asyncio
|
|
57
|
+
async def test_set_fan(service: MockDiscoveryService) -> None:
|
|
58
|
+
controller = cast(MockController, service._controllers["000000001"])
|
|
59
|
+
fan = Controller.Fan.LOW
|
|
60
|
+
|
|
61
|
+
await controller.set_fan(fan)
|
|
62
|
+
assert controller.fan == fan
|
|
63
|
+
assert controller.sent[-1] == ("SystemFAN", {"SystemFAN": "low"})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.asyncio
|
|
67
|
+
async def test_set_sleep_timer(service: MockDiscoveryService) -> None:
|
|
68
|
+
controller = cast(MockController, service._controllers["000000001"])
|
|
69
|
+
|
|
70
|
+
await controller.set_sleep_timer(30)
|
|
71
|
+
assert controller.sleep_timer == 30
|
|
72
|
+
|
|
73
|
+
await controller.set_sleep_timer(0)
|
|
74
|
+
assert controller.sleep_timer == 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@pytest.mark.asyncio
|
|
78
|
+
async def test_set_temp_setpoint(service: MockDiscoveryService) -> None:
|
|
79
|
+
controller = cast(MockController, service._controllers["000000001"])
|
|
80
|
+
|
|
81
|
+
await controller.set_temp_setpoint(22.5)
|
|
82
|
+
assert controller.temp_setpoint == pytest.approx(22.5)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.asyncio
|
|
86
|
+
async def test_set_fan_validation(service: MockDiscoveryService) -> None:
|
|
87
|
+
controller = cast(MockController, service._controllers["000000001"])
|
|
88
|
+
|
|
89
|
+
with raises(AttributeError, match="Fan mode top not allowed"):
|
|
90
|
+
await controller.set_fan(Controller.Fan.TOP)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@pytest.mark.asyncio
|
|
94
|
+
async def test_set_sleep_timer_validation(service: MockDiscoveryService) -> None:
|
|
95
|
+
controller = cast(MockController, service._controllers["000000001"])
|
|
96
|
+
|
|
97
|
+
with raises(AttributeError, match="Invalid Sleep Timer"):
|
|
98
|
+
await controller.set_sleep_timer(45)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@pytest.mark.asyncio
|
|
102
|
+
async def test_set_temp_setpoint_validation(service: MockDiscoveryService) -> None:
|
|
103
|
+
controller = cast(MockController, service._controllers["000000001"])
|
|
104
|
+
|
|
105
|
+
with raises(AttributeError, match="not rounded to nearest 0.5"):
|
|
106
|
+
await controller.set_temp_setpoint(23.3)
|
|
107
|
+
with raises(AttributeError, match="out of range"):
|
|
108
|
+
await controller.set_temp_setpoint(35.0)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@pytest.mark.asyncio
|
|
112
|
+
async def test_refresh_all(service: MockDiscoveryService) -> None:
|
|
113
|
+
controller = cast(MockController, service._controllers["000000001"])
|
|
114
|
+
|
|
115
|
+
await controller._refresh_all(notify=False)
|
|
116
|
+
|
|
117
|
+
assert controller.mode == Controller.Mode.HEAT
|
|
118
|
+
assert controller.zones[0].name == "LIVING"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@pytest.mark.asyncio
|
|
122
|
+
async def test_initialize_succeeds_when_power_init_fails() -> None:
|
|
123
|
+
svc = MockDiscoveryService()
|
|
124
|
+
original_create = svc._create_controller
|
|
125
|
+
|
|
126
|
+
def create_controller(
|
|
127
|
+
device_uid: str, device_ip: str, is_v2: bool, is_ipower: bool
|
|
128
|
+
) -> MockController:
|
|
129
|
+
controller = original_create(device_uid, device_ip, is_v2, is_ipower)
|
|
130
|
+
controller.fail_power_types.add(1)
|
|
131
|
+
return controller
|
|
132
|
+
|
|
133
|
+
svc._create_controller = create_controller
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
await _register_mock_service(
|
|
137
|
+
svc,
|
|
138
|
+
b"ASPort_12107,Mac_000000003,IP_10.0.0.1,iZone,iPower",
|
|
139
|
+
)
|
|
140
|
+
controller = cast(MockController, svc._controllers["000000003"])
|
|
141
|
+
|
|
142
|
+
assert controller.connected is True
|
|
143
|
+
assert controller.power is None
|
|
144
|
+
assert controller.is_ipower is False
|
|
145
|
+
assert controller._initialized is True
|
|
146
|
+
finally:
|
|
147
|
+
await svc.close()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@pytest.mark.asyncio
|
|
151
|
+
async def test_initialize_sets_power_when_ipower_works(
|
|
152
|
+
ipower_service: MockDiscoveryService,
|
|
153
|
+
) -> None:
|
|
154
|
+
controller = cast(MockController, ipower_service._controllers["000000003"])
|
|
155
|
+
|
|
156
|
+
assert controller.power is not None
|
|
157
|
+
assert controller.power.enabled is True
|
|
158
|
+
assert controller.is_ipower is True
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@pytest.mark.asyncio
|
|
162
|
+
async def test_initialize_does_not_fetch_power_status(
|
|
163
|
+
ipower_service: MockDiscoveryService,
|
|
164
|
+
) -> None:
|
|
165
|
+
controller = cast(MockController, ipower_service._controllers["000000003"])
|
|
166
|
+
power_requests = [
|
|
167
|
+
data
|
|
168
|
+
for command, data in controller.sent
|
|
169
|
+
if command == "PowerRequest"
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
assert len(power_requests) == 1
|
|
173
|
+
assert power_requests[0]["PowerRequest"]["Type"] == 1
|
|
174
|
+
|
|
175
|
+
await controller._refresh_power(notify=False)
|
|
176
|
+
|
|
177
|
+
status_requests = [
|
|
178
|
+
data
|
|
179
|
+
for command, data in controller.sent
|
|
180
|
+
if command == "PowerRequest" and data["PowerRequest"]["Type"] == 2
|
|
181
|
+
]
|
|
182
|
+
assert len(status_requests) == 1
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@pytest.mark.asyncio
|
|
186
|
+
async def test_initialize_clears_ipower_when_config_disabled() -> None:
|
|
187
|
+
disabled_config = deepcopy(POWER_CONFIG)
|
|
188
|
+
disabled_config["Enabled"] = 0
|
|
189
|
+
|
|
190
|
+
svc = MockDiscoveryService()
|
|
191
|
+
original_create = svc._create_controller
|
|
192
|
+
|
|
193
|
+
def create_controller(
|
|
194
|
+
device_uid: str, device_ip: str, is_v2: bool, is_ipower: bool
|
|
195
|
+
) -> MockController:
|
|
196
|
+
controller = original_create(device_uid, device_ip, is_v2, is_ipower)
|
|
197
|
+
controller.power_config = disabled_config
|
|
198
|
+
return controller
|
|
199
|
+
|
|
200
|
+
svc._create_controller = create_controller
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
await _register_mock_service(
|
|
204
|
+
svc,
|
|
205
|
+
b"ASPort_12107,Mac_000000003,IP_10.0.0.1,iZone,iPower",
|
|
206
|
+
)
|
|
207
|
+
controller = cast(MockController, svc._controllers["000000003"])
|
|
208
|
+
|
|
209
|
+
assert controller.power is None
|
|
210
|
+
assert controller.is_ipower is False
|
|
211
|
+
finally:
|
|
212
|
+
await svc.close()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@pytest.mark.asyncio
|
|
216
|
+
async def test_power_init_probe_timeout_does_not_mark_disconnected() -> None:
|
|
217
|
+
svc = MockDiscoveryService()
|
|
218
|
+
original_create = svc._create_controller
|
|
219
|
+
|
|
220
|
+
def create_controller(
|
|
221
|
+
device_uid: str, device_ip: str, is_v2: bool, is_ipower: bool
|
|
222
|
+
) -> MockController:
|
|
223
|
+
controller = original_create(device_uid, device_ip, is_v2, is_ipower)
|
|
224
|
+
controller.fail_power_types.add(1)
|
|
225
|
+
return controller
|
|
226
|
+
|
|
227
|
+
svc._create_controller = create_controller
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
await _register_mock_service(
|
|
231
|
+
svc,
|
|
232
|
+
b"ASPort_12107,Mac_000000003,IP_10.0.0.1,iZone,iPower",
|
|
233
|
+
)
|
|
234
|
+
controller = cast(MockController, svc._controllers["000000003"])
|
|
235
|
+
|
|
236
|
+
assert controller.connected is True
|
|
237
|
+
assert controller._fail_exception is None
|
|
238
|
+
finally:
|
|
239
|
+
await svc.close()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@pytest.mark.asyncio
|
|
243
|
+
async def test_power_poll_timeout_marks_disconnected(
|
|
244
|
+
ipower_service: MockDiscoveryService,
|
|
245
|
+
) -> None:
|
|
246
|
+
controller = cast(MockController, ipower_service._controllers["000000003"])
|
|
247
|
+
controller.fail_power_types.add(2)
|
|
248
|
+
|
|
249
|
+
with raises(ConnectionError):
|
|
250
|
+
await controller._refresh_power(notify=False)
|
|
251
|
+
|
|
252
|
+
assert controller.connected is False
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@pytest.mark.asyncio
|
|
256
|
+
async def test_v2_probe_sets_is_v2_when_systemv2_returned() -> None:
|
|
257
|
+
svc = MockDiscoveryService()
|
|
258
|
+
original_create = svc._create_controller
|
|
259
|
+
|
|
260
|
+
def create_controller(
|
|
261
|
+
device_uid: str, device_ip: str, is_v2: bool, is_ipower: bool
|
|
262
|
+
) -> MockController:
|
|
263
|
+
controller = original_create(device_uid, device_ip, is_v2, is_ipower)
|
|
264
|
+
controller.v2_probe_response = (
|
|
265
|
+
'{"AirStreamDeviceUId":"000000001","SystemV2":{"SysOn":"on"}}'
|
|
266
|
+
)
|
|
267
|
+
return controller
|
|
268
|
+
|
|
269
|
+
svc._create_controller = create_controller
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
await _register_mock_service(
|
|
273
|
+
svc,
|
|
274
|
+
b"ASPort_12107,Mac_000000001,IP_8.8.8.8,iZone,iLight,iDrate",
|
|
275
|
+
)
|
|
276
|
+
controller = cast(MockController, svc._controllers["000000001"])
|
|
277
|
+
|
|
278
|
+
assert controller.is_v2 is True
|
|
279
|
+
finally:
|
|
280
|
+
await svc.close()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@pytest.mark.asyncio
|
|
284
|
+
async def test_v2_probe_clears_is_v2_on_http_error() -> None:
|
|
285
|
+
svc = MockDiscoveryService()
|
|
286
|
+
try:
|
|
287
|
+
await _register_mock_service(
|
|
288
|
+
svc,
|
|
289
|
+
b"ASPort_12107,Mac_000000001,IP_8.8.8.8,iZoneV2,iLight,iDrate",
|
|
290
|
+
)
|
|
291
|
+
controller = cast(MockController, svc._controllers["000000001"])
|
|
292
|
+
|
|
293
|
+
assert controller.is_v2 is False
|
|
294
|
+
finally:
|
|
295
|
+
await svc.close()
|
|
@@ -109,7 +109,7 @@ async def test_discovery(service: MockDiscoveryService) -> None:
|
|
|
109
109
|
assert controller.mode == Controller.Mode.HEAT
|
|
110
110
|
|
|
111
111
|
await controller.set_mode(Controller.Mode.COOL)
|
|
112
|
-
assert controller.sent[
|
|
112
|
+
assert controller.sent[-1] == ("SystemMODE", {"SystemMODE": "cool"})
|
|
113
113
|
assert controller.mode == Controller.Mode.COOL
|
|
114
114
|
|
|
115
115
|
|
|
@@ -126,7 +126,7 @@ async def test_legacy_discovery(legacy_service: MockDiscoveryService) -> None:
|
|
|
126
126
|
assert controller.mode == Controller.Mode.HEAT
|
|
127
127
|
|
|
128
128
|
await controller.set_mode(Controller.Mode.COOL)
|
|
129
|
-
assert controller.sent[
|
|
129
|
+
assert controller.sent[-1] == ("SystemMODE", {"SystemMODE": "cool"})
|
|
130
130
|
assert controller.mode == Controller.Mode.COOL
|
|
131
131
|
|
|
132
132
|
|
|
@@ -236,6 +236,7 @@ async def test_reconnect(
|
|
|
236
236
|
assert controller.mode == Controller.Mode.HEAT
|
|
237
237
|
|
|
238
238
|
controller._failed_connection(ConnectionError("Fake connection error"))
|
|
239
|
+
controller.sent.clear()
|
|
239
240
|
with raises(ConnectionError):
|
|
240
241
|
await controller.set_mode(Controller.Mode.COOL)
|
|
241
242
|
|
|
@@ -251,7 +252,7 @@ async def test_reconnect(
|
|
|
251
252
|
# Reconnect OK
|
|
252
253
|
assert caplog.messages[1][:23] == "Controller reconnected:"
|
|
253
254
|
await controller.set_mode(Controller.Mode.COOL)
|
|
254
|
-
assert controller.sent[
|
|
255
|
+
assert controller.sent[-1] == ("SystemMODE", {"SystemMODE": "cool"})
|
|
255
256
|
|
|
256
257
|
|
|
257
258
|
@pytest.mark.asyncio
|
|
@@ -19,8 +19,9 @@ class MockPowerController:
|
|
|
19
19
|
self.sent: list[tuple[str, dict[str, Any]]] = []
|
|
20
20
|
|
|
21
21
|
async def _send_command_async(
|
|
22
|
-
self, command: str, data: dict[str, Any]
|
|
22
|
+
self, command: str, data: dict[str, Any], *, mark_disconnected: bool = True
|
|
23
23
|
) -> str:
|
|
24
|
+
del mark_disconnected
|
|
24
25
|
self.sent.append((command, data))
|
|
25
26
|
req_type: int = data["PowerRequest"]["Type"]
|
|
26
27
|
return json.dumps(self._responses[req_type])
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
"""Tests for controller property reads and command paths."""
|
|
2
|
-
|
|
3
|
-
# pylint: disable=protected-access
|
|
4
|
-
from typing import cast
|
|
5
|
-
|
|
6
|
-
import pytest
|
|
7
|
-
from pytest import raises
|
|
8
|
-
|
|
9
|
-
from pizone import Controller
|
|
10
|
-
|
|
11
|
-
from .conftest import MockController, MockDiscoveryService
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@pytest.mark.asyncio
|
|
15
|
-
async def test_controller_property_reads(service: MockDiscoveryService) -> None:
|
|
16
|
-
controller = cast(MockController, service._controllers["000000001"])
|
|
17
|
-
|
|
18
|
-
assert controller.connected is True
|
|
19
|
-
assert controller.power is None
|
|
20
|
-
assert controller.device_ip == "8.8.8.8"
|
|
21
|
-
assert controller.device_uid == "000000001"
|
|
22
|
-
assert controller.is_v2 is False
|
|
23
|
-
assert controller.is_on is True
|
|
24
|
-
assert controller.mode == Controller.Mode.HEAT
|
|
25
|
-
assert controller.fan == Controller.Fan.AUTO
|
|
26
|
-
assert controller.sleep_timer == 0
|
|
27
|
-
assert controller.free_air_enabled is True
|
|
28
|
-
assert controller.free_air is False
|
|
29
|
-
assert controller.temp_supply == pytest.approx(25.1)
|
|
30
|
-
assert controller.temp_setpoint == pytest.approx(23.5)
|
|
31
|
-
assert controller.temp_return == pytest.approx(23.6)
|
|
32
|
-
assert controller.eco_lock is True
|
|
33
|
-
assert controller.temp_min == pytest.approx(15.0)
|
|
34
|
-
assert controller.temp_max == pytest.approx(30.0)
|
|
35
|
-
assert controller.ras_mode == "zones"
|
|
36
|
-
assert controller.zone_ctrl == 1
|
|
37
|
-
assert controller.zones_total == 8
|
|
38
|
-
assert controller.zones_const == 1
|
|
39
|
-
assert controller.sys_type == "320"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
@pytest.mark.asyncio
|
|
43
|
-
async def test_set_on(service: MockDiscoveryService) -> None:
|
|
44
|
-
controller = cast(MockController, service._controllers["000000001"])
|
|
45
|
-
|
|
46
|
-
await controller.set_on(False)
|
|
47
|
-
assert controller.is_on is False
|
|
48
|
-
assert controller.sent[-1] == ("SystemON", {"SystemON": "off"})
|
|
49
|
-
|
|
50
|
-
await controller.set_on(True)
|
|
51
|
-
assert controller.is_on is True
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
@pytest.mark.asyncio
|
|
55
|
-
async def test_set_fan(service: MockDiscoveryService) -> None:
|
|
56
|
-
controller = cast(MockController, service._controllers["000000001"])
|
|
57
|
-
fan = Controller.Fan.LOW
|
|
58
|
-
|
|
59
|
-
await controller.set_fan(fan)
|
|
60
|
-
assert controller.fan == fan
|
|
61
|
-
assert controller.sent[-1] == ("SystemFAN", {"SystemFAN": "low"})
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
@pytest.mark.asyncio
|
|
65
|
-
async def test_set_sleep_timer(service: MockDiscoveryService) -> None:
|
|
66
|
-
controller = cast(MockController, service._controllers["000000001"])
|
|
67
|
-
|
|
68
|
-
await controller.set_sleep_timer(30)
|
|
69
|
-
assert controller.sleep_timer == 30
|
|
70
|
-
|
|
71
|
-
await controller.set_sleep_timer(0)
|
|
72
|
-
assert controller.sleep_timer == 0
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
@pytest.mark.asyncio
|
|
76
|
-
async def test_set_temp_setpoint(service: MockDiscoveryService) -> None:
|
|
77
|
-
controller = cast(MockController, service._controllers["000000001"])
|
|
78
|
-
|
|
79
|
-
await controller.set_temp_setpoint(22.5)
|
|
80
|
-
assert controller.temp_setpoint == pytest.approx(22.5)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
@pytest.mark.asyncio
|
|
84
|
-
async def test_set_fan_validation(service: MockDiscoveryService) -> None:
|
|
85
|
-
controller = cast(MockController, service._controllers["000000001"])
|
|
86
|
-
|
|
87
|
-
with raises(AttributeError, match="Fan mode top not allowed"):
|
|
88
|
-
await controller.set_fan(Controller.Fan.TOP)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
@pytest.mark.asyncio
|
|
92
|
-
async def test_set_sleep_timer_validation(service: MockDiscoveryService) -> None:
|
|
93
|
-
controller = cast(MockController, service._controllers["000000001"])
|
|
94
|
-
|
|
95
|
-
with raises(AttributeError, match="Invalid Sleep Timer"):
|
|
96
|
-
await controller.set_sleep_timer(45)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
@pytest.mark.asyncio
|
|
100
|
-
async def test_set_temp_setpoint_validation(service: MockDiscoveryService) -> None:
|
|
101
|
-
controller = cast(MockController, service._controllers["000000001"])
|
|
102
|
-
|
|
103
|
-
with raises(AttributeError, match="not rounded to nearest 0.5"):
|
|
104
|
-
await controller.set_temp_setpoint(23.3)
|
|
105
|
-
with raises(AttributeError, match="out of range"):
|
|
106
|
-
await controller.set_temp_setpoint(35.0)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
@pytest.mark.asyncio
|
|
110
|
-
async def test_refresh_all(service: MockDiscoveryService) -> None:
|
|
111
|
-
controller = cast(MockController, service._controllers["000000001"])
|
|
112
|
-
|
|
113
|
-
await controller._refresh_all(notify=False)
|
|
114
|
-
|
|
115
|
-
assert controller.mode == Controller.Mode.HEAT
|
|
116
|
-
assert controller.zones[0].name == "LIVING"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_izone-1.3.1 → python_izone-1.3.2}/api_docs/AC-DOC-1401-11_iZoneEthernetInterface.pdf
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|