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.
Files changed (26) hide show
  1. {python_duco_connectivity-0.5.0/src/python_duco_connectivity.egg-info → python_duco_connectivity-0.6.0}/PKG-INFO +6 -2
  2. {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/README.md +4 -0
  3. {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/pyproject.toml +4 -3
  4. {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/src/duco_connectivity/__init__.py +15 -15
  5. {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/src/duco_connectivity/cli.py +3 -0
  6. {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/src/duco_connectivity/client.py +69 -21
  7. {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/src/duco_connectivity/models.py +96 -39
  8. {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0/src/python_duco_connectivity.egg-info}/PKG-INFO +6 -2
  9. {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/src/python_duco_connectivity.egg-info/requires.txt +1 -1
  10. {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/tests/test_client.py +138 -10
  11. {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/LICENSE +0 -0
  12. {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/setup.cfg +0 -0
  13. {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/src/duco_connectivity/__main__.py +0 -0
  14. {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/src/duco_connectivity/exceptions.py +0 -0
  15. {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/src/duco_connectivity/py.typed +0 -0
  16. {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/src/python_duco_connectivity.egg-info/SOURCES.txt +0 -0
  17. {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/src/python_duco_connectivity.egg-info/dependency_links.txt +0 -0
  18. {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/src/python_duco_connectivity.egg-info/entry_points.txt +0 -0
  19. {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/src/python_duco_connectivity.egg-info/top_level.txt +0 -0
  20. {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/tests/test_api_reference.py +0 -0
  21. {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/tests/test_cli.py +0 -0
  22. {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/tests/test_exceptions.py +0 -0
  23. {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/tests/test_local_sample_validation.py +0 -0
  24. {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/tests/test_models.py +0 -0
  25. {python_duco_connectivity-0.5.0 → python_duco_connectivity-0.6.0}/tests/test_pytest_live_support.py +0 -0
  26. {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.5.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.11; extra == "dev"
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.5.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.11",
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"
@@ -51,7 +51,7 @@ from .models import (
51
51
  ConfigZoneWithGroupStruct,
52
52
  DeviceGroupConfigSubmoduleSelector,
53
53
  DiagComponent,
54
- DiagStatus,
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
- "ConfigAutoRebootComm",
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",
@@ -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."),
@@ -53,7 +53,7 @@ from .models import (
53
53
  ConfigZoneWithGroupStruct,
54
54
  DeviceGroupConfigSubmoduleSelector,
55
55
  DiagComponent,
56
- DiagStatus,
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
- payload = await self.async_get_info(module="Diag")
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."""
@@ -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 = _patch_field("TimeZone")
1154
- dst: PatchConfigValue | None = _patch_field("Dst")
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 = _patch_field("Addr")
1162
- offset: PatchConfigValue | None = _patch_field("Offset")
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 = _patch_field("Mode")
1170
- dhcp: PatchConfigValue | None = _patch_field("Dhcp")
1171
- static_ip: PatchConfigValue | None = _patch_field("StaticIp")
1172
- static_net_mask: PatchConfigValue | None = _patch_field("StaticNetMask")
1173
- static_default_gateway: PatchConfigValue | None = _patch_field("StaticDefaultGateway")
1174
- static_dns: PatchConfigValue | None = _patch_field("StaticDns")
1175
- wifi_client_ssid: PatchConfigValue | None = _patch_field("WifiClientSsid")
1176
- wifi_client_key: PatchConfigValue | None = _patch_field("WifiClientKey")
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 = _patch_field("Period")
1184
- time: PatchConfigValue | None = _patch_field("Time")
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 = _patch_field("Time")
1192
- modbus: PatchConfigModbus | None = _patch_field("Modbus")
1193
- lan: PatchConfigLan | None = _patch_field("Lan")
1194
- auto_reboot_comm: PatchConfigAutoRebootComm | None = _patch_field("AutoRebootComm")
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 = _patch_field("TempSupTgtZone1")
1202
- temp_sup_tgt_zone_2: PatchConfigValue | None = _patch_field("TempSupTgtZone2")
1203
- temp_sup_tgt_zone_3: PatchConfigValue | None = _patch_field("TempSupTgtZone3")
1204
- temp_sup_tgt_zone_4: PatchConfigValue | None = _patch_field("TempSupTgtZone4")
1205
- temp_sup_tgt_zone_5: PatchConfigValue | None = _patch_field("TempSupTgtZone5")
1206
- temp_sup_tgt_zone_6: PatchConfigValue | None = _patch_field("TempSupTgtZone6")
1207
- temp_sup_tgt_zone_7: PatchConfigValue | None = _patch_field("TempSupTgtZone7")
1208
- temp_sup_tgt_zone_8: PatchConfigValue | None = _patch_field("TempSupTgtZone8")
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 = _patch_field("Bypass")
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 = _patch_field("General")
1223
- heat_recovery: PatchConfigHeatRecovery | None = _patch_field("HeatRecovery")
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 = _patch_field("Name")
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 = _patch_field("General")
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 = _patch_field("DeviceGroupConfig")
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 = _patch_field("Name")
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: DiagStatus
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.5.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.11; extra == "dev"
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
@@ -8,4 +8,4 @@ pip-audit>=2.7
8
8
  pytest>=8.0
9
9
  pytest-asyncio>=0.23
10
10
  pytest-cov>=5.0
11
- ruff>=0.11
11
+ ruff>=0.15.14
@@ -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
- DiagStatus,
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="Expected string Val for node config value /config/nodes/7.Name, got int",
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 == DiagStatus.OK
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 test_get_diagnostics_unknown_status_falls_back_to_unknown() -> None:
2068
- """Unknown diagnostic statuses should not break parsing."""
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 == DiagStatus.UNKNOWN
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
  ),