python-duco-connectivity 0.6.0__tar.gz → 0.7.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.6.0/src/python_duco_connectivity.egg-info → python_duco_connectivity-0.7.0}/PKG-INFO +3 -1
  2. {python_duco_connectivity-0.6.0 → python_duco_connectivity-0.7.0}/README.md +2 -0
  3. {python_duco_connectivity-0.6.0 → python_duco_connectivity-0.7.0}/pyproject.toml +1 -1
  4. {python_duco_connectivity-0.6.0 → python_duco_connectivity-0.7.0}/src/duco_connectivity/client.py +58 -0
  5. {python_duco_connectivity-0.6.0 → python_duco_connectivity-0.7.0/src/python_duco_connectivity.egg-info}/PKG-INFO +3 -1
  6. {python_duco_connectivity-0.6.0 → python_duco_connectivity-0.7.0}/tests/test_client.py +82 -0
  7. {python_duco_connectivity-0.6.0 → python_duco_connectivity-0.7.0}/LICENSE +0 -0
  8. {python_duco_connectivity-0.6.0 → python_duco_connectivity-0.7.0}/setup.cfg +0 -0
  9. {python_duco_connectivity-0.6.0 → python_duco_connectivity-0.7.0}/src/duco_connectivity/__init__.py +0 -0
  10. {python_duco_connectivity-0.6.0 → python_duco_connectivity-0.7.0}/src/duco_connectivity/__main__.py +0 -0
  11. {python_duco_connectivity-0.6.0 → python_duco_connectivity-0.7.0}/src/duco_connectivity/cli.py +0 -0
  12. {python_duco_connectivity-0.6.0 → python_duco_connectivity-0.7.0}/src/duco_connectivity/exceptions.py +0 -0
  13. {python_duco_connectivity-0.6.0 → python_duco_connectivity-0.7.0}/src/duco_connectivity/models.py +0 -0
  14. {python_duco_connectivity-0.6.0 → python_duco_connectivity-0.7.0}/src/duco_connectivity/py.typed +0 -0
  15. {python_duco_connectivity-0.6.0 → python_duco_connectivity-0.7.0}/src/python_duco_connectivity.egg-info/SOURCES.txt +0 -0
  16. {python_duco_connectivity-0.6.0 → python_duco_connectivity-0.7.0}/src/python_duco_connectivity.egg-info/dependency_links.txt +0 -0
  17. {python_duco_connectivity-0.6.0 → python_duco_connectivity-0.7.0}/src/python_duco_connectivity.egg-info/entry_points.txt +0 -0
  18. {python_duco_connectivity-0.6.0 → python_duco_connectivity-0.7.0}/src/python_duco_connectivity.egg-info/requires.txt +0 -0
  19. {python_duco_connectivity-0.6.0 → python_duco_connectivity-0.7.0}/src/python_duco_connectivity.egg-info/top_level.txt +0 -0
  20. {python_duco_connectivity-0.6.0 → python_duco_connectivity-0.7.0}/tests/test_api_reference.py +0 -0
  21. {python_duco_connectivity-0.6.0 → python_duco_connectivity-0.7.0}/tests/test_cli.py +0 -0
  22. {python_duco_connectivity-0.6.0 → python_duco_connectivity-0.7.0}/tests/test_exceptions.py +0 -0
  23. {python_duco_connectivity-0.6.0 → python_duco_connectivity-0.7.0}/tests/test_local_sample_validation.py +0 -0
  24. {python_duco_connectivity-0.6.0 → python_duco_connectivity-0.7.0}/tests/test_models.py +0 -0
  25. {python_duco_connectivity-0.6.0 → python_duco_connectivity-0.7.0}/tests/test_pytest_live_support.py +0 -0
  26. {python_duco_connectivity-0.6.0 → python_duco_connectivity-0.7.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.6.0
3
+ Version: 0.7.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
@@ -63,6 +63,7 @@ in the development examples below.
63
63
  - HTTP only
64
64
  - asynchronous communication via `aiohttp`
65
65
  - typed stable config families for the documented `/config` branches
66
+ - typed helpers for stable `/info` fields such as heat recovery filter time
66
67
  - typed models that stay close to the API response shape
67
68
  - preserved `raw_payload` data on typed response models for forward compatibility
68
69
 
@@ -192,6 +193,7 @@ development pass, covering:
192
193
  - `PATCH /config` with a no-op `TimeZone` write against the current value
193
194
  - `GET /info?module=General&submodule=Board`
194
195
  - `GET /info?module=General&submodule=Lan`
196
+ - `GET /info?module=HeatRecovery`
195
197
  - `GET /info/nodes`
196
198
  - `GET /info?module=General&submodule=PublicApi`
197
199
  - `POST /action/nodes/{node}` with a no-op `SetVentilationState`
@@ -31,6 +31,7 @@ in the development examples below.
31
31
  - HTTP only
32
32
  - asynchronous communication via `aiohttp`
33
33
  - typed stable config families for the documented `/config` branches
34
+ - typed helpers for stable `/info` fields such as heat recovery filter time
34
35
  - typed models that stay close to the API response shape
35
36
  - preserved `raw_payload` data on typed response models for forward compatibility
36
37
 
@@ -160,6 +161,7 @@ development pass, covering:
160
161
  - `PATCH /config` with a no-op `TimeZone` write against the current value
161
162
  - `GET /info?module=General&submodule=Board`
162
163
  - `GET /info?module=General&submodule=Lan`
164
+ - `GET /info?module=HeatRecovery`
163
165
  - `GET /info/nodes`
164
166
  - `GET /info?module=General&submodule=PublicApi`
165
167
  - `POST /action/nodes/{node}` with a no-op `SetVentilationState`
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-duco-connectivity"
7
- version = "0.6.0"
7
+ version = "0.7.0"
8
8
  description = "Async HTTP client for the local Duco Connectivity API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -281,6 +281,31 @@ class DucoClient:
281
281
  def _read_wrapped_value(payload: dict[str, Any], key: str) -> Any:
282
282
  return payload[key]["Val"]
283
283
 
284
+ @staticmethod
285
+ def _read_optional_wrapped_int(
286
+ payload: dict[str, Any],
287
+ key: str,
288
+ *,
289
+ path: str,
290
+ ) -> int | None:
291
+ """Read an optional wrapped integer from a typed-stable API branch."""
292
+ if key not in payload:
293
+ return None
294
+ raw_entry = payload[key]
295
+ if not isinstance(raw_entry, dict):
296
+ msg = f"Expected wrapped Val object for {path}.{key}, got {type(raw_entry).__name__}"
297
+ raise DucoError(msg)
298
+ if "Val" not in raw_entry:
299
+ msg = f"Expected wrapped Val object for {path}.{key}"
300
+ raise DucoError(msg)
301
+
302
+ raw_value = raw_entry["Val"]
303
+ if type(raw_value) is not int:
304
+ msg = f"Expected integer value for {path}.{key}, got {type(raw_value).__name__}"
305
+ raise DucoError(msg)
306
+
307
+ return raw_value
308
+
284
309
  @staticmethod
285
310
  def _read_scalar_value(payload: dict[str, Any], key: str) -> Any:
286
311
  raw_value = payload[key]
@@ -2085,6 +2110,39 @@ class DucoClient:
2085
2110
  )
