python-duco-connectivity 0.1.1__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.1 → python_duco_connectivity-0.2.0}/PKG-INFO +1 -1
- {python_duco_connectivity-0.1.1 → python_duco_connectivity-0.2.0}/pyproject.toml +1 -1
- {python_duco_connectivity-0.1.1 → python_duco_connectivity-0.2.0}/src/duco_connectivity/__init__.py +9 -1
- {python_duco_connectivity-0.1.1 → python_duco_connectivity-0.2.0}/src/duco_connectivity/client.py +115 -8
- {python_duco_connectivity-0.1.1 → python_duco_connectivity-0.2.0}/src/duco_connectivity/exceptions.py +4 -0
- {python_duco_connectivity-0.1.1 → python_duco_connectivity-0.2.0}/src/duco_connectivity/models.py +76 -1
- {python_duco_connectivity-0.1.1 → python_duco_connectivity-0.2.0}/src/python_duco_connectivity.egg-info/PKG-INFO +1 -1
- {python_duco_connectivity-0.1.1 → python_duco_connectivity-0.2.0}/tests/test_client.py +156 -1
- {python_duco_connectivity-0.1.1 → 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.1/tests/test_models.py +0 -91
- {python_duco_connectivity-0.1.1 → python_duco_connectivity-0.2.0}/LICENSE +0 -0
- {python_duco_connectivity-0.1.1 → python_duco_connectivity-0.2.0}/README.md +0 -0
- {python_duco_connectivity-0.1.1 → python_duco_connectivity-0.2.0}/setup.cfg +0 -0
- {python_duco_connectivity-0.1.1 → python_duco_connectivity-0.2.0}/src/duco_connectivity/py.typed +0 -0
- {python_duco_connectivity-0.1.1 → python_duco_connectivity-0.2.0}/src/python_duco_connectivity.egg-info/SOURCES.txt +0 -0
- {python_duco_connectivity-0.1.1 → python_duco_connectivity-0.2.0}/src/python_duco_connectivity.egg-info/dependency_links.txt +0 -0
- {python_duco_connectivity-0.1.1 → python_duco_connectivity-0.2.0}/src/python_duco_connectivity.egg-info/requires.txt +0 -0
- {python_duco_connectivity-0.1.1 → python_duco_connectivity-0.2.0}/src/python_duco_connectivity.egg-info/top_level.txt +0 -0
{python_duco_connectivity-0.1.1 → 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.1 → python_duco_connectivity-0.2.0}/src/duco_connectivity/client.py
RENAMED
|
@@ -1,6 +1,9 @@
|
|
|
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
|
|
5
8
|
from urllib.parse import urlsplit
|
|
6
9
|
|
|
@@ -24,6 +27,21 @@ from .models import (
|
|
|
24
27
|
VentilationState,
|
|
25
28
|
)
|
|
26
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
|
+
|
|
27
45
|
|
|
28
46
|
class DucoClient:
|
|
29
47
|
"""Client for a Duco box that exposes the local HTTP API."""
|
|
@@ -94,27 +112,63 @@ class DucoClient:
|
|
|
94
112
|
else:
|
|
95
113
|
self._base_url = f"http://{normalized_host}:{resolved_port}"
|
|
96
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
|
+
)
|
|
120
|
+
|
|
97
121
|
@property
|
|
98
122
|
def base_url(self) -> str:
|
|
99
123
|
"""Normalized base URL used for requests."""
|
|
100
124
|
return self._base_url
|
|
101
125
|
|
|
102
126
|
async def _request_json(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
127
|
+
json_payload = None
|
|
103
128
|
if "json" in kwargs:
|
|
104
|
-
|
|
105
|
-
kwargs["data"] = json.dumps(
|
|
129
|
+
json_payload = kwargs.pop("json")
|
|
130
|
+
kwargs["data"] = json.dumps(json_payload, separators=(",", ":")).encode()
|
|
106
131
|
kwargs.setdefault("headers", {})["Content-Type"] = "application/json"
|
|
107
132
|
kwargs.setdefault("timeout", self._timeout)
|
|
108
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
|
+
|
|
109
143
|
try:
|
|
110
144
|
request = self._session.request(method, f"{self._base_url}{path}", **kwargs)
|
|
111
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
|
+
)
|
|
112
153
|
msg = f"Could not reach Duco device at {self._base_url}: {err}"
|
|
113
154
|
raise DucoConnectionError(msg) from err
|
|
114
155
|
|
|
115
156
|
try:
|
|
116
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
|
+
)
|
|
117
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
|
+
)
|
|
118
172
|
raise DucoWriteLimitError()
|
|
119
173
|
|
|
120
174
|
if response.status >= 400:
|
|
@@ -130,6 +184,13 @@ class DucoClient:
|
|
|
130
184
|
except DucoError:
|
|
131
185
|
raise
|
|
132
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
|
+
)
|
|
133
194
|
msg = f"Could not reach Duco device at {self._base_url}: {err}"
|
|
134
195
|
raise DucoConnectionError(msg) from err
|
|
135
196
|
|
|
@@ -142,6 +203,10 @@ class DucoClient:
|
|
|
142
203
|
try:
|
|
143
204
|
return NodeType(raw_value)
|
|
144
205
|
except ValueError:
|
|
206
|
+
_LOGGER.debug(
|
|
207
|
+
"Unknown node type %r received from Duco API; falling back to UNKNOWN",
|
|
208
|
+
raw_value,
|
|
209
|
+
)
|
|
145
210
|
return NodeType.UNKNOWN
|
|
146
211
|
|
|
147
212
|
@staticmethod
|
|
@@ -149,6 +214,10 @@ class DucoClient:
|
|
|
149
214
|
try:
|
|
150
215
|
return NetworkType(raw_value)
|
|
151
216
|
except ValueError:
|
|
217
|
+
_LOGGER.debug(
|
|
218
|
+
"Unknown network type %r received from Duco API; falling back to UNKNOWN",
|
|
219
|
+
raw_value,
|
|
220
|
+
)
|
|
152
221
|
return NetworkType.UNKNOWN
|
|
153
222
|
|
|
154
223
|
@staticmethod
|
|
@@ -156,6 +225,10 @@ class DucoClient:
|
|
|
156
225
|
try:
|
|
157
226
|
return DiagStatus(raw_value)
|
|
158
227
|
except ValueError:
|
|
228
|
+
_LOGGER.debug(
|
|
229
|
+
"Unknown diagnostic status %r received from Duco API; falling back to UNKNOWN",
|
|
230
|
+
raw_value,
|
|
231
|
+
)
|
|
159
232
|
return DiagStatus.UNKNOWN
|
|
160
233
|
|
|
161
234
|
@staticmethod
|
|
@@ -163,6 +236,10 @@ class DucoClient:
|
|
|
163
236
|
try:
|
|
164
237
|
return VentilationState(raw_value)
|
|
165
238
|
except ValueError:
|
|
239
|
+
_LOGGER.debug(
|
|
240
|
+
"Unknown ventilation state %r received from Duco API; falling back to UNKNOWN",
|
|
241
|
+
raw_value,
|
|
242
|
+
)
|
|
166
243
|
return VentilationState.UNKNOWN
|
|
167
244
|
|
|
168
245
|
@staticmethod
|
|
@@ -170,6 +247,10 @@ class DucoClient:
|
|
|
170
247
|
try:
|
|
171
248
|
return VentilationMode(raw_value)
|
|
172
249
|
except ValueError:
|
|
250
|
+
_LOGGER.debug(
|
|
251
|
+
"Unknown ventilation mode %r received from Duco API; falling back to UNKNOWN",
|
|
252
|
+
raw_value,
|
|
253
|
+
)
|
|
173
254
|
return VentilationMode.UNKNOWN
|
|
174
255
|
|
|
175
256
|
async def async_get_api_info(self) -> ApiInfo:
|
|
@@ -262,6 +343,22 @@ class DucoClient:
|
|
|
262
343
|
)
|
|
263
344
|
return int(self._read_wrapped_value(payload["General"]["PublicApi"], "WriteReqCntRemain"))
|
|
264
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
|
+
|
|
265
362
|
async def async_set_ventilation_state(
|
|
266
363
|
self, node_id: int, state: VentilationState | str
|
|
267
364
|
) -> None:
|
|
@@ -300,13 +397,23 @@ class DucoClient:
|
|
|
300
397
|
|
|
301
398
|
sensor = None
|
|
302
399
|
if "Sensor" in payload:
|
|
303
|
-
|
|
400
|
+
sensor_payload = payload["Sensor"]
|
|
304
401
|
sensor = NodeSensorInfo(
|
|
305
|
-
co2=self._read_wrapped_value(
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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,
|
|
310
417
|
)
|
|
311
418
|
|
|
312
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.1 → 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):
|
|
@@ -78,7 +96,7 @@ class ApiEndpoint:
|
|
|
78
96
|
modules: list[str] = field(default_factory=list)
|
|
79
97
|
|
|
80
98
|
|
|
81
|
-
@dataclass(frozen=True, slots=True)
|
|
99
|
+
@dataclass(frozen=True, slots=True, init=False)
|
|
82
100
|
class ApiInfo:
|
|
83
101
|
"""Version and endpoint metadata returned by the API root."""
|
|
84
102
|
|
|
@@ -86,6 +104,63 @@ class ApiInfo:
|
|
|
86
104
|
reported_api_version: str | None = None
|
|
87
105
|
endpoints: list[ApiEndpoint] = field(default_factory=list)
|
|
88
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
|
+
|
|
89
164
|
|
|
90
165
|
@dataclass(frozen=True, slots=True)
|
|
91
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
|
|
@@ -49,6 +50,13 @@ def _request(response: MagicMock) -> MagicMock:
|
|
|
49
50
|
return MagicMock(return_value=_request_context(response))
|
|
50
51
|
|
|
51
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
|
+
|
|
52
60
|
async def test_https_is_rejected() -> None:
|
|
53
61
|
"""HTTPS hosts should be rejected during client construction."""
|
|
54
62
|
async with aiohttp.ClientSession() as session:
|
|
@@ -139,7 +147,30 @@ async def test_api_info_is_parsed(api_info_full_data: dict[str, object]) -> None
|
|
|
139
147
|
assert api_info.reported_api_version == "MOCKAPI 2.6.0"
|
|
140
148
|
assert len(api_info.endpoints) == 2
|
|
141
149
|
assert api_info.endpoints[1].url == "/info"
|
|
142
|
-
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
|
|
143
174
|
|
|
144
175
|
|
|
145
176
|
async def test_board_info_is_parsed(board_info_data: dict[str, object]) -> None:
|
|
@@ -305,6 +336,45 @@ async def test_get_nodes_unknown_network_type_falls_back_to_unknown() -> None:
|
|
|
305
336
|
assert nodes[0].general.network_type == NetworkType.UNKNOWN
|
|
306
337
|
|
|
307
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
|
+
|
|
308
378
|
async def test_get_nodes_mb_network_type_is_parsed() -> None:
|
|
309
379
|
"""Known MB network types should be parsed explicitly."""
|
|
310
380
|
payload: dict[str, object] = {
|
|
@@ -382,6 +452,91 @@ async def test_get_write_requests_remaining_is_parsed() -> None:
|
|
|
382
452
|
assert remaining == 197
|
|
383
453
|
|
|
384
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
|
+
|
|
385
540
|
async def test_timed_manual_state_is_parsed() -> None:
|
|
386
541
|
"""Test that timed manual ventilation states are accepted from the API."""
|
|
387
542
|
payload: dict[str, object] = {
|
|
@@ -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,91 +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]
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def test_network_type_includes_mb() -> None:
|
|
90
|
-
"""NetworkType should expose MB as an explicit known value."""
|
|
91
|
-
assert NetworkType.MB.value == "MB"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_duco_connectivity-0.1.1 → python_duco_connectivity-0.2.0}/src/duco_connectivity/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|