python-duco-connectivity 0.5.0__tar.gz → 0.7.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.5.0/src/python_duco_connectivity.egg-info → python_duco_connectivity-0.7.0}/PKG-INFO +8 -2
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/README.md +6 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/pyproject.toml +4 -3
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/src/duco_connectivity/__init__.py +15 -15
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/src/duco_connectivity/cli.py +3 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/src/duco_connectivity/client.py +127 -21
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/src/duco_connectivity/models.py +96 -39
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0/src/python_duco_connectivity.egg-info}/PKG-INFO +8 -2
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/src/python_duco_connectivity.egg-info/requires.txt +1 -1
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/tests/test_client.py +220 -10
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/LICENSE +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/setup.cfg +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/src/duco_connectivity/__main__.py +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/src/duco_connectivity/exceptions.py +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/src/duco_connectivity/py.typed +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/src/python_duco_connectivity.egg-info/SOURCES.txt +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/src/python_duco_connectivity.egg-info/dependency_links.txt +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/src/python_duco_connectivity.egg-info/entry_points.txt +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/src/python_duco_connectivity.egg-info/top_level.txt +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/tests/test_api_reference.py +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/tests/test_cli.py +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/tests/test_exceptions.py +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/tests/test_local_sample_validation.py +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/tests/test_models.py +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/tests/test_pytest_live_support.py +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.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.7.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
|
|
@@ -27,7 +27,7 @@ Requires-Dist: pip-audit>=2.7; extra == "dev"
|
|
|
27
27
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
28
28
|
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
29
29
|
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
30
|
-
Requires-Dist: ruff>=0.
|
|
30
|
+
Requires-Dist: ruff>=0.15.14; extra == "dev"
|
|
31
31
|
Dynamic: license-file
|
|
32
32
|
|
|
33
33
|
# python-duco-connectivity
|
|
@@ -63,9 +63,14 @@ in the development examples below.
|
|
|
63
63
|
- HTTP only
|
|
64
64
|
- asynchronous communication via `aiohttp`
|
|
65
65
|
- typed stable config families for the documented `/config` branches
|
|
66
|
+
- typed helpers for stable `/info` fields such as heat recovery filter time
|
|
66
67
|
- typed models that stay close to the API response shape
|
|
67
68
|
- preserved `raw_payload` data on typed response models for forward compatibility
|
|
68
69
|
|
|
70
|
+
Diagnostic subsystem reads now keep raw component and status strings from
|
|
71
|
+
`Diag.SubSystems`, so future subsystem names or status values remain available
|
|
72
|
+
to downstream consumers without parse fallbacks or product-specific filtering.
|
|
73
|
+
|
|
69
74
|
## Getting started
|
|
70
75
|
|
|
71
76
|
```python
|
|
@@ -188,6 +193,7 @@ development pass, covering:
|
|
|
188
193
|
- `PATCH /config` with a no-op `TimeZone` write against the current value
|
|
189
194
|
- `GET /info?module=General&submodule=Board`
|
|
190
195
|
- `GET /info?module=General&submodule=Lan`
|
|
196
|
+
- `GET /info?module=HeatRecovery`
|
|
191
197
|
- `GET /info/nodes`
|
|
192
198
|
- `GET /info?module=General&submodule=PublicApi`
|
|
193
199
|
- `POST /action/nodes/{node}` with a no-op `SetVentilationState`
|
|
@@ -31,9 +31,14 @@ in the development examples below.
|
|
|
31
31
|
- HTTP only
|
|
32
32
|
- asynchronous communication via `aiohttp`
|
|
33
33
|
- typed stable config families for the documented `/config` branches
|
|
34
|
+
- typed helpers for stable `/info` fields such as heat recovery filter time
|
|
34
35
|
- typed models that stay close to the API response shape
|
|
35
36
|
- preserved `raw_payload` data on typed response models for forward compatibility
|
|
36
37
|
|
|
38
|
+
Diagnostic subsystem reads now keep raw component and status strings from
|
|
39
|
+
`Diag.SubSystems`, so future subsystem names or status values remain available
|
|
40
|
+
to downstream consumers without parse fallbacks or product-specific filtering.
|
|
41
|
+
|
|
37
42
|
## Getting started
|
|
38
43
|
|
|
39
44
|
```python
|
|
@@ -156,6 +161,7 @@ development pass, covering:
|
|
|
156
161
|
- `PATCH /config` with a no-op `TimeZone` write against the current value
|
|
157
162
|
- `GET /info?module=General&submodule=Board`
|
|
158
163
|
- `GET /info?module=General&submodule=Lan`
|
|
164
|
+
- `GET /info?module=HeatRecovery`
|
|
159
165
|
- `GET /info/nodes`
|
|
160
166
|
- `GET /info?module=General&submodule=PublicApi`
|
|
161
167
|
- `POST /action/nodes/{node}` with a no-op `SetVentilationState`
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-duco-connectivity"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.7.0"
|
|
8
8
|
description = "Async HTTP client for the local Duco Connectivity API"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -38,7 +38,7 @@ dev = [
|
|
|
38
38
|
"pytest>=8.0",
|
|
39
39
|
"pytest-asyncio>=0.23",
|
|
40
40
|
"pytest-cov>=5.0",
|
|
41
|
-
"ruff>=0.
|
|
41
|
+
"ruff>=0.15.14",
|
|
42
42
|
]
|
|
43
43
|
|
|
44
44
|
[project.urls]
|
|
@@ -80,9 +80,10 @@ exclude_lines = [
|
|
|
80
80
|
[tool.ruff]
|
|
81
81
|
line-length = 100
|
|
82
82
|
target-version = "py312"
|
|
83
|
+
required-version = ">=0.15.14"
|
|
83
84
|
|
|
84
85
|
[tool.ruff.lint]
|
|
85
|
-
select = ["E", "F", "I", "UP"]
|
|
86
|
+
select = ["B", "E", "F", "I", "RUF", "UP"]
|
|
86
87
|
|
|
87
88
|
[tool.mypy]
|
|
88
89
|
python_version = "3.12"
|
{python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/src/duco_connectivity/__init__.py
RENAMED
|
@@ -51,7 +51,7 @@ from .models import (
|
|
|
51
51
|
ConfigZoneWithGroupStruct,
|
|
52
52
|
DeviceGroupConfigSubmoduleSelector,
|
|
53
53
|
DiagComponent,
|
|
54
|
-
|
|
54
|
+
DiagInfo,
|
|
55
55
|
DucoSerialNumber,
|
|
56
56
|
DucoVersion,
|
|
57
57
|
HostName,
|
|
@@ -134,10 +134,10 @@ __all__ = [
|
|
|
134
134
|
"ApiEndpoint",
|
|
135
135
|
"ApiEndpointInfo",
|
|
136
136
|
"ApiInfo",
|
|
137
|
-
"BoardName",
|
|
138
137
|
"BoardInfo",
|
|
139
|
-
"
|
|
138
|
+
"BoardName",
|
|
140
139
|
"Config",
|
|
140
|
+
"ConfigAutoRebootComm",
|
|
141
141
|
"ConfigGeneral",
|
|
142
142
|
"ConfigGeneralSubmoduleSelector",
|
|
143
143
|
"ConfigGroup",
|
|
@@ -153,33 +153,33 @@ __all__ = [
|
|
|
153
153
|
"ConfigNodeStruct",
|
|
154
154
|
"ConfigSection",
|
|
155
155
|
"ConfigTime",
|
|
156
|
-
"ConfigZone",
|
|
157
|
-
"ConfigZoneWithGroupStruct",
|
|
158
|
-
"ConfigZonesOverview",
|
|
159
|
-
"ConfigZoneStruct",
|
|
160
156
|
"ConfigValue",
|
|
161
157
|
"ConfigValueOptions",
|
|
162
158
|
"ConfigValueString",
|
|
159
|
+
"ConfigZone",
|
|
160
|
+
"ConfigZoneStruct",
|
|
161
|
+
"ConfigZoneWithGroupStruct",
|
|
162
|
+
"ConfigZonesOverview",
|
|
163
163
|
"DeviceGroupConfigSubmoduleSelector",
|
|
164
|
+
"DiagComponent",
|
|
165
|
+
"DiagInfo",
|
|
164
166
|
"DucoClient",
|
|
165
167
|
"DucoConnectionError",
|
|
166
168
|
"DucoError",
|
|
167
169
|
"DucoRateLimitError",
|
|
168
170
|
"DucoResponseError",
|
|
169
171
|
"DucoSerialNumber",
|
|
170
|
-
"DucoWriteLimitError",
|
|
171
|
-
"DiagComponent",
|
|
172
|
-
"DiagStatus",
|
|
173
172
|
"DucoVersion",
|
|
173
|
+
"DucoWriteLimitError",
|
|
174
174
|
"HostName",
|
|
175
|
-
"InfoGroup",
|
|
176
175
|
"InfoGeneralSubmoduleSelector",
|
|
176
|
+
"InfoGroup",
|
|
177
177
|
"InfoGroupStruct",
|
|
178
178
|
"InfoModuleSelector",
|
|
179
179
|
"InfoZone",
|
|
180
180
|
"InfoZoneGroup",
|
|
181
|
-
"InfoZonesOverview",
|
|
182
181
|
"InfoZoneStruct",
|
|
182
|
+
"InfoZonesOverview",
|
|
183
183
|
"IpAddress",
|
|
184
184
|
"KnownActionName",
|
|
185
185
|
"KnownBoardName",
|
|
@@ -189,18 +189,18 @@ __all__ = [
|
|
|
189
189
|
"MacAddress",
|
|
190
190
|
"NetworkType",
|
|
191
191
|
"Node",
|
|
192
|
+
"NodeActionItemList",
|
|
192
193
|
"NodeAirQualityIndex",
|
|
193
194
|
"NodeAssociationId",
|
|
194
|
-
"NodeActionItemList",
|
|
195
195
|
"NodeCo2Ppm",
|
|
196
196
|
"NodeGeneralInfo",
|
|
197
197
|
"NodeIdentify",
|
|
198
198
|
"NodeInfoModuleSelector",
|
|
199
199
|
"NodeListActionItemList",
|
|
200
200
|
"NodeMotorDeviceType",
|
|
201
|
-
"NodeMotorStateInfo",
|
|
202
201
|
"NodeMotorPosition",
|
|
203
202
|
"NodeMotorRequest",
|
|
203
|
+
"NodeMotorStateInfo",
|
|
204
204
|
"NodeName",
|
|
205
205
|
"NodeOverview",
|
|
206
206
|
"NodeParentId",
|
|
@@ -216,8 +216,8 @@ __all__ = [
|
|
|
216
216
|
"PatchConfigHeatRecovery",
|
|
217
217
|
"PatchConfigHeatRecoveryBypass",
|
|
218
218
|
"PatchConfigLan",
|
|
219
|
-
"PatchConfigModel",
|
|
220
219
|
"PatchConfigModbus",
|
|
220
|
+
"PatchConfigModel",
|
|
221
221
|
"PatchConfigNodeStruct",
|
|
222
222
|
"PatchConfigNodeValue",
|
|
223
223
|
"PatchConfigTime",
|
{python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/src/duco_connectivity/cli.py
RENAMED
|
@@ -77,6 +77,9 @@ METHOD_SPECS: dict[str, MethodSpec] = {
|
|
|
77
77
|
),
|
|
78
78
|
"async_get_board_info": MethodSpec(description="Return board identity details."),
|
|
79
79
|
"async_get_lan_info": MethodSpec(description="Return LAN settings."),
|
|
80
|
+
"async_get_diagnostics_info": MethodSpec(
|
|
81
|
+
description="Return the typed diagnostics response from /info?module=Diag."
|
|
82
|
+
),
|
|
80
83
|
"async_get_diagnostics": MethodSpec(description="Return diagnostic subsystem states."),
|
|
81
84
|
"async_get_nodes": MethodSpec(description="Return full node information."),
|
|
82
85
|
"async_get_zones_info": MethodSpec(description="Return zone information."),
|
{python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/src/duco_connectivity/client.py
RENAMED
|
@@ -53,7 +53,7 @@ from .models import (
|
|
|
53
53
|
ConfigZoneWithGroupStruct,
|
|
54
54
|
DeviceGroupConfigSubmoduleSelector,
|
|
55
55
|
DiagComponent,
|
|
56
|
-
|
|
56
|
+
DiagInfo,
|
|
57
57
|
InfoGeneralSubmoduleSelector,
|
|
58
58
|
InfoGroup,
|
|
59
59
|
InfoGroupStruct,
|
|
@@ -281,6 +281,31 @@ class DucoClient:
|
|
|
281
281
|
def _read_wrapped_value(payload: dict[str, Any], key: str) -> Any:
|
|
282
282
|
return payload[key]["Val"]
|
|
283
283
|
|
|
284
|
+
@staticmethod
|
|
285
|
+
def _read_optional_wrapped_int(
|
|
286
|
+
payload: dict[str, Any],
|
|
287
|
+
key: str,
|
|
288
|
+
*,
|
|
289
|
+
path: str,
|
|
290
|
+
) -> int | None:
|
|
291
|
+
"""Read an optional wrapped integer from a typed-stable API branch."""
|
|
292
|
+
if key not in payload:
|
|
293
|
+
return None
|
|
294
|
+
raw_entry = payload[key]
|
|
295
|
+
if not isinstance(raw_entry, dict):
|
|
296
|
+
msg = f"Expected wrapped Val object for {path}.{key}, got {type(raw_entry).__name__}"
|
|
297
|
+
raise DucoError(msg)
|
|
298
|
+
if "Val" not in raw_entry:
|
|
299
|
+
msg = f"Expected wrapped Val object for {path}.{key}"
|
|
300
|
+
raise DucoError(msg)
|
|
301
|
+
|
|
302
|
+
raw_value = raw_entry["Val"]
|
|
303
|
+
if type(raw_value) is not int:
|
|
304
|
+
msg = f"Expected integer value for {path}.{key}, got {type(raw_value).__name__}"
|
|
305
|
+
raise DucoError(msg)
|
|
306
|
+
|
|
307
|
+
return raw_value
|
|
308
|
+
|
|
284
309
|
@staticmethod
|
|
285
310
|
def _read_scalar_value(payload: dict[str, Any], key: str) -> Any:
|
|
286
311
|
raw_value = payload[key]
|
|
@@ -1240,17 +1265,6 @@ class DucoClient:
|
|
|
1240
1265
|
)
|
|
1241
1266
|
return NetworkType.UNKNOWN
|
|
1242
1267
|
|
|
1243
|
-
@staticmethod
|
|
1244
|
-
def _to_diag_status(raw_value: str) -> DiagStatus:
|
|
1245
|
-
try:
|
|
1246
|
-
return DiagStatus(raw_value)
|
|
1247
|
-
except ValueError:
|
|
1248
|
-
_LOGGER.debug(
|
|
1249
|
-
"Unknown diagnostic status %r received from Duco API; falling back to UNKNOWN",
|
|
1250
|
-
raw_value,
|
|
1251
|
-
)
|
|
1252
|
-
return DiagStatus.UNKNOWN
|
|
1253
|
-
|
|
1254
1268
|
@staticmethod
|
|
1255
1269
|
def _to_action_result_status(raw_value: str) -> ActionResultStatus:
|
|
1256
1270
|
try:
|
|
@@ -1919,17 +1933,76 @@ class DucoClient:
|
|
|
1919
1933
|
raw_payload=self._preserve_raw_payload(lan),
|
|
1920
1934
|
)
|
|
1921
1935
|
|
|
1936
|
+
@classmethod
|
|
1937
|
+
def _parse_diag_component(cls, payload: Any, *, path: str) -> DiagComponent | None:
|
|
1938
|
+
if not isinstance(payload, dict):
|
|
1939
|
+
_LOGGER.debug(
|
|
1940
|
+
"Skipping diagnostic subsystem at %s: expected object, got %s",
|
|
1941
|
+
path,
|
|
1942
|
+
type(payload).__name__,
|
|
1943
|
+
)
|
|
1944
|
+
return None
|
|
1945
|
+
|
|
1946
|
+
component = payload.get("Component")
|
|
1947
|
+
status = payload.get("Status")
|
|
1948
|
+
if not isinstance(component, str) or not component:
|
|
1949
|
+
_LOGGER.debug(
|
|
1950
|
+
"Skipping diagnostic subsystem at %s: missing non-empty string Component",
|
|
1951
|
+
path,
|
|
1952
|
+
)
|
|
1953
|
+
return None
|
|
1954
|
+
if not isinstance(status, str) or not status:
|
|
1955
|
+
_LOGGER.debug(
|
|
1956
|
+
"Skipping diagnostic subsystem at %s: missing non-empty string Status",
|
|
1957
|
+
path,
|
|
1958
|
+
)
|
|
1959
|
+
return None
|
|
1960
|
+
|
|
1961
|
+
return DiagComponent(
|
|
1962
|
+
component=component,
|
|
1963
|
+
status=status,
|
|
1964
|
+
raw_payload=cls._preserve_raw_payload(payload),
|
|
1965
|
+
)
|
|
1966
|
+
|
|
1967
|
+
@classmethod
|
|
1968
|
+
def _parse_diag_info(cls, payload: Any) -> DiagInfo:
|
|
1969
|
+
if not isinstance(payload, dict):
|
|
1970
|
+
msg = f"Expected object payload from /info?module=Diag, got {type(payload).__name__}"
|
|
1971
|
+
raise DucoError(msg)
|
|
1972
|
+
|
|
1973
|
+
diag_payload = payload.get("Diag")
|
|
1974
|
+
if diag_payload is None:
|
|
1975
|
+
return DiagInfo()
|
|
1976
|
+
if not isinstance(diag_payload, dict):
|
|
1977
|
+
msg = f"Expected object for Diag, got {type(diag_payload).__name__}"
|
|
1978
|
+
raise DucoError(msg)
|
|
1979
|
+
|
|
1980
|
+
raw_subsystems = diag_payload.get("SubSystems")
|
|
1981
|
+
if raw_subsystems is None:
|
|
1982
|
+
return DiagInfo(raw_payload=cls._preserve_raw_payload(diag_payload))
|
|
1983
|
+
if not isinstance(raw_subsystems, list):
|
|
1984
|
+
msg = f"Expected list for Diag.SubSystems, got {type(raw_subsystems).__name__}"
|
|
1985
|
+
raise DucoError(msg)
|
|
1986
|
+
|
|
1987
|
+
diagnostic_subsystems: list[DiagComponent] = []
|
|
1988
|
+
for index, item in enumerate(raw_subsystems):
|
|
1989
|
+
subsystem = cls._parse_diag_component(item, path=f"Diag.SubSystems[{index}]")
|
|
1990
|
+
if subsystem is not None:
|
|
1991
|
+
diagnostic_subsystems.append(subsystem)
|
|
1992
|
+
|
|
1993
|
+
return DiagInfo(
|
|
1994
|
+
diagnostic_subsystems=tuple(diagnostic_subsystems),
|
|
1995
|
+
raw_payload=cls._preserve_raw_payload(diag_payload),
|
|
1996
|
+
)
|
|
1997
|
+
|
|
1998
|
+
async def async_get_diagnostics_info(self) -> DiagInfo:
|
|
1999
|
+
"""Return diagnostic subsystem details reported by the box."""
|
|
2000
|
+
payload = await self.async_get_info(module="Diag")
|
|
2001
|
+
return self._parse_diag_info(payload)
|
|
2002
|
+
|
|
1922
2003
|
async def async_get_diagnostics(self) -> list[DiagComponent]:
|
|
1923
2004
|
"""Return health states for diagnostic subsystems."""
|
|
1924
|
-
|
|
1925
|
-
return [
|
|
1926
|
-
DiagComponent(
|
|
1927
|
-
component=item["Component"],
|
|
1928
|
-
status=self._to_diag_status(item["Status"]),
|
|
1929
|
-
raw_payload=self._preserve_raw_payload(item),
|
|
1930
|
-
)
|
|
1931
|
-
for item in payload["Diag"]["SubSystems"]
|
|
1932
|
-
]
|
|
2005
|
+
return list((await self.async_get_diagnostics_info()).diagnostic_subsystems)
|
|
1933
2006
|
|
|
1934
2007
|
async def async_get_nodes(self) -> list[Node]:
|
|
1935
2008
|
"""Return nodes reported by the local API."""
|
|
@@ -2037,6 +2110,39 @@ class DucoClient:
|
|
|
2037
2110
|
)
|
|
2038
2111
|
return int(self._read_wrapped_value(payload["General"]["PublicApi"], "WriteReqCntRemain"))
|
|
2039
2112
|
|
|
2113
|
+
async def async_get_time_filter_remaining(self) -> int | None:
|
|
2114
|
+
"""Return the remaining heat recovery filter time when the box reports it."""
|
|
2115
|
+
payload = await self.async_get_info(module=InfoModuleSelector.HEAT_RECOVERY)
|
|
2116
|
+
if not isinstance(payload, dict):
|
|
2117
|
+
msg = (
|
|
2118
|
+
"Expected object payload from /info?module=HeatRecovery, got "
|
|
2119
|
+
f"{type(payload).__name__}"
|
|
2120
|
+
)
|
|
2121
|
+
raise DucoError(msg)
|
|
2122
|
+
|
|
2123
|
+
heat_recovery = payload.get("HeatRecovery")
|
|
2124
|
+
if heat_recovery is None:
|
|
2125
|
+
return None
|
|
2126
|
+
if not isinstance(heat_recovery, dict):
|
|
2127
|
+
msg = "Expected object payload at HeatRecovery in /info?module=HeatRecovery response"
|
|
2128
|
+
raise DucoError(msg)
|
|
2129
|
+
|
|
2130
|
+
general = heat_recovery.get("General")
|
|
2131
|
+
if general is None:
|
|
2132
|
+
return None
|
|
2133
|
+
if not isinstance(general, dict):
|
|
2134
|
+
msg = (
|
|
2135
|
+
"Expected object payload at HeatRecovery.General in "
|
|
2136
|
+
"/info?module=HeatRecovery response"
|
|
2137
|
+
)
|
|
2138
|
+
raise DucoError(msg)
|
|
2139
|
+
|
|
2140
|
+
return self._read_optional_wrapped_int(
|
|
2141
|
+
general,
|
|
2142
|
+
"TimeFilterRemain",
|
|
2143
|
+
path="HeatRecovery.General",
|
|
2144
|
+
)
|
|
2145
|
+
|
|
2040
2146
|
async def async_get_write_req_remaining(self) -> int:
|
|
2041
2147
|
"""Backward-compatible alias for the old write budget method name."""
|
|
2042
2148
|
caller = _compat_caller()
|
{python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/src/duco_connectivity/models.py
RENAMED
|
@@ -1128,11 +1128,6 @@ class _PatchPayloadModel:
|
|
|
1128
1128
|
__slots__ = ()
|
|
1129
1129
|
|
|
1130
1130
|
|
|
1131
|
-
def _patch_field(api_name: str) -> Any:
|
|
1132
|
-
"""Return a dataclass field that stores the API key mapping."""
|
|
1133
|
-
return field(default=None, metadata={"api_name": api_name})
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
1131
|
class PatchConfigModel(_PatchPayloadModel):
|
|
1137
1132
|
"""Base class for typed `/config` patch payload models."""
|
|
1138
1133
|
|
|
@@ -1150,98 +1145,152 @@ class PatchConfigValue:
|
|
|
1150
1145
|
class PatchConfigTime(PatchConfigModel):
|
|
1151
1146
|
"""Typed time-related patch payload for `/config`."""
|
|
1152
1147
|
|
|
1153
|
-
time_zone: PatchConfigValue | None =
|
|
1154
|
-
|
|
1148
|
+
time_zone: PatchConfigValue | None = field(
|
|
1149
|
+
default=None,
|
|
1150
|
+
metadata={"api_name": "TimeZone"},
|
|
1151
|
+
)
|
|
1152
|
+
dst: PatchConfigValue | None = field(default=None, metadata={"api_name": "Dst"})
|
|
1155
1153
|
|
|
1156
1154
|
|
|
1157
1155
|
@dataclass(frozen=True, slots=True)
|
|
1158
1156
|
class PatchConfigModbus(PatchConfigModel):
|
|
1159
1157
|
"""Typed Modbus patch payload for `/config`."""
|
|
1160
1158
|
|
|
1161
|
-
addr: PatchConfigValue | None =
|
|
1162
|
-
offset: PatchConfigValue | None =
|
|
1159
|
+
addr: PatchConfigValue | None = field(default=None, metadata={"api_name": "Addr"})
|
|
1160
|
+
offset: PatchConfigValue | None = field(default=None, metadata={"api_name": "Offset"})
|
|
1163
1161
|
|
|
1164
1162
|
|
|
1165
1163
|
@dataclass(frozen=True, slots=True)
|
|
1166
1164
|
class PatchConfigLan(PatchConfigModel):
|
|
1167
1165
|
"""Typed LAN patch payload for `/config`."""
|
|
1168
1166
|
|
|
1169
|
-
mode: PatchConfigValue | None =
|
|
1170
|
-
dhcp: PatchConfigValue | None =
|
|
1171
|
-
static_ip: PatchConfigValue | None =
|
|
1172
|
-
static_net_mask: PatchConfigValue | None =
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1167
|
+
mode: PatchConfigValue | None = field(default=None, metadata={"api_name": "Mode"})
|
|
1168
|
+
dhcp: PatchConfigValue | None = field(default=None, metadata={"api_name": "Dhcp"})
|
|
1169
|
+
static_ip: PatchConfigValue | None = field(default=None, metadata={"api_name": "StaticIp"})
|
|
1170
|
+
static_net_mask: PatchConfigValue | None = field(
|
|
1171
|
+
default=None,
|
|
1172
|
+
metadata={"api_name": "StaticNetMask"},
|
|
1173
|
+
)
|
|
1174
|
+
static_default_gateway: PatchConfigValue | None = field(
|
|
1175
|
+
default=None,
|
|
1176
|
+
metadata={"api_name": "StaticDefaultGateway"},
|
|
1177
|
+
)
|
|
1178
|
+
static_dns: PatchConfigValue | None = field(default=None, metadata={"api_name": "StaticDns"})
|
|
1179
|
+
wifi_client_ssid: PatchConfigValue | None = field(
|
|
1180
|
+
default=None,
|
|
1181
|
+
metadata={"api_name": "WifiClientSsid"},
|
|
1182
|
+
)
|
|
1183
|
+
wifi_client_key: PatchConfigValue | None = field(
|
|
1184
|
+
default=None,
|
|
1185
|
+
metadata={"api_name": "WifiClientKey"},
|
|
1186
|
+
)
|
|
1177
1187
|
|
|
1178
1188
|
|
|
1179
1189
|
@dataclass(frozen=True, slots=True)
|
|
1180
1190
|
class PatchConfigAutoRebootComm(PatchConfigModel):
|
|
1181
1191
|
"""Typed auto-reboot communication patch payload for `/config`."""
|
|
1182
1192
|
|
|
1183
|
-
period: PatchConfigValue | None =
|
|
1184
|
-
time: PatchConfigValue | None =
|
|
1193
|
+
period: PatchConfigValue | None = field(default=None, metadata={"api_name": "Period"})
|
|
1194
|
+
time: PatchConfigValue | None = field(default=None, metadata={"api_name": "Time"})
|
|
1185
1195
|
|
|
1186
1196
|
|
|
1187
1197
|
@dataclass(frozen=True, slots=True)
|
|
1188
1198
|
class PatchConfigGeneral(PatchConfigModel):
|
|
1189
1199
|
"""Typed `General` patch payload for `/config`."""
|
|
1190
1200
|
|
|
1191
|
-
time: PatchConfigTime | None =
|
|
1192
|
-
modbus: PatchConfigModbus | None =
|
|
1193
|
-
lan: PatchConfigLan | None =
|
|
1194
|
-
auto_reboot_comm: PatchConfigAutoRebootComm | None =
|
|
1201
|
+
time: PatchConfigTime | None = field(default=None, metadata={"api_name": "Time"})
|
|
1202
|
+
modbus: PatchConfigModbus | None = field(default=None, metadata={"api_name": "Modbus"})
|
|
1203
|
+
lan: PatchConfigLan | None = field(default=None, metadata={"api_name": "Lan"})
|
|
1204
|
+
auto_reboot_comm: PatchConfigAutoRebootComm | None = field(
|
|
1205
|
+
default=None,
|
|
1206
|
+
metadata={"api_name": "AutoRebootComm"},
|
|
1207
|
+
)
|
|
1195
1208
|
|
|
1196
1209
|
|
|
1197
1210
|
@dataclass(frozen=True, slots=True)
|
|
1198
1211
|
class PatchConfigHeatRecoveryBypass(PatchConfigModel):
|
|
1199
1212
|
"""Typed bypass patch payload for `/config`."""
|
|
1200
1213
|
|
|
1201
|
-
temp_sup_tgt_zone_1: PatchConfigValue | None =
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1214
|
+
temp_sup_tgt_zone_1: PatchConfigValue | None = field(
|
|
1215
|
+
default=None,
|
|
1216
|
+
metadata={"api_name": "TempSupTgtZone1"},
|
|
1217
|
+
)
|
|
1218
|
+
temp_sup_tgt_zone_2: PatchConfigValue | None = field(
|
|
1219
|
+
default=None,
|
|
1220
|
+
metadata={"api_name": "TempSupTgtZone2"},
|
|
1221
|
+
)
|
|
1222
|
+
temp_sup_tgt_zone_3: PatchConfigValue | None = field(
|
|
1223
|
+
default=None,
|
|
1224
|
+
metadata={"api_name": "TempSupTgtZone3"},
|
|
1225
|
+
)
|
|
1226
|
+
temp_sup_tgt_zone_4: PatchConfigValue | None = field(
|
|
1227
|
+
default=None,
|
|
1228
|
+
metadata={"api_name": "TempSupTgtZone4"},
|
|
1229
|
+
)
|
|
1230
|
+
temp_sup_tgt_zone_5: PatchConfigValue | None = field(
|
|
1231
|
+
default=None,
|
|
1232
|
+
metadata={"api_name": "TempSupTgtZone5"},
|
|
1233
|
+
)
|
|
1234
|
+
temp_sup_tgt_zone_6: PatchConfigValue | None = field(
|
|
1235
|
+
default=None,
|
|
1236
|
+
metadata={"api_name": "TempSupTgtZone6"},
|
|
1237
|
+
)
|
|
1238
|
+
temp_sup_tgt_zone_7: PatchConfigValue | None = field(
|
|
1239
|
+
default=None,
|
|
1240
|
+
metadata={"api_name": "TempSupTgtZone7"},
|
|
1241
|
+
)
|
|
1242
|
+
temp_sup_tgt_zone_8: PatchConfigValue | None = field(
|
|
1243
|
+
default=None,
|
|
1244
|
+
metadata={"api_name": "TempSupTgtZone8"},
|
|
1245
|
+
)
|
|
1209
1246
|
|
|
1210
1247
|
|
|
1211
1248
|
@dataclass(frozen=True, slots=True)
|
|
1212
1249
|
class PatchConfigHeatRecovery(PatchConfigModel):
|
|
1213
1250
|
"""Typed `HeatRecovery` patch payload for `/config`."""
|
|
1214
1251
|
|
|
1215
|
-
bypass: PatchConfigHeatRecoveryBypass | None =
|
|
1252
|
+
bypass: PatchConfigHeatRecoveryBypass | None = field(
|
|
1253
|
+
default=None,
|
|
1254
|
+
metadata={"api_name": "Bypass"},
|
|
1255
|
+
)
|
|
1216
1256
|
|
|
1217
1257
|
|
|
1218
1258
|
@dataclass(frozen=True, slots=True)
|
|
1219
1259
|
class PatchConfig(PatchConfigModel):
|
|
1220
1260
|
"""Typed top-level patch payload for `/config`."""
|
|
1221
1261
|
|
|
1222
|
-
general: PatchConfigGeneral | None =
|
|
1223
|
-
heat_recovery: PatchConfigHeatRecovery | None =
|
|
1262
|
+
general: PatchConfigGeneral | None = field(default=None, metadata={"api_name": "General"})
|
|
1263
|
+
heat_recovery: PatchConfigHeatRecovery | None = field(
|
|
1264
|
+
default=None,
|
|
1265
|
+
metadata={"api_name": "HeatRecovery"},
|
|
1266
|
+
)
|
|
1224
1267
|
|
|
1225
1268
|
|
|
1226
1269
|
@dataclass(frozen=True, slots=True)
|
|
1227
1270
|
class PatchConfigZoneGeneral(_PatchPayloadModel):
|
|
1228
1271
|
"""Typed `General` patch payload under `DeviceGroupConfig` for a zone."""
|
|
1229
1272
|
|
|
1230
|
-
name: PatchConfigValue | None =
|
|
1273
|
+
name: PatchConfigValue | None = field(default=None, metadata={"api_name": "Name"})
|
|
1231
1274
|
|
|
1232
1275
|
|
|
1233
1276
|
@dataclass(frozen=True, slots=True)
|
|
1234
1277
|
class PatchConfigZoneDeviceGroupConfig(_PatchPayloadModel):
|
|
1235
1278
|
"""Typed `DeviceGroupConfig` patch payload for `/config/zones/{zone}`."""
|
|
1236
1279
|
|
|
1237
|
-
general: PatchConfigZoneGeneral | None =
|
|
1280
|
+
general: PatchConfigZoneGeneral | None = field(
|
|
1281
|
+
default=None,
|
|
1282
|
+
metadata={"api_name": "General"},
|
|
1283
|
+
)
|
|
1238
1284
|
|
|
1239
1285
|
|
|
1240
1286
|
@dataclass(frozen=True, slots=True)
|
|
1241
1287
|
class PatchConfigZoneStruct(_PatchPayloadModel):
|
|
1242
1288
|
"""Typed zone config patch payload for stable writable zone fields."""
|
|
1243
1289
|
|
|
1244
|
-
device_group_config: PatchConfigZoneDeviceGroupConfig | None =
|
|
1290
|
+
device_group_config: PatchConfigZoneDeviceGroupConfig | None = field(
|
|
1291
|
+
default=None,
|
|
1292
|
+
metadata={"api_name": "DeviceGroupConfig"},
|
|
1293
|
+
)
|
|
1245
1294
|
|
|
1246
1295
|
|
|
1247
1296
|
@dataclass(frozen=True, slots=True)
|
|
@@ -1309,7 +1358,7 @@ class PatchConfigNodeValue:
|
|
|
1309
1358
|
class PatchConfigNodeStruct(_PatchPayloadModel):
|
|
1310
1359
|
"""Typed node config patch payload for stable writable node fields."""
|
|
1311
1360
|
|
|
1312
|
-
name: PatchConfigNodeValue | None =
|
|
1361
|
+
name: PatchConfigNodeValue | None = field(default=None, metadata={"api_name": "Name"})
|
|
1313
1362
|
|
|
1314
1363
|
|
|
1315
1364
|
@dataclass(frozen=True, slots=True, init=False)
|
|
@@ -1500,7 +1549,15 @@ class DiagComponent:
|
|
|
1500
1549
|
"""Health state for a diagnostic subsystem."""
|
|
1501
1550
|
|
|
1502
1551
|
component: str
|
|
1503
|
-
status:
|
|
1552
|
+
status: str
|
|
1553
|
+
raw_payload: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
@dataclass(frozen=True, slots=True)
|
|
1557
|
+
class DiagInfo:
|
|
1558
|
+
"""Diagnostic subsystem payload returned by the local Duco API."""
|
|
1559
|
+
|
|
1560
|
+
diagnostic_subsystems: tuple[DiagComponent, ...] = ()
|
|
1504
1561
|
raw_payload: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
1505
1562
|
|
|
1506
1563
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-duco-connectivity
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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
|
|
@@ -27,7 +27,7 @@ Requires-Dist: pip-audit>=2.7; extra == "dev"
|
|
|
27
27
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
28
28
|
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
29
29
|
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
30
|
-
Requires-Dist: ruff>=0.
|
|
30
|
+
Requires-Dist: ruff>=0.15.14; extra == "dev"
|
|
31
31
|
Dynamic: license-file
|
|
32
32
|
|
|
33
33
|
# python-duco-connectivity
|
|
@@ -63,9 +63,14 @@ in the development examples below.
|
|
|
63
63
|
- HTTP only
|
|
64
64
|
- asynchronous communication via `aiohttp`
|
|
65
65
|
- typed stable config families for the documented `/config` branches
|
|
66
|
+
- typed helpers for stable `/info` fields such as heat recovery filter time
|
|
66
67
|
- typed models that stay close to the API response shape
|
|
67
68
|
- preserved `raw_payload` data on typed response models for forward compatibility
|
|
68
69
|
|
|
70
|
+
Diagnostic subsystem reads now keep raw component and status strings from
|
|
71
|
+
`Diag.SubSystems`, so future subsystem names or status values remain available
|
|
72
|
+
to downstream consumers without parse fallbacks or product-specific filtering.
|
|
73
|
+
|
|
69
74
|
## Getting started
|
|
70
75
|
|
|
71
76
|
```python
|
|
@@ -188,6 +193,7 @@ development pass, covering:
|
|
|
188
193
|
- `PATCH /config` with a no-op `TimeZone` write against the current value
|
|
189
194
|
- `GET /info?module=General&submodule=Board`
|
|
190
195
|
- `GET /info?module=General&submodule=Lan`
|
|
196
|
+
- `GET /info?module=HeatRecovery`
|
|
191
197
|
- `GET /info/nodes`
|
|
192
198
|
- `GET /info?module=General&submodule=PublicApi`
|
|
193
199
|
- `POST /action/nodes/{node}` with a no-op `SetVentilationState`
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import logging
|
|
4
4
|
import re
|
|
5
5
|
from dataclasses import dataclass, field
|
|
6
|
-
from typing import Any, cast
|
|
6
|
+
from typing import Any, assert_type, cast
|
|
7
7
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
8
8
|
|
|
9
9
|
import aiohttp
|
|
@@ -39,7 +39,8 @@ from duco_connectivity import (
|
|
|
39
39
|
ConfigZonesOverview,
|
|
40
40
|
ConfigZoneWithGroupStruct,
|
|
41
41
|
DeviceGroupConfigSubmoduleSelector,
|
|
42
|
-
|
|
42
|
+
DiagComponent,
|
|
43
|
+
DiagInfo,
|
|
43
44
|
DucoClient,
|
|
44
45
|
DucoConnectionError,
|
|
45
46
|
DucoError,
|
|
@@ -1284,7 +1285,7 @@ async def test_set_config_rejects_patch_model_without_api_name_metadata() -> Non
|
|
|
1284
1285
|
client = DucoClient(session=session, host="192.0.2.94")
|
|
1285
1286
|
with pytest.raises(
|
|
1286
1287
|
ValueError,
|
|
1287
|
-
match="Patch model field BrokenPatch.mode must declare api_name metadata",
|
|
1288
|
+
match=re.escape("Patch model field BrokenPatch.mode must declare api_name metadata"),
|
|
1288
1289
|
):
|
|
1289
1290
|
await client.async_set_config(BrokenPatch(mode=PatchConfigValue(value=2)))
|
|
1290
1291
|
|
|
@@ -1299,7 +1300,7 @@ async def test_set_config_invalid_response_raises_duco_error() -> None:
|
|
|
1299
1300
|
patch.object(session, "request", _request(mock_response)),
|
|
1300
1301
|
pytest.raises(
|
|
1301
1302
|
DucoError,
|
|
1302
|
-
match="Unsupported config value type bool for General.Lan.Mode",
|
|
1303
|
+
match=re.escape("Unsupported config value type bool for General.Lan.Mode"),
|
|
1303
1304
|
),
|
|
1304
1305
|
):
|
|
1305
1306
|
await client.async_set_config({"General": {"Lan": {"Mode": PatchConfigValue(value=2)}}})
|
|
@@ -1829,7 +1830,9 @@ async def test_set_node_config_invalid_response_raises_duco_error() -> None:
|
|
|
1829
1830
|
patch.object(session, "request", _request(mock_response)),
|
|
1830
1831
|
pytest.raises(
|
|
1831
1832
|
DucoError,
|
|
1832
|
-
match=
|
|
1833
|
+
match=re.escape(
|
|
1834
|
+
"Expected string Val for node config value /config/nodes/7.Name, got int"
|
|
1835
|
+
),
|
|
1833
1836
|
),
|
|
1834
1837
|
):
|
|
1835
1838
|
await client.async_set_node_config(
|
|
@@ -2060,12 +2063,40 @@ async def test_get_diagnostics(diag_data: dict[str, object]) -> None:
|
|
|
2060
2063
|
|
|
2061
2064
|
assert len(diags) == 3
|
|
2062
2065
|
assert diags[0].component == "Ventilation"
|
|
2063
|
-
assert diags[0].status ==
|
|
2066
|
+
assert diags[0].status == "Ok"
|
|
2064
2067
|
assert diags[0].raw_payload is diag_data["Diag"]["SubSystems"][0]
|
|
2065
2068
|
|
|
2066
2069
|
|
|
2067
|
-
async def
|
|
2068
|
-
|
|
2070
|
+
async def test_get_diagnostics_info_exposes_typed_subsystems(
|
|
2071
|
+
diag_data: dict[str, object],
|
|
2072
|
+
) -> None:
|
|
2073
|
+
"""Typed diagnostics info should expose an immutable subsystem collection."""
|
|
2074
|
+
mock_response = _response(json_payload=diag_data)
|
|
2075
|
+
|
|
2076
|
+
async with aiohttp.ClientSession() as session:
|
|
2077
|
+
client = DucoClient(session=session, host="192.0.2.94")
|
|
2078
|
+
with patch.object(session, "request", _request(mock_response)):
|
|
2079
|
+
diag_info = await client.async_get_diagnostics_info()
|
|
2080
|
+
|
|
2081
|
+
assert isinstance(diag_info, DiagInfo)
|
|
2082
|
+
assert_type(diag_info, DiagInfo)
|
|
2083
|
+
assert_type(diag_info.diagnostic_subsystems, tuple[DiagComponent, ...])
|
|
2084
|
+
assert_type(diag_info.diagnostic_subsystems[0].component, str)
|
|
2085
|
+
assert_type(diag_info.diagnostic_subsystems[0].status, str)
|
|
2086
|
+
assert tuple(item.component for item in diag_info.diagnostic_subsystems) == (
|
|
2087
|
+
"Ventilation",
|
|
2088
|
+
"VentCool",
|
|
2089
|
+
"SunCtrl",
|
|
2090
|
+
)
|
|
2091
|
+
assert tuple(item.status for item in diag_info.diagnostic_subsystems) == (
|
|
2092
|
+
"Ok",
|
|
2093
|
+
"Ok",
|
|
2094
|
+
"Ok",
|
|
2095
|
+
)
|
|
2096
|
+
|
|
2097
|
+
|
|
2098
|
+
async def test_get_diagnostics_preserves_unknown_status() -> None:
|
|
2099
|
+
"""Unknown diagnostic statuses should be preserved as raw strings."""
|
|
2069
2100
|
payload: dict[str, object] = {
|
|
2070
2101
|
"Diag": {
|
|
2071
2102
|
"SubSystems": [
|
|
@@ -2081,7 +2112,104 @@ async def test_get_diagnostics_unknown_status_falls_back_to_unknown() -> None:
|
|
|
2081
2112
|
diags = await client.async_get_diagnostics()
|
|
2082
2113
|
|
|
2083
2114
|
assert len(diags) == 1
|
|
2084
|
-
assert diags[0].status ==
|
|
2115
|
+
assert diags[0].status == "FutureState"
|
|
2116
|
+
|
|
2117
|
+
|
|
2118
|
+
async def test_get_diagnostics_preserves_unknown_component_name() -> None:
|
|
2119
|
+
"""Unknown diagnostic subsystem names should remain available to consumers."""
|
|
2120
|
+
payload: dict[str, object] = {
|
|
2121
|
+
"Diag": {
|
|
2122
|
+
"SubSystems": [
|
|
2123
|
+
{"Component": "FutureSubsystem", "Status": "Ok"},
|
|
2124
|
+
]
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
mock_response = _response(json_payload=payload)
|
|
2128
|
+
|
|
2129
|
+
async with aiohttp.ClientSession() as session:
|
|
2130
|
+
client = DucoClient(session=session, host="192.0.2.94")
|
|
2131
|
+
with patch.object(session, "request", _request(mock_response)):
|
|
2132
|
+
diag_info = await client.async_get_diagnostics_info()
|
|
2133
|
+
|
|
2134
|
+
assert tuple(item.component for item in diag_info.diagnostic_subsystems) == ("FutureSubsystem",)
|
|
2135
|
+
|
|
2136
|
+
|
|
2137
|
+
@pytest.mark.parametrize(
|
|
2138
|
+
("payload", "expected_raw_payload"),
|
|
2139
|
+
[
|
|
2140
|
+
pytest.param({}, {}, id="diag-missing"),
|
|
2141
|
+
pytest.param({"Diag": {}}, {}, id="subsystems-missing"),
|
|
2142
|
+
pytest.param({"Diag": {"SubSystems": []}}, {"SubSystems": []}, id="subsystems-empty"),
|
|
2143
|
+
],
|
|
2144
|
+
)
|
|
2145
|
+
async def test_get_diagnostics_info_missing_data_returns_empty_collection(
|
|
2146
|
+
payload: dict[str, object],
|
|
2147
|
+
expected_raw_payload: dict[str, object],
|
|
2148
|
+
) -> None:
|
|
2149
|
+
"""Missing diagnostic data should produce an empty typed subsystem collection."""
|
|
2150
|
+
mock_response = _response(json_payload=payload)
|
|
2151
|
+
|
|
2152
|
+
async with aiohttp.ClientSession() as session:
|
|
2153
|
+
client = DucoClient(session=session, host="192.0.2.94")
|
|
2154
|
+
with patch.object(session, "request", _request(mock_response)):
|
|
2155
|
+
diag_info = await client.async_get_diagnostics_info()
|
|
2156
|
+
|
|
2157
|
+
assert diag_info.diagnostic_subsystems == ()
|
|
2158
|
+
assert diag_info.raw_payload == expected_raw_payload
|
|
2159
|
+
|
|
2160
|
+
|
|
2161
|
+
async def test_get_diagnostics_info_skips_partial_entries() -> None:
|
|
2162
|
+
"""Incomplete diagnostic entries should be ignored instead of failing parsing."""
|
|
2163
|
+
payload: dict[str, object] = {
|
|
2164
|
+
"Diag": {
|
|
2165
|
+
"SubSystems": [
|
|
2166
|
+
{"Component": "Ventilation", "Status": "Ok"},
|
|
2167
|
+
{"Component": "VentCool"},
|
|
2168
|
+
{"Status": "Error"},
|
|
2169
|
+
{},
|
|
2170
|
+
"not-a-dict",
|
|
2171
|
+
{"Component": "SunCtrl", "Status": "FutureState"},
|
|
2172
|
+
]
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
mock_response = _response(json_payload=payload)
|
|
2176
|
+
|
|
2177
|
+
async with aiohttp.ClientSession() as session:
|
|
2178
|
+
client = DucoClient(session=session, host="192.0.2.94")
|
|
2179
|
+
with patch.object(session, "request", _request(mock_response)):
|
|
2180
|
+
diag_info = await client.async_get_diagnostics_info()
|
|
2181
|
+
|
|
2182
|
+
assert tuple((item.component, item.status) for item in diag_info.diagnostic_subsystems) == (
|
|
2183
|
+
("Ventilation", "Ok"),
|
|
2184
|
+
("SunCtrl", "FutureState"),
|
|
2185
|
+
)
|
|
2186
|
+
|
|
2187
|
+
|
|
2188
|
+
async def test_get_diagnostics_info_does_not_filter_product_specific_subsystems() -> None:
|
|
2189
|
+
"""Subsystems should be passed through without product-specific filtering."""
|
|
2190
|
+
payload: dict[str, object] = {
|
|
2191
|
+
"Diag": {
|
|
2192
|
+
"SubSystems": [
|
|
2193
|
+
{"Component": "Ventilation", "Status": "Error"},
|
|
2194
|
+
{"Component": "VentCool", "Status": "Ok"},
|
|
2195
|
+
{"Component": "SunCtrl", "Status": "Ok"},
|
|
2196
|
+
{"Component": "FutureSubsystem", "Status": "Disable"},
|
|
2197
|
+
]
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
mock_response = _response(json_payload=payload)
|
|
2201
|
+
|
|
2202
|
+
async with aiohttp.ClientSession() as session:
|
|
2203
|
+
client = DucoClient(session=session, host="192.0.2.94")
|
|
2204
|
+
with patch.object(session, "request", _request(mock_response)):
|
|
2205
|
+
diag_info = await client.async_get_diagnostics_info()
|
|
2206
|
+
|
|
2207
|
+
assert tuple(item.component for item in diag_info.diagnostic_subsystems) == (
|
|
2208
|
+
"Ventilation",
|
|
2209
|
+
"VentCool",
|
|
2210
|
+
"SunCtrl",
|
|
2211
|
+
"FutureSubsystem",
|
|
2212
|
+
)
|
|
2085
2213
|
|
|
2086
2214
|
|
|
2087
2215
|
async def test_get_nodes_parses_full_payload(nodes_data: dict[str, object]) -> None:
|
|
@@ -2880,7 +3008,7 @@ async def test_set_zone_config_invalid_response_raises_duco_error() -> None:
|
|
|
2880
3008
|
patch.object(session, "request", _request(mock_response)),
|
|
2881
3009
|
pytest.raises(
|
|
2882
3010
|
DucoError,
|
|
2883
|
-
match=(
|
|
3011
|
+
match=re.escape(
|
|
2884
3012
|
"Expected string Val for zone config value "
|
|
2885
3013
|
"/config/zones/1.DeviceGroupConfig.General.Name, got int"
|
|
2886
3014
|
),
|
|
@@ -3538,6 +3666,88 @@ async def test_get_write_requests_remaining_is_parsed() -> None:
|
|
|
3538
3666
|
assert remaining == 197
|
|
3539
3667
|
|
|
3540
3668
|
|
|
3669
|
+
async def test_get_time_filter_remaining_is_parsed() -> None:
|
|
3670
|
+
"""Heat recovery filter time should be parsed when the box reports it."""
|
|
3671
|
+
payload: dict[str, object] = {"HeatRecovery": {"General": {"TimeFilterRemain": {"Val": 180}}}}
|
|
3672
|
+
mock_response = _response(json_payload=payload)
|
|
3673
|
+
|
|
3674
|
+
async with aiohttp.ClientSession() as session:
|
|
3675
|
+
client = DucoClient(session=session, host="192.0.2.94")
|
|
3676
|
+
with patch.object(session, "request", _request(mock_response)):
|
|
3677
|
+
remaining = await client.async_get_time_filter_remaining()
|
|
3678
|
+
|
|
3679
|
+
assert remaining == 180
|
|
3680
|
+
|
|
3681
|
+
|
|
3682
|
+
async def test_get_time_filter_remaining_returns_none_when_not_reported() -> None:
|
|
3683
|
+
"""Heat recovery filter time should stay optional for boxes that omit it."""
|
|
3684
|
+
payload: dict[str, object] = {"HeatRecovery": {"General": {}}}
|
|
3685
|
+
mock_response = _response(json_payload=payload)
|
|
3686
|
+
|
|
3687
|
+
async with aiohttp.ClientSession() as session:
|
|
3688
|
+
client = DucoClient(session=session, host="192.0.2.94")
|
|
3689
|
+
with patch.object(session, "request", _request(mock_response)):
|
|
3690
|
+
remaining = await client.async_get_time_filter_remaining()
|
|
3691
|
+
|
|
3692
|
+
assert remaining is None
|
|
3693
|
+
|
|
3694
|
+
|
|
3695
|
+
@pytest.mark.parametrize(
|
|
3696
|
+
("payload", "message"),
|
|
3697
|
+
[
|
|
3698
|
+
pytest.param(
|
|
3699
|
+
[],
|
|
3700
|
+
"Expected object payload from /info?module=HeatRecovery, got list",
|
|
3701
|
+
id="root_not_object",
|
|
3702
|
+
),
|
|
3703
|
+
pytest.param(
|
|
3704
|
+
{"HeatRecovery": []},
|
|
3705
|
+
"Expected object payload at HeatRecovery in /info?module=HeatRecovery response",
|
|
3706
|
+
id="heat_recovery_not_object",
|
|
3707
|
+
),
|
|
3708
|
+
pytest.param(
|
|
3709
|
+
{"HeatRecovery": {"General": []}},
|
|
3710
|
+
"Expected object payload at HeatRecovery.General in /info?module=HeatRecovery response",
|
|
3711
|
+
id="general_not_object",
|
|
3712
|
+
),
|
|
3713
|
+
pytest.param(
|
|
3714
|
+
{"HeatRecovery": {"General": {"TimeFilterRemain": 180}}},
|
|
3715
|
+
"Expected wrapped Val object for HeatRecovery.General.TimeFilterRemain, got int",
|
|
3716
|
+
id="leaf_not_object",
|
|
3717
|
+
),
|
|
3718
|
+
pytest.param(
|
|
3719
|
+
{"HeatRecovery": {"General": {"TimeFilterRemain": None}}},
|
|
3720
|
+
"Expected wrapped Val object for HeatRecovery.General.TimeFilterRemain, got NoneType",
|
|
3721
|
+
id="leaf_null",
|
|
3722
|
+
),
|
|
3723
|
+
pytest.param(
|
|
3724
|
+
{"HeatRecovery": {"General": {"TimeFilterRemain": {}}}},
|
|
3725
|
+
"Expected wrapped Val object for HeatRecovery.General.TimeFilterRemain",
|
|
3726
|
+
id="leaf_missing_val",
|
|
3727
|
+
),
|
|
3728
|
+
pytest.param(
|
|
3729
|
+
{"HeatRecovery": {"General": {"TimeFilterRemain": {"Val": "180"}}}},
|
|
3730
|
+
"Expected integer value for HeatRecovery.General.TimeFilterRemain, got str",
|
|
3731
|
+
id="leaf_non_int_val",
|
|
3732
|
+
),
|
|
3733
|
+
],
|
|
3734
|
+
)
|
|
3735
|
+
async def test_get_time_filter_remaining_rejects_malformed_payloads(
|
|
3736
|
+
payload: object,
|
|
3737
|
+
message: str,
|
|
3738
|
+
) -> None:
|
|
3739
|
+
"""Malformed heat recovery payloads should raise DucoError."""
|
|
3740
|
+
mock_response = _response(json_payload=payload)
|
|
3741
|
+
|
|
3742
|
+
async with aiohttp.ClientSession() as session:
|
|
3743
|
+
client = DucoClient(session=session, host="192.0.2.94")
|
|
3744
|
+
with (
|
|
3745
|
+
patch.object(session, "request", _request(mock_response)),
|
|
3746
|
+
pytest.raises(DucoError, match=re.escape(message)),
|
|
3747
|
+
):
|
|
3748
|
+
await client.async_get_time_filter_remaining()
|
|
3749
|
+
|
|
3750
|
+
|
|
3541
3751
|
async def test_get_write_req_remaining_alias_is_parsed() -> None:
|
|
3542
3752
|
"""The old write budget method name should remain available."""
|
|
3543
3753
|
payload: dict[str, object] = {"General": {"PublicApi": {"WriteReqCntRemain": {"Val": 197}}}}
|
|
File without changes
|
|
File without changes
|
{python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/src/duco_connectivity/__main__.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/src/duco_connectivity/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/tests/test_api_reference.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/tests/test_pytest_live_support.py
RENAMED
|
File without changes
|
{python_duco_connectivity-0.5.0 → python_duco_connectivity-0.7.0}/tests/test_replay_helpers.py
RENAMED
|
File without changes
|