python-duco-connectivity 0.8.0__tar.gz → 0.9.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.9.0}/PKG-INFO +3 -1
  2. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.9.0}/README.md +2 -0
  3. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.9.0}/pyproject.toml +1 -1
  4. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.9.0}/src/duco_connectivity/client.py +37 -20
  5. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.9.0/src/python_duco_connectivity.egg-info}/PKG-INFO +3 -1
  6. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.9.0}/tests/test_client.py +56 -0
  7. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.9.0}/LICENSE +0 -0
  8. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.9.0}/setup.cfg +0 -0
  9. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.9.0}/src/duco_connectivity/__init__.py +0 -0
  10. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.9.0}/src/duco_connectivity/__main__.py +0 -0
  11. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.9.0}/src/duco_connectivity/cli.py +0 -0
  12. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.9.0}/src/duco_connectivity/exceptions.py +0 -0
  13. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.9.0}/src/duco_connectivity/models.py +0 -0
  14. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.9.0}/src/duco_connectivity/py.typed +0 -0
  15. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.9.0}/src/python_duco_connectivity.egg-info/SOURCES.txt +0 -0
  16. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.9.0}/src/python_duco_connectivity.egg-info/dependency_links.txt +0 -0
  17. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.9.0}/src/python_duco_connectivity.egg-info/entry_points.txt +0 -0
  18. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.9.0}/src/python_duco_connectivity.egg-info/requires.txt +0 -0
  19. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.9.0}/src/python_duco_connectivity.egg-info/top_level.txt +0 -0
  20. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.9.0}/tests/test_api_reference.py +0 -0
  21. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.9.0}/tests/test_cli.py +0 -0
  22. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.9.0}/tests/test_exceptions.py +0 -0
  23. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.9.0}/tests/test_local_sample_validation.py +0 -0
  24. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.9.0}/tests/test_models.py +0 -0
  25. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.9.0}/tests/test_pytest_live_support.py +0 -0
  26. {python_duco_connectivity-0.8.0 → python_duco_connectivity-0.9.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.9.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,6 +64,8 @@ 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
69
  - typed models that stay close to the API response shape
68
70
  - preserved `raw_payload` data on typed response models for forward compatibility
69
71
 
@@ -32,6 +32,8 @@ 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
37
  - typed models that stay close to the API response shape
36
38
  - preserved `raw_payload` data on typed response models for forward compatibility
37
39
 
@@ -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.9.0"
8
8
  description = "Async HTTP client for the local Duco Connectivity API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -105,6 +105,23 @@ def _compat_caller() -> str | None:
105
105
  return None
106
106
 
107
107
 
108
+ def _is_unsupported_optional_endpoint_error(error: DucoResponseError, path: str) -> bool:
109
+ """Return whether a Duco response reports an unavailable optional endpoint."""
110
+ if error.status != 400 or error.path != path:
111
+ return False
112
+
113
+ try:
114
+ error_payload = cast(object, json.loads(error.body))
115
+ except json.JSONDecodeError:
116
+ return False
117
+
118
+ if not isinstance(error_payload, dict):
119
+ return False
120
+
121
+ response = cast(dict[str, object], error_payload)
122
+ return response.get("Code") == 3 and response.get("Result") == "FAILED"
123
+
124
+
108
125
  class DucoClient:
109
126
  """Client for a Duco box that exposes the local HTTP API."""
110
127
 
@@ -2191,18 +2208,8 @@ class DucoClient:
2191
2208
  try:
2192
2209
  payload = await self.async_get_info(module=InfoModuleSelector.HEAT_RECOVERY)
2193
2210
  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
2211
+ if _is_unsupported_optional_endpoint_error(err, "/info"):
2212
+ return None
2206
2213
  raise
2207
2214
 
2208
2215
  if not isinstance(payload, dict):
@@ -2235,9 +2242,14 @@ class DucoClient:
2235
2242
  path="HeatRecovery.General",
2236
2243
  )
2237
2244
 
2238
- 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)
2245
+ async def async_get_ventilation_temperature_info(self) -> VentilationTemperatureInfo | None:
2246
+ """Return ventilation temperatures when the box exposes them, in Celsius."""
2247
+ try:
2248
+ payload = await self.async_get_info(module=InfoModuleSelector.VENTILATION)
2249
+ except DucoResponseError as err:
2250
+ if _is_unsupported_optional_endpoint_error(err, "/info"):
2251
+ return None
2252
+ raise
2241
2253
 
