python-duco-connectivity 0.1.0__tar.gz → 0.1.1__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 (18) hide show
  1. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.1.1}/PKG-INFO +1 -1
  2. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.1.1}/pyproject.toml +1 -1
  3. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.1.1}/src/duco_connectivity/client.py +74 -8
  4. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.1.1}/src/duco_connectivity/models.py +7 -3
  5. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.1.1}/src/python_duco_connectivity.egg-info/PKG-INFO +1 -1
  6. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.1.1}/tests/test_client.py +193 -0
  7. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.1.1}/tests/test_models.py +5 -0
  8. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.1.1}/LICENSE +0 -0
  9. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.1.1}/README.md +0 -0
  10. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.1.1}/setup.cfg +0 -0
  11. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.1.1}/src/duco_connectivity/__init__.py +0 -0
  12. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.1.1}/src/duco_connectivity/exceptions.py +0 -0
  13. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.1.1}/src/duco_connectivity/py.typed +0 -0
  14. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.1.1}/src/python_duco_connectivity.egg-info/SOURCES.txt +0 -0
  15. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.1.1}/src/python_duco_connectivity.egg-info/dependency_links.txt +0 -0
  16. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.1.1}/src/python_duco_connectivity.egg-info/requires.txt +0 -0
  17. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.1.1}/src/python_duco_connectivity.egg-info/top_level.txt +0 -0
  18. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.1.1}/tests/test_exceptions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-duco-connectivity
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Async HTTP client for the local Duco Connectivity API
5
5
  Author: Ronald van der Meer
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-duco-connectivity"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "Async HTTP client for the local Duco Connectivity API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -2,6 +2,7 @@
2
2
 
3
3
  import json
4
4
  from typing import Any
5
+ from urllib.parse import urlsplit
5
6
 
6
7
  import aiohttp
7
8
 
@@ -37,17 +38,61 @@ class DucoClient:
37
38
  ) -> None:
38
39
  self._session = session
39
40
  self._timeout = aiohttp.ClientTimeout(total=request_timeout)
40
- if host.startswith("https://"):
41
+
42
+ raw_host = host.rstrip("/")
43
+ authority = raw_host.split("://", 1)[1] if "://" in raw_host else raw_host
44
+ authority = authority.split("/", 1)[0].split("?", 1)[0].split("#", 1)[0]
45
+
46
+ if "@" not in authority and authority.count(":") >= 2 and not authority.startswith("["):
47
+ msg = "Unbracketed IPv6 host values are not supported; use [addr] or [addr]:port"
48
+ raise ValueError(msg)
49
+
50
+ parsed_host = urlsplit(raw_host if "://" in host else f"//{raw_host}")
51
+ scheme = parsed_host.scheme.lower()
52
+
53
+ if scheme == "https":
41
54
  msg = "HTTPS is not supported by this client"
42
55
  raise ValueError(msg)
43
- normalized_host = host.removeprefix("http://").removeprefix("https://").rstrip("/")
44
- if "://" in host and not host.startswith("http://"):
56
+
57
+ if scheme not in ("", "http"):
45
58
  msg = f"Unsupported scheme in host value: {host}"
46
59
  raise ValueError(msg)
47
- if port is None:
60
+
61
+ if parsed_host.username is not None or parsed_host.password is not None:
62
+ msg = f"Host value must not include user credentials: {host}"
63
+ raise ValueError(msg)
64
+
65
+ if parsed_host.path not in ("", "/") or parsed_host.query or parsed_host.fragment:
66
+ msg = f"Host value must not include a path, query, or fragment: {host}"
67
+ raise ValueError(msg)
68
+
69
+ normalized_host = parsed_host.hostname
70
+ if normalized_host is None:
71
+ msg = f"Invalid host value: {host}"
72
+ raise ValueError(msg)
73
+
74
+ try:
75
+ embedded_port = parsed_host.port
76
+ except ValueError as err:
77
+ msg = f"Invalid port in host value: {host}"
78
+ raise ValueError(msg) from err
79
+
80
+ if port is not None and not 0 <= port <= 65535:
81
+ msg = f"Invalid port argument: {port}"
82
+ raise ValueError(msg)
83
+
84
+ if embedded_port is not None and port is not None:
85
+ msg = "Port specified both in host and port argument"
86
+ raise ValueError(msg)
87
+
88
+ if ":" in normalized_host:
89
+ normalized_host = f"[{normalized_host}]"
90
+
91
+ resolved_port = port if port is not None else embedded_port
92
+ if resolved_port is None:
48
93
  self._base_url = f"http://{normalized_host}"
