python-duco-connectivity 0.8.0__tar.gz → 0.10.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.8.0/src/python_duco_connectivity.egg-info → python_duco_connectivity-0.10.0}/PKG-INFO +13 -1
  2. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.10.0}/README.md +12 -0
  3. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.10.0}/pyproject.toml +1 -1
  4. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.10.0}/src/duco_connectivity/__init__.py +2 -0
  5. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.10.0}/src/duco_connectivity/client.py +45 -19
  6. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.10.0}/src/duco_connectivity/exceptions.py +10 -0
  7. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.10.0/src/python_duco_connectivity.egg-info}/PKG-INFO +13 -1
  8. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.10.0}/tests/test_api_reference.py +9 -0
  9. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.10.0}/tests/test_client.py +68 -2
  10. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.10.0}/tests/test_exceptions.py +17 -0
  11. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.10.0}/LICENSE +0 -0
  12. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.10.0}/setup.cfg +0 -0
  13. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.10.0}/src/duco_connectivity/__main__.py +0 -0
  14. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.10.0}/src/duco_connectivity/cli.py +0 -0
  15. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.10.0}/src/duco_connectivity/models.py +0 -0
  16. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.10.0}/src/duco_connectivity/py.typed +0 -0
  17. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.10.0}/src/python_duco_connectivity.egg-info/SOURCES.txt +0 -0
  18. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.10.0}/src/python_duco_connectivity.egg-info/dependency_links.txt +0 -0
  19. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.10.0}/src/python_duco_connectivity.egg-info/entry_points.txt +0 -0
  20. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.10.0}/src/python_duco_connectivity.egg-info/requires.txt +0 -0
  21. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.10.0}/src/python_duco_connectivity.egg-info/top_level.txt +0 -0
  22. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.10.0}/tests/test_cli.py +0 -0
  23. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.10.0}/tests/test_local_sample_validation.py +0 -0
  24. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.10.0}/tests/test_models.py +0 -0
  25. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.10.0}/tests/test_pytest_live_support.py +0 -0
  26. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.10.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.8.0
3
+ Version: 0.10.0
4
4
  Summary: Async HTTP client for the local Duco Connectivity API
5
5
  Author: Ronald van der Meer
6
6
  License-Expression: MIT
@@ -64,9 +64,21 @@ in the development examples below.
64
64
  - asynchronous communication via `aiohttp`
65
65
  - typed stable config families for the documented `/config` branches
66
66
  - typed helpers for stable `/info` fields such as heat recovery filter time
67
+ - optional endpoint helpers with explicit unsupported-capability errors
67
68
  - typed models that stay close to the API response shape
68
69
  - preserved `raw_payload` data on typed response models for forward compatibility
69
70
 
71
+ ## Error handling
72
+
73
+ When a Duco box explicitly reports that the optional ventilation-temperature or
74
+ bypass-target endpoint is unsupported, the relevant helper raises
75
+ `DucoUnsupportedCapabilityError`. This exception is a `DucoResponseError`
76
+ subclass and preserves the HTTP status, path, and response body. A valid
77
+ ventilation-temperature response always returns a `VentilationTemperatureInfo`
78
+ model, whose individual temperature fields can be `None` when omitted. The
79
+ bypass-target helper returns `None` when its target is omitted from a valid
80
+ response.
81
+
70
82
  Diagnostic subsystem reads now keep raw component and status strings from
71
83
  `Diag.SubSystems`, so future subsystem names or status values remain available
72
84
  to downstream consumers without parse fallbacks or product-specific filtering.
@@ -32,9 +32,21 @@ in the development examples below.
32
32
  - asynchronous communication via `aiohttp`
33
33
  - typed stable config families for the documented `/config` branches
34
34
  - typed helpers for stable `/info` fields such as heat recovery filter time
35
+ - optional endpoint helpers with explicit unsupported-capability errors
35
36
  - typed models that stay close to the API response shape
36
37
  - preserved `raw_payload` data on typed response models for forward compatibility