2086
2111
  return int(self._read_wrapped_value(payload["General"]["PublicApi"], "WriteReqCntRemain"))
2087
2112
 
2113
+ async def async_get_time_filter_remaining(self) -> int | None:
2114
+ """Return the remaining heat recovery filter time when the box reports it."""
2115
+ payload = await self.async_get_info(module=InfoModuleSelector.HEAT_RECOVERY)
2116
+ if not isinstance(payload, dict):
2117
+ msg = (
2118
+ "Expected object payload from /info?module=HeatRecovery, got "
2119
+ f"{type(payload).__name__}"
2120
+ )
2121
+ raise DucoError(msg)
2122
+
2123
+ heat_recovery = payload.get("HeatRecovery")
2124
+ if heat_recovery is None:
2125
+ return None
2126
+ if not isinstance(heat_recovery, dict):
2127
+ msg = "Expected object payload at HeatRecovery in /info?module=HeatRecovery response"
2128
+ raise DucoError(msg)
2129
+
2130
+ general = heat_recovery.get("General")
2131
+ if general is None:
2132
+ return None
2133
+ if not isinstance(general, dict):
2134
+ msg = (
2135
+ "Expected object payload at HeatRecovery.General in "
2136
+ "/info?module=HeatRecovery response"
2137
+ )
2138
+ raise DucoError(msg)
2139
+
2140
+ return self._read_optional_wrapped_int(
2141
+ general,
2142
+ "TimeFilterRemain",
2143
+ path="HeatRecovery.General",
2144
+ )
2145
+
2088
2146
  async def async_get_write_req_remaining(self) -> int:
2089
2147
  """Backward-compatible alias for the old write budget method name."""
2090
2148
  caller = _compat_caller()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-duco-connectivity
3
- Version: 0.6.0
3
+ Version: 0.7.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
@@ -63,6 +63,7 @@ in the development examples below.
63
63
  - HTTP only
64
64
  - asynchronous communication via `aiohttp`
65
65
  - typed stable config families for the documented `/config` branches
66
+ - typed helpers for stable `/info` fields such as heat recovery filter time
66
67
  - typed models that stay close to the API response shape
67
68
  - preserved `raw_payload` data on typed response models for forward compatibility
68
69
 
@@ -192,6 +193,7 @@ development pass, covering:
192
193
  - `PATCH /config` with a no-op `TimeZone` write against the current value
