python-duco-connectivity 0.1.0__tar.gz → 0.2.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 (19) hide show
  1. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/PKG-INFO +1 -1
  2. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/pyproject.toml +1 -1
  3. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/src/duco_connectivity/__init__.py +9 -1
  4. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/src/duco_connectivity/client.py +189 -16
  5. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/src/duco_connectivity/exceptions.py +4 -0
  6. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/src/duco_connectivity/models.py +83 -4
  7. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/src/python_duco_connectivity.egg-info/PKG-INFO +1 -1
  8. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/tests/test_client.py +349 -1
  9. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/tests/test_exceptions.py +14 -1
  10. python_duco_connectivity-0.2.0/tests/test_models.py +193 -0
  11. python_duco_connectivity-0.1.0/tests/test_models.py +0 -86
  12. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/LICENSE +0 -0
  13. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/README.md +0 -0
  14. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/setup.cfg +0 -0
  15. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/src/duco_connectivity/py.typed +0 -0
  16. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/src/python_duco_connectivity.egg-info/SOURCES.txt +0 -0
  17. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/src/python_duco_connectivity.egg-info/dependency_links.txt +0 -0
  18. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/src/python_duco_connectivity.egg-info/requires.txt +0 -0
  19. {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/src/python_duco_connectivity.egg-info/top_level.txt +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.2.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
@@ -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.2.0"
8
8
  description = "Async HTTP client for the local Duco Connectivity API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -3,9 +3,15 @@
3
3
  from importlib.metadata import PackageNotFoundError, version
4
4
 
5
5
  from .client import DucoClient
6
- from .exceptions import DucoConnectionError, DucoError, DucoWriteLimitError
6
+ from .exceptions import (
7
+ DucoConnectionError,
8
+ DucoError,
9
+ DucoRateLimitError,
10
+ DucoWriteLimitError,
11
+ )
7
12
  from .models import (
8
13
  ApiEndpoint,
14
+ ApiEndpointInfo,
9
15
  ApiInfo,
10
16
  BoardInfo,
11
17
  DiagComponent,
@@ -28,11 +34,13 @@ except PackageNotFoundError:
28
34
 
29
35
  __all__ = [
30
36
  "ApiEndpoint",
37
+ "ApiEndpointInfo",
31
38
  "ApiInfo",
32
39
  "BoardInfo",
33
40
  "DucoClient",
34
41
  "DucoConnectionError",
35
42
  "DucoError",
43
+ "DucoRateLimitError",
36
44
  "DucoWriteLimitError",
37
45
  "DiagComponent",
38
46
  "DiagStatus",
@@ -1,7 +1,11 @@
1
1
  """Async client for the local Duco HTTP API."""
2
2
 
3
3
  import json
4
+ import logging
5
+ import sys
6
+ from types import FrameType
4
7
  from typing import Any
8
+ from urllib.parse import urlsplit
5
9
 
6
10
  import aiohttp
7
11
 
@@ -23,6 +27,21 @@ from .models import (
23
27
  VentilationState,
24
28
  )
25
29
 
30
+ _LOGGER = logging.getLogger(__name__)
31
+
32
+
33
+ def _compat_caller() -> str | None:
34
+ """Return the first external caller that reached a compatibility path."""
35
+ frame: FrameType | None = sys._getframe(1)
36
+
37
+ while frame is not None:
38
+ module_name = frame.f_globals.get("__name__", "")
39
+ if module_name != "duco_connectivity" and not module_name.startswith("duco_connectivity."):
40
+ return f"{module_name}.{frame.f_code.co_name}"
41
+ frame = frame.f_back
42
+
43
+ return None
44
+
26
45
 
27
46
  class DucoClient:
28
47
  """Client for a Duco box that exposes the local HTTP API."""
@@ -37,17 +56,67 @@ class DucoClient:
37
56
  ) -> None:
38
57
  self._session = session
39
58
  self._timeout = aiohttp.ClientTimeout(total=request_timeout)
40
- if host.startswith("https://"):
59
+
60
+ raw_host = host.rstrip("/")
61
+ authority = raw_host.split("://", 1)[1] if "://" in raw_host else raw_host
62
+ authority = authority.split("/", 1)[0].split("?", 1)[0].split("#", 1)[0]
63
+
64
+ if "@" not in authority and authority.count(":") >= 2 and not authority.startswith("["):
65
+ msg = "Unbracketed IPv6 host values are not supported; use [addr] or [addr]:port"
66
+ raise ValueError(msg)
67
+
68
+ parsed_host = urlsplit(raw_host if "://" in host else f"//{raw_host}")
69
+ scheme = parsed_host.scheme.lower()
70
+
71
+ if scheme == "https":
41
72
  msg = "HTTPS is not supported by this client"
42
73
  raise ValueError(msg)
43
- normalized_host = host.removeprefix("http://").removeprefix("https://").rstrip("/")
44
- if "://" in host and not host.startswith("http://"):
74
+
75
+ if scheme not in ("", "http"):
45
76
  msg = f"Unsupported scheme in host value: {host}"
46
77
  raise ValueError(msg)
47
- if port is None:
78
+
79
+ if parsed_host.username is not None or parsed_host.password is not None:
80
+ msg = f"Host value must not include user credentials: {host}"
81
+ raise ValueError(msg)
82
+
83
+ if parsed_host.path not in ("", "/") or parsed_host.query or parsed_host.fragment:
84
+ msg = f"Host value must not include a path, query, or fragment: {host}"
85
+ raise ValueError(msg)
86
+
87
+ normalized_host = parsed_host.hostname
88
+ if normalized_host is None:
89
+ msg = f"Invalid host value: {host}"
90
+ raise ValueError(msg)
91
+
92
+ try:
93
+ embedded_port = parsed_host.port
94
+ except ValueError as err:
95
+ msg = f"Invalid port in host value: {host}"
96
+ raise ValueError(msg) from err
97
+
98
+ if port is not None and not 0 <= port <= 65535:
99
+ msg = f"Invalid port argument: {port}"
100
+ raise ValueError(msg)
101
+
102
+ if embedded_port is not None and port is not None:
103
+ msg = "Port specified both in host and port argument"
104
+ raise ValueError(msg)
105
+
106
+ if ":" in normalized_host:
107
+ normalized_host = f"[{normalized_host}]"
108
+
109
+ resolved_port = port if port is not None else embedded_port
110
+ if resolved_port is None:
48
111
  self._base_url = f"http://{normalized_host}"
49
112
  else:
50
- self._base_url = f"http://{normalized_host}:{port}"
113
+ self._base_url = f"http://{normalized_host}:{resolved_port}"
114
+
115
+ _LOGGER.debug("Initialized DucoClient for %s", self._base_url)
116
+ _LOGGER.debug(
117
+ "Using HTTP-only duco_connectivity transport for %s.",
118
+ self._base_url,
119
+ )
51
120
 
52
121
  @property
53
122
  def base_url(self) -> str:
@@ -55,21 +124,51 @@ class DucoClient:
55
124
  return self._base_url
56
125
 
57
126
  async def _request_json(self, method: str, path: str, **kwargs: Any) -> Any:
127
+ json_payload = None
58
128
  if "json" in kwargs:
59
- payload = kwargs.pop("json")
60
- kwargs["data"] = json.dumps(payload, separators=(",", ":")).encode()
129
+ json_payload = kwargs.pop("json")
130
+ kwargs["data"] = json.dumps(json_payload, separators=(",", ":")).encode()
61
131
  kwargs.setdefault("headers", {})["Content-Type"] = "application/json"
62
132
  kwargs.setdefault("timeout", self._timeout)
63
133
 
134
+ _LOGGER.debug(
135
+ "Requesting %s %s%s with params=%s json=%s",
136
+ method,
137
+ self._base_url,
138
+ path,
139
+ kwargs.get("params"),
140
+ json_payload,
141
+ )
142
+
64
143
  try:
65
144
  request = self._session.request(method, f"{self._base_url}{path}", **kwargs)
66
145
  except (aiohttp.ClientError, TimeoutError) as err:
146
+ _LOGGER.debug(
147
+ "Request setup failed for %s %s%s: %s",
148
+ method,
149
+ self._base_url,
150
+ path,
151
+ err,
152
+ )
67
153
  msg = f"Could not reach Duco device at {self._base_url}: {err}"
68
154
  raise DucoConnectionError(msg) from err
69
155
 
70
156
  try:
71
157
  async with request as response:
158
+ _LOGGER.debug(
159
+ "Received response %s for %s %s%s",
160
+ response.status,
161
+ method,
162
+ self._base_url,
163
+ path,
164
+ )
72
165
  if response.status == 429:
166
+ _LOGGER.debug(
167
+ "Write limit reached for %s %s%s",
168
+ method,
169
+ self._base_url,
170
+ path,
171
+ )
73
172
  raise DucoWriteLimitError()
74
173
 
75
174
  if response.status >= 400:
@@ -85,6 +184,13 @@ class DucoClient:
85
184
  except DucoError:
86
185
  raise
87
186
  except (aiohttp.ClientError, TimeoutError) as err:
187
+ _LOGGER.debug(
188
+ "Request failed for %s %s%s: %s",
189
+ method,
190
+ self._base_url,
191
+ path,
192
+ err,
193
+ )
88
194
  msg = f"Could not reach Duco device at {self._base_url}: {err}"
89
195
  raise DucoConnectionError(msg) from err
90
196
 
@@ -97,6 +203,10 @@ class DucoClient:
97
203
  try:
98
204
  return NodeType(raw_value)
99
205
  except ValueError:
206
+ _LOGGER.debug(
207
+ "Unknown node type %r received from Duco API; falling back to UNKNOWN",
208
+ raw_value,
209
+ )
100
210
  return NodeType.UNKNOWN
101
211
 
102
212
  @staticmethod
@@ -104,8 +214,45 @@ class DucoClient:
104
214
  try:
105
215
  return NetworkType(raw_value)
106
216
  except ValueError:
217
+ _LOGGER.debug(
218
+ "Unknown network type %r received from Duco API; falling back to UNKNOWN",
219
+ raw_value,
220
+ )
107
221
  return NetworkType.UNKNOWN
108
222
 
223
+ @staticmethod
224
+ def _to_diag_status(raw_value: str) -> DiagStatus:
225
+ try:
226
+ return DiagStatus(raw_value)
227
+ except ValueError:
228
+ _LOGGER.debug(
229
+ "Unknown diagnostic status %r received from Duco API; falling back to UNKNOWN",
230
+ raw_value,
231
+ )
232
+ return DiagStatus.UNKNOWN
233
+
234
+ @staticmethod
235
+ def _to_ventilation_state(raw_value: str) -> VentilationState:
236
+ try:
237
+ return VentilationState(raw_value)
238
+ except ValueError:
239
+ _LOGGER.debug(
240
+ "Unknown ventilation state %r received from Duco API; falling back to UNKNOWN",
241
+ raw_value,
242
+ )
243
+ return VentilationState.UNKNOWN
244
+
245
+ @staticmethod
246
+ def _to_ventilation_mode(raw_value: str) -> VentilationMode:
247
+ try:
248
+ return VentilationMode(raw_value)
249
+ except ValueError:
250
+ _LOGGER.debug(
251
+ "Unknown ventilation mode %r received from Duco API; falling back to UNKNOWN",
252
+ raw_value,
253
+ )
254
+ return VentilationMode.UNKNOWN
255
+
109
256
  async def async_get_api_info(self) -> ApiInfo:
110
257
  """Return API metadata advertised by the box."""
111
258
  payload = await self._request_json("GET", "/api")
@@ -177,7 +324,7 @@ class DucoClient:
177
324
  return [
178
325
  DiagComponent(
179
326
  component=item["Component"],
180
- status=DiagStatus(item["Status"]),
327
+ status=self._to_diag_status(item["Status"]),
181
328
  )
182
329
  for item in payload["Diag"]["SubSystems"]
183
330
  ]
@@ -196,6 +343,22 @@ class DucoClient:
196
343
  )
197
344
  return int(self._read_wrapped_value(payload["General"]["PublicApi"], "WriteReqCntRemain"))
198
345
 
346
+ async def async_get_write_req_remaining(self) -> int:
347
+ """Backward-compatible alias for the old write budget method name."""
348
+ caller = _compat_caller()
349
+ if caller is None:
350
+ _LOGGER.debug(
351
+ "Compatibility alias async_get_write_req_remaining() used; "
352
+ "delegating to async_get_write_requests_remaining()."
353
+ )
354
+ else:
355
+ _LOGGER.debug(
356
+ "Compatibility alias async_get_write_req_remaining() used by %s; "
357
+ "delegating to async_get_write_requests_remaining().",
358
+ caller,
359
+ )
360
+ return await self.async_get_write_requests_remaining()
361
+
199
362
  async def async_set_ventilation_state(
200
363
  self, node_id: int, state: VentilationState | str
201
364
  ) -> None:
@@ -223,8 +386,8 @@ class DucoClient:
223
386
  if "Ventilation" in payload:
224
387
  vent = payload["Ventilation"]
225
388
  ventilation = NodeVentilationInfo(
226
- state=VentilationState(self._read_wrapped_value(vent, "State")),
227
- mode=VentilationMode(self._read_wrapped_value(vent, "Mode")),
389
+ state=self._to_ventilation_state(self._read_wrapped_value(vent, "State")),
390
+ mode=self._to_ventilation_mode(self._read_wrapped_value(vent, "Mode")),
228
391
  time_state_remain=self._read_wrapped_value(vent, "TimeStateRemain"),
229
392
  time_state_end=self._read_wrapped_value(vent, "TimeStateEnd"),
230
393
  flow_lvl_tgt=self._read_wrapped_value(vent, "FlowLvlTgt")
@@ -234,13 +397,23 @@ class DucoClient:
234
397
 
235
398
  sensor = None
236
399
  if "Sensor" in payload:
237
- sensor = payload["Sensor"]
400
+ sensor_payload = payload["Sensor"]
238
401
  sensor = NodeSensorInfo(
239
- co2=self._read_wrapped_value(sensor, "Co2") if "Co2" in sensor else None,
240
- iaq_co2=self._read_wrapped_value(sensor, "IaqCo2") if "IaqCo2" in sensor else None,
241
- rh=self._read_wrapped_value(sensor, "Rh") if "Rh" in sensor else None,
242
- iaq_rh=self._read_wrapped_value(sensor, "IaqRh") if "IaqRh" in sensor else None,
243
- temp=self._read_wrapped_value(sensor, "Temp") if "Temp" in sensor else None,
402
+ co2=self._read_wrapped_value(sensor_payload, "Co2")
403
+ if "Co2" in sensor_payload
404
+ else None,
405
+ iaq_co2=self._read_wrapped_value(sensor_payload, "IaqCo2")
406
+ if "IaqCo2" in sensor_payload
407
+ else None,
408
+ rh=self._read_wrapped_value(sensor_payload, "Rh")
409
+ if "Rh" in sensor_payload
410
+ else None,
411
+ iaq_rh=self._read_wrapped_value(sensor_payload, "IaqRh")
412
+ if "IaqRh" in sensor_payload
413
+ else None,
414
+ temp=self._read_wrapped_value(sensor_payload, "Temp")
415
+ if "Temp" in sensor_payload
416
+ else None,
244
417
  )
245
418
 
246
419
  return Node(
@@ -18,3 +18,7 @@ class DucoWriteLimitError(DucoError):
18
18
  if remaining is not None:
19
19
  detail = f"{detail} ({remaining} writes remaining)"
20
20
  super().__init__(detail)
21
+
22
+
23
+ # Backward-compatible alias for the old python-duco-client exception name.
24
+ DucoRateLimitError = DucoWriteLimitError
@@ -1,7 +1,25 @@
1
1
  """Typed models for values exposed by the local Duco API."""
2
2
 
3
+ import logging
4
+ import sys
3
5
  from dataclasses import dataclass, field
4
6
  from enum import StrEnum
7
+ from types import FrameType
8
+
9
+ _LOGGER = logging.getLogger(__name__)
10
+
11
+
12
+ def _compat_caller() -> str | None:
13
+ """Return the first external caller that reached a compatibility path."""
14
+ frame: FrameType | None = sys._getframe(1)
15
+
16
+ while frame is not None:
17
+ module_name = frame.f_globals.get("__name__", "")
18
+ if module_name != "duco_connectivity" and not module_name.startswith("duco_connectivity."):
19
+ return f"{module_name}.{frame.f_code.co_name}"
20
+ frame = frame.f_back
21
+
22
+ return None
5
23
 
6
24
 
7
25
  class NodeType(StrEnum):
@@ -23,19 +41,21 @@ class NetworkType(StrEnum):
23
41
  VIRT = "VIRT"
24
42
  RF = "RF"
25
43
  WI = "WI"
44
+ MB = "MB"
26
45
  UNKNOWN = "UNKNOWN"
27
46
 
28
47
 
29
48
  class VentilationMode(StrEnum):
30
- """Control modes reported for node ventilation."""
49
+ """Control modes reported for node ventilation, plus a client-side fallback."""
31
50
 
32
51
  AUTO = "AUTO"
33
52
  MANU = "MANU"
34
53
  NONE = "-"
54
+ UNKNOWN = "UNKNOWN"
35
55
 
36
56
 
37
57
  class VentilationState(StrEnum):
38
- """State values accepted and reported for node ventilation."""
58
+ """Ventilation states reported by the API, plus a client-side fallback."""
39
59
 
40
60
  AUTO = "AUTO"
41
61
  AUT1 = "AUT1"
@@ -54,14 +74,16 @@ class VentilationState(StrEnum):
54
74
  MAN1x3 = "MAN1x3"
55
75
  MAN2x3 = "MAN2x3"
56
76
  MAN3x3 = "MAN3x3"
77
+ UNKNOWN = "UNKNOWN"
57
78
 
58
79
 
59
80
  class DiagStatus(StrEnum):
60
- """Health states returned by the diagnostics API."""
81
+ """Health states returned by the diagnostics API, plus a client-side fallback."""
61
82
 
62
83
  OK = "Ok"
63
84
  DISABLE = "Disable"
64
85
  ERROR = "Error"
86
+ UNKNOWN = "UNKNOWN"
65
87
 
66
88
 
67
89
  @dataclass(frozen=True, slots=True)
@@ -74,7 +96,7 @@ class ApiEndpoint:
74
96
  modules: list[str] = field(default_factory=list)
75
97
 
76
98
 
77
- @dataclass(frozen=True, slots=True)
99
+ @dataclass(frozen=True, slots=True, init=False)
78
100
  class ApiInfo:
79
101
  """Version and endpoint metadata returned by the API root."""
80
102
 
@@ -82,6 +104,63 @@ class ApiInfo:
82
104
  reported_api_version: str | None = None
83
105
  endpoints: list[ApiEndpoint] = field(default_factory=list)
84
106
 
107
+ def __init__(
108
+ self,
109
+ public_api_version: str | None = None,
110
+ reported_api_version: str | None = None,
111
+ endpoints: list[ApiEndpoint] | None = None,
112
+ *,
113
+ api_version: str | None = None,
114
+ ) -> None:
115
+ """Initialize API info with backward-compatible api_version support."""
116
+ if api_version is not None:
117
+ caller = _compat_caller()
118
+ if caller is None:
119
+ _LOGGER.debug(
120
+ "Compatibility constructor argument api_version used; "
121
+ "mapping to public_api_version."
122
+ )
123
+ else:
124
+ _LOGGER.debug(
125
+ "Compatibility constructor argument api_version used by %s; "
126
+ "mapping to public_api_version.",
127
+ caller,
128
+ )
129
+
130
+ resolved_public_api_version = public_api_version
131
+ if resolved_public_api_version is None:
132
+ resolved_public_api_version = api_version
133
+ elif api_version is not None and api_version != public_api_version:
134
+ msg = "api_version must match public_api_version when both are provided"
135
+ raise ValueError(msg)
136
+
137
+ if resolved_public_api_version is None:
138
+ msg = "public_api_version or api_version must be provided"
139
+ raise TypeError(msg)
140
+
141
+ object.__setattr__(self, "public_api_version", resolved_public_api_version)
142
+ object.__setattr__(self, "reported_api_version", reported_api_version)
143
+ object.__setattr__(self, "endpoints", [] if endpoints is None else endpoints)
144
+
145
+ @property
146
+ def api_version(self) -> str:
147
+ """Backward-compatible alias for the old api_version field name."""
148
+ caller = _compat_caller()
149
+ if caller is None:
150
+ _LOGGER.debug(
151
+ "Compatibility property api_version accessed; delegating to public_api_version."
152
+ )
153
+ else:
154
+ _LOGGER.debug(
155
+ "Compatibility property api_version accessed by %s; delegating to "
156
+ "public_api_version.",
157
+ caller,
158
+ )
159
+ return self.public_api_version
160
+
161
+
162
+ ApiEndpointInfo = ApiEndpoint
163
+
85
164
 
86
165
  @dataclass(frozen=True, slots=True)
87
166
  class BoardInfo:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-duco-connectivity
3
- Version: 0.1.0
3
+ Version: 0.2.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
@@ -1,5 +1,6 @@
1
1
  """Tests for the HTTP-only Duco connectivity client."""
2
2
 
3
+ import logging
3
4
  from unittest.mock import AsyncMock, MagicMock, patch
4
5
 
5
6
  import aiohttp
@@ -13,6 +14,7 @@ from duco_connectivity import (
13
14
  DucoWriteLimitError,
14
15
  NetworkType,
15
16
  NodeType,
17
+ VentilationMode,
16
18
  VentilationState,
17
19
  )
18
20
 
@@ -48,6 +50,13 @@ def _request(response: MagicMock) -> MagicMock:
48
50
  return MagicMock(return_value=_request_context(response))
49
51
 
50
52
 
53
+ def _build_async_wrapper(module_name: str, source: str) -> object:
54
+ """Create an async wrapper function in a custom module namespace."""
55
+ namespace = {"__name__": module_name}
56
+ exec(source, namespace)
57
+ return namespace["external_wrapper"]
58
+
59
+
51
60
  async def test_https_is_rejected() -> None:
52
61
  """HTTPS hosts should be rejected during client construction."""
53
62
  async with aiohttp.ClientSession() as session:
@@ -62,6 +71,69 @@ async def test_base_url_defaults_to_http() -> None:
62
71
  assert client.base_url == "http://192.0.2.94"
63
72
 
64
73
 
74
+ async def test_base_url_uses_embedded_port_from_host() -> None:
75
+ """Hosts with an embedded port should preserve that port in the base URL."""
76
+ async with aiohttp.ClientSession() as session:
77
+ client = DucoClient(session=session, host="http://192.0.2.94:8080")
78
+ assert client.base_url == "http://192.0.2.94:8080"
79
+
80
+
81
+ async def test_bracketed_ipv6_host_is_accepted() -> None:
82
+ """Bracketed IPv6 hosts should be accepted and normalized."""
83
+ async with aiohttp.ClientSession() as session:
84
+ client = DucoClient(session=session, host="[fe80::1]")
85
+ assert client.base_url == "http://[fe80::1]"
86
+
87
+
88
+ async def test_uppercase_http_scheme_is_accepted() -> None:
89
+ """HTTP hosts should be accepted regardless of scheme casing."""
90
+ async with aiohttp.ClientSession() as session:
91
+ client = DucoClient(session=session, host="HTTP://192.0.2.94:8080")
92
+ assert client.base_url == "http://192.0.2.94:8080"
93
+
94
+
95
+ async def test_conflicting_port_sources_are_rejected() -> None:
96
+ """The client should reject a separate port when the host already includes one."""
97
+ async with aiohttp.ClientSession() as session:
98
+ with pytest.raises(ValueError, match="Port specified both"):
99
+ DucoClient(session=session, host="192.0.2.94:8080", port=8081)
100
+
101
+
102
+ async def test_userinfo_in_host_is_rejected() -> None:
103
+ """The unauthenticated client should reject credentials embedded in the host value."""
104
+ async with aiohttp.ClientSession() as session:
105
+ with pytest.raises(ValueError, match="must not include user credentials"):
106
+ DucoClient(session=session, host="user:pass@192.0.2.94")
107
+
108
+
109
+ async def test_invalid_embedded_port_is_rejected() -> None:
110
+ """Malformed embedded ports should raise a consistent client ValueError."""
111
+ async with aiohttp.ClientSession() as session:
112
+ with pytest.raises(ValueError, match="Invalid port in host value"):
113
+ DucoClient(session=session, host="192.0.2.94:abc")
114
+
115
+
116
+ async def test_unbracketed_ipv6_host_is_rejected() -> None:
117
+ """Bare IPv6 hosts should require brackets to avoid ambiguous parsing."""
118
+ async with aiohttp.ClientSession() as session:
119
+ with pytest.raises(ValueError, match="Unbracketed IPv6 host values"):
120
+ DucoClient(session=session, host="fe80::1")
121
+
122
+
123
+ async def test_negative_port_argument_is_rejected() -> None:
124
+ """Explicit negative ports should be rejected during client construction."""
125
+ async with aiohttp.ClientSession() as session:
126
+ with pytest.raises(ValueError, match="Invalid port argument"):
127
+ DucoClient(session=session, host="192.0.2.94", port=-1)
128
+
129
+
130
+ async def test_out_of_range_port_argument_is_rejected() -> None:
131
+ """Explicit ports above 65535 should be rejected during client construction."""
132
+ async with aiohttp.ClientSession() as session:
133
+ with pytest.raises(ValueError, match="Invalid port argument"):
134
+ DucoClient(session=session, host="192.0.2.94", port=99999)
135
+
136
+
65
137
  async def test_api_info_is_parsed(api_info_full_data: dict[str, object]) -> None:
66
138
  """Test that the API info model follows the public API payload."""
67
139
  mock_response = _response(json_payload=api_info_full_data)
@@ -75,7 +147,30 @@ async def test_api_info_is_parsed(api_info_full_data: dict[str, object]) -> None
75
147
  assert api_info.reported_api_version == "MOCKAPI 2.6.0"
76
148
  assert len(api_info.endpoints) == 2
77
149
  assert api_info.endpoints[1].url == "/info"
78
- assert api_info.endpoints[1].query_parameters == ["module", "submodule", "parameter"]
150
+ assert api_info.endpoints[1].query_parameters == [
151
+ "module",
152
+ "submodule",
153
+ "parameter",
154
+ ]
155
+
156
+
157
+ async def test_request_logging_includes_method_path_and_status(
158
+ caplog: pytest.LogCaptureFixture,
159
+ api_info_full_data: dict[str, object],
160
+ ) -> None:
161
+ """Requests should emit debug logs for the request and response."""
162
+ mock_response = _response(json_payload=api_info_full_data)
163
+
164
+ async with aiohttp.ClientSession() as session:
165
+ with caplog.at_level(logging.DEBUG, logger="duco_connectivity.client"):
166
+ client = DucoClient(session=session, host="192.0.2.94")
167
+ with patch.object(session, "request", _request(mock_response)):
168
+ await client.async_get_api_info()
169
+
170
+ assert "Initialized DucoClient for http://192.0.2.94" in caplog.text
171
+ assert "Using HTTP-only duco_connectivity transport for http://192.0.2.94." in caplog.text
172
+ assert "Requesting GET http://192.0.2.94/api" in caplog.text
173
+ assert "Received response 200 for GET http://192.0.2.94/api" in caplog.text
79
174
 
80
175
 
81
176
  async def test_board_info_is_parsed(board_info_data: dict[str, object]) -> None:
@@ -154,6 +249,26 @@ async def test_get_diagnostics(diag_data: dict[str, object]) -> None:
154
249
  assert diags[0].status == DiagStatus.OK
155
250
 
156
251
 
252
+ async def test_get_diagnostics_unknown_status_falls_back_to_unknown() -> None:
253
+ """Unknown diagnostic statuses should not break parsing."""
254
+ payload: dict[str, object] = {
255
+ "Diag": {
256
+ "SubSystems": [
257
+ {"Component": "Ventilation", "Status": "FutureState"},
258
+ ]
259
+ }
260
+ }
261
+ mock_response = _response(json_payload=payload)
262
+
263
+ async with aiohttp.ClientSession() as session:
264
+ client = DucoClient(session=session, host="192.0.2.94")
265
+ with patch.object(session, "request", _request(mock_response)):
266
+ diags = await client.async_get_diagnostics()
267
+
268
+ assert len(diags) == 1
269
+ assert diags[0].status == DiagStatus.UNKNOWN
270
+
271
+
157
272
  async def test_get_nodes_parses_full_payload(nodes_data: dict[str, object]) -> None:
158
273
  """Test node parsing across box and sensor nodes."""
159
274
  mock_response = _response(json_payload=nodes_data)
@@ -221,6 +336,80 @@ async def test_get_nodes_unknown_network_type_falls_back_to_unknown() -> None:
221
336
  assert nodes[0].general.network_type == NetworkType.UNKNOWN
222
337
 
223
338
 
339
+ async def test_get_nodes_unknown_network_type_logs_fallback(
340
+ caplog: pytest.LogCaptureFixture,
341
+ ) -> None:
342
+ """Unknown enum fallbacks should emit a debug log for troubleshooting."""
343
+ payload: dict[str, object] = {
344
+ "Nodes": [
345
+ {
346
+ "Node": 4,
347
+ "General": {
348
+ "Type": {"Val": "UCCO2"},
349
+ "SubType": {"Val": 0},
350
+ "NetworkType": {"Val": "FUTURE_TYPE"},
351
+ "Parent": {"Val": 1},
352
+ "Asso": {"Val": 1},
353
+ "Name": {"Val": ""},
354
+ "Identify": {"Val": 0},
355
+ },
356
+ "Ventilation": {
357
+ "State": {"Val": "AUTO"},
358
+ "TimeStateRemain": {"Val": 0},
359
+ "TimeStateEnd": {"Val": 0},
360
+ "Mode": {"Val": "-"},
361
+ },
362
+ }
363
+ ]
364
+ }
365
+ mock_response = _response(json_payload=payload)
366
+
367
+ async with aiohttp.ClientSession() as session:
368
+ client = DucoClient(session=session, host="192.0.2.94")
369
+ with (
370
+ caplog.at_level(logging.DEBUG, logger="duco_connectivity.client"),
371
+ patch.object(session, "request", _request(mock_response)),
372
+ ):
373
+ await client.async_get_nodes()
374
+
375
+ assert "Unknown network type 'FUTURE_TYPE' received from Duco API" in caplog.text
376
+
377
+
378
+ async def test_get_nodes_mb_network_type_is_parsed() -> None:
379
+ """Known MB network types should be parsed explicitly."""
380
+ payload: dict[str, object] = {
381
+ "Nodes": [
382
+ {
383
+ "Node": 4,
384
+ "General": {
385
+ "Type": {"Val": "UCCO2"},
386
+ "SubType": {"Val": 0},
387
+ "NetworkType": {"Val": "MB"},
388
+ "Parent": {"Val": 1},
389
+ "Asso": {"Val": 1},
390
+ "Name": {"Val": ""},
391
+ "Identify": {"Val": 0},
392
+ },
393
+ "Ventilation": {
394
+ "State": {"Val": "AUTO"},
395
+ "TimeStateRemain": {"Val": 0},
396
+ "TimeStateEnd": {"Val": 0},
397
+ "Mode": {"Val": "-"},
398
+ },
399
+ }
400
+ ]
401
+ }
402
+ mock_response = _response(json_payload=payload)
403
+
404
+ async with aiohttp.ClientSession() as session:
405
+ client = DucoClient(session=session, host="192.0.2.94")
406
+ with patch.object(session, "request", _request(mock_response)):
407
+ nodes = await client.async_get_nodes()
408
+
409
+ assert len(nodes) == 1
410
+ assert nodes[0].general.network_type == NetworkType.MB
411
+
412
+
224
413
  async def test_get_nodes_unknown_node_type_falls_back_to_unknown() -> None:
225
414
  """Test that unknown node types do not crash parsing."""
226
415
  payload: dict[str, object] = {
@@ -263,6 +452,91 @@ async def test_get_write_requests_remaining_is_parsed() -> None:
263
452
  assert remaining == 197
264
453
 
265
454
 
455
+ async def test_get_write_req_remaining_alias_is_parsed() -> None:
456
+ """The old write budget method name should remain available."""
457
+ payload: dict[str, object] = {"General": {"PublicApi": {"WriteReqCntRemain": {"Val": 197}}}}
458
+ mock_response = _response(json_payload=payload)
459
+
460
+ async with aiohttp.ClientSession() as session:
461
+ client = DucoClient(session=session, host="192.0.2.94")
462
+ with patch.object(session, "request", _request(mock_response)):
463
+ remaining = await client.async_get_write_req_remaining()
464
+
465
+ assert remaining == 197
466
+
467
+
468
+ async def test_get_write_req_remaining_alias_logs_external_caller(
469
+ caplog: pytest.LogCaptureFixture,
470
+ ) -> None:
471
+ """Compatibility logging should include the external caller path."""
472
+ payload: dict[str, object] = {"General": {"PublicApi": {"WriteReqCntRemain": {"Val": 197}}}}
473
+ mock_response = _response(json_payload=payload)
474
+
475
+ async def external_wrapper(client: DucoClient) -> int:
476
+ return await client.async_get_write_req_remaining()
477
+
478
+ async with aiohttp.ClientSession() as session:
479
+ client = DucoClient(session=session, host="192.0.2.94")
480
+ with (
481
+ caplog.at_level(logging.DEBUG, logger="duco_connectivity.client"),
482
+ patch.object(session, "request", _request(mock_response)),
483
+ ):
484
+ remaining = await external_wrapper(client)
485
+
486
+ assert remaining == 197
487
+ assert (
488
+ "Compatibility alias async_get_write_req_remaining() used by "
489
+ "test_client.external_wrapper; delegating to "
490
+ "async_get_write_requests_remaining()."
491
+ ) in caplog.text
492
+
493
+
494
+ async def test_get_write_req_remaining_alias_treats_prefixed_external_module_as_external(
495
+ caplog: pytest.LogCaptureFixture,
496
+ ) -> None:
497
+ """Modules like duco_connectivity_tools should not be treated as internal."""
498
+ payload: dict[str, object] = {"General": {"PublicApi": {"WriteReqCntRemain": {"Val": 197}}}}
499
+ mock_response = _response(json_payload=payload)
500
+ external_wrapper = _build_async_wrapper(
501
+ "duco_connectivity_tools",
502
+ "async def external_wrapper(client):\n"
503
+ " return await client.async_get_write_req_remaining()\n",
504
+ )
505
+
506
+ async with aiohttp.ClientSession() as session:
507
+ client = DucoClient(session=session, host="192.0.2.94")
508
+ with (
509
+ caplog.at_level(logging.DEBUG, logger="duco_connectivity.client"),
510
+ patch.object(session, "request", _request(mock_response)),
511
+ ):
512
+ remaining = await external_wrapper(client)
513
+
514
+ assert remaining == 197
515
+ assert (
516
+ "Compatibility alias async_get_write_req_remaining() used by "
517
+ "duco_connectivity_tools.external_wrapper; delegating to "
518
+ "async_get_write_requests_remaining()."
519
+ ) in caplog.text
520
+
521
+
522
+ async def test_get_write_req_remaining_alias_logs_compat_usage(
523
+ caplog: pytest.LogCaptureFixture,
524
+ ) -> None:
525
+ """The compatibility alias should emit a debug log when it is used."""
526
+ payload: dict[str, object] = {"General": {"PublicApi": {"WriteReqCntRemain": {"Val": 197}}}}
527
+ mock_response = _response(json_payload=payload)
528
+
529
+ async with aiohttp.ClientSession() as session:
530
+ client = DucoClient(session=session, host="192.0.2.94")
531
+ with (
532
+ caplog.at_level(logging.DEBUG, logger="duco_connectivity.client"),
533
+ patch.object(session, "request", _request(mock_response)),
534
+ ):
535
+ await client.async_get_write_req_remaining()
536
+
537
+ assert "Compatibility alias async_get_write_req_remaining() used" in caplog.text
538
+
539
+
266
540
  async def test_timed_manual_state_is_parsed() -> None:
267
541
  """Test that timed manual ventilation states are accepted from the API."""
268
542
  payload: dict[str, object] = {
@@ -301,6 +575,80 @@ async def test_timed_manual_state_is_parsed() -> None:
301
575
  assert nodes[0].ventilation.state is VentilationState.MAN3x2
302
576
 
303
577
 
578
+ async def test_get_nodes_unknown_ventilation_state_falls_back_to_unknown() -> None:
579
+ """Unknown ventilation states should not break node parsing."""
580
+ payload: dict[str, object] = {
581
+ "Nodes": [
582
+ {
583
+ "Node": 6,
584
+ "General": {
585
+ "Type": {"Val": "BOX"},
586
+ "SubType": {"Val": 1},
587
+ "NetworkType": {"Val": "VIRT"},
588
+ "Parent": {"Val": 0},
589
+ "Asso": {"Val": 0},
590
+ "Name": {"Val": "Living"},
591
+ "Identify": {"Val": 0},
592
+ },
593
+ "Ventilation": {
594
+ "State": {"Val": "FUTURE_STATE"},
595
+ "Mode": {"Val": "AUTO"},
596
+ "TimeStateRemain": {"Val": 0},
597
+ "TimeStateEnd": {"Val": 0},
598
+ },
599
+ }
600
+ ]
601
+ }
602
+
603
+ mock_response = _response(json_payload=payload)
604
+
605
+ async with aiohttp.ClientSession() as session:
606
+ client = DucoClient(session=session, host="192.0.2.94")
607
+ with patch.object(session, "request", _request(mock_response)):
608
+ nodes = await client.async_get_nodes()
609
+
610
+ assert len(nodes) == 1
611
+ assert nodes[0].ventilation is not None
612
+ assert nodes[0].ventilation.state is VentilationState.UNKNOWN
613
+
614
+
615
+ async def test_get_nodes_unknown_ventilation_mode_falls_back_to_unknown() -> None:
616
+ """Unknown ventilation modes should not break node parsing."""
617
+ payload: dict[str, object] = {
618
+ "Nodes": [
619
+ {
620
+ "Node": 7,
621
+ "General": {
622
+ "Type": {"Val": "BOX"},
623
+ "SubType": {"Val": 1},
624
+ "NetworkType": {"Val": "VIRT"},
625
+ "Parent": {"Val": 0},
626
+ "Asso": {"Val": 0},
627
+ "Name": {"Val": "Bedroom"},
628
+ "Identify": {"Val": 0},
629
+ },
630
+ "Ventilation": {
631
+ "State": {"Val": "AUTO"},
632
+ "Mode": {"Val": "FUTURE_MODE"},
633
+ "TimeStateRemain": {"Val": 0},
634
+ "TimeStateEnd": {"Val": 0},
635
+ },
636
+ }
637
+ ]
638
+ }
639
+
640
+ mock_response = _response(json_payload=payload)
641
+
642
+ async with aiohttp.ClientSession() as session:
643
+ client = DucoClient(session=session, host="192.0.2.94")
644
+ with patch.object(session, "request", _request(mock_response)):
645
+ nodes = await client.async_get_nodes()
646
+
647
+ assert len(nodes) == 1
648
+ assert nodes[0].ventilation is not None
649
+ assert nodes[0].ventilation.mode is VentilationMode.UNKNOWN
650
+
651
+
304
652
  async def test_connection_error_raises_duco_connection_error() -> None:
305
653
  """Transport errors should raise DucoConnectionError."""
306
654
  async with aiohttp.ClientSession() as session:
@@ -1,6 +1,11 @@
1
1
  """Tests for the public exceptions."""
2
2
 
3
- from duco_connectivity import DucoConnectionError, DucoError, DucoWriteLimitError
3
+ from duco_connectivity import (
4
+ DucoConnectionError,
5
+ DucoError,
6
+ DucoRateLimitError,
7
+ DucoWriteLimitError,
8
+ )
4
9
 
5
10
 
6
11
  def test_connection_error_inherits_from_base_error() -> None:
@@ -20,3 +25,11 @@ def test_write_limit_error_stores_remaining_count() -> None:
20
25
  err = DucoWriteLimitError(remaining=10)
21
26
  assert err.remaining == 10
22
27
  assert "10" in str(err)
28
+
29
+
30
+ def test_rate_limit_error_alias_points_to_write_limit_error() -> None:
31
+ """The old rate-limit exception name should remain import-compatible."""
32
+ assert DucoRateLimitError is DucoWriteLimitError
33
+ err = DucoRateLimitError(remaining=3)
34
+ assert isinstance(err, DucoWriteLimitError)
35
+ assert err.remaining == 3
@@ -0,0 +1,193 @@
1
+ """Tests for the public data models."""
2
+
3
+ import logging
4
+
5
+ import pytest
6
+
7
+ from duco_connectivity import (
8
+ ApiEndpoint,
9
+ ApiEndpointInfo,
10
+ ApiInfo,
11
+ BoardInfo,
12
+ NetworkType,
13
+ Node,
14
+ NodeGeneralInfo,
15
+ NodeSensorInfo,
16
+ NodeType,
17
+ NodeVentilationInfo,
18
+ VentilationMode,
19
+ VentilationState,
20
+ )
21
+
22
+
23
+ def _build_wrapper(module_name: str, source: str) -> object:
24
+ """Create a wrapper function in a custom module namespace."""
25
+ namespace = {"__name__": module_name}
26
+ exec(source, namespace)
27
+ return namespace["external_wrapper"]
28
+
29
+
30
+ def test_api_endpoint_defaults() -> None:
31
+ """ApiEndpoint should default to empty metadata lists."""
32
+ endpoint = ApiEndpoint(url="/api")
33
+ assert endpoint.methods == []
34
+ assert endpoint.query_parameters == []
35
+ assert endpoint.modules == []
36
+
37
+
38
+ def test_api_info_defaults() -> None:
39
+ """ApiInfo should allow omitted optional fields."""
40
+ info = ApiInfo(public_api_version="2.5")
41
+ assert info.public_api_version == "2.5"
42
+ assert info.api_version == "2.5"
43
+ assert info.reported_api_version is None
44
+ assert info.endpoints == []
45
+
46
+
47
+ def test_api_info_accepts_legacy_api_version_argument() -> None:
48
+ """ApiInfo should accept the old api_version keyword."""
49
+ info = ApiInfo(api_version="2.5")
50
+ assert info.public_api_version == "2.5"
51
+ assert info.api_version == "2.5"
52
+
53
+
54
+ def test_api_info_accepts_previous_positional_signature() -> None:
55
+ """ApiInfo should still accept the old positional optional fields."""
56
+ endpoint = ApiEndpoint(url="/api")
57
+
58
+ info = ApiInfo("2.5", "reported", [endpoint])
59
+
60
+ assert info.public_api_version == "2.5"
61
+ assert info.reported_api_version == "reported"
62
+ assert info.endpoints == [endpoint]
63
+
64
+
65
+ def test_api_info_accepts_positional_optional_fields_with_legacy_keyword() -> None:
66
+ """Legacy api_version support should not block the old positional optional fields."""
67
+ endpoint = ApiEndpoint(url="/api")
68
+
69
+ info = ApiInfo(None, "reported", [endpoint], api_version="2.5")
70
+
71
+ assert info.public_api_version == "2.5"
72
+ assert info.reported_api_version == "reported"
73
+ assert info.endpoints == [endpoint]
74
+
75
+
76
+ def test_api_info_legacy_api_version_argument_logs_external_caller(
77
+ caplog: pytest.LogCaptureFixture,
78
+ ) -> None:
79
+ """Using the old constructor keyword should log the external caller."""
80
+
81
+ def external_wrapper() -> ApiInfo:
82
+ return ApiInfo(api_version="2.5")
83
+
84
+ with caplog.at_level(logging.DEBUG, logger="duco_connectivity.models"):
85
+ info = external_wrapper()
86
+
87
+ assert info.public_api_version == "2.5"
88
+ assert (
89
+ "Compatibility constructor argument api_version used by "
90
+ "test_models.external_wrapper; mapping to public_api_version."
91
+ ) in caplog.text
92
+
93
+
94
+ def test_api_info_legacy_api_version_property_logs_external_caller(
95
+ caplog: pytest.LogCaptureFixture,
96
+ ) -> None:
97
+ """Using the old property should log the external caller."""
98
+ info = ApiInfo(public_api_version="2.5")
99
+
100
+ def external_wrapper() -> str:
101
+ return info.api_version
102
+
103
+ with caplog.at_level(logging.DEBUG, logger="duco_connectivity.models"):
104
+ value = external_wrapper()
105
+
106
+ assert value == "2.5"
107
+ assert (
108
+ "Compatibility property api_version accessed by "
109
+ "test_models.external_wrapper; delegating to public_api_version."
110
+ ) in caplog.text
111
+
112
+
113
+ def test_api_info_legacy_api_version_property_treats_prefixed_external_module_as_external(
114
+ caplog: pytest.LogCaptureFixture,
115
+ ) -> None:
116
+ """Modules like duco_connectivity_tools should not be treated as internal."""
117
+ info = ApiInfo(public_api_version="2.5")
118
+ external_wrapper = _build_wrapper(
119
+ "duco_connectivity_tools",
120
+ "def external_wrapper(info):\n return info.api_version\n",
121
+ )
122
+
123
+ with caplog.at_level(logging.DEBUG, logger="duco_connectivity.models"):
124
+ value = external_wrapper(info)
125
+
126
+ assert value == "2.5"
127
+ assert (
128
+ "Compatibility property api_version accessed by "
129
+ "duco_connectivity_tools.external_wrapper; delegating to public_api_version."
130
+ ) in caplog.text
131
+
132
+
133
+ def test_api_endpoint_info_alias_points_to_api_endpoint() -> None:
134
+ """ApiEndpointInfo should remain import-compatible with ApiEndpoint."""
135
+ assert ApiEndpointInfo is ApiEndpoint
136
+
137
+
138
+ def test_board_info_optional_software_version() -> None:
139
+ """BoardInfo should keep software_version optional."""
140
+ board = BoardInfo(
141
+ box_name="SILENT_CONNECT",
142
+ box_sub_type_name="Eu",
143
+ serial_board_box="RS0000000001",
144
+ serial_board_comm="PS0000000001",
145
+ serial_duco_box="n/a",
146
+ serial_duco_comm="P000000-000000-001",
147
+ time=1775082497,
148
+ )
149
+ assert board.software_version is None
150
+
151
+
152
+ def test_node_sensor_info_defaults() -> None:
153
+ """NodeSensorInfo should default all optional fields to None."""
154
+ sensor = NodeSensorInfo()
155
+ assert sensor.co2 is None
156
+ assert sensor.iaq_co2 is None
157
+ assert sensor.rh is None
158
+ assert sensor.iaq_rh is None
159
+ assert sensor.temp is None
160
+
161
+
162
+ def test_node_ventilation_info_flow_target_is_optional() -> None:
163
+ """NodeVentilationInfo should allow omitted flow target."""
164
+ ventilation = NodeVentilationInfo(
165
+ state=VentilationState.CNT1,
166
+ time_state_remain=0,
167
+ time_state_end=0,
168
+ mode=VentilationMode.NONE,
169
+ )
170
+ assert ventilation.flow_lvl_tgt is None
171
+
172
+
173
+ def test_node_is_frozen() -> None:
174
+ """The public node model should remain immutable."""
175
+ node = Node(
176
+ node_id=1,
177
+ general=NodeGeneralInfo(
178
+ node_type=NodeType.BOX,
179
+ sub_type=1,
180
+ network_type=NetworkType.VIRT,
181
+ parent=0,
182
+ asso=0,
183
+ name="",
184
+ identify=0,
185
+ ),
186
+ )
187
+ with pytest.raises(AttributeError):
188
+ node.node_id = 2 # type: ignore[misc]
189
+
190
+
191
+ def test_network_type_includes_mb() -> None:
192
+ """NetworkType should expose MB as an explicit known value."""
193
+ assert NetworkType.MB.value == "MB"
@@ -1,86 +0,0 @@
1
- """Tests for the public data models."""
2
-
3
- import pytest
4
-
5
- from duco_connectivity import (
6
- ApiEndpoint,
7
- ApiInfo,
8
- BoardInfo,
9
- NetworkType,
10
- Node,
11
- NodeGeneralInfo,
12
- NodeSensorInfo,
13
- NodeType,
14
- NodeVentilationInfo,
15
- VentilationMode,
16
- VentilationState,
17
- )
18
-
19
-
20
- def test_api_endpoint_defaults() -> None:
21
- """ApiEndpoint should default to empty metadata lists."""
22
- endpoint = ApiEndpoint(url="/api")
23
- assert endpoint.methods == []
24
- assert endpoint.query_parameters == []
25
- assert endpoint.modules == []
26
-
27
-
28
- def test_api_info_defaults() -> None:
29
- """ApiInfo should allow omitted optional fields."""
30
- info = ApiInfo(public_api_version="2.5")
31
- assert info.public_api_version == "2.5"
32
- assert info.reported_api_version is None
33
- assert info.endpoints == []
34
-
35
-
36
- def test_board_info_optional_software_version() -> None:
37
- """BoardInfo should keep software_version optional."""
38
- board = BoardInfo(
39
- box_name="SILENT_CONNECT",
40
- box_sub_type_name="Eu",
41
- serial_board_box="RS0000000001",
42
- serial_board_comm="PS0000000001",
43
- serial_duco_box="n/a",
44
- serial_duco_comm="P000000-000000-001",
45
- time=1775082497,
46
- )
47
- assert board.software_version is None
48
-
49
-
50
- def test_node_sensor_info_defaults() -> None:
51
- """NodeSensorInfo should default all optional fields to None."""
52
- sensor = NodeSensorInfo()
53
- assert sensor.co2 is None
54
- assert sensor.iaq_co2 is None
55
- assert sensor.rh is None
56
- assert sensor.iaq_rh is None
57
- assert sensor.temp is None
58
-
59
-
60
- def test_node_ventilation_info_flow_target_is_optional() -> None:
61
- """NodeVentilationInfo should allow omitted flow target."""
62
- ventilation = NodeVentilationInfo(
63
- state=VentilationState.CNT1,
64
- time_state_remain=0,
65
- time_state_end=0,
66
- mode=VentilationMode.NONE,
67
- )
68
- assert ventilation.flow_lvl_tgt is None
69
-
70
-
71
- def test_node_is_frozen() -> None:
72
- """The public node model should remain immutable."""
73
- node = Node(
74
- node_id=1,
75
- general=NodeGeneralInfo(
76
- node_type=NodeType.BOX,
77
- sub_type=1,
78
- network_type=NetworkType.VIRT,
79
- parent=0,
80
- asso=0,
81
- name="",
82
- identify=0,
83
- ),
84
- )
85
- with pytest.raises(AttributeError):
86
- node.node_id = 2 # type: ignore[misc]