python-duco-connectivity 0.9.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.9.0/src/python_duco_connectivity.egg-info → python_duco_connectivity-0.10.0}/PKG-INFO +13 -3
  2. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.10.0}/README.md +12 -2
  3. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.10.0}/pyproject.toml +1 -1
  4. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.10.0}/src/duco_connectivity/__init__.py +2 -0
  5. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.10.0}/src/duco_connectivity/client.py +12 -3
  6. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.10.0}/src/duco_connectivity/exceptions.py +10 -0
  7. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.10.0/src/python_duco_connectivity.egg-info}/PKG-INFO +13 -3
  8. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.10.0}/tests/test_api_reference.py +9 -0
  9. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.10.0}/tests/test_client.py +22 -12
  10. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.10.0}/tests/test_exceptions.py +17 -0
  11. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.10.0}/LICENSE +0 -0
  12. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.10.0}/setup.cfg +0 -0
  13. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.10.0}/src/duco_connectivity/__main__.py +0 -0
  14. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.10.0}/src/duco_connectivity/cli.py +0 -0
  15. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.10.0}/src/duco_connectivity/models.py +0 -0
  16. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.10.0}/src/duco_connectivity/py.typed +0 -0
  17. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.10.0}/src/python_duco_connectivity.egg-info/SOURCES.txt +0 -0
  18. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.10.0}/src/python_duco_connectivity.egg-info/dependency_links.txt +0 -0
  19. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.10.0}/src/python_duco_connectivity.egg-info/entry_points.txt +0 -0
  20. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.10.0}/src/python_duco_connectivity.egg-info/requires.txt +0 -0
  21. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.10.0}/src/python_duco_connectivity.egg-info/top_level.txt +0 -0
  22. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.10.0}/tests/test_cli.py +0 -0
  23. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.10.0}/tests/test_local_sample_validation.py +0 -0
  24. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.10.0}/tests/test_models.py +0 -0
  25. {python_duco_connectivity-0.9.0 → python_duco_connectivity-0.10.0}/tests/test_pytest_live_support.py +0 -0
  26. {python_duco_connectivity-0.9.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.9.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,11 +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 temperature helpers that return `None` when the box reports their
68
- endpoint as unavailable
67
+ - optional endpoint helpers with explicit unsupported-capability errors
69
68
  - typed models that stay close to the API response shape
70
69
  - preserved `raw_payload` data on typed response models for forward compatibility
71
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
+
72
82
  Diagnostic subsystem reads now keep raw component and status strings from
73
83
  `Diag.SubSystems`, so future subsystem names or status values remain available
74
84
  to downstream consumers without parse fallbacks or product-specific filtering.
@@ -32,11 +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 temperature helpers that return `None` when the box reports their
36
- endpoint as unavailable
35
+ - optional endpoint helpers with explicit unsupported-capability errors
37
36
  - typed models that stay close to the API response shape
38
37
  - preserved `raw_payload` data on typed response models for forward compatibility
39
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
+
40
50
  Diagnostic subsystem reads now keep raw component and status strings from
41
51
  `Diag.SubSystems`, so future subsystem names or status values remain available
42
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.9.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 (
@@ -2242,13 +2243,17 @@ class DucoClient:
2242
2243
  path="HeatRecovery.General",
2243
2244
  )
2244
2245
 
2245
- async def async_get_ventilation_temperature_info(self) -> VentilationTemperatureInfo | None:
2246
+ async def async_get_ventilation_temperature_info(self) -> VentilationTemperatureInfo:
2246
2247
  """Return ventilation temperatures when the box exposes them, in Celsius."""
2247
2248
  try:
2248
2249
  payload = await self.async_get_info(module=InfoModuleSelector.VENTILATION)
2249
2250
  except DucoResponseError as err:
2250
2251
  if _is_unsupported_optional_endpoint_error(err, "/info"):
2251
- return None
2252
+ raise DucoUnsupportedCapabilityError(
2253
+ err.status,
2254
+ err.path,
2255
+ err.body,
2256
+ ) from err
2252
2257
  raise
2253
2258
 
2254
2259
  if not isinstance(payload, dict):
@@ -2301,7 +2306,11 @@ class DucoClient:
2301
2306
  )