37
38
 
39
+ ## Error handling
40
+
41
+ When a Duco box explicitly reports that the optional ventilation-temperature or
42
+ bypass-target endpoint is unsupported, the relevant helper raises
43
+ `DucoUnsupportedCapabilityError`. This exception is a `DucoResponseError`
44
+ subclass and preserves the HTTP status, path, and response body. A valid
45
+ ventilation-temperature response always returns a `VentilationTemperatureInfo`
46
+ model, whose individual temperature fields can be `None` when omitted. The
47
+ bypass-target helper returns `None` when its target is omitted from a valid
48
+ response.
49
+
38
50
  Diagnostic subsystem reads now keep raw component and status strings from
39
51
  `Diag.SubSystems`, so future subsystem names or status values remain available
40
52
  to downstream consumers without parse fallbacks or product-specific filtering.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-duco-connectivity"
7
- version = "0.8.0"
7
+ version = "0.10.0"
8
8
  description = "Async HTTP client for the local Duco Connectivity API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -8,6 +8,7 @@ from .exceptions import (
8
8
  DucoError,
9
9
  DucoRateLimitError,
10
10
  DucoResponseError,
11
+ DucoUnsupportedCapabilityError,
11
12
  DucoWriteLimitError,
12
13
  )
13
14
  from .models import (
@@ -172,6 +173,7 @@ __all__ = [
172
173
  "DucoRateLimitError",
173
174
  "DucoResponseError",
174
175
  "DucoSerialNumber",
176
+ "DucoUnsupportedCapabilityError",
175
177
  "DucoVersion",
176
178
  "DucoWriteLimitError",
177
179
  "HostName",
@@ -16,6 +16,7 @@ from .exceptions import (
16
16
  DucoConnectionError,
17
17
  DucoError,
18
18
  DucoResponseError,
19
+ DucoUnsupportedCapabilityError,
19
20
  DucoWriteLimitError,
20
21
  )
21
22
  from .models import (
@@ -105,6 +106,23 @@ def _compat_caller() -> str | None:
105
106
  return None
106
107
 
107
108
 
109
+ def _is_unsupported_optional_endpoint_error(error: DucoResponseError, path: str) -> bool:
110
+ """Return whether a Duco response reports an unavailable optional endpoint."""
111
+ if error.status != 400 or error.path != path:
112
+ return False
113
+
114
+ try:
115
+ error_payload = cast(object, json.loads(error.body))
116
+ except json.JSONDecodeError:
117
+ return False
118
+
119
+ if not isinstance(error_payload, dict):
120
+ return False
121
+
122
+ response = cast(dict[str, object], error_payload)
123
+ return response.get("Code") == 3 and response.get("Result") == "FAILED"
124
+
125
+
108
126
  class DucoClient:
109
127
  """Client for a Duco box that exposes the local HTTP API."""
110
128
 
@@ -2191,18 +2209,8 @@ class DucoClient:
2191
2209
  try:
2192
2210
  payload = await self.async_get_info(module=InfoModuleSelector.HEAT_RECOVERY)
2193
2211
  except DucoResponseError as err:
2194
- if err.status == 400 and err.path == "/info":
2195
- try:
2196
- error_payload = json.loads(err.body)
2197
- except json.JSONDecodeError:
2198
- pass
2199
- else:
2200
- if (
2201
- isinstance(error_payload, dict)
2202
- and error_payload.get("Code") == 3
2203
- and error_payload.get("Result") == "FAILED"
2204
- ):
2205
- return None
2212
+ if _is_unsupported_optional_endpoint_error(err, "/info"):
2213
+ return None
2206
2214
  raise
2207
2215
 
2208
2216
  if not isinstance(payload, dict):
@@ -2236,8 +2244,17 @@ class DucoClient:
2236
2244
  )
2237
2245
 
2238
2246
  async def async_get_ventilation_temperature_info(self) -> VentilationTemperatureInfo:
2239
- """Return ventilation temperatures from `/info?module=Ventilation` in Celsius."""
2240
- payload = await self.async_get_info(module=InfoModuleSelector.VENTILATION)
2247
+ """Return ventilation temperatures when the box exposes them, in Celsius."""
2248
+ try:
2249
+ payload = await self.async_get_info(module=InfoModuleSelector.VENTILATION)
2250
+ except DucoResponseError as err:
2251
+ if _is_unsupported_optional_endpoint_error(err, "/info"):
2252
+ raise DucoUnsupportedCapabilityError(
2253
+ err.status,
2254
+ err.path,
2255
+ err.body,
2256
+ ) from err
2257
+ raise
2241
2258
 
2242
2259
  if not isinstance(payload, dict):
2243
2260
  msg = (
@@ -2281,11 +2298,20 @@ class DucoClient:
2281
2298
  ) -> BypassSupplyTemperatureTarget | None:
2282
2299
  """Return a bypass supply target from `/config` in Celsius."""
2283
2300
  parameter = self._validate_bypass_zone_id(zone_id)
2284
- config = await self.async_get_config(
2285
- module=ConfigModuleSelector.HEAT_RECOVERY,
2286
- submodule=ConfigHeatRecoverySubmoduleSelector.BYPASS,
2287
- parameter=parameter,
2288
- )
2301
+ try:
2302
+ config = await self.async_get_config(
2303
+ module=ConfigModuleSelector.HEAT_RECOVERY,
2304
+ submodule=ConfigHeatRecoverySubmoduleSelector.BYPASS,
2305
+ parameter=parameter,
2306
+ )
2307
+ except DucoResponseError as err:
2308
+ if _is_unsupported_optional_endpoint_error(err, "/config"):
2309
+ raise DucoUnsupportedCapabilityError(
2310
+ err.status,
2311
+ err.path,
2312
+ err.body,
2313
+ ) from err
2314
+ raise
2289
2315
  return self._extract_bypass_supply_temperature_target(config, zone_id)
2290
2316
 
2291
2317
  async def async_set_bypass_supply_temperature_target(
@@ -32,6 +32,16 @@ class DucoResponseError(DucoError):
32
32
  super().__init__(detail)
33
33
 
34
34
 
35
+ class DucoUnsupportedCapabilityError(DucoResponseError):
36
+ """Raised when the box does not support an optional capability."""
37
+
38
+ def __init__(self, status: int, path: str, body: str = "") -> None:
39
+ detail = f"Unsupported capability for {path}"
40
+ if body.strip():
41
+ detail = f"{detail}: {body}"
42
+ super().__init__(status, path, body, message=detail)
43
+
44
+
35
45
  class DucoWriteLimitError(DucoResponseError):
36
46
  """Raised when the box rejects writes because its budget is exhausted."""
37
47
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-duco-connectivity
3
- Version: 0.8.0
3
+ Version: 0.10.0
4
4
  Summary: Async HTTP client for the local Duco Connectivity API
5
5
  Author: Ronald van der Meer
6
6
  License-Expression: MIT
@@ -64,9 +64,21 @@ in the development examples below.
64
64
  - asynchronous communication via `aiohttp`
65
65
  - typed stable config families for the documented `/config` branches
66
66
  - typed helpers for stable `/info` fields such as heat recovery filter time
67
+ - optional endpoint helpers with explicit unsupported-capability errors
67
68
  - typed models that stay close to the API response shape
68
69
  - preserved `raw_payload` data on typed response models for forward compatibility
69
70
 
71
+ ## Error handling
72
+
73
+ When a Duco box explicitly reports that the optional ventilation-temperature or
74
+ bypass-target endpoint is unsupported, the relevant helper raises
75
+ `DucoUnsupportedCapabilityError`. This exception is a `DucoResponseError`
76
+ subclass and preserves the HTTP status, path, and response body. A valid
77
+ ventilation-temperature response always returns a `VentilationTemperatureInfo`
78
+ model, whose individual temperature fields can be `None` when omitted. The
79
+ bypass-target helper returns `None` when its target is omitted from a valid
80
+ response.
81
+
70
82
  Diagnostic subsystem reads now keep raw component and status strings from
71
83
  `Diag.SubSystems`, so future subsystem names or status values remain available
72
84
  to downstream consumers without parse fallbacks or product-specific filtering.
@@ -85,6 +85,15 @@ def test_api_reference_contains_expected_sections() -> None:
85
85
  assert "selectors.md" in content
86
86
 
87
87
 
88
+ def test_api_reference_documents_optional_capability_contracts() -> None:
89
+ """Optional endpoint return and exception contracts should stay explicit."""
90
+ content = render_api_reference()
91
+
92
+ assert "optional endpoint as unsupported" in content
93
+ assert "target is omitted from an otherwise valid response" in content
94
+ assert "optional target endpoint is unsupported" in content
95
+
96
+
88
97
  def test_api_reference_hides_compatibility_only_method_aliases() -> None:
89
98
  """Compatibility-only client aliases should not be published as documented methods."""
90
99
  content = render_api_reference()
@@ -47,6 +47,7 @@ from duco_connectivity import (
47
47
  DucoError,
48
48
  DucoResponseError,
49
49
  DucoSerialNumber,
50
+ DucoUnsupportedCapabilityError,
50
51
  DucoVersion,
51
52
  DucoWriteLimitError,
52
53
  HostName,
@@ -3666,6 +3667,7 @@ async def test_get_ventilation_temperature_info_is_parsed(
3666
3667
  with patch.object(session, "request", _request(mock_response)):
3667
3668
  payload = await client.async_get_ventilation_temperature_info()
3668
3669
 
3670
+ assert_type(payload, VentilationTemperatureInfo)
3669
3671
  assert payload == VentilationTemperatureInfo(
3670
3672
  temp_oda=17.5,
3671
3673
  temp_sup=18.0,
@@ -3688,6 +3690,38 @@ async def test_get_ventilation_temperature_info_returns_empty_model_when_sensor_
3688
3690
  assert payload.raw_payload == {}
3689
3691
 
3690
3692
 
3693
+ async def test_get_ventilation_temperature_info_raises_for_unsupported_module() -> None:
3694
+ """Unsupported ventilation modules should raise a typed capability error."""
3695
+ mock_response = _response(
3696
+ status=400,
3697
+ text_payload='{"Code":3,"Result":"FAILED"}',
3698
+ )
3699
+
3700
+ async with aiohttp.ClientSession() as session:
3701
+ client = DucoClient(session=session, host="192.0.2.94")
3702
+ with (
3703
+ patch.object(session, "request", _request(mock_response)),
3704
+ pytest.raises(DucoUnsupportedCapabilityError) as err_info,
3705
+ ):
3706
+ await client.async_get_ventilation_temperature_info()
3707
+
3708
+ assert err_info.value.status == 400
3709
+ assert err_info.value.path == "/info"
3710
+
3711
+
3712
+ async def test_get_ventilation_temperature_info_reraises_other_client_errors() -> None:
3713
+ """Unexpected ventilation endpoint failures should remain visible to callers."""
3714
+ mock_response = _response(status=400, text_payload='{"Code":4,"Result":"FAILED"}')
3715
+
3716
+ async with aiohttp.ClientSession() as session:
3717
+ client = DucoClient(session=session, host="192.0.2.94")
3718
+ with (
3719
+ patch.object(session, "request", _request(mock_response)),
3720
+ pytest.raises(DucoResponseError),
3721
+ ):
3722
+ await client.async_get_ventilation_temperature_info()
3723
+
3724
+
3691
3725
  @pytest.mark.parametrize(
3692
3726
  ("payload", "message"),
3693
3727
  [
@@ -3772,6 +3806,38 @@ async def test_get_bypass_supply_temperature_target_returns_none_when_not_report
3772
3806
  assert payload is None
3773
3807
 
3774
3808
 
3809
+ async def test_get_bypass_supply_temperature_target_raises_when_unsupported() -> None:
3810
+ """Unsupported bypass targets should raise a typed capability error."""
3811
+ mock_response = _response(
3812
+ status=400,
3813
+ text_payload='{"Code":3,"Result":"FAILED"}',
3814
+ )
3815
+
3816
+ async with aiohttp.ClientSession() as session:
3817
+ client = DucoClient(session=session, host="192.0.2.94")
3818
+ with (
3819
+ patch.object(session, "request", _request(mock_response)),
3820
+ pytest.raises(DucoUnsupportedCapabilityError) as err_info,
3821
+ ):
3822
+ await client.async_get_bypass_supply_temperature_target(1)
3823
+
3824
+ assert err_info.value.status == 400
3825
+ assert err_info.value.path == "/config"
3826
+
3827
+
3828
+ async def test_get_bypass_supply_temperature_target_reraises_other_client_errors() -> None:
3829
+ """Unexpected bypass target failures should remain visible to callers."""
3830
+ mock_response = _response(status=400, text_payload='{"Code":4,"Result":"FAILED"}')
3831
+
3832
+ async with aiohttp.ClientSession() as session:
3833
+ client = DucoClient(session=session, host="192.0.2.94")
3834
+ with (
3835
+ patch.object(session, "request", _request(mock_response)),
3836
+ pytest.raises(DucoResponseError),
3837
+ ):
3838
+ await client.async_get_bypass_supply_temperature_target(1)
3839
+
3840
+
3775
3841
  @pytest.mark.parametrize("zone_id", [0, 9, "1"])
3776
3842
  async def test_bypass_supply_temperature_helpers_reject_invalid_zone_ids(zone_id: object) -> None:
3777
3843
  """Bypass target helpers should validate the supported zone range."""
@@ -3871,8 +3937,8 @@ async def test_get_time_filter_remaining_returns_none_when_not_reported() -> Non
3871
3937
  assert remaining is None
3872
3938
 
3873
3939
 
3874
- async def test_get_time_filter_remaining_returns_none_when_module_unsupported() -> None:
3875
- """Heat recovery filter time should stay optional for boxes without that module."""
3940
+ async def test_get_time_filter_remaining_returns_none_for_unsupported_module() -> None:
3941
+ """Unsupported heat recovery modules should remain an optional value."""
3876
3942
  mock_response = _response(
3877
3943
  status=400,
3878
3944
  text_payload='{"Code":3,"Result":"FAILED"}',
@@ -7,6 +7,7 @@ from duco_connectivity import (
7
7
  DucoError,
8
8
  DucoRateLimitError,
9
9
  DucoResponseError,
10
+ DucoUnsupportedCapabilityError,
10
11
  DucoWriteLimitError,
11
12
  )
12
13
 
@@ -41,6 +42,22 @@ def test_response_error_omits_empty_body_from_default_message(body: str) -> None
41
42
  assert str(err) == "Unexpected response 404 for /info"
42
43
 
43
44
 
45
+ def test_unsupported_capability_error_preserves_response_context() -> None:
46
+ """DucoUnsupportedCapabilityError should retain HTTP response context."""
47
+ err = DucoUnsupportedCapabilityError(400, "/info", "unsupported")
48
+ assert isinstance(err, DucoResponseError)
49
+ assert err.status == 400
50
+ assert err.path == "/info"
51
+ assert err.body == "unsupported"
52
+ assert str(err) == "Unsupported capability for /info: unsupported"
53
+
54
+
55
+ def test_unsupported_capability_error_omits_empty_body_from_message() -> None:
56
+ """DucoUnsupportedCapabilityError should omit an empty body from its message."""
57
+ err = DucoUnsupportedCapabilityError(400, "/info", " ")
58
+ assert str(err) == "Unsupported capability for /info"
59
+
60
+
44
61
  def test_write_limit_error_stores_remaining_count() -> None:
45
62
  """DucoWriteLimitError should expose the remaining count when present."""
46
63
  err = DucoWriteLimitError(remaining=10)