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.
- {python_duco_connectivity-0.7.0/src/python_duco_connectivity.egg-info → python_duco_connectivity-0.8.0}/PKG-INFO +6 -1
- {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/README.md +5 -0
- {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/pyproject.toml +1 -1
- {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/src/duco_connectivity/__init__.py +4 -0
- {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/src/duco_connectivity/client.py +169 -1
- {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/src/duco_connectivity/models.py +23 -0
- {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0/src/python_duco_connectivity.egg-info}/PKG-INFO +6 -1
- {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/tests/test_client.py +194 -0
- {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/tests/test_models.py +29 -0
- {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/LICENSE +0 -0
- {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/setup.cfg +0 -0
- {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/src/duco_connectivity/__main__.py +0 -0
- {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/src/duco_connectivity/cli.py +0 -0
- {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/src/duco_connectivity/exceptions.py +0 -0
- {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/src/duco_connectivity/py.typed +0 -0
- {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/src/python_duco_connectivity.egg-info/SOURCES.txt +0 -0
- {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/src/python_duco_connectivity.egg-info/dependency_links.txt +0 -0
- {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/src/python_duco_connectivity.egg-info/entry_points.txt +0 -0
- {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/src/python_duco_connectivity.egg-info/requires.txt +0 -0
- {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/src/python_duco_connectivity.egg-info/top_level.txt +0 -0
- {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/tests/test_api_reference.py +0 -0
- {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/tests/test_cli.py +0 -0
- {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/tests/test_exceptions.py +0 -0
- {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/tests/test_local_sample_validation.py +0 -0
- {python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/tests/test_pytest_live_support.py +0 -0
- {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.
|
|
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¶meter=TempSupTgtZone1`
|
|
203
|
+
- `PATCH /config?module=HeatRecovery&submodule=Bypass¶meter=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¶meter=TempSupTgtZone1`
|
|
171
|
+
- `PATCH /config?module=HeatRecovery&submodule=Bypass¶meter=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
|
{python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.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.0 → python_duco_connectivity-0.8.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
|
)
|
|
@@ -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
|
-
|
|
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()
|
{python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.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.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¶meter=TempSupTgtZone1`
|
|
203
|
+
- `PATCH /config?module=HeatRecovery&submodule=Bypass¶meter=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()
|
|
File without changes
|
|
File without changes
|
{python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/src/duco_connectivity/__main__.py
RENAMED
|
File without changes
|
{python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/src/duco_connectivity/cli.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.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.0 → python_duco_connectivity-0.8.0}/tests/test_api_reference.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/tests/test_pytest_live_support.py
RENAMED
|
File without changes
|
{python_duco_connectivity-0.7.0 → python_duco_connectivity-0.8.0}/tests/test_replay_helpers.py
RENAMED
|
File without changes
|