python-duco-connectivity 0.5.0__tar.gz → 0.6.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.6.0}/PKG-INFO +6 -2
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/README.md +4 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/pyproject.toml +4 -3
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/src/duco_connectivity/__init__.py +15 -15
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/src/duco_connectivity/cli.py +3 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/src/duco_connectivity/client.py +69 -21
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/src/duco_connectivity/models.py +96 -39
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0/src/python_duco_connectivity.egg-info}/PKG-INFO +6 -2
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/src/python_duco_connectivity.egg-info/requires.txt +1 -1
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/tests/test_client.py +138 -10
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/LICENSE +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/setup.cfg +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/src/duco_connectivity/__main__.py +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/src/duco_connectivity/exceptions.py +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/src/duco_connectivity/py.typed +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/src/python_duco_connectivity.egg-info/SOURCES.txt +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/src/python_duco_connectivity.egg-info/dependency_links.txt +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/src/python_duco_connectivity.egg-info/entry_points.txt +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/src/python_duco_connectivity.egg-info/top_level.txt +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/tests/test_api_reference.py +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/tests/test_cli.py +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/tests/test_exceptions.py +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/tests/test_local_sample_validation.py +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/tests/test_models.py +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/tests/test_pytest_live_support.py +0 -0
- {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.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.6.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
|
|
@@ -66,6 +66,10 @@ in the development examples below.
|
|
|
66
66
|
- typed models that stay close to the API response shape
|
|
67
67
|
- preserved `raw_payload` data on typed response models for forward compatibility
|
|
68
68
|
|
|
69
|
+
Diagnostic subsystem reads now keep raw component and status strings from
|
|
70
|
+
`Diag.SubSystems`, so future subsystem names or status values remain available
|
|
71
|
+
to downstream consumers without parse fallbacks or product-specific filtering.
|
|
72
|
+
|
|
69
73
|
## Getting started
|
|
70
74
|
|
|
71
75
|
```python
|
|
@@ -34,6 +34,10 @@ in the development examples below.
|
|
|
34
34
|
- typed models that stay close to the API response shape
|
|
35
35
|
- preserved `raw_payload` data on typed response models for forward compatibility
|
|
36
36
|
|
|
37
|
+
Diagnostic subsystem reads now keep raw component and status strings from
|
|
38
|
+
`Diag.SubSystems`, so future subsystem names or status values remain available
|
|
39
|
+
to downstream consumers without parse fallbacks or product-specific filtering.
|
|
40
|
+
|
|
37
41
|
## Getting started
|
|
38
42
|
|
|
39
43
|
```python
|
|
@@ -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.6.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.6.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.6.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.6.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,
|
|
@@ -1240,17 +1240,6 @@ class DucoClient:
|
|
|
1240
1240
|
)
|
|
1241
1241
|
return NetworkType.UNKNOWN
|
|
1242
1242
|
|
|
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
1243
|
@staticmethod
|
|
1255
1244
|
def _to_action_result_status(raw_value: str) -> ActionResultStatus:
|
|
1256
1245
|
try:
|
|
@@ -1919,17 +1908,76 @@ class DucoClient:
|
|
|
1919
1908
|
raw_payload=self._preserve_raw_payload(lan),
|
|
1920
1909
|
)
|
|
1921
1910
|
|
|
1911
|
+
@classmethod
|
|
1912
|
+
def _parse_diag_component(cls, payload: Any, *, path: str) -> DiagComponent | None:
|
|
1913
|
+
if not isinstance(payload, dict):
|
|
1914
|
+
_LOGGER.debug(
|
|
1915
|
+
"Skipping diagnostic subsystem at %s: expected object, got %s",
|
|
1916
|
+
path,
|
|
1917
|
+
type(payload).__name__,
|
|
1918
|
+
)
|
|
1919
|
+
return None
|
|
1920
|
+
|
|
1921
|
+
component = payload.get("Component")
|
|
1922
|
+
status = payload.get("Status")
|
|
1923
|
+
if not isinstance(component, str) or not component:
|
|
1924
|
+
_LOGGER.debug(
|
|
1925
|
+
"Skipping diagnostic subsystem at %s: missing non-empty string Component",
|
|
1926
|
+
path,
|
|
1927
|
+
)
|
|
1928
|
+
return None
|
|
1929
|
+
if not isinstance(status, str) or not status:
|
|
1930
|
+
_LOGGER.debug(
|
|
1931
|
+
"Skipping diagnostic subsystem at %s: missing non-empty string Status",
|
|
1932
|
+
path,
|
|
1933
|
+
)
|
|
1934
|
+
return None
|
|
1935
|
+
|
|
1936
|
+
return DiagComponent(
|
|
1937
|
+
component=component,
|
|
1938
|
+
status=status,
|
|
1939
|
+
raw_payload=cls._preserve_raw_payload(payload),
|
|
1940
|
+
)
|
|
1941
|
+
|
|
1942
|
+
@classmethod
|
|
1943
|
+
def _parse_diag_info(cls, payload: Any) -> DiagInfo:
|
|
1944
|
+
if not isinstance(payload, dict):
|
|
1945
|
+
msg = f"Expected object payload from /info?module=Diag, got {type(payload).__name__}"
|
|
1946
|
+
raise DucoError(msg)
|
|
1947
|
+
|
|
1948
|
+
diag_payload = payload.get("Diag")
|
|
1949
|
+
if diag_payload is None:
|
|
1950
|
+
return DiagInfo()
|
|
1951
|
+
if not isinstance(diag_payload, dict):
|
|
1952
|
+
msg = f"Expected object for Diag, got {type(diag_payload).__name__}"
|
|
1953
|
+
raise DucoError(msg)
|
|
1954
|
+
|
|
1955
|
+
raw_subsystems = diag_payload.get("SubSystems")
|
|
1956
|
+
if raw_subsystems is None:
|
|
1957
|
+
return DiagInfo(raw_payload=cls._preserve_raw_payload(diag_payload))
|
|
1958
|
+
if not isinstance(raw_subsystems, list):
|
|
1959
|
+
msg = f"Expected list for Diag.SubSystems, got {type(raw_subsystems).__name__}"
|
|
1960
|
+
raise DucoError(msg)
|
|
1961
|
+
|
|
1962
|
+
diagnostic_subsystems: list[DiagComponent] = []
|
|
1963
|
+
for index, item in enumerate(raw_subsystems):
|
|
1964
|
+
subsystem = cls._parse_diag_component(item, path=f"Diag.SubSystems[{index}]")
|
|
1965
|
+
if subsystem is not None:
|
|
1966
|
+
diagnostic_subsystems.append(subsystem)
|
|
1967
|
+
|
|
1968
|
+
return DiagInfo(
|
|
1969
|
+
diagnostic_subsystems=tuple(diagnostic_subsystems),
|
|
1970
|
+
raw_payload=cls._preserve_raw_payload(diag_payload),
|
|
1971
|
+
)
|
|
1972
|
+
|
|
1973
|
+
async def async_get_diagnostics_info(self) -> DiagInfo:
|
|
1974
|
+
"""Return diagnostic subsystem details reported by the box."""
|
|
1975
|
+
payload = await self.async_get_info(module="Diag")
|
|
1976
|
+
return self._parse_diag_info(payload)
|
|
1977
|
+
|
|
1922
1978
|
async def async_get_diagnostics(self) -> list[DiagComponent]:
|
|
1923
1979
|
"""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
|
-
]
|
|
1980
|
+
return list((await self.async_get_diagnostics_info()).diagnostic_subsystems)
|
|
1933
1981
|
|
|
1934
1982
|
async def async_get_nodes(self) -> list[Node]:
|
|
1935
1983
|
"""Return nodes reported by the local API."""
|
{python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.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.6.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
|
|
@@ -66,6 +66,10 @@ in the development examples below.
|
|
|
66
66
|
- typed models that stay close to the API response shape
|
|
67
67
|
- preserved `raw_payload` data on typed response models for forward compatibility
|
|
68
68
|
|
|
69
|
+
Diagnostic subsystem reads now keep raw component and status strings from
|
|
70
|
+
`Diag.SubSystems`, so future subsystem names or status values remain available
|
|
71
|
+
to downstream consumers without parse fallbacks or product-specific filtering.
|
|
72
|
+
|
|
69
73
|
## Getting started
|
|
70
74
|
|
|
71
75
|
```python
|
|
@@ -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
|
),
|
|
File without changes
|
|
File without changes
|
{python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/src/duco_connectivity/__main__.py
RENAMED
|
File without changes
|
|
File without changes
|
{python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.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.6.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.6.0}/tests/test_pytest_live_support.py
RENAMED
|
File without changes
|
{python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/tests/test_replay_helpers.py
RENAMED
|
File without changes
|