2242
2254
  if not isinstance(payload, dict):
2243
2255
  msg = (
@@ -2281,11 +2293,16 @@ class DucoClient:
2281
2293
  ) -> BypassSupplyTemperatureTarget | None:
2282
2294
  """Return a bypass supply target from `/config` in Celsius."""
2283
2295
  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
- )
2296
+ try:
2297
+ config = await self.async_get_config(
2298
+ module=ConfigModuleSelector.HEAT_RECOVERY,
2299
+ submodule=ConfigHeatRecoverySubmoduleSelector.BYPASS,
2300
+ parameter=parameter,
2301
+ )
2302
+ except DucoResponseError as err:
2303
+ if _is_unsupported_optional_endpoint_error(err, "/config"):
2304
+ return None
2305
+ raise
2289
2306
  return self._extract_bypass_supply_temperature_target(config, zone_id)
2290
2307
 
2291
2308
  async def async_set_bypass_supply_temperature_target(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-duco-connectivity
3
- Version: 0.8.0
3
+ Version: 0.9.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,6 +64,8 @@ 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
69
  - typed models that stay close to the API response shape
68
70
  - preserved `raw_payload` data on typed response models for forward compatibility
69
71
 
@@ -3688,6 +3688,34 @@ async def test_get_ventilation_temperature_info_returns_empty_model_when_sensor_
3688
3688
  assert payload.raw_payload == {}
3689
3689
 
3690
3690
 
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
+ mock_response = _response(
3694
+ status=400,
3695
+ text_payload='{"Code":3,"Result":"FAILED"}',
3696
+ )
3697
+
3698
+ async with aiohttp.ClientSession() as session:
3699
+ 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
+
3703
+ assert payload is None
3704
+
3705
+
3706
+ async def test_get_ventilation_temperature_info_reraises_other_client_errors() -> None:
3707
+ """Unexpected ventilation endpoint failures should remain visible to callers."""
3708
+ mock_response = _response(status=400, text_payload='{"Code":4,"Result":"FAILED"}')
3709
+
3710
+ async with aiohttp.ClientSession() as session:
3711
+ client = DucoClient(session=session, host="192.0.2.94")
3712
+ with (
3713
+ patch.object(session, "request", _request(mock_response)),
3714
+ pytest.raises(DucoResponseError),
3715
+ ):
3716
+ await client.async_get_ventilation_temperature_info()
3717
+
3718
+
3691
3719
  @pytest.mark.parametrize(
3692
3720
  ("payload", "message"),
3693
3721
  [
@@ -3772,6 +3800,34 @@ async def test_get_bypass_supply_temperature_target_returns_none_when_not_report
3772
3800
  assert payload is None
3773
3801
 
3774
3802
 
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."""
3805
+ mock_response = _response(
3806
+ status=400,
3807
+ text_payload='{"Code":3,"Result":"FAILED"}',
3808
+ )
3809
+
3810
+ async with aiohttp.ClientSession() as session:
3811
+ 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)
3814
+
3815
+ assert payload is None
3816
+
3817
+
3818
+ async def test_get_bypass_supply_temperature_target_reraises_other_client_errors() -> None:
3819
+ """Unexpected bypass target failures should remain visible to callers."""
3820
+ mock_response = _response(status=400, text_payload='{"Code":4,"Result":"FAILED"}')
3821
+
3822
+ async with aiohttp.ClientSession() as session:
3823
+ client = DucoClient(session=session, host="192.0.2.94")
3824
+ with (
3825
+ patch.object(session, "request", _request(mock_response)),
3826
+ pytest.raises(DucoResponseError),
3827
+ ):
3828
+ await client.async_get_bypass_supply_temperature_target(1)
3829
+
3830
+
3775
3831
  @pytest.mark.parametrize("zone_id", [0, 9, "1"])
3776
3832
  async def test_bypass_supply_temperature_helpers_reject_invalid_zone_ids(zone_id: object) -> None:
3777
3833
  """Bypass target helpers should validate the supported zone range."""