python-duco-connectivity 0.11.0__tar.gz → 0.13.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.11.0/src/python_duco_connectivity.egg-info → python_duco_connectivity-0.13.0}/PKG-INFO +7 -4
  2. {python_duco_connectivity-0.11.0 → python_duco_connectivity-0.13.0}/README.md +6 -3
  3. {python_duco_connectivity-0.11.0 → python_duco_connectivity-0.13.0}/pyproject.toml +1 -1
  4. {python_duco_connectivity-0.11.0 → python_duco_connectivity-0.13.0}/src/duco_connectivity/__init__.py +2 -0
  5. {python_duco_connectivity-0.11.0 → python_duco_connectivity-0.13.0}/src/duco_connectivity/client.py +35 -1
  6. {python_duco_connectivity-0.11.0 → python_duco_connectivity-0.13.0}/src/duco_connectivity/models.py +15 -6
  7. {python_duco_connectivity-0.11.0 → python_duco_connectivity-0.13.0/src/python_duco_connectivity.egg-info}/PKG-INFO +7 -4
  8. {python_duco_connectivity-0.11.0 → python_duco_connectivity-0.13.0}/tests/test_client.py +98 -6
  9. {python_duco_connectivity-0.11.0 → python_duco_connectivity-0.13.0}/LICENSE +0 -0
  10. {python_duco_connectivity-0.11.0 → python_duco_connectivity-0.13.0}/setup.cfg +0 -0
  11. {python_duco_connectivity-0.11.0 → python_duco_connectivity-0.13.0}/src/duco_connectivity/__main__.py +0 -0
  12. {python_duco_connectivity-0.11.0 → python_duco_connectivity-0.13.0}/src/duco_connectivity/cli.py +0 -0
  13. {python_duco_connectivity-0.11.0 → python_duco_connectivity-0.13.0}/src/duco_connectivity/exceptions.py +0 -0
  14. {python_duco_connectivity-0.11.0 → python_duco_connectivity-0.13.0}/src/duco_connectivity/py.typed +0 -0
  15. {python_duco_connectivity-0.11.0 → python_duco_connectivity-0.13.0}/src/python_duco_connectivity.egg-info/SOURCES.txt +0 -0
  16. {python_duco_connectivity-0.11.0 → python_duco_connectivity-0.13.0}/src/python_duco_connectivity.egg-info/dependency_links.txt +0 -0
  17. {python_duco_connectivity-0.11.0 → python_duco_connectivity-0.13.0}/src/python_duco_connectivity.egg-info/entry_points.txt +0 -0
  18. {python_duco_connectivity-0.11.0 → python_duco_connectivity-0.13.0}/src/python_duco_connectivity.egg-info/requires.txt +0 -0
  19. {python_duco_connectivity-0.11.0 → python_duco_connectivity-0.13.0}/src/python_duco_connectivity.egg-info/top_level.txt +0 -0
  20. {python_duco_connectivity-0.11.0 → python_duco_connectivity-0.13.0}/tests/test_api_reference.py +0 -0
  21. {python_duco_connectivity-0.11.0 → python_duco_connectivity-0.13.0}/tests/test_cli.py +0 -0
  22. {python_duco_connectivity-0.11.0 → python_duco_connectivity-0.13.0}/tests/test_exceptions.py +0 -0
  23. {python_duco_connectivity-0.11.0 → python_duco_connectivity-0.13.0}/tests/test_local_sample_validation.py +0 -0
  24. {python_duco_connectivity-0.11.0 → python_duco_connectivity-0.13.0}/tests/test_models.py +0 -0
  25. {python_duco_connectivity-0.11.0 → python_duco_connectivity-0.13.0}/tests/test_pytest_live_support.py +0 -0
  26. {python_duco_connectivity-0.11.0 → python_duco_connectivity-0.13.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.11.0
3
+ Version: 0.13.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
@@ -80,9 +80,11 @@ bypass-target helper returns a typed target model for successful parameter-
80
80
  specific reads and raises `DucoError` if the requested target field is missing
81
81
  from an otherwise valid `/config` response.
82
82
 
83
- Diagnostic subsystem reads now keep raw component and status strings from
84
- `Diag.SubSystems`, so future subsystem names or status values remain available
85
- to downstream consumers without parse fallbacks or product-specific filtering.
83
+ Diagnostic subsystem reads expose known status values as normalized `DiagStatus`
84
+ members (`ok`, `disabled`, or `error`). Each `DiagComponent` also keeps the exact
85
+ API value in `raw_status`; an unrecognized future value produces `status=None`
86
+ without discarding the raw value. Subsystem names remain unfiltered so future
87
+ components are available to downstream consumers.
86
88
 
87
89
  ## Getting started
88
90
 
@@ -184,6 +186,7 @@ For local function probes without activating the environment first:
184
186
  .venv/bin/python -m duco_connectivity --host 192.168.1.10 call async_get_board_info
185
187
  .venv/bin/duco-probe --host 192.168.1.10 call async_get_board_info
186
188
  .venv/bin/duco-probe --host 192.168.1.10 call async_get_ventilation_temperature_info
189
+ .venv/bin/duco-probe --host 192.168.1.10 call async_get_bypass_supply_temperature_targets
187
190
  .venv/bin/duco-probe --host 192.168.1.10 call async_get_bypass_supply_temperature_target --kwargs '{"zone_id": 1}'
188
191
  ```
189
192
 
@@ -48,9 +48,11 @@ bypass-target helper returns a typed target model for successful parameter-
48
48
  specific reads and raises `DucoError` if the requested target field is missing
49
49
  from an otherwise valid `/config` response.
50
50
 
51
- Diagnostic subsystem reads now keep raw component and status strings from
52
- `Diag.SubSystems`, so future subsystem names or status values remain available
53
- to downstream consumers without parse fallbacks or product-specific filtering.
51
+ Diagnostic subsystem reads expose known status values as normalized `DiagStatus`
52
+ members (`ok`, `disabled`, or `error`). Each `DiagComponent` also keeps the exact
53
+ API value in `raw_status`; an unrecognized future value produces `status=None`
54
+ without discarding the raw value. Subsystem names remain unfiltered so future
55
+ components are available to downstream consumers.
54
56
 
55
57
  ## Getting started
56
58
 
@@ -152,6 +154,7 @@ For local function probes without activating the environment first:
152
154
  .venv/bin/python -m duco_connectivity --host 192.168.1.10 call async_get_board_info
153
155
  .venv/bin/duco-probe --host 192.168.1.10 call async_get_board_info
154
156
  .venv/bin/duco-probe --host 192.168.1.10 call async_get_ventilation_temperature_info
157
+ .venv/bin/duco-probe --host 192.168.1.10 call async_get_bypass_supply_temperature_targets
155
158
  .venv/bin/duco-probe --host 192.168.1.10 call async_get_bypass_supply_temperature_target --kwargs '{"zone_id": 1}'
156
159
  ```
157
160
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-duco-connectivity"
7
- version = "0.11.0"
7
+ version = "0.13.0"
8
8
  description = "Async HTTP client for the local Duco Connectivity API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -54,6 +54,7 @@ from .models import (
54
54
  DeviceGroupConfigSubmoduleSelector,
55
55
  DiagComponent,
56
56
  DiagInfo,
57
+ DiagStatus,
57
58
  DucoSerialNumber,
58
59
  DucoVersion,
59
60
  HostName,
@@ -167,6 +168,7 @@ __all__ = [
167
168
  "DeviceGroupConfigSubmoduleSelector",
168
169
  "DiagComponent",
169
170
  "DiagInfo",
171
+ "DiagStatus",
170
172
  "DucoClient",
171
173
  "DucoConnectionError",
172
174
  "DucoError",
@@ -58,6 +58,7 @@ from .models import (
58
58
  DeviceGroupConfigSubmoduleSelector,
59
59
  DiagComponent,
60
60
  DiagInfo,
61
+ DiagStatus,
61
62
  InfoGeneralSubmoduleSelector,
62
63
  InfoGroup,
63
64
  InfoGroupStruct,
@@ -400,6 +401,19 @@ class DucoClient:
400
401
 
401
402
  return cls._build_bypass_supply_temperature_target(zone_id, raw_value)
402
403
 
404
+ @classmethod
405
+ def _extract_bypass_supply_temperature_targets(
406
+ cls,
407
+ config: Config,
408
+ ) -> dict[int, BypassSupplyTemperatureTarget]:
409
+ """Read all bypass supply targets from a typed config response."""
410
+ return {
411
+ zone_id: target
412
+ for zone_id in range(1, 9)
413
+ if (target := cls._extract_bypass_supply_temperature_target(config, zone_id))
414
+ is not None
415
+ }
416
+
403
417
  @staticmethod
404
418
  def _read_scalar_value(payload: dict[str, Any], key: str) -> Any:
405
419
  raw_value = payload[key]
@@ -2054,7 +2068,8 @@ class DucoClient:
2054
2068
 
2055
2069
  return DiagComponent(
2056
2070
  component=component,
2057
- status=status,
2071
+ status=DiagStatus.from_api_value(status),
2072
+ raw_status=status,
2058
2073
  raw_payload=cls._preserve_raw_payload(payload),
2059
2074
  )
2060
2075
 
@@ -2318,6 +2333,25 @@ class DucoClient:
2318
2333
  raise DucoError(msg)
2319
2334
  return target
2320
2335
 
2336
+ async def async_get_bypass_supply_temperature_targets(
2337
+ self,
2338
+ ) -> dict[int, BypassSupplyTemperatureTarget]:
2339
+ """Return all bypass supply targets exposed through `/config` in Celsius."""
2340
+ try:
2341
+ config = await self.async_get_config(
2342
+ module=ConfigModuleSelector.HEAT_RECOVERY,
2343
+ submodule=ConfigHeatRecoverySubmoduleSelector.BYPASS,
2344
+ )
2345
+ except DucoResponseError as err:
2346
+ if _is_unsupported_optional_endpoint_error(err, "/config"):
2347
+ raise DucoUnsupportedCapabilityError(
2348
+ err.status,
2349
+ err.path,
2350
+ err.body,
2351
+ ) from err
2352
+ raise
2353
+ return self._extract_bypass_supply_temperature_targets(config)
2354
+
2321
2355
  async def async_set_bypass_supply_temperature_target(
2322
2356
  self,
2323
2357
  zone_id: int,
@@ -587,12 +587,20 @@ def _coerce_node_motor_position(value: NodeMotorPosition | int) -> NodeMotorPosi
587
587
 
588
588
 
589
589
  class DiagStatus(StrEnum):
590
- """Health states returned by the diagnostics API, plus a client-side fallback."""
590
+ """Known normalized health states returned by the diagnostics API."""
591
591
 
592
- OK = "Ok"
593
- DISABLE = "Disable"
594
- ERROR = "Error"
595
- UNKNOWN = "UNKNOWN"
592
+ OK = "ok"
593
+ DISABLED = "disabled"
594
+ ERROR = "error"
595
+
596
+ @classmethod
597
+ def from_api_value(cls, value: str) -> Self | None:
598
+ """Return the known normalized status for a raw API value."""
599
+ return {
600
+ "Ok": cls.OK,
601
+ "Disable": cls.DISABLED,
602
+ "Error": cls.ERROR,
603
+ }.get(value)
596
604
 
597
605
 
598
606
  class ActionResultStatus(StrEnum):
@@ -1572,7 +1580,8 @@ class DiagComponent:
1572
1580
  """Health state for a diagnostic subsystem."""
1573
1581
 
1574
1582
  component: str
1575
- status: str
1583
+ status: DiagStatus | None
1584
+ raw_status: str
1576
1585
  raw_payload: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
1577
1586
 
1578
1587
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-duco-connectivity
3
- Version: 0.11.0
3
+ Version: 0.13.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
@@ -80,9 +80,11 @@ bypass-target helper returns a typed target model for successful parameter-
80
80
  specific reads and raises `DucoError` if the requested target field is missing
81
81
  from an otherwise valid `/config` response.
82
82
 
83
- Diagnostic subsystem reads now keep raw component and status strings from
84
- `Diag.SubSystems`, so future subsystem names or status values remain available
85
- to downstream consumers without parse fallbacks or product-specific filtering.
83
+ Diagnostic subsystem reads expose known status values as normalized `DiagStatus`
84
+ members (`ok`, `disabled`, or `error`). Each `DiagComponent` also keeps the exact
85
+ API value in `raw_status`; an unrecognized future value produces `status=None`
86
+ without discarding the raw value. Subsystem names remain unfiltered so future
87
+ components are available to downstream consumers.
86
88
 
87
89
  ## Getting started
88
90
 
@@ -184,6 +186,7 @@ For local function probes without activating the environment first:
184
186
  .venv/bin/python -m duco_connectivity --host 192.168.1.10 call async_get_board_info
185
187
  .venv/bin/duco-probe --host 192.168.1.10 call async_get_board_info
186
188
  .venv/bin/duco-probe --host 192.168.1.10 call async_get_ventilation_temperature_info
189
+ .venv/bin/duco-probe --host 192.168.1.10 call async_get_bypass_supply_temperature_targets
187
190
  .venv/bin/duco-probe --host 192.168.1.10 call async_get_bypass_supply_temperature_target --kwargs '{"zone_id": 1}'
188
191
  ```
189
192
 
@@ -42,6 +42,7 @@ from duco_connectivity import (
42
42
  DeviceGroupConfigSubmoduleSelector,
43
43
  DiagComponent,
44
44
  DiagInfo,
45
+ DiagStatus,
45
46
  DucoClient,
46
47
  DucoConnectionError,
47
48
  DucoError,
@@ -2066,7 +2067,8 @@ async def test_get_diagnostics(diag_data: dict[str, object]) -> None:
2066
2067
 
2067
2068
  assert len(diags) == 3
2068
2069
  assert diags[0].component == "Ventilation"
2069
- assert diags[0].status == "Ok"
2070
+ assert diags[0].status is DiagStatus.OK
2071
+ assert diags[0].raw_status == "Ok"
2070
2072
  assert diags[0].raw_payload is diag_data["Diag"]["SubSystems"][0]
2071
2073
 
2072
2074
 
@@ -2085,13 +2087,19 @@ async def test_get_diagnostics_info_exposes_typed_subsystems(
2085
2087
  assert_type(diag_info, DiagInfo)
2086
2088
  assert_type(diag_info.diagnostic_subsystems, tuple[DiagComponent, ...])
2087
2089
  assert_type(diag_info.diagnostic_subsystems[0].component, str)
2088
- assert_type(diag_info.diagnostic_subsystems[0].status, str)
2090
+ assert_type(diag_info.diagnostic_subsystems[0].status, DiagStatus | None)
2091
+ assert_type(diag_info.diagnostic_subsystems[0].raw_status, str)
2089
2092
  assert tuple(item.component for item in diag_info.diagnostic_subsystems) == (
2090
2093
  "Ventilation",
2091
2094
  "VentCool",
2092
2095
  "SunCtrl",
2093
2096
  )
2094
2097
  assert tuple(item.status for item in diag_info.diagnostic_subsystems) == (
2098
+ DiagStatus.OK,
2099
+ DiagStatus.OK,
2100
+ DiagStatus.OK,
2101
+ )
2102
+ assert tuple(item.raw_status for item in diag_info.diagnostic_subsystems) == (
2095
2103
  "Ok",
2096
2104
  "Ok",
2097
2105
  "Ok",
@@ -2115,7 +2123,8 @@ async def test_get_diagnostics_preserves_unknown_status() -> None:
2115
2123
  diags = await client.async_get_diagnostics()
2116
2124
 
2117
2125
  assert len(diags) == 1
2118
- assert diags[0].status == "FutureState"
2126
+ assert diags[0].status is None
2127
+ assert diags[0].raw_status == "FutureState"
2119
2128
 
2120
2129
 
2121
2130
  async def test_get_diagnostics_preserves_unknown_component_name() -> None:
@@ -2182,9 +2191,11 @@ async def test_get_diagnostics_info_skips_partial_entries() -> None:
2182
2191
  with patch.object(session, "request", _request(mock_response)):
2183
2192
  diag_info = await client.async_get_diagnostics_info()
2184
2193
 
2185
- assert tuple((item.component, item.status) for item in diag_info.diagnostic_subsystems) == (
2186
- ("Ventilation", "Ok"),
2187
- ("SunCtrl", "FutureState"),
2194
+ assert tuple(
2195
+ (item.component, item.status, item.raw_status) for item in diag_info.diagnostic_subsystems
2196
+ ) == (
2197
+ ("Ventilation", DiagStatus.OK, "Ok"),
2198
+ ("SunCtrl", None, "FutureState"),
2188
2199
  )
2189
2200
 
2190
2201
 
@@ -2213,6 +2224,12 @@ async def test_get_diagnostics_info_does_not_filter_product_specific_subsystems(
2213
2224
  "SunCtrl",
2214
2225
  "FutureSubsystem",
2215
2226
  )
2227
+ assert tuple((item.status, item.raw_status) for item in diag_info.diagnostic_subsystems) == (
2228
+ (DiagStatus.ERROR, "Error"),
2229
+ (DiagStatus.OK, "Ok"),
2230
+ (DiagStatus.OK, "Ok"),
2231
+ (DiagStatus.DISABLED, "Disable"),
2232
+ )
2216
2233
 
2217
2234
 
2218
2235
  async def test_get_nodes_parses_full_payload(nodes_data: dict[str, object]) -> None:
@@ -3794,6 +3811,81 @@ async def test_get_bypass_supply_temperature_target_returns_converted_values(
3794
3811
  }
3795
3812
 
3796
3813
 
3814
+ async def test_get_bypass_supply_temperature_targets_returns_available_targets(
3815
+ config_data: dict[str, object],
3816
+ ) -> None:
3817
+ """Bulk bypass target reads should return all available targets in Celsius."""
3818
+ mock_response = _response(json_payload=config_data)
3819
+
3820
+ async with aiohttp.ClientSession() as session:
3821
+ client = DucoClient(session=session, host="192.0.2.94")
3822
+ request_mock = _request(mock_response)
3823
+ with patch.object(session, "request", request_mock):
3824
+ targets = await client.async_get_bypass_supply_temperature_targets()
3825
+
3826
+ assert targets == {
3827
+ 1: BypassSupplyTemperatureTarget(
3828
+ zone_id=1,
3829
+ value=18.0,
3830
+ minimum=12.0,
3831
+ increment=0.5,
3832
+ maximum=22.0,
3833
+ ),
3834
+ 2: BypassSupplyTemperatureTarget(
3835
+ zone_id=2,
3836
+ value=18.5,
3837
+ minimum=12.0,
3838
+ increment=0.5,
3839
+ maximum=22.0,
3840
+ ),
3841
+ }
3842
+ assert request_mock.call_args.kwargs["params"] == {
3843
+ "module": "HeatRecovery",
3844
+ "submodule": "Bypass",
3845
+ }
3846
+
3847
+
3848
+ async def test_get_bypass_supply_temperature_targets_returns_empty_when_absent() -> None:
3849
+ """Bulk bypass target reads should omit targets absent from the response."""
3850
+ mock_response = _response(json_payload={"HeatRecovery": {"Bypass": {}}})
3851
+
3852
+ async with aiohttp.ClientSession() as session:
3853
+ client = DucoClient(session=session, host="192.0.2.94")
3854
+ with patch.object(session, "request", _request(mock_response)):
3855
+ targets = await client.async_get_bypass_supply_temperature_targets()
3856
+
3857
+ assert targets == {}
3858
+
3859
+
3860
+ async def test_get_bypass_supply_temperature_targets_raises_when_unsupported() -> None:
3861
+ """Unsupported bulk bypass target reads should raise a typed capability error."""
3862
+ mock_response = _response(
3863
+ status=400,
3864
+ text_payload='{"Code":3,"Result":"FAILED"}',
3865
+ )
3866
+
3867
+ async with aiohttp.ClientSession() as session:
3868
+ client = DucoClient(session=session, host="192.0.2.94")
3869
+ with (
3870
+ patch.object(session, "request", _request(mock_response)),
3871
+ pytest.raises(DucoUnsupportedCapabilityError),
3872
+ ):
3873
+ await client.async_get_bypass_supply_temperature_targets()
3874
+
3875
+
3876
+ async def test_get_bypass_supply_temperature_targets_reraises_other_client_errors() -> None:
3877
+ """Unexpected bulk bypass target failures should remain visible to callers."""
3878
+ mock_response = _response(status=400, text_payload='{"Code":4,"Result":"FAILED"}')
3879
+
3880
+ async with aiohttp.ClientSession() as session:
3881
+ client = DucoClient(session=session, host="192.0.2.94")
3882
+ with (
3883
+ patch.object(session, "request", _request(mock_response)),
3884
+ pytest.raises(DucoResponseError),
3885
+ ):
3886
+ await client.async_get_bypass_supply_temperature_targets()
3887
+
3888
+
3797
3889
  async def test_get_bypass_supply_temperature_target_raises_when_not_reported() -> None:
3798
3890
  """Missing bypass targets should fail for parameter-specific helper reads."""
3799
3891
  mock_response = _response(json_payload={"HeatRecovery": {"Bypass": {}}})