python-duco-connectivity 0.7.1__tar.gz → 0.9.0__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 (26) hide show
  1. {python_duco_connectivity-0.7.1/src/python_duco_connectivity.egg-info → python_duco_connectivity-0.9.0}/PKG-INFO +8 -1
  2. {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/README.md +7 -0
  3. {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/pyproject.toml +1 -1
  4. {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/duco_connectivity/__init__.py +4 -0
  5. {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/duco_connectivity/client.py +181 -12
  6. {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/duco_connectivity/models.py +23 -0
  7. {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0/src/python_duco_connectivity.egg-info}/PKG-INFO +8 -1
  8. {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/tests/test_client.py +235 -0
  9. {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/tests/test_models.py +29 -0
  10. {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/LICENSE +0 -0
  11. {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/setup.cfg +0 -0
  12. {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/duco_connectivity/__main__.py +0 -0
  13. {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/duco_connectivity/cli.py +0 -0
  14. {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/duco_connectivity/exceptions.py +0 -0
  15. {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/duco_connectivity/py.typed +0 -0
  16. {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/python_duco_connectivity.egg-info/SOURCES.txt +0 -0
  17. {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/python_duco_connectivity.egg-info/dependency_links.txt +0 -0
  18. {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/python_duco_connectivity.egg-info/entry_points.txt +0 -0
  19. {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/python_duco_connectivity.egg-info/requires.txt +0 -0
  20. {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/python_duco_connectivity.egg-info/top_level.txt +0 -0
  21. {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/tests/test_api_reference.py +0 -0
  22. {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/tests/test_cli.py +0 -0
  23. {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/tests/test_exceptions.py +0 -0
  24. {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/tests/test_local_sample_validation.py +0 -0
  25. {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/tests/test_pytest_live_support.py +0 -0
  26. {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/tests/test_replay_helpers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-duco-connectivity
3
- Version: 0.7.1
3
+ Version: 0.9.0
4
4
  Summary: Async HTTP client for the local Duco Connectivity API
5
5
  Author: Ronald van der Meer
6
6
  License-Expression: MIT
@@ -64,6 +64,8 @@ in the development examples below.
64
64
  - asynchronous communication via `aiohttp`
65
65
  - typed stable config families for the documented `/config` branches
66
66
  - typed helpers for stable `/info` fields such as heat recovery filter time
67
+ - optional temperature helpers that return `None` when the box reports their
68
+ endpoint as unavailable
67
69
  - typed models that stay close to the API response shape
68
70
  - preserved `raw_payload` data on typed response models for forward compatibility
69
71
 
@@ -170,6 +172,8 @@ For local function probes without activating the environment first:
170
172
  ```bash
171
173
  .venv/bin/python -m duco_connectivity --host 192.168.1.10 call async_get_board_info
172
174
  .venv/bin/duco-probe --host 192.168.1.10 call async_get_board_info
175
+ .venv/bin/duco-probe --host 192.168.1.10 call async_get_ventilation_temperature_info
176
+ .venv/bin/duco-probe --host 192.168.1.10 call async_get_bypass_supply_temperature_target --kwargs '{"zone_id": 1}'
173
177
  ```
174
178
 
175
179
  For local real-device validation against your own Duco box, use the opt-in
@@ -193,9 +197,12 @@ development pass, covering:
193
197
  - `PATCH /config` with a no-op `TimeZone` write against the current value
194
198
  - `GET /info?module=General&submodule=Board`
195
199
  - `GET /info?module=General&submodule=Lan`
200
+ - `GET /info?module=Ventilation`
196
201
  - `GET /info?module=HeatRecovery`
197
202
  - `GET /info/nodes`
198
203
  - `GET /info?module=General&submodule=PublicApi`
204
+ - `GET /config?module=HeatRecovery&submodule=Bypass&parameter=TempSupTgtZone1`
205
+ - `PATCH /config?module=HeatRecovery&submodule=Bypass&parameter=TempSupTgtZone1` with a no-op write against the current value
199
206
  - `POST /action/nodes/{node}` with a no-op `SetVentilationState`
200
207
 
201
208
  The repository now also includes opt-in local live tests so the same read and
@@ -32,6 +32,8 @@ in the development examples below.
32
32
  - asynchronous communication via `aiohttp`
33
33
  - typed stable config families for the documented `/config` branches
34
34
  - typed helpers for stable `/info` fields such as heat recovery filter time
35
+ - optional temperature helpers that return `None` when the box reports their
36
+ endpoint as unavailable
35
37
  - typed models that stay close to the API response shape
36
38
  - preserved `raw_payload` data on typed response models for forward compatibility
37
39
 
@@ -138,6 +140,8 @@ For local function probes without activating the environment first:
138
140
  ```bash
139
141
  .venv/bin/python -m duco_connectivity --host 192.168.1.10 call async_get_board_info
140
142
  .venv/bin/duco-probe --host 192.168.1.10 call async_get_board_info
143
+ .venv/bin/duco-probe --host 192.168.1.10 call async_get_ventilation_temperature_info
144
+ .venv/bin/duco-probe --host 192.168.1.10 call async_get_bypass_supply_temperature_target --kwargs '{"zone_id": 1}'
141
145
  ```
142
146
 
143
147
  For local real-device validation against your own Duco box, use the opt-in
@@ -161,9 +165,12 @@ development pass, covering:
161
165
  - `PATCH /config` with a no-op `TimeZone` write against the current value
162
166
  - `GET /info?module=General&submodule=Board`
163
167
  - `GET /info?module=General&submodule=Lan`
168
+ - `GET /info?module=Ventilation`
164
169
  - `GET /info?module=HeatRecovery`
165
170
  - `GET /info/nodes`
166
171
  - `GET /info?module=General&submodule=PublicApi`
172
+ - `GET /config?module=HeatRecovery&submodule=Bypass&parameter=TempSupTgtZone1`
173
+ - `PATCH /config?module=HeatRecovery&submodule=Bypass&parameter=TempSupTgtZone1` with a no-op write against the current value
167
174
  - `POST /action/nodes/{node}` with a no-op `SetVentilationState`
168
175
 
169
176
  The repository now also includes opt-in local live tests so the same read and
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-duco-connectivity"
7
- version = "0.7.1"
7
+ version = "0.9.0"
8
8
  description = "Async HTTP client for the local Duco Connectivity API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -25,6 +25,7 @@ from .models import (
25
25
  ApiInfo,
26
26
  BoardInfo,
27
27
  BoardName,
28
+ BypassSupplyTemperatureTarget,
28
29
  Config,
29
30
  ConfigAutoRebootComm,
30
31
  ConfigGeneral,
@@ -111,6 +112,7 @@ from .models import (
111
112
  VentilationFlowLevelTarget,
112
113
  VentilationMode,
113
114
  VentilationState,
115
+ VentilationTemperatureInfo,
114
116
  VentilationTimeEnd,
115
117
  VentilationTimeRemaining,
116
118
  ZoneModuleSelector,
@@ -136,6 +138,7 @@ __all__ = [
136
138
  "ApiInfo",
137
139
  "BoardInfo",
138
140
  "BoardName",
141
+ "BypassSupplyTemperatureTarget",
139
142
  "Config",
140
143
  "ConfigAutoRebootComm",
141
144
  "ConfigGeneral",
@@ -228,6 +231,7 @@ __all__ = [
228
231
  "VentilationFlowLevelTarget",
229
232
  "VentilationMode",
230
233
  "VentilationState",
234
+ "VentilationTemperatureInfo",
231
235
  "VentilationTimeEnd",
232
236
  "VentilationTimeRemaining",
233
237
  "ZoneModuleSelector",
@@ -2,8 +2,10 @@
2
2
 
3
3
  import json
4
4
  import logging
5
+ import math
5
6
  import sys
6
7
  from dataclasses import fields, is_dataclass
8
+ from decimal import Decimal, InvalidOperation
7
9
  from types import FrameType
8
10
  from typing import Any, Literal, cast
9
11
  from urllib.parse import urlsplit
@@ -26,6 +28,7 @@ from .models import (
26
28
  ApiEndpoint,
27
29
  ApiInfo,
28
30
  BoardInfo,
31
+ BypassSupplyTemperatureTarget,
29
32
  Config,
30
33
  ConfigAutoRebootComm,
31
34
  ConfigGeneral,
@@ -81,6 +84,7 @@ from .models import (
81
84
  PatchConfigZoneStruct,
82
85
  VentilationMode,
83
86
  VentilationState,
87
+ VentilationTemperatureInfo,
84
88
  ZoneModuleSelector,
85
89
  _PatchPayloadModel,
86
90
  )
@@ -101,6 +105,23 @@ def _compat_caller() -> str | None:
101
105
  return None
102
106
 
103
107
 
108
+ def _is_unsupported_optional_endpoint_error(error: DucoResponseError, path: str) -> bool:
109
+ """Return whether a Duco response reports an unavailable optional endpoint."""
110
+ if error.status != 400 or error.path != path:
111
+ return False
112
+
113
+ try:
114
+ error_payload = cast(object, json.loads(error.body))
115
+ except json.JSONDecodeError:
116
+ return False
117
+
118
+ if not isinstance(error_payload, dict):
119
+ return False
120
+
121
+ response = cast(dict[str, object], error_payload)
122
+ return response.get("Code") == 3 and response.get("Result") == "FAILED"
123
+
124
+
104
125
  class DucoClient:
105
126
  """Client for a Duco box that exposes the local HTTP API."""
106
127
 
@@ -306,6 +327,78 @@ class DucoClient:
306
327
 
307
328
  return raw_value
308
329
 
330
+ @staticmethod
331
+ def _validate_bypass_zone_id(zone_id: int) -> str:
332
+ """Return the API parameter name for a supported bypass target zone."""
333
+ if type(zone_id) is not int or not 1 <= zone_id <= 8:
334
+ msg = "zone_id must be an integer between 1 and 8"
335
+ raise ValueError(msg)
336
+ return f"TempSupTgtZone{zone_id}"
337
+
338
+ @staticmethod
339
+ def _decicelsius_to_celsius(value: int) -> float:
340
+ """Convert Duco decicelsius values to Celsius floats."""
341
+ return value / 10.0
342
+
343
+ @classmethod
344
+ def _celsius_to_decicelsius(cls, value: float, *, path: str) -> int:
345
+ """Convert a Celsius float to the Duco decicelsius integer representation."""
346
+ if type(value) not in (int, float):
347
+ msg = f"{path} must be an int or float, got {type(value).__name__}"
348
+ raise ValueError(msg)
349
+
350
+ numeric_value = float(value)
351
+ if not math.isfinite(numeric_value):
352
+ msg = f"{path} must be a finite temperature value"
353
+ raise ValueError(msg)
354
+
355
+ try:
356
+ scaled = Decimal(str(value)) * 10
357
+ except InvalidOperation as err:
358
+ msg = f"{path} must be representable as a decimal temperature"
359
+ raise ValueError(msg) from err
360
+
361
+ if scaled != scaled.to_integral_value():
362
+ msg = f"{path} must be representable in 0.1°C increments"
363
+ raise ValueError(msg)
364
+
365
+ return int(scaled)
366
+
367
+ @classmethod
368
+ def _build_bypass_supply_temperature_target(
369
+ cls,
370
+ zone_id: int,
371
+ value: ConfigValue,
372
+ ) -> BypassSupplyTemperatureTarget:
373
+ """Convert a raw config value into a Celsius convenience model."""
374
+ return BypassSupplyTemperatureTarget(
375
+ zone_id=zone_id,
376
+ value=cls._decicelsius_to_celsius(value.value),
377
+ minimum=None if value.minimum is None else cls._decicelsius_to_celsius(value.minimum),
378
+ increment=None
379
+ if value.increment is None
380
+ else cls._decicelsius_to_celsius(value.increment),
381
+ maximum=None if value.maximum is None else cls._decicelsius_to_celsius(value.maximum),
382
+ raw_payload=cls._preserve_raw_payload(value.raw_payload),
383
+ )
384
+
385
+ @classmethod
386
+ def _extract_bypass_supply_temperature_target(
387
+ cls,
388
+ config: Config,
389
+ zone_id: int,
390
+ ) -> BypassSupplyTemperatureTarget | None:
391
+ """Read a single bypass supply target from a typed config response."""
392
+ heat_recovery = config.heat_recovery
393
+ if heat_recovery is None or heat_recovery.bypass is None:
394
+ return None
395
+
396
+ raw_value = getattr(heat_recovery.bypass, f"temp_sup_tgt_zone_{zone_id}")
397
+ if raw_value is None:
398
+ return None
399
+
400
+ return cls._build_bypass_supply_temperature_target(zone_id, raw_value)
401
+
309
402
  @staticmethod
310
403
  def _read_scalar_value(payload: dict[str, Any], key: str) -> Any:
311
404
  raw_value = payload[key]
@@ -2115,18 +2208,8 @@ class DucoClient:
2115
2208
  try:
2116
2209
  payload = await self.async_get_info(module=InfoModuleSelector.HEAT_RECOVERY)
2117
2210
  except DucoResponseError as err:
2118
- if err.status == 400 and err.path == "/info":
2119
- try:
2120
- error_payload = json.loads(err.body)
2121
- except json.JSONDecodeError:
2122
- pass
2123
- else:
2124
- if (
2125
- isinstance(error_payload, dict)
2126
- and error_payload.get("Code") == 3
2127
- and error_payload.get("Result") == "FAILED"
2128
- ):
2129
- return None
2211
+ if _is_unsupported_optional_endpoint_error(err, "/info"):
2212
+ return None
2130
2213
  raise
2131
2214
 
2132
2215
  if not isinstance(payload, dict):
@@ -2159,6 +2242,92 @@ class DucoClient:
2159
2242
  path="HeatRecovery.General",
2160
2243
  )
2161
2244
 
2245
+ async def async_get_ventilation_temperature_info(self) -> VentilationTemperatureInfo | None:
2246
+ """Return ventilation temperatures when the box exposes them, in Celsius."""
2247
+ try:
2248
+ payload = await self.async_get_info(module=InfoModuleSelector.VENTILATION)
2249
+ except DucoResponseError as err:
2250
+ if _is_unsupported_optional_endpoint_error(err, "/info"):
2251
+ return None
2252
+ raise
2253
+
2254
+ if not isinstance(payload, dict):
2255
+ msg = (
2256
+ "Expected object payload from /info?module=Ventilation, got "
2257
+ f"{type(payload).__name__}"
2258
+ )
2259
+ raise DucoError(msg)
2260
+
2261
+ ventilation = payload.get("Ventilation")
2262
+ if ventilation is None:
2263
+ return VentilationTemperatureInfo()
2264
+ if not isinstance(ventilation, dict):
2265
+ msg = "Expected object payload at Ventilation in /info?module=Ventilation response"
2266
+ raise DucoError(msg)
2267
+
2268
+ sensor = ventilation.get("Sensor")
2269
+ if sensor is None:
2270
+ return VentilationTemperatureInfo(raw_payload=self._preserve_raw_payload(ventilation))
2271
+ if not isinstance(sensor, dict):
2272
+ msg = (
2273
+ "Expected object payload at Ventilation.Sensor in /info?module=Ventilation response"
2274
+ )
2275
+ raise DucoError(msg)
2276
+
2277
+ temp_oda = self._read_optional_wrapped_int(sensor, "TempOda", path="Ventilation.Sensor")
2278
+ temp_sup = self._read_optional_wrapped_int(sensor, "TempSup", path="Ventilation.Sensor")
2279
+ temp_eta = self._read_optional_wrapped_int(sensor, "TempEta", path="Ventilation.Sensor")
2280
+ temp_eha = self._read_optional_wrapped_int(sensor, "TempEha", path="Ventilation.Sensor")
2281
+
2282
+ return VentilationTemperatureInfo(
2283
+ temp_oda=None if temp_oda is None else self._decicelsius_to_celsius(temp_oda),
2284
+ temp_sup=None if temp_sup is None else self._decicelsius_to_celsius(temp_sup),
2285
+ temp_eta=None if temp_eta is None else self._decicelsius_to_celsius(temp_eta),
2286
+ temp_eha=None if temp_eha is None else self._decicelsius_to_celsius(temp_eha),
2287
+ raw_payload=self._preserve_raw_payload(sensor),
2288
+ )
2289
+
2290
+ async def async_get_bypass_supply_temperature_target(
2291
+ self,
2292
+ zone_id: int,
2293
+ ) -> BypassSupplyTemperatureTarget | None:
2294
+ """Return a bypass supply target from `/config` in Celsius."""
2295
+ parameter = self._validate_bypass_zone_id(zone_id)
2296
+ try:
2297
+ config = await self.async_get_config(
2298
+ module=ConfigModuleSelector.HEAT_RECOVERY,
2299
+ submodule=ConfigHeatRecoverySubmoduleSelector.BYPASS,
2300
+ parameter=parameter,
2301
+ )
2302
+ except DucoResponseError as err:
2303
+ if _is_unsupported_optional_endpoint_error(err, "/config"):
2304
+ return None
2305
+ raise
2306
+ return self._extract_bypass_supply_temperature_target(config, zone_id)
2307
+
2308
+ async def async_set_bypass_supply_temperature_target(
2309
+ self,
2310
+ zone_id: int,
2311
+ temperature: float,
2312
+ ) -> BypassSupplyTemperatureTarget:
2313
+ """Set a bypass supply target through `/config` using Celsius input."""
2314
+ parameter = self._validate_bypass_zone_id(zone_id)
2315
+ raw_value = self._celsius_to_decicelsius(
2316
+ temperature,
2317
+ path=f"bypass_supply_temperature_target[{zone_id}]",
2318
+ )
2319
+ response = await self.async_set_config(
2320
+ {"HeatRecovery": {"Bypass": {parameter: PatchConfigValue(value=raw_value)}}},
2321
+ module=ConfigModuleSelector.HEAT_RECOVERY,
2322
+ submodule=ConfigHeatRecoverySubmoduleSelector.BYPASS,
2323
+ parameter=parameter,
2324
+ )
2325
+ target = self._extract_bypass_supply_temperature_target(response, zone_id)
2326
+ if target is None:
2327
+ msg = f"Expected {parameter} in /config response after bypass target write"
2328
+ raise DucoError(msg)
2329
+ return target
2330
+
2162
2331
  async def async_get_write_req_remaining(self) -> int:
2163
2332
  """Backward-compatible alias for the old write budget method name."""
2164
2333
  caller = _compat_caller()
@@ -923,6 +923,29 @@ class LanInfo:
923
923
  object.__setattr__(self, "raw_payload", {} if raw_payload is None else raw_payload)
924
924
 
925
925
 
926
+ @dataclass(frozen=True, slots=True)
927
+ class VentilationTemperatureInfo:
928
+ """Ventilation temperature values reported by `/info?module=Ventilation`."""
929
+
930
+ temp_oda: float | None = None
931
+ temp_sup: float | None = None
932
+ temp_eta: float | None = None
933
+ temp_eha: float | None = None
934
+ raw_payload: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
935
+
936
+
937
+ @dataclass(frozen=True, slots=True)
938
+ class BypassSupplyTemperatureTarget:
939
+ """Bypass supply temperature target values exposed through `/config`."""
940
+
941
+ zone_id: int
942
+ value: float
943
+ minimum: float | None = None
944
+ increment: float | None = None
945
+ maximum: float | None = None
946
+ raw_payload: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
947
+
948
+
926
949
  @dataclass(frozen=True, slots=True)
927
950
  class ConfigValue:
928
951
  """Integer config value reported by the local Duco API."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-duco-connectivity
3
- Version: 0.7.1
3
+ Version: 0.9.0
4
4
  Summary: Async HTTP client for the local Duco Connectivity API
5
5
  Author: Ronald van der Meer
6
6
  License-Expression: MIT
@@ -64,6 +64,8 @@ in the development examples below.
64
64
  - asynchronous communication via `aiohttp`
65
65
  - typed stable config families for the documented `/config` branches
66
66
  - typed helpers for stable `/info` fields such as heat recovery filter time
67
+ - optional temperature helpers that return `None` when the box reports their
68
+ endpoint as unavailable
67
69
  - typed models that stay close to the API response shape
68
70
  - preserved `raw_payload` data on typed response models for forward compatibility
69
71
 
@@ -170,6 +172,8 @@ For local function probes without activating the environment first:
170
172
  ```bash
171
173
  .venv/bin/python -m duco_connectivity --host 192.168.1.10 call async_get_board_info
172
174
  .venv/bin/duco-probe --host 192.168.1.10 call async_get_board_info
175
+ .venv/bin/duco-probe --host 192.168.1.10 call async_get_ventilation_temperature_info
176
+ .venv/bin/duco-probe --host 192.168.1.10 call async_get_bypass_supply_temperature_target --kwargs '{"zone_id": 1}'
173
177
  ```
174
178
 
175
179
  For local real-device validation against your own Duco box, use the opt-in
@@ -193,9 +197,12 @@ development pass, covering:
193
197
  - `PATCH /config` with a no-op `TimeZone` write against the current value
194
198
  - `GET /info?module=General&submodule=Board`
195
199
  - `GET /info?module=General&submodule=Lan`
200
+ - `GET /info?module=Ventilation`
196
201
  - `GET /info?module=HeatRecovery`
197
202
  - `GET /info/nodes`
198
203
  - `GET /info?module=General&submodule=PublicApi`
204
+ - `GET /config?module=HeatRecovery&submodule=Bypass&parameter=TempSupTgtZone1`
205
+ - `PATCH /config?module=HeatRecovery&submodule=Bypass&parameter=TempSupTgtZone1` with a no-op write against the current value
199
206
  - `POST /action/nodes/{node}` with a no-op `SetVentilationState`
200
207
 
201
208
  The repository now also includes opt-in local live tests so the same read and
@@ -16,6 +16,7 @@ from duco_connectivity import (
16
16
  ActionResultStatus,
17
17
  ActionValueType,
18
18
  BoardName,
19
+ BypassSupplyTemperatureTarget,
19
20
  Config,
20
21
  ConfigAutoRebootComm,
21
22
  ConfigGeneral,
@@ -96,6 +97,7 @@ from duco_connectivity import (
96
97
  VentilationFlowLevelTarget,
97
98
  VentilationMode,
98
99
  VentilationState,
100
+ VentilationTemperatureInfo,
99
101
  VentilationTimeEnd,
100
102
  VentilationTimeRemaining,
101
103
  ZoneModuleSelector,
@@ -3653,6 +3655,239 @@ async def test_get_nodes_known_spec_node_types_are_parsed(node_type: str) -> Non
3653
3655
  assert nodes[0].general.node_type == NodeType(node_type)
3654
3656
 
3655
3657
 
3658
+ async def test_get_ventilation_temperature_info_is_parsed(
3659
+ ventilation_info_data: dict[str, object],
3660
+ ) -> None:
3661
+ """Ventilation temperatures should be converted from decicelsius to Celsius."""
3662
+ mock_response = _response(json_payload=ventilation_info_data)
3663
+
3664
+ async with aiohttp.ClientSession() as session:
3665
+ client = DucoClient(session=session, host="192.0.2.94")
3666
+ with patch.object(session, "request", _request(mock_response)):
3667
+ payload = await client.async_get_ventilation_temperature_info()
3668
+
3669
+ assert payload == VentilationTemperatureInfo(
3670
+ temp_oda=17.5,
3671
+ temp_sup=18.0,
3672
+ temp_eta=21.5,
3673
+ temp_eha=22.5,
3674
+ )
3675
+ assert payload.raw_payload is ventilation_info_data["Ventilation"]["Sensor"]
3676
+
3677
+
3678
+ async def test_get_ventilation_temperature_info_returns_empty_model_when_sensor_missing() -> None:
3679
+ """Missing ventilation sensors should return an empty typed model."""
3680
+ mock_response = _response(json_payload={"Ventilation": {}})
3681
+
3682
+ async with aiohttp.ClientSession() as session:
3683
+ client = DucoClient(session=session, host="192.0.2.94")
3684
+ with patch.object(session, "request", _request(mock_response)):
3685
+ payload = await client.async_get_ventilation_temperature_info()
3686
+
3687
+ assert payload == VentilationTemperatureInfo()
3688
+ assert payload.raw_payload == {}
3689
+
3690
+
3691
+ async def test_get_ventilation_temperature_info_returns_none_when_module_unsupported() -> None:
3692
+ """Unsupported ventilation modules should be treated as an absent optional capability."""
3693
+ mock_response = _response(
3694
+ status=400,
3695
+ text_payload='{"Code":3,"Result":"FAILED"}',
3696
+ )
3697
+
3698
+ async with aiohttp.ClientSession() as session:
3699
+ client = DucoClient(session=session, host="192.0.2.94")
3700
+ with patch.object(session, "request", _request(mock_response)):
3701
+ payload = await client.async_get_ventilation_temperature_info()
3702
+
3703
+ assert payload is None
3704
+
3705
+
3706
+ async def test_get_ventilation_temperature_info_reraises_other_client_errors() -> None:
3707
+ """Unexpected ventilation endpoint failures should remain visible to callers."""
3708
+ mock_response = _response(status=400, text_payload='{"Code":4,"Result":"FAILED"}')
3709
+
3710
+ async with aiohttp.ClientSession() as session:
3711
+ client = DucoClient(session=session, host="192.0.2.94")
3712
+ with (
3713
+ patch.object(session, "request", _request(mock_response)),
3714
+ pytest.raises(DucoResponseError),
3715
+ ):
3716
+ await client.async_get_ventilation_temperature_info()
3717
+
3718
+
3719
+ @pytest.mark.parametrize(
3720
+ ("payload", "message"),
3721
+ [
3722
+ pytest.param(
3723
+ [],
3724
+ "Expected object payload from /info?module=Ventilation, got list",
3725
+ id="root_not_object",
3726
+ ),
3727
+ pytest.param(
3728
+ {"Ventilation": []},
3729
+ "Expected object payload at Ventilation in /info?module=Ventilation response",
3730
+ id="ventilation_not_object",
3731
+ ),
3732
+ pytest.param(
3733
+ {"Ventilation": {"Sensor": []}},
3734
+ "Expected object payload at Ventilation.Sensor in /info?module=Ventilation response",
3735
+ id="sensor_not_object",
3736
+ ),
3737
+ pytest.param(
3738
+ {"Ventilation": {"Sensor": {"TempOda": 175}}},
3739
+ "Expected wrapped Val object for Ventilation.Sensor.TempOda, got int",
3740
+ id="leaf_not_object",
3741
+ ),
3742
+ pytest.param(
3743
+ {"Ventilation": {"Sensor": {"TempOda": {"Val": "175"}}}},
3744
+ "Expected integer value for Ventilation.Sensor.TempOda, got str",
3745
+ id="leaf_non_int_val",
3746
+ ),
3747
+ ],
3748
+ )
3749
+ async def test_get_ventilation_temperature_info_rejects_malformed_payloads(
3750
+ payload: object,
3751
+ message: str,
3752
+ ) -> None:
3753
+ """Malformed ventilation temperature payloads should raise DucoError."""
3754
+ mock_response = _response(json_payload=payload)
3755
+
3756
+ async with aiohttp.ClientSession() as session:
3757
+ client = DucoClient(session=session, host="192.0.2.94")
3758
+ with (
3759
+ patch.object(session, "request", _request(mock_response)),
3760
+ pytest.raises(DucoError, match=re.escape(message)),
3761
+ ):
3762
+ await client.async_get_ventilation_temperature_info()
3763
+
3764
+
3765
+ async def test_get_bypass_supply_temperature_target_returns_converted_values(
3766
+ config_data: dict[str, object],
3767
+ ) -> None:
3768
+ """Bypass target helpers should convert raw config metadata to Celsius."""
3769
+ mock_response = _response(json_payload=config_data)
3770
+
3771
+ async with aiohttp.ClientSession() as session:
3772
+ client = DucoClient(session=session, host="192.0.2.94")
3773
+ request_mock = _request(mock_response)
3774
+ with patch.object(session, "request", request_mock):
3775
+ payload = await client.async_get_bypass_supply_temperature_target(1)
3776
+
3777
+ assert payload == BypassSupplyTemperatureTarget(
3778
+ zone_id=1,
3779
+ value=18.0,
3780
+ minimum=12.0,
3781
+ increment=0.5,
3782
+ maximum=22.0,
3783
+ )
3784
+ assert request_mock.call_args.kwargs["params"] == {
3785
+ "module": "HeatRecovery",
3786
+ "submodule": "Bypass",
3787
+ "parameter": "TempSupTgtZone1",
3788
+ }
3789
+
3790
+
3791
+ async def test_get_bypass_supply_temperature_target_returns_none_when_not_reported() -> None:
3792
+ """Missing bypass targets should return None instead of failing."""
3793
+ mock_response = _response(json_payload={"HeatRecovery": {"Bypass": {}}})
3794
+
3795
+ async with aiohttp.ClientSession() as session:
3796
+ client = DucoClient(session=session, host="192.0.2.94")
3797
+ with patch.object(session, "request", _request(mock_response)):
3798
+ payload = await client.async_get_bypass_supply_temperature_target(1)
3799
+
3800
+ assert payload is None
3801
+
3802
+
3803
+ async def test_get_bypass_supply_temperature_target_returns_none_when_unsupported() -> None:
3804
+ """Unsupported bypass targets should be treated as an absent optional capability."""
3805
+ mock_response = _response(
3806
+ status=400,
3807
+ text_payload='{"Code":3,"Result":"FAILED"}',
3808
+ )
3809
+
3810
+ async with aiohttp.ClientSession() as session:
3811
+ client = DucoClient(session=session, host="192.0.2.94")
3812
+ with patch.object(session, "request", _request(mock_response)):
3813
+ payload = await client.async_get_bypass_supply_temperature_target(1)
3814
+
3815
+ assert payload is None
3816
+
3817
+
3818
+ async def test_get_bypass_supply_temperature_target_reraises_other_client_errors() -> None:
3819
+ """Unexpected bypass target failures should remain visible to callers."""
3820
+ mock_response = _response(status=400, text_payload='{"Code":4,"Result":"FAILED"}')
3821
+
3822
+ async with aiohttp.ClientSession() as session:
3823
+ client = DucoClient(session=session, host="192.0.2.94")
3824
+ with (
3825
+ patch.object(session, "request", _request(mock_response)),
3826
+ pytest.raises(DucoResponseError),
3827
+ ):
3828
+ await client.async_get_bypass_supply_temperature_target(1)
3829
+
3830
+
3831
+ @pytest.mark.parametrize("zone_id", [0, 9, "1"])
3832
+ async def test_bypass_supply_temperature_helpers_reject_invalid_zone_ids(zone_id: object) -> None:
3833
+ """Bypass target helpers should validate the supported zone range."""
3834
+ async with aiohttp.ClientSession() as session:
3835
+ client = DucoClient(session=session, host="192.0.2.94")
3836
+ with pytest.raises(ValueError, match="zone_id must be an integer between 1 and 8"):
3837
+ await client.async_get_bypass_supply_temperature_target(cast(Any, zone_id))
3838
+
3839
+ with pytest.raises(ValueError, match="zone_id must be an integer between 1 and 8"):
3840
+ await client.async_set_bypass_supply_temperature_target(cast(Any, zone_id), 18.0)
3841
+
3842
+
3843
+ async def test_set_bypass_supply_temperature_target_serializes_decicelsius(
3844
+ config_data: dict[str, object],
3845
+ ) -> None:
3846
+ """Bypass target helpers should serialize Celsius writes as raw decicelsius values."""
3847
+ mock_response = _response(json_payload=config_data)
3848
+
3849
+ async with aiohttp.ClientSession() as session:
3850
+ client = DucoClient(session=session, host="192.0.2.94")
3851
+ request = MagicMock(return_value=_request_context(mock_response))
3852
+ with patch.object(session, "request", request):
3853
+ payload = await client.async_set_bypass_supply_temperature_target(2, 18.5)
3854
+
3855
+ assert payload == BypassSupplyTemperatureTarget(
3856
+ zone_id=2,
3857
+ value=18.5,
3858
+ minimum=12.0,
3859
+ increment=0.5,
3860
+ maximum=22.0,
3861
+ )
3862
+ _, kwargs = request.call_args
3863
+ assert kwargs["params"] == {
3864
+ "module": "HeatRecovery",
3865
+ "submodule": "Bypass",
3866
+ "parameter": "TempSupTgtZone2",
3867
+ }
3868
+ assert kwargs["data"] == b'{"HeatRecovery":{"Bypass":{"TempSupTgtZone2":{"Val":185}}}}'
3869
+ assert kwargs["headers"] == {"Content-Type": "application/json"}
3870
+
3871
+
3872
+ @pytest.mark.parametrize(
3873
+ ("value", "message"),
3874
+ [
3875
+ pytest.param(18.34, "must be representable in 0.1°C increments", id="not_decicelsius"),
3876
+ pytest.param(float("inf"), "must be a finite temperature value", id="not_finite"),
3877
+ pytest.param(True, "must be an int or float, got bool", id="bool"),
3878
+ ],
3879
+ )
3880
+ async def test_set_bypass_supply_temperature_target_rejects_invalid_temperatures(
3881
+ value: object,
3882
+ message: str,
3883
+ ) -> None:
3884
+ """Invalid convenience write values should fail before a request is sent."""
3885
+ async with aiohttp.ClientSession() as session:
3886
+ client = DucoClient(session=session, host="192.0.2.94")
3887
+ with pytest.raises(ValueError, match=re.escape(message)):
3888
+ await client.async_set_bypass_supply_temperature_target(1, cast(Any, value))
3889
+
3890
+
3656
3891
  async def test_get_write_requests_remaining_is_parsed() -> None:
3657
3892
  """Test parsing of the remaining write budget."""
3658
3893
  payload: dict[str, object] = {"General": {"PublicApi": {"WriteReqCntRemain": {"Val": 197}}}}
@@ -17,6 +17,7 @@ from duco_connectivity import (
17
17
  ApiInfo,
18
18
  BoardInfo,
19
19
  BoardName,
20
+ BypassSupplyTemperatureTarget,
20
21
  Config,
21
22
  ConfigAutoRebootComm,
22
23
  ConfigGeneral,
@@ -95,6 +96,7 @@ from duco_connectivity import (
95
96
  VentilationFlowLevelTarget,
96
97
  VentilationMode,
97
98
  VentilationState,
99
+ VentilationTemperatureInfo,
98
100
  VentilationTimeEnd,
99
101
  VentilationTimeRemaining,
100
102
  ZoneModuleSelector,
@@ -659,6 +661,33 @@ def test_patch_config_models_build_typed_write_families() -> None:
659
661
  assert node_patch.name == PatchConfigNodeValue(value="Kitchen valve")
660
662
 
661
663
 
664
+ def test_temperature_convenience_models_default_and_preserve_values() -> None:
665
+ """Temperature convenience models should keep converted Celsius values explicit."""
666
+ ventilation = VentilationTemperatureInfo(
667
+ temp_oda=17.5,
668
+ temp_sup=18.0,
669
+ temp_eta=21.5,
670
+ temp_eha=22.5,
671
+ )
672
+ target = BypassSupplyTemperatureTarget(
673
+ zone_id=2,
674
+ value=18.5,
675
+ minimum=12.0,
676
+ increment=0.5,
677
+ maximum=22.0,
678
+ )
679
+
680
+ assert ventilation.temp_oda == 17.5
681
+ assert ventilation.temp_sup == 18.0
682
+ assert ventilation.temp_eta == 21.5
683
+ assert ventilation.temp_eha == 22.5
684
+ assert target.zone_id == 2
685
+ assert target.value == 18.5
686
+ assert target.minimum == 12.0
687
+ assert target.increment == 0.5
688
+ assert target.maximum == 22.0
689
+
690
+
662
691
  def test_config_group_struct_defaults() -> None:
663
692
  """ConfigGroupStruct should preserve an empty raw payload by default."""
664
693
  group = ConfigGroupStruct()