python-duco-connectivity 0.7.0__tar.gz → 0.8.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.0/src/python_duco_connectivity.egg-info → python_duco_connectivity-0.8.0}/PKG-INFO +6 -1
  2. {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/README.md +5 -0
  3. {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/pyproject.toml +1 -1
  4. {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/src/duco_connectivity/__init__.py +4 -0
  5. {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/src/duco_connectivity/client.py +169 -1
  6. {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/src/duco_connectivity/models.py +23 -0
  7. {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0/src/python_duco_connectivity.egg-info}/PKG-INFO +6 -1
  8. {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/tests/test_client.py +194 -0
  9. {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/tests/test_models.py +29 -0
  10. {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/LICENSE +0 -0
  11. {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/setup.cfg +0 -0
  12. {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/src/duco_connectivity/__main__.py +0 -0
  13. {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/src/duco_connectivity/cli.py +0 -0
  14. {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/src/duco_connectivity/exceptions.py +0 -0
  15. {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/src/duco_connectivity/py.typed +0 -0
  16. {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/src/python_duco_connectivity.egg-info/SOURCES.txt +0 -0
  17. {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/src/python_duco_connectivity.egg-info/dependency_links.txt +0 -0
  18. {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/src/python_duco_connectivity.egg-info/entry_points.txt +0 -0
  19. {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/src/python_duco_connectivity.egg-info/requires.txt +0 -0
  20. {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/src/python_duco_connectivity.egg-info/top_level.txt +0 -0
  21. {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/tests/test_api_reference.py +0 -0
  22. {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/tests/test_cli.py +0 -0
  23. {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/tests/test_exceptions.py +0 -0
  24. {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/tests/test_local_sample_validation.py +0 -0
  25. {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/tests/test_pytest_live_support.py +0 -0
  26. {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.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.0
3
+ Version: 0.8.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
@@ -170,6 +170,8 @@ For local function probes without activating the environment first:
170
170
  ```bash
171
171
  .venv/bin/python -m duco_connectivity --host 192.168.1.10 call async_get_board_info
172
172
  .venv/bin/duco-probe --host 192.168.1.10 call async_get_board_info
173
+ .venv/bin/duco-probe --host 192.168.1.10 call async_get_ventilation_temperature_info
174
+ .venv/bin/duco-probe --host 192.168.1.10 call async_get_bypass_supply_temperature_target --kwargs '{"zone_id": 1}'
173
175
  ```
174
176
 
175
177
  For local real-device validation against your own Duco box, use the opt-in
@@ -193,9 +195,12 @@ development pass, covering:
193
195
  - `PATCH /config` with a no-op `TimeZone` write against the current value
194
196
  - `GET /info?module=General&submodule=Board`
195
197
  - `GET /info?module=General&submodule=Lan`
198
+ - `GET /info?module=Ventilation`
196
199
  - `GET /info?module=HeatRecovery`
197
200
  - `GET /info/nodes`
198
201
  - `GET /info?module=General&submodule=PublicApi`
202
+ - `GET /config?module=HeatRecovery&submodule=Bypass&parameter=TempSupTgtZone1`
203
+ - `PATCH /config?module=HeatRecovery&submodule=Bypass&parameter=TempSupTgtZone1` with a no-op write against the current value
199
204
  - `POST /action/nodes/{node}` with a no-op `SetVentilationState`
200
205
 
201
206
  The repository now also includes opt-in local live tests so the same read and
@@ -138,6 +138,8 @@ For local function probes without activating the environment first:
138
138
  ```bash
139
139
  .venv/bin/python -m duco_connectivity --host 192.168.1.10 call async_get_board_info
140
140
  .venv/bin/duco-probe --host 192.168.1.10 call async_get_board_info
141
+ .venv/bin/duco-probe --host 192.168.1.10 call async_get_ventilation_temperature_info
142
+ .venv/bin/duco-probe --host 192.168.1.10 call async_get_bypass_supply_temperature_target --kwargs '{"zone_id": 1}'
141
143
  ```
142
144
 
143
145
  For local real-device validation against your own Duco box, use the opt-in
@@ -161,9 +163,12 @@ development pass, covering:
161
163
  - `PATCH /config` with a no-op `TimeZone` write against the current value
162
164
  - `GET /info?module=General&submodule=Board`
163
165
  - `GET /info?module=General&submodule=Lan`
166
+ - `GET /info?module=Ventilation`
164
167
  - `GET /info?module=HeatRecovery`
165
168
  - `GET /info/nodes`
166
169
  - `GET /info?module=General&submodule=PublicApi`
170
+ - `GET /config?module=HeatRecovery&submodule=Bypass&parameter=TempSupTgtZone1`
171
+ - `PATCH /config?module=HeatRecovery&submodule=Bypass&parameter=TempSupTgtZone1` with a no-op write against the current value
167
172
  - `POST /action/nodes/{node}` with a no-op `SetVentilationState`
168
173
 
169
174
  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.0"
7
+ version = "0.8.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
  )
@@ -306,6 +310,78 @@ class DucoClient:
306
310
 
307
311
  return raw_value
308
312
 
313
+ @staticmethod
314
+ def _validate_bypass_zone_id(zone_id: int) -> str:
315
+ """Return the API parameter name for a supported bypass target zone."""
316
+ if type(zone_id) is not int or not 1 <= zone_id <= 8:
317
+ msg = "zone_id must be an integer between 1 and 8"
318
+ raise ValueError(msg)
319
+ return f"TempSupTgtZone{zone_id}"
320
+
321
+ @staticmethod
322
+ def _decicelsius_to_celsius(value: int) -> float:
323
+ """Convert Duco decicelsius values to Celsius floats."""
324
+ return value / 10.0
325
+
326
+ @classmethod
327
+ def _celsius_to_decicelsius(cls, value: float, *, path: str) -> int:
328
+ """Convert a Celsius float to the Duco decicelsius integer representation."""
329
+ if type(value) not in (int, float):
330
+ msg = f"{path} must be an int or float, got {type(value).__name__}"
331
+ raise ValueError(msg)
332
+
333
+ numeric_value = float(value)
334
+ if not math.isfinite(numeric_value):
335
+ msg = f"{path} must be a finite temperature value"
336
+ raise ValueError(msg)
337
+
338
+ try:
339
+ scaled = Decimal(str(value)) * 10
340
+ except InvalidOperation as err:
341
+ msg = f"{path} must be representable as a decimal temperature"
342
+ raise ValueError(msg) from err
343
+
344
+ if scaled != scaled.to_integral_value():
345
+ msg = f"{path} must be representable in 0.1°C increments"
346
+ raise ValueError(msg)
347
+
348
+ return int(scaled)
349
+
350
+ @classmethod
351
+ def _build_bypass_supply_temperature_target(
352
+ cls,
353
+ zone_id: int,
354
+ value: ConfigValue,
355
+ ) -> BypassSupplyTemperatureTarget:
356
+ """Convert a raw config value into a Celsius convenience model."""
357
+ return BypassSupplyTemperatureTarget(
358
+ zone_id=zone_id,
359
+ value=cls._decicelsius_to_celsius(value.value),
360
+ minimum=None if value.minimum is None else cls._decicelsius_to_celsius(value.minimum),
361
+ increment=None
362
+ if value.increment is None
363
+ else cls._decicelsius_to_celsius(value.increment),
364
+ maximum=None if value.maximum is None else cls._decicelsius_to_celsius(value.maximum),
365
+ raw_payload=cls._preserve_raw_payload(value.raw_payload),
366
+ )
367
+
368
+ @classmethod
369
+ def _extract_bypass_supply_temperature_target(
370
+ cls,
371
+ config: Config,
372
+ zone_id: int,
373
+ ) -> BypassSupplyTemperatureTarget | None:
374
+ """Read a single bypass supply target from a typed config response."""
375
+ heat_recovery = config.heat_recovery
376
+ if heat_recovery is None or heat_recovery.bypass is None:
377
+ return None
378
+
379
+ raw_value = getattr(heat_recovery.bypass, f"temp_sup_tgt_zone_{zone_id}")
380
+ if raw_value is None:
381
+ return None
382
+
383
+ return cls._build_bypass_supply_temperature_target(zone_id, raw_value)
384
+
309
385
  @staticmethod
310
386
  def _read_scalar_value(payload: dict[str, Any], key: str) -> Any:
311
387
  raw_value = payload[key]
@@ -2112,7 +2188,23 @@ class DucoClient:
2112
2188
 
2113
2189
  async def async_get_time_filter_remaining(self) -> int | None:
2114
2190
  """Return the remaining heat recovery filter time when the box reports it."""
2115
- payload = await self.async_get_info(module=InfoModuleSelector.HEAT_RECOVERY)
2191
+ try:
2192
+ payload = await self.async_get_info(module=InfoModuleSelector.HEAT_RECOVERY)
2193
+ except DucoResponseError as err:
2194
+ if err.status == 400 and err.path == "/info":
2195
+ try:
2196
+ error_payload = json.loads(err.body)
2197
+ except json.JSONDecodeError:
2198
+ pass
2199
+ else:
2200
+ if (
2201
+ isinstance(error_payload, dict)
2202
+ and error_payload.get("Code") == 3
2203
+ and error_payload.get("Result") == "FAILED"
2204
+ ):
2205
+ return None
2206
+ raise
2207
+
2116
2208
  if not isinstance(payload, dict):
2117
2209
  msg = (
2118
2210
  "Expected object payload from /info?module=HeatRecovery, got "
@@ -2143,6 +2235,82 @@ class DucoClient:
2143
2235
  path="HeatRecovery.General",
2144
2236
  )
2145
2237
 
2238
+ async def async_get_ventilation_temperature_info(self) -> VentilationTemperatureInfo:
2239
+ """Return ventilation temperatures from `/info?module=Ventilation` in Celsius."""
2240
+ payload = await self.async_get_info(module=InfoModuleSelector.VENTILATION)
2241
+
2242
+ if not isinstance(payload, dict):
2243
+ msg = (
2244
+ "Expected object payload from /info?module=Ventilation, got "
2245
+ f"{type(payload).__name__}"
2246
+ )
2247
+ raise DucoError(msg)
2248
+
2249
+ ventilation = payload.get("Ventilation")
2250
+ if ventilation is None:
2251
+ return VentilationTemperatureInfo()
2252
+ if not isinstance(ventilation, dict):
2253
+ msg = "Expected object payload at Ventilation in /info?module=Ventilation response"
2254
+ raise DucoError(msg)
2255
+
2256
+ sensor = ventilation.get("Sensor")
2257
+ if sensor is None:
2258
+ return VentilationTemperatureInfo(raw_payload=self._preserve_raw_payload(ventilation))
2259
+ if not isinstance(sensor, dict):
2260
+ msg = (
2261
+ "Expected object payload at Ventilation.Sensor in /info?module=Ventilation response"
2262
+ )
2263
+ raise DucoError(msg)
2264
+
2265
+ temp_oda = self._read_optional_wrapped_int(sensor, "TempOda", path="Ventilation.Sensor")
2266
+ temp_sup = self._read_optional_wrapped_int(sensor, "TempSup", path="Ventilation.Sensor")
2267
+ temp_eta = self._read_optional_wrapped_int(sensor, "TempEta", path="Ventilation.Sensor")
2268
+ temp_eha = self._read_optional_wrapped_int(sensor, "TempEha", path="Ventilation.Sensor")
2269
+
2270
+ return VentilationTemperatureInfo(
2271
+ temp_oda=None if temp_oda is None else self._decicelsius_to_celsius(temp_oda),
2272
+ temp_sup=None if temp_sup is None else self._decicelsius_to_celsius(temp_sup),
2273
+ temp_eta=None if temp_eta is None else self._decicelsius_to_celsius(temp_eta),
2274
+ temp_eha=None if temp_eha is None else self._decicelsius_to_celsius(temp_eha),
2275
+ raw_payload=self._preserve_raw_payload(sensor),
2276
+ )
2277
+
2278
+ async def async_get_bypass_supply_temperature_target(
2279
+ self,
2280
+ zone_id: int,
2281
+ ) -> BypassSupplyTemperatureTarget | None:
2282
+ """Return a bypass supply target from `/config` in Celsius."""
2283
+ parameter = self._validate_bypass_zone_id(zone_id)
2284
+ config = await self.async_get_config(
2285
+ module=ConfigModuleSelector.HEAT_RECOVERY,
2286
+ submodule=ConfigHeatRecoverySubmoduleSelector.BYPASS,
2287
+ parameter=parameter,
2288
+ )
2289
+ return self._extract_bypass_supply_temperature_target(config, zone_id)
2290
+
2291
+ async def async_set_bypass_supply_temperature_target(
2292
+ self,
2293
+ zone_id: int,
2294
+ temperature: float,
2295
+ ) -> BypassSupplyTemperatureTarget:
2296
+ """Set a bypass supply target through `/config` using Celsius input."""
2297
+ parameter = self._validate_bypass_zone_id(zone_id)
2298
+ raw_value = self._celsius_to_decicelsius(
2299
+ temperature,
2300
+ path=f"bypass_supply_temperature_target[{zone_id}]",
2301
+ )
2302
+ response = await self.async_set_config(
2303
+ {"HeatRecovery": {"Bypass": {parameter: PatchConfigValue(value=raw_value)}}},
2304
+ module=ConfigModuleSelector.HEAT_RECOVERY,
2305
+ submodule=ConfigHeatRecoverySubmoduleSelector.BYPASS,
2306
+ parameter=parameter,
2307
+ )
2308
+ target = self._extract_bypass_supply_temperature_target(response, zone_id)
2309
+ if target is None:
2310
+ msg = f"Expected {parameter} in /config response after bypass target write"
2311
+ raise DucoError(msg)
2312
+ return target
2313
+
2146
2314
  async def async_get_write_req_remaining(self) -> int:
2147
2315
  """Backward-compatible alias for the old write budget method name."""
2148
2316
  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.0
3
+ Version: 0.8.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
@@ -170,6 +170,8 @@ For local function probes without activating the environment first:
170
170
  ```bash
171
171
  .venv/bin/python -m duco_connectivity --host 192.168.1.10 call async_get_board_info
172
172
  .venv/bin/duco-probe --host 192.168.1.10 call async_get_board_info
173
+ .venv/bin/duco-probe --host 192.168.1.10 call async_get_ventilation_temperature_info
174
+ .venv/bin/duco-probe --host 192.168.1.10 call async_get_bypass_supply_temperature_target --kwargs '{"zone_id": 1}'
173
175
  ```
174
176
 
175
177
  For local real-device validation against your own Duco box, use the opt-in
@@ -193,9 +195,12 @@ development pass, covering:
193
195
  - `PATCH /config` with a no-op `TimeZone` write against the current value
194
196
  - `GET /info?module=General&submodule=Board`
195
197
  - `GET /info?module=General&submodule=Lan`
198
+ - `GET /info?module=Ventilation`
196
199
  - `GET /info?module=HeatRecovery`
197
200
  - `GET /info/nodes`
198
201
  - `GET /info?module=General&submodule=PublicApi`
202
+ - `GET /config?module=HeatRecovery&submodule=Bypass&parameter=TempSupTgtZone1`
203
+ - `PATCH /config?module=HeatRecovery&submodule=Bypass&parameter=TempSupTgtZone1` with a no-op write against the current value
199
204
  - `POST /action/nodes/{node}` with a no-op `SetVentilationState`
200
205
 
201
206
  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,183 @@ 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
+ @pytest.mark.parametrize(
3692
+ ("payload", "message"),
3693
+ [
3694
+ pytest.param(
3695
+ [],
3696
+ "Expected object payload from /info?module=Ventilation, got list",
3697
+ id="root_not_object",
3698
+ ),
3699
+ pytest.param(
3700
+ {"Ventilation": []},
3701
+ "Expected object payload at Ventilation in /info?module=Ventilation response",
3702
+ id="ventilation_not_object",
3703
+ ),
3704
+ pytest.param(
3705
+ {"Ventilation": {"Sensor": []}},
3706
+ "Expected object payload at Ventilation.Sensor in /info?module=Ventilation response",
3707
+ id="sensor_not_object",
3708
+ ),
3709
+ pytest.param(
3710
+ {"Ventilation": {"Sensor": {"TempOda": 175}}},
3711
+ "Expected wrapped Val object for Ventilation.Sensor.TempOda, got int",
3712
+ id="leaf_not_object",
3713
+ ),
3714
+ pytest.param(
3715
+ {"Ventilation": {"Sensor": {"TempOda": {"Val": "175"}}}},
3716
+ "Expected integer value for Ventilation.Sensor.TempOda, got str",
3717
+ id="leaf_non_int_val",
3718
+ ),
3719
+ ],
3720
+ )
3721
+ async def test_get_ventilation_temperature_info_rejects_malformed_payloads(
3722
+ payload: object,
3723
+ message: str,
3724
+ ) -> None:
3725
+ """Malformed ventilation temperature payloads should raise DucoError."""
3726
+ mock_response = _response(json_payload=payload)
3727
+
3728
+ async with aiohttp.ClientSession() as session:
3729
+ client = DucoClient(session=session, host="192.0.2.94")
3730
+ with (
3731
+ patch.object(session, "request", _request(mock_response)),
3732
+ pytest.raises(DucoError, match=re.escape(message)),
3733
+ ):
3734
+ await client.async_get_ventilation_temperature_info()
3735
+
3736
+
3737
+ async def test_get_bypass_supply_temperature_target_returns_converted_values(
3738
+ config_data: dict[str, object],
3739
+ ) -> None:
3740
+ """Bypass target helpers should convert raw config metadata to Celsius."""
3741
+ mock_response = _response(json_payload=config_data)
3742
+
3743
+ async with aiohttp.ClientSession() as session:
3744
+ client = DucoClient(session=session, host="192.0.2.94")
3745
+ request_mock = _request(mock_response)
3746
+ with patch.object(session, "request", request_mock):
3747
+ payload = await client.async_get_bypass_supply_temperature_target(1)
3748
+
3749
+ assert payload == BypassSupplyTemperatureTarget(
3750
+ zone_id=1,
3751
+ value=18.0,
3752
+ minimum=12.0,
3753
+ increment=0.5,
3754
+ maximum=22.0,
3755
+ )
3756
+ assert request_mock.call_args.kwargs["params"] == {
3757
+ "module": "HeatRecovery",
3758
+ "submodule": "Bypass",
3759
+ "parameter": "TempSupTgtZone1",
3760
+ }
3761
+
3762
+
3763
+ async def test_get_bypass_supply_temperature_target_returns_none_when_not_reported() -> None:
3764
+ """Missing bypass targets should return None instead of failing."""
3765
+ mock_response = _response(json_payload={"HeatRecovery": {"Bypass": {}}})
3766
+
3767
+ async with aiohttp.ClientSession() as session:
3768
+ client = DucoClient(session=session, host="192.0.2.94")
3769
+ with patch.object(session, "request", _request(mock_response)):
3770
+ payload = await client.async_get_bypass_supply_temperature_target(1)
3771
+
3772
+ assert payload is None
3773
+
3774
+
3775
+ @pytest.mark.parametrize("zone_id", [0, 9, "1"])
3776
+ async def test_bypass_supply_temperature_helpers_reject_invalid_zone_ids(zone_id: object) -> None:
3777
+ """Bypass target helpers should validate the supported zone range."""
3778
+ async with aiohttp.ClientSession() as session:
3779
+ client = DucoClient(session=session, host="192.0.2.94")
3780
+ with pytest.raises(ValueError, match="zone_id must be an integer between 1 and 8"):
3781
+ await client.async_get_bypass_supply_temperature_target(cast(Any, zone_id))
3782
+
3783
+ with pytest.raises(ValueError, match="zone_id must be an integer between 1 and 8"):
3784
+ await client.async_set_bypass_supply_temperature_target(cast(Any, zone_id), 18.0)
3785
+
3786
+
3787
+ async def test_set_bypass_supply_temperature_target_serializes_decicelsius(
3788
+ config_data: dict[str, object],
3789
+ ) -> None:
3790
+ """Bypass target helpers should serialize Celsius writes as raw decicelsius values."""
3791
+ mock_response = _response(json_payload=config_data)
3792
+
3793
+ async with aiohttp.ClientSession() as session:
3794
+ client = DucoClient(session=session, host="192.0.2.94")
3795
+ request = MagicMock(return_value=_request_context(mock_response))
3796
+ with patch.object(session, "request", request):
3797
+ payload = await client.async_set_bypass_supply_temperature_target(2, 18.5)
3798
+
3799
+ assert payload == BypassSupplyTemperatureTarget(
3800
+ zone_id=2,
3801
+ value=18.5,
3802
+ minimum=12.0,
3803
+ increment=0.5,
3804
+ maximum=22.0,
3805
+ )
3806
+ _, kwargs = request.call_args
3807
+ assert kwargs["params"] == {
3808
+ "module": "HeatRecovery",
3809
+ "submodule": "Bypass",
3810
+ "parameter": "TempSupTgtZone2",
3811
+ }
3812
+ assert kwargs["data"] == b'{"HeatRecovery":{"Bypass":{"TempSupTgtZone2":{"Val":185}}}}'
3813
+ assert kwargs["headers"] == {"Content-Type": "application/json"}
3814
+
3815
+
3816
+ @pytest.mark.parametrize(
3817
+ ("value", "message"),
3818
+ [
3819
+ pytest.param(18.34, "must be representable in 0.1°C increments", id="not_decicelsius"),
3820
+ pytest.param(float("inf"), "must be a finite temperature value", id="not_finite"),
3821
+ pytest.param(True, "must be an int or float, got bool", id="bool"),
3822
+ ],
3823
+ )
3824
+ async def test_set_bypass_supply_temperature_target_rejects_invalid_temperatures(
3825
+ value: object,
3826
+ message: str,
3827
+ ) -> None:
3828
+ """Invalid convenience write values should fail before a request is sent."""
3829
+ async with aiohttp.ClientSession() as session:
3830
+ client = DucoClient(session=session, host="192.0.2.94")
3831
+ with pytest.raises(ValueError, match=re.escape(message)):
3832
+ await client.async_set_bypass_supply_temperature_target(1, cast(Any, value))
3833
+
3834
+
3656
3835
  async def test_get_write_requests_remaining_is_parsed() -> None:
3657
3836
  """Test parsing of the remaining write budget."""
3658
3837
  payload: dict[str, object] = {"General": {"PublicApi": {"WriteReqCntRemain": {"Val": 197}}}}
@@ -3692,6 +3871,21 @@ async def test_get_time_filter_remaining_returns_none_when_not_reported() -> Non
3692
3871
  assert remaining is None
3693
3872
 
3694
3873
 
3874
+ async def test_get_time_filter_remaining_returns_none_when_module_unsupported() -> None:
3875
+ """Heat recovery filter time should stay optional for boxes without that module."""
3876
+ mock_response = _response(
3877
+ status=400,
3878
+ text_payload='{"Code":3,"Result":"FAILED"}',
3879
+ )
3880
+
3881
+ async with aiohttp.ClientSession() as session:
3882
+ client = DucoClient(session=session, host="192.0.2.94")
3883
+ with patch.object(session, "request", _request(mock_response)):
3884
+ remaining = await client.async_get_time_filter_remaining()
3885
+
3886
+ assert remaining is None
3887
+
3888
+
3695
3889
  @pytest.mark.parametrize(
3696
3890
  ("payload", "message"),
3697
3891
  [
@@ -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()