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.
Files changed (39) hide show
  1. {python_izone-1.3.1 → python_izone-1.3.2}/PKG-INFO +1 -1
  2. {python_izone-1.3.1 → python_izone-1.3.2}/pizone/controller.py +58 -14
  3. {python_izone-1.3.1 → python_izone-1.3.2}/pizone/power.py +18 -4
  4. {python_izone-1.3.1 → python_izone-1.3.2}/pizone/version.py +2 -2
  5. {python_izone-1.3.1 → python_izone-1.3.2}/tests/conftest.py +22 -3
  6. python_izone-1.3.2/tests/test_controller.py +295 -0
  7. {python_izone-1.3.1 → python_izone-1.3.2}/tests/test_discovery.py +4 -3
  8. {python_izone-1.3.1 → python_izone-1.3.2}/tests/test_power.py +2 -1
  9. python_izone-1.3.1/tests/test_controller.py +0 -116
  10. {python_izone-1.3.1 → python_izone-1.3.2}/.gitattributes +0 -0
  11. {python_izone-1.3.1 → python_izone-1.3.2}/.github/workflows/main.yml +0 -0
  12. {python_izone-1.3.1 → python_izone-1.3.2}/.gitignore +0 -0
  13. {python_izone-1.3.1 → python_izone-1.3.2}/.pre-commit-config.yaml +0 -0
  14. {python_izone-1.3.1 → python_izone-1.3.2}/.pylintrc +0 -0
  15. {python_izone-1.3.1 → python_izone-1.3.2}/.python-version +0 -0
  16. {python_izone-1.3.1 → python_izone-1.3.2}/.vscode/.ropeproject/config.py +0 -0
  17. {python_izone-1.3.1 → python_izone-1.3.2}/.vscode/launch.json +0 -0
  18. {python_izone-1.3.1 → python_izone-1.3.2}/.vscode/settings.json +0 -0
  19. {python_izone-1.3.1 → python_izone-1.3.2}/README.md +0 -0
  20. {python_izone-1.3.1 → python_izone-1.3.2}/api_docs/AC-DOC-1401-11_iZoneEthernetInterface.pdf +0 -0
  21. {python_izone-1.3.1 → python_izone-1.3.2}/api_docs/iPower_JSON_datastrings_Out.h +0 -0
  22. {python_izone-1.3.1 → python_izone-1.3.2}/api_docs/iZone_JSON_datastrings.h +0 -0
  23. {python_izone-1.3.1 → python_izone-1.3.2}/licence.txt +0 -0
  24. {python_izone-1.3.1 → python_izone-1.3.2}/pizone/__init__.py +0 -0
  25. {python_izone-1.3.1 → python_izone-1.3.2}/pizone/discovery.py +0 -0
  26. {python_izone-1.3.1 → python_izone-1.3.2}/pizone/exceptions.py +0 -0
  27. {python_izone-1.3.1 → python_izone-1.3.2}/pizone/py.typed +0 -0
  28. {python_izone-1.3.1 → python_izone-1.3.2}/pizone/zone.py +0 -0
  29. {python_izone-1.3.1 → python_izone-1.3.2}/pyproject.toml +0 -0
  30. {python_izone-1.3.1 → python_izone-1.3.2}/scripts/check +0 -0
  31. {python_izone-1.3.1 → python_izone-1.3.2}/scripts/coverage +0 -0
  32. {python_izone-1.3.1 → python_izone-1.3.2}/tests/__init__.py +0 -0
  33. {python_izone-1.3.1 → python_izone-1.3.2}/tests/http_fakes.py +0 -0
  34. {python_izone-1.3.1 → python_izone-1.3.2}/tests/power_data.py +0 -0
  35. {python_izone-1.3.1 → python_izone-1.3.2}/tests/resources.py +0 -0
  36. {python_izone-1.3.1 → python_izone-1.3.2}/tests/test_fullstack.py +0 -0
  37. {python_izone-1.3.1 → python_izone-1.3.2}/tests/test_http.py +0 -0
  38. {python_izone-1.3.1 → python_izone-1.3.2}/tests/test_zone.py +0 -0
  39. {python_izone-1.3.1 → python_izone-1.3.2}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-izone
3
- Version: 1.3.1
3
+ Version: 1.3.2
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>
@@ -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 any initial HTTP request fails.
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
- if self._is_ipower:
138
- self._power = Power(self)
139
- await asyncio.gather(
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
- self._failed_connection(ex)
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(1, "PowerMonitorConfig")
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(self, req_type: int, result: str) -> dict[str, Any]:
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", {"PowerRequest": {"Type": req_type, "No": 0, "No1": 0}}
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.1'
22
- __version_tuple__ = version_tuple = (1, 3, 1)
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
- self._restored_connection()
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
- return json.dumps({"PowerMonitorConfig": POWER_CONFIG})
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[0] == ("SystemMODE", {"SystemMODE": "cool"})
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[0] == ("SystemMODE", {"SystemMODE": "cool"})
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[0] == ("SystemMODE", {"SystemMODE": "cool"})
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