2302
2307
  except DucoResponseError as err:
2303
2308
  if _is_unsupported_optional_endpoint_error(err, "/config"):
2304
- return None
2309
+ raise DucoUnsupportedCapabilityError(
2310
+ err.status,
2311
+ err.path,
2312
+ err.body,
2313
+ ) from err
2305
2314
  raise
2306
2315
  return self._extract_bypass_supply_temperature_target(config, zone_id)
2307
2316
 
@@ -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.9.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,11 +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 temperature helpers that return `None` when the box reports their
68
- endpoint as unavailable
67
+ - optional endpoint helpers with explicit unsupported-capability errors
69
68
  - typed models that stay close to the API response shape
70
69
  - preserved `raw_payload` data on typed response models for forward compatibility
71
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
+
72
82
  Diagnostic subsystem reads now keep raw component and status strings from
73
83
  `Diag.SubSystems`, so future subsystem names or status values remain available
74
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,8 +3690,8 @@ async def test_get_ventilation_temperature_info_returns_empty_model_when_sensor_
3688
3690
  assert payload.raw_payload == {}
3689
3691
 
3690
3692
 
3691
- async def test_get_ventilation_temperature_info_returns_none_when_module_unsupported() -> None:
3692
- """Unsupported ventilation modules should be treated as an absent optional capability."""
3693
+ async def test_get_ventilation_temperature_info_raises_for_unsupported_module() -> None:
3694
+ """Unsupported ventilation modules should raise a typed capability error."""
3693
3695
  mock_response = _response(
3694
3696
  status=400,
3695
3697
  text_payload='{"Code":3,"Result":"FAILED"}',
@@ -3697,10 +3699,14 @@ async def test_get_ventilation_temperature_info_returns_none_when_module_unsuppo
3697
3699
 
3698
3700
  async with aiohttp.ClientSession() as session:
3699
3701
  client = DucoClient(session=session, host="192.0.2.94")
3700
- with patch.object(session, "request", _request(mock_response)):
3701
- payload = await client.async_get_ventilation_temperature_info()
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()
3702
3707
 
3703
- assert payload is None
3708
+ assert err_info.value.status == 400
3709
+ assert err_info.value.path == "/info"
3704
3710
 
3705
3711
 
3706
3712
  async def test_get_ventilation_temperature_info_reraises_other_client_errors() -> None:
@@ -3800,8 +3806,8 @@ async def test_get_bypass_supply_temperature_target_returns_none_when_not_report
3800
3806
  assert payload is None
3801
3807
 
3802
3808
 
3803
- async def test_get_bypass_supply_temperature_target_returns_none_when_unsupported() -> None:
3804
- """Unsupported bypass targets should be treated as an absent optional capability."""
3809
+ async def test_get_bypass_supply_temperature_target_raises_when_unsupported() -> None:
3810
+ """Unsupported bypass targets should raise a typed capability error."""
3805
3811
  mock_response = _response(
3806
3812
  status=400,
3807
3813
  text_payload='{"Code":3,"Result":"FAILED"}',
@@ -3809,10 +3815,14 @@ async def test_get_bypass_supply_temperature_target_returns_none_when_unsupporte
3809
3815
 
3810
3816
  async with aiohttp.ClientSession() as session:
3811
3817
  client = DucoClient(session=session, host="192.0.2.94")
3812
- with patch.object(session, "request", _request(mock_response)):
3813
- payload = await client.async_get_bypass_supply_temperature_target(1)
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)
3814
3823
 
3815
- assert payload is None
3824
+ assert err_info.value.status == 400
3825
+ assert err_info.value.path == "/config"
3816
3826
 
3817
3827
 
3818
3828
  async def test_get_bypass_supply_temperature_target_reraises_other_client_errors() -> None:
@@ -3927,8 +3937,8 @@ async def test_get_time_filter_remaining_returns_none_when_not_reported() -> Non
3927
3937
  assert remaining is None
3928
3938
 
3929
3939
 
3930
- async def test_get_time_filter_remaining_returns_none_when_module_unsupported() -> None:
3931
- """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."""
3932
3942
  mock_response = _response(
3933
3943
  status=400,
3934
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)