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.
- {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/PKG-INFO +1 -1
- {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/pyproject.toml +1 -1
- {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/src/duco_connectivity/__init__.py +9 -1
- {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/src/duco_connectivity/client.py +189 -16
- {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/src/duco_connectivity/exceptions.py +4 -0
- {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/src/duco_connectivity/models.py +83 -4
- {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/src/python_duco_connectivity.egg-info/PKG-INFO +1 -1
- {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/tests/test_client.py +349 -1
- {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/tests/test_exceptions.py +14 -1
- python_duco_connectivity-0.2.0/tests/test_models.py +193 -0
- python_duco_connectivity-0.1.0/tests/test_models.py +0 -86
- {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/LICENSE +0 -0
- {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/README.md +0 -0
- {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/setup.cfg +0 -0
- {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/src/duco_connectivity/py.typed +0 -0
- {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/src/python_duco_connectivity.egg-info/SOURCES.txt +0 -0
- {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/src/python_duco_connectivity.egg-info/dependency_links.txt +0 -0
- {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/src/python_duco_connectivity.egg-info/requires.txt +0 -0
- {python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/src/python_duco_connectivity.egg-info/top_level.txt +0 -0
{python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/src/duco_connectivity/__init__.py
RENAMED
|
@@ -3,9 +3,15 @@
|
|
|
3
3
|
from importlib.metadata import PackageNotFoundError, version
|
|
4
4
|
|
|
5
5
|
from .client import DucoClient
|
|
6
|
-
from .exceptions import
|
|
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",
|
{python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/src/duco_connectivity/client.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
|
|
44
|
-
if
|
|
74
|
+
|
|
75
|
+
if scheme not in ("", "http"):
|
|
45
76
|
msg = f"Unsupported scheme in host value: {host}"
|
|
46
77
|
raise ValueError(msg)
|
|
47
|
-
|
|
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}:{
|
|
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
|
-
|
|
60
|
-
kwargs["data"] = json.dumps(
|
|
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=
|
|
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=
|
|
227
|
-
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
|
-
|
|
400
|
+
sensor_payload = payload["Sensor"]
|
|
238
401
|
sensor = NodeSensorInfo(
|
|
239
|
-
co2=self._read_wrapped_value(
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
{python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/src/duco_connectivity/models.py
RENAMED
|
@@ -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
|
-
"""
|
|
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,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 == [
|
|
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
|
|
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]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_duco_connectivity-0.1.0 → python_duco_connectivity-0.2.0}/src/duco_connectivity/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|