49
94
  else:
50
- self._base_url = f"http://{normalized_host}:{port}"
95
+ self._base_url = f"http://{normalized_host}:{resolved_port}"
51
96
 
52
97
  @property
53
98
  def base_url(self) -> str:
@@ -106,6 +151,27 @@ class DucoClient:
106
151
  except ValueError:
107
152
  return NetworkType.UNKNOWN
108
153
 
154
+ @staticmethod
155
+ def _to_diag_status(raw_value: str) -> DiagStatus:
156
+ try:
157
+ return DiagStatus(raw_value)
158
+ except ValueError:
159
+ return DiagStatus.UNKNOWN
160
+
161
+ @staticmethod
162
+ def _to_ventilation_state(raw_value: str) -> VentilationState:
163
+ try:
164
+ return VentilationState(raw_value)
165
+ except ValueError:
166
+ return VentilationState.UNKNOWN
167
+
168
+ @staticmethod
169
+ def _to_ventilation_mode(raw_value: str) -> VentilationMode:
170
+ try:
171
+ return VentilationMode(raw_value)
172
+ except ValueError:
173
+ return VentilationMode.UNKNOWN
174
+
109
175
  async def async_get_api_info(self) -> ApiInfo:
110
176
  """Return API metadata advertised by the box."""
111
177
  payload = await self._request_json("GET", "/api")
@@ -177,7 +243,7 @@ class DucoClient:
177
243
  return [
178
244
  DiagComponent(
179
245
  component=item["Component"],
180
- status=DiagStatus(item["Status"]),
246
+ status=self._to_diag_status(item["Status"]),
181
247
  )
182
248
  for item in payload["Diag"]["SubSystems"]
183
249
  ]
@@ -223,8 +289,8 @@ class DucoClient:
223
289
  if "Ventilation" in payload:
224
290
  vent = payload["Ventilation"]
