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.
- {python_duco_connectivity-0.7.1/src/python_duco_connectivity.egg-info → python_duco_connectivity-0.9.0}/PKG-INFO +8 -1
- {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/README.md +7 -0
- {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/pyproject.toml +1 -1
- {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/duco_connectivity/__init__.py +4 -0
- {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/duco_connectivity/client.py +181 -12
- {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/duco_connectivity/models.py +23 -0
- {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0/src/python_duco_connectivity.egg-info}/PKG-INFO +8 -1
- {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/tests/test_client.py +235 -0
- {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/tests/test_models.py +29 -0
- {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/LICENSE +0 -0
- {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/setup.cfg +0 -0
- {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/duco_connectivity/__main__.py +0 -0
- {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/duco_connectivity/cli.py +0 -0
- {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/duco_connectivity/exceptions.py +0 -0
- {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/duco_connectivity/py.typed +0 -0
- {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/python_duco_connectivity.egg-info/SOURCES.txt +0 -0
- {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/python_duco_connectivity.egg-info/dependency_links.txt +0 -0
- {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/python_duco_connectivity.egg-info/entry_points.txt +0 -0
- {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/python_duco_connectivity.egg-info/requires.txt +0 -0
- {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/python_duco_connectivity.egg-info/top_level.txt +0 -0
- {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/tests/test_api_reference.py +0 -0
- {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/tests/test_cli.py +0 -0
- {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/tests/test_exceptions.py +0 -0
- {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/tests/test_local_sample_validation.py +0 -0
- {python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/tests/test_pytest_live_support.py +0 -0
- {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.
|
|
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¶meter=TempSupTgtZone1`
|
|
205
|
+
- `PATCH /config?module=HeatRecovery&submodule=Bypass¶meter=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¶meter=TempSupTgtZone1`
|
|
173
|
+
- `PATCH /config?module=HeatRecovery&submodule=Bypass¶meter=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
|
{python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/duco_connectivity/__init__.py
RENAMED
|
@@ -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",
|
{python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/duco_connectivity/client.py
RENAMED
|
@@ -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
|
|
2119
|
-
|
|
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()
|
{python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/duco_connectivity/models.py
RENAMED
|
@@ -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.
|
|
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¶meter=TempSupTgtZone1`
|
|
205
|
+
- `PATCH /config?module=HeatRecovery&submodule=Bypass¶meter=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()
|
|
File without changes
|
|
File without changes
|
{python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/duco_connectivity/__main__.py
RENAMED
|
File without changes
|
{python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/duco_connectivity/cli.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/src/duco_connectivity/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/tests/test_api_reference.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/tests/test_pytest_live_support.py
RENAMED
|
File without changes
|
{python_duco_connectivity-0.7.1 → python_duco_connectivity-0.9.0}/tests/test_replay_helpers.py
RENAMED
|
File without changes
|