193
194
  - `GET /info?module=General&submodule=Board`
194
195
  - `GET /info?module=General&submodule=Lan`
196
+ - `GET /info?module=HeatRecovery`
195
197
  - `GET /info/nodes`
196
198
  - `GET /info?module=General&submodule=PublicApi`
197
199
  - `POST /action/nodes/{node}` with a no-op `SetVentilationState`
@@ -3666,6 +3666,88 @@ async def test_get_write_requests_remaining_is_parsed() -> None:
3666
3666
  assert remaining == 197
3667
3667
 
3668
3668
 
3669
+ async def test_get_time_filter_remaining_is_parsed() -> None:
3670
+ """Heat recovery filter time should be parsed when the box reports it."""
3671
+ payload: dict[str, object] = {"HeatRecovery": {"General": {"TimeFilterRemain": {"Val": 180}}}}
3672
+ mock_response = _response(json_payload=payload)
3673
+
3674
+ async with aiohttp.ClientSession() as session:
3675
+ client = DucoClient(session=session, host="192.0.2.94")
3676
+ with patch.object(session, "request", _request(mock_response)):
3677
+ remaining = await client.async_get_time_filter_remaining()
3678
+
3679
+ assert remaining == 180
3680
+
3681
+
3682
+ async def test_get_time_filter_remaining_returns_none_when_not_reported() -> None:
3683
+ """Heat recovery filter time should stay optional for boxes that omit it."""
3684
+ payload: dict[str, object] = {"HeatRecovery": {"General": {}}}
3685
+ mock_response = _response(json_payload=payload)
3686
+
3687
+ async with aiohttp.ClientSession() as session:
3688
+ client = DucoClient(session=session, host="192.0.2.94")
3689
+ with patch.object(session, "request", _request(mock_response)):
3690
+ remaining = await client.async_get_time_filter_remaining()
3691
+
3692
+ assert remaining is None
3693
+
3694
+
3695
+ @pytest.mark.parametrize(
3696
+ ("payload", "message"),
3697
+ [
3698
+ pytest.param(
3699
+ [],
3700
+ "Expected object payload from /info?module=HeatRecovery, got list",
3701
+ id="root_not_object",
3702
+ ),
3703
+ pytest.param(
3704
+ {"HeatRecovery": []},
3705
+ "Expected object payload at HeatRecovery in /info?module=HeatRecovery response",
3706
+ id="heat_recovery_not_object",
3707
+ ),
3708
+ pytest.param(
3709
+ {"HeatRecovery": {"General": []}},
3710
+ "Expected object payload at HeatRecovery.General in /info?module=HeatRecovery response",
3711
+ id="general_not_object",
3712
+ ),
3713
+ pytest.param(
3714
+ {"HeatRecovery": {"General": {"TimeFilterRemain": 180}}},
3715
+ "Expected wrapped Val object for HeatRecovery.General.TimeFilterRemain, got int",
3716
+ id="leaf_not_object",
3717
+ ),
3718
+ pytest.param(
3719
+ {"HeatRecovery": {"General": {"TimeFilterRemain": None}}},
3720
+ "Expected wrapped Val object for HeatRecovery.General.TimeFilterRemain, got NoneType",
3721
+ id="leaf_null",
3722
+ ),
3723
+ pytest.param(
3724
+ {"HeatRecovery": {"General": {"TimeFilterRemain": {}}}},
3725
+ "Expected wrapped Val object for HeatRecovery.General.TimeFilterRemain",
3726
+ id="leaf_missing_val",
3727
+ ),
3728
+ pytest.param(
3729
+ {"HeatRecovery": {"General": {"TimeFilterRemain": {"Val": "180"}}}},
3730
+ "Expected integer value for HeatRecovery.General.TimeFilterRemain, got str",
3731
+ id="leaf_non_int_val",
3732
+ ),
3733
+ ],
3734
+ )
3735
+ async def test_get_time_filter_remaining_rejects_malformed_payloads(
3736
+ payload: object,
3737
+ message: str,
3738
+ ) -> None:
3739
+ """Malformed heat recovery payloads should raise DucoError."""
3740
+ mock_response = _response(json_payload=payload)
3741
+
3742
+ async with aiohttp.ClientSession() as session:
3743
+ client = DucoClient(session=session, host="192.0.2.94")
3744
+ with (
3745
+ patch.object(session, "request", _request(mock_response)),
3746
+ pytest.raises(DucoError, match=re.escape(message)),
3747
+ ):
3748
+ await client.async_get_time_filter_remaining()
3749
+
3750
+
3669
3751
  async def test_get_write_req_remaining_alias_is_parsed() -> None:
3670
3752
  """The old write budget method name should remain available."""
3671
3753
  payload: dict[str, object] = {"General": {"PublicApi": {"WriteReqCntRemain": {"Val": 197}}}}