225
291
  ventilation = NodeVentilationInfo(
226
- state=VentilationState(self._read_wrapped_value(vent, "State")),
227
- mode=VentilationMode(self._read_wrapped_value(vent, "Mode")),
292
+ state=self._to_ventilation_state(self._read_wrapped_value(vent, "State")),
293
+ mode=self._to_ventilation_mode(self._read_wrapped_value(vent, "Mode")),
228
294
  time_state_remain=self._read_wrapped_value(vent, "TimeStateRemain"),
229
295
  time_state_end=self._read_wrapped_value(vent, "TimeStateEnd"),
230
296
  flow_lvl_tgt=self._read_wrapped_value(vent, "FlowLvlTgt")
@@ -23,19 +23,21 @@ class NetworkType(StrEnum):
23
23
  VIRT = "VIRT"
24
24
  RF = "RF"
25
25
  WI = "WI"
26
+ MB = "MB"
26
27
  UNKNOWN = "UNKNOWN"
27
28
 
28
29
 
29
30
  class VentilationMode(StrEnum):
30
- """Control modes reported for node ventilation."""
31
+ """Control modes reported for node ventilation, plus a client-side fallback."""
31
32
 
32
33
  AUTO = "AUTO"
33
34
  MANU = "MANU"
34
35
  NONE = "-"
36
+ UNKNOWN = "UNKNOWN"
35
37
 
36
38
 
37
39
  class VentilationState(StrEnum):
38
- """State values accepted and reported for node ventilation."""
40
+ """Ventilation states reported by the API, plus a client-side fallback."""
39
41
 
40
42
  AUTO = "AUTO"
41
43
  AUT1 = "AUT1"
@@ -54,14 +56,16 @@ class VentilationState(StrEnum):
54
56
  MAN1x3 = "MAN1x3"
55
57
  MAN2x3 = "MAN2x3"
56
58
  MAN3x3 = "MAN3x3"
59
+ UNKNOWN = "UNKNOWN"
57
60
 
58
61
 
59
62
  class DiagStatus(StrEnum):
60
- """Health states returned by the diagnostics API."""
63
+ """Health states returned by the diagnostics API, plus a client-side fallback."""
61
64
 
62
65
  OK = "Ok"
63
66
  DISABLE = "Disable"
64
67
  ERROR = "Error"
68
+ UNKNOWN = "UNKNOWN"
65
69
 
66
70
 
67
71
  @dataclass(frozen=True, slots=True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-duco-connectivity
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Async HTTP client for the local Duco Connectivity API
5
5
  Author: Ronald van der Meer
6
6
  License-Expression: MIT
@@ -13,6 +13,7 @@ from duco_connectivity import (
13
13
  DucoWriteLimitError,
14
14
  NetworkType,
15
15
  NodeType,
16
+ VentilationMode,
16
17
  VentilationState,
17
18
  )
18
19
 
@@ -62,6 +63,69 @@ async def test_base_url_defaults_to_http() -> None:
62
63
  assert client.base_url == "http://192.0.2.94"
63
64
 
64
65
 
66
+ async def test_base_url_uses_embedded_port_from_host() -> None:
67
+ """Hosts with an embedded port should preserve that port in the base URL."""
68
+ async with aiohttp.ClientSession() as session:
69
+ client = DucoClient(session=session, host="http://192.0.2.94:8080")
70
+ assert client.base_url == "http://192.0.2.94:8080"
71
+
72
+
73
+ async def test_bracketed_ipv6_host_is_accepted() -> None:
74
+ """Bracketed IPv6 hosts should be accepted and normalized."""
75
+ async with aiohttp.ClientSession() as session:
76
+ client = DucoClient(session=session, host="[fe80::1]")
77
+ assert client.base_url == "http://[fe80::1]"
78
+
79
+
80
+ async def test_uppercase_http_scheme_is_accepted() -> None:
81
+ """HTTP hosts should be accepted regardless of scheme casing."""
82
+ async with aiohttp.ClientSession() as session:
83
+ client = DucoClient(session=session, host="HTTP://192.0.2.94:8080")
84
+ assert client.base_url == "http://192.0.2.94:8080"
85
+
86
+
87
+ async def test_conflicting_port_sources_are_rejected() -> None:
88
+ """The client should reject a separate port when the host already includes one."""
89
+ async with aiohttp.ClientSession() as session:
90
+ with pytest.raises(ValueError, match="Port specified both"):
91
+ DucoClient(session=session, host="192.0.2.94:8080", port=8081)
92
+
93
+
94
+ async def test_userinfo_in_host_is_rejected() -> None:
95
+ """The unauthenticated client should reject credentials embedded in the host value."""
96
+ async with aiohttp.ClientSession() as session:
97
+ with pytest.raises(ValueError, match="must not include user credentials"):
98
+ DucoClient(session=session, host="user:pass@192.0.2.94")
99
+
100
+
101
+ async def test_invalid_embedded_port_is_rejected() -> None:
102
+ """Malformed embedded ports should raise a consistent client ValueError."""
103
+ async with aiohttp.ClientSession() as session:
104
+ with pytest.raises(ValueError, match="Invalid port in host value"):
105
+ DucoClient(session=session, host="192.0.2.94:abc")
106
+
107
+
108
+ async def test_unbracketed_ipv6_host_is_rejected() -> None:
109
+ """Bare IPv6 hosts should require brackets to avoid ambiguous parsing."""
110
+ async with aiohttp.ClientSession() as session:
111
+ with pytest.raises(ValueError, match="Unbracketed IPv6 host values"):
112
+ DucoClient(session=session, host="fe80::1")
113
+
114
+
115
+ async def test_negative_port_argument_is_rejected() -> None:
116
+ """Explicit negative ports should be rejected during client construction."""
117
+ async with aiohttp.ClientSession() as session:
118
+ with pytest.raises(ValueError, match="Invalid port argument"):
119
+ DucoClient(session=session, host="192.0.2.94", port=-1)
120
+
121
+
122
+ async def test_out_of_range_port_argument_is_rejected() -> None:
123
+ """Explicit ports above 65535 should be rejected during client construction."""
124
+ async with aiohttp.ClientSession() as session:
125
+ with pytest.raises(ValueError, match="Invalid port argument"):
126
+ DucoClient(session=session, host="192.0.2.94", port=99999)
127
+
128
+
65
129
  async def test_api_info_is_parsed(api_info_full_data: dict[str, object]) -> None:
66
130
  """Test that the API info model follows the public API payload."""
67
131
  mock_response = _response(json_payload=api_info_full_data)
@@ -154,6 +218,26 @@ async def test_get_diagnostics(diag_data: dict[str, object]) -> None:
154
218
  assert diags[0].status == DiagStatus.OK
155
219
 
156
220
 
221
+ async def test_get_diagnostics_unknown_status_falls_back_to_unknown() -> None:
222
+ """Unknown diagnostic statuses should not break parsing."""
223
+ payload: dict[str, object] = {
224
+ "Diag": {
225
+ "SubSystems": [
226
+ {"Component": "Ventilation", "Status": "FutureState"},
227
+ ]
228
+ }
229
+ }
230
+ mock_response = _response(json_payload=payload)
231
+
232
+ async with aiohttp.ClientSession() as session:
233
+ client = DucoClient(session=session, host="192.0.2.94")
234
+ with patch.object(session, "request", _request(mock_response)):
235
+ diags = await client.async_get_diagnostics()
236
+
237
+ assert len(diags) == 1
238
+ assert diags[0].status == DiagStatus.UNKNOWN
239
+
240
+
157
241
  async def test_get_nodes_parses_full_payload(nodes_data: dict[str, object]) -> None:
158
242
  """Test node parsing across box and sensor nodes."""
159
243
  mock_response = _response(json_payload=nodes_data)
@@ -221,6 +305,41 @@ async def test_get_nodes_unknown_network_type_falls_back_to_unknown() -> None:
221
305
  assert nodes[0].general.network_type == NetworkType.UNKNOWN
222
306
 
223
307
 
308
+ async def test_get_nodes_mb_network_type_is_parsed() -> None:
309
+ """Known MB network types should be parsed explicitly."""
310
+ payload: dict[str, object] = {
311
+ "Nodes": [
312
+ {
313
+ "Node": 4,
314
+ "General": {
315
+ "Type": {"Val": "UCCO2"},
316
+ "SubType": {"Val": 0},
317
+ "NetworkType": {"Val": "MB"},
318
+ "Parent": {"Val": 1},
319
+ "Asso": {"Val": 1},
320
+ "Name": {"Val": ""},
321
+ "Identify": {"Val": 0},
322
+ },
323
+ "Ventilation": {
324
+ "State": {"Val": "AUTO"},
325
+ "TimeStateRemain": {"Val": 0},
326
+ "TimeStateEnd": {"Val": 0},
327
+ "Mode": {"Val": "-"},
328
+ },
329
+ }
330
+ ]
331
+ }
332
+ mock_response = _response(json_payload=payload)
333
+
334
+ async with aiohttp.ClientSession() as session:
335
+ client = DucoClient(session=session, host="192.0.2.94")
336
+ with patch.object(session, "request", _request(mock_response)):
337
+ nodes = await client.async_get_nodes()
338
+
339
+ assert len(nodes) == 1
340
+ assert nodes[0].general.network_type == NetworkType.MB
341
+
342
+
224
343
  async def test_get_nodes_unknown_node_type_falls_back_to_unknown() -> None:
225
344
  """Test that unknown node types do not crash parsing."""
226
345
  payload: dict[str, object] = {
@@ -301,6 +420,80 @@ async def test_timed_manual_state_is_parsed() -> None:
301
420
  assert nodes[0].ventilation.state is VentilationState.MAN3x2
302
421
 
303
422
 
423
+ async def test_get_nodes_unknown_ventilation_state_falls_back_to_unknown() -> None:
424
+ """Unknown ventilation states should not break node parsing."""
425
+ payload: dict[str, object] = {
426
+ "Nodes": [
427
+ {
428
+ "Node": 6,
429
+ "General": {
430
+ "Type": {"Val": "BOX"},
431
+ "SubType": {"Val": 1},
432
+ "NetworkType": {"Val": "VIRT"},
433
+ "Parent": {"Val": 0},
434
+ "Asso": {"Val": 0},
435
+ "Name": {"Val": "Living"},
436
+ "Identify": {"Val": 0},
437
+ },
438
+ "Ventilation": {
439
+ "State": {"Val": "FUTURE_STATE"},
440
+ "Mode": {"Val": "AUTO"},
441
+ "TimeStateRemain": {"Val": 0},
442
+ "TimeStateEnd": {"Val": 0},
443
+ },
444
+ }
445
+ ]
446
+ }
447
+
448
+ mock_response = _response(json_payload=payload)
449
+
450
+ async with aiohttp.ClientSession() as session:
451
+ client = DucoClient(session=session, host="192.0.2.94")
452
+ with patch.object(session, "request", _request(mock_response)):
453
+ nodes = await client.async_get_nodes()
454
+
455
+ assert len(nodes) == 1
456
+ assert nodes[0].ventilation is not None
457
+ assert nodes[0].ventilation.state is VentilationState.UNKNOWN
458
+
459
+
460
+ async def test_get_nodes_unknown_ventilation_mode_falls_back_to_unknown() -> None:
461
+ """Unknown ventilation modes should not break node parsing."""
462
+ payload: dict[str, object] = {
463
+ "Nodes": [
464
+ {
465
+ "Node": 7,
466
+ "General": {
467
+ "Type": {"Val": "BOX"},
468
+ "SubType": {"Val": 1},
469
+ "NetworkType": {"Val": "VIRT"},
470
+ "Parent": {"Val": 0},
471
+ "Asso": {"Val": 0},
472
+ "Name": {"Val": "Bedroom"},
473
+ "Identify": {"Val": 0},
474
+ },
475
+ "Ventilation": {
476
+ "State": {"Val": "AUTO"},
477
+ "Mode": {"Val": "FUTURE_MODE"},
478
+ "TimeStateRemain": {"Val": 0},
479
+ "TimeStateEnd": {"Val": 0},
480
+ },
481
+ }
482
+ ]
483
+ }
484
+
485
+ mock_response = _response(json_payload=payload)
486
+
487
+ async with aiohttp.ClientSession() as session:
488
+ client = DucoClient(session=session, host="192.0.2.94")
489
+ with patch.object(session, "request", _request(mock_response)):
490
+ nodes = await client.async_get_nodes()
491
+
492
+ assert len(nodes) == 1
493
+ assert nodes[0].ventilation is not None
494
+ assert nodes[0].ventilation.mode is VentilationMode.UNKNOWN
495
+
496
+
304
497
  async def test_connection_error_raises_duco_connection_error() -> None:
305
498
  """Transport errors should raise DucoConnectionError."""
306
499
  async with aiohttp.ClientSession() as session:
@@ -84,3 +84,8 @@ def test_node_is_frozen() -> None:
84
84
  )
85
85
  with pytest.raises(AttributeError):
86
86
  node.node_id = 2 # type: ignore[misc]
87
+
88
+
89
+ def test_network_type_includes_mb() -> None:
90
+ """NetworkType should expose MB as an explicit known value."""
91
+ assert NetworkType.MB.value == "MB"