python-duco-connectivity 0.1.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/LICENSE +21 -0
- python_duco_connectivity-0.1.0/PKG-INFO +102 -0
- python_duco_connectivity-0.1.0/README.md +70 -0
- python_duco_connectivity-0.1.0/pyproject.toml +80 -0
- python_duco_connectivity-0.1.0/setup.cfg +4 -0
- python_duco_connectivity-0.1.0/src/duco_connectivity/__init__.py +49 -0
- python_duco_connectivity-0.1.0/src/duco_connectivity/client.py +251 -0
- python_duco_connectivity-0.1.0/src/duco_connectivity/exceptions.py +20 -0
- python_duco_connectivity-0.1.0/src/duco_connectivity/models.py +165 -0
- python_duco_connectivity-0.1.0/src/duco_connectivity/py.typed +0 -0
- python_duco_connectivity-0.1.0/src/python_duco_connectivity.egg-info/PKG-INFO +102 -0
- python_duco_connectivity-0.1.0/src/python_duco_connectivity.egg-info/SOURCES.txt +16 -0
- python_duco_connectivity-0.1.0/src/python_duco_connectivity.egg-info/dependency_links.txt +1 -0
- python_duco_connectivity-0.1.0/src/python_duco_connectivity.egg-info/requires.txt +11 -0
- python_duco_connectivity-0.1.0/src/python_duco_connectivity.egg-info/top_level.txt +1 -0
- python_duco_connectivity-0.1.0/tests/test_client.py +379 -0
- python_duco_connectivity-0.1.0/tests/test_exceptions.py +22 -0
- python_duco_connectivity-0.1.0/tests/test_models.py +86 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ronald van der Meer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-duco-connectivity
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async HTTP client for the local Duco Connectivity API
|
|
5
|
+
Author: Ronald van der Meer
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ronaldvdmeer/python-duco-connectivity
|
|
8
|
+
Project-URL: Repository, https://github.com/ronaldvdmeer/python-duco-connectivity
|
|
9
|
+
Project-URL: Issues, https://github.com/ronaldvdmeer/python-duco-connectivity/issues
|
|
10
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Home Automation
|
|
16
|
+
Classifier: Framework :: AsyncIO
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: aioresponses>=0.7; extra == "dev"
|
|
24
|
+
Requires-Dist: bandit>=1.7; extra == "dev"
|
|
25
|
+
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
26
|
+
Requires-Dist: pip-audit>=2.7; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
30
|
+
Requires-Dist: ruff>=0.11; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# python-duco-connectivity
|
|
34
|
+
|
|
35
|
+
Async Python client for the local Duco HTTP API.
|
|
36
|
+
|
|
37
|
+
`python-duco-connectivity` is a small async client for the unauthenticated
|
|
38
|
+
local Duco HTTP endpoints that were validated during initial development. The
|
|
39
|
+
library keeps its public models close to the API payload shape and is intended
|
|
40
|
+
to stay reusable outside Home Assistant.
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
Until the first PyPI release is published, install directly from GitHub:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install git+https://github.com/ronaldvdmeer/python-duco-connectivity.git
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
After the package is published on PyPI, install it with:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install python-duco-connectivity
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Current scope
|
|
57
|
+
|
|
58
|
+
- HTTP only
|
|
59
|
+
- asynchronous communication via `aiohttp`
|
|
60
|
+
- typed models that stay close to the API response shape
|
|
61
|
+
|
|
62
|
+
## Public API surface
|
|
63
|
+
|
|
64
|
+
The current client exposes:
|
|
65
|
+
|
|
66
|
+
- `async_get_api_info()` for `GET /api`
|
|
67
|
+
- `async_get_board_info()` for `GET /info?module=General&submodule=Board`
|
|
68
|
+
- `async_get_lan_info()` for `GET /info?module=General&submodule=Lan`
|
|
69
|
+
- `async_get_nodes()` for `GET /info/nodes`
|
|
70
|
+
- `async_get_diagnostics()` for `GET /info?module=Diag`
|
|
71
|
+
- `async_get_write_requests_remaining()` for `GET /info?module=General&submodule=PublicApi`
|
|
72
|
+
- `async_set_ventilation_state()` for `POST /action/nodes/{node}` with `SetVentilationState`
|
|
73
|
+
|
|
74
|
+
The model layer includes `ApiInfo`, `BoardInfo`, `LanInfo`, `Node`,
|
|
75
|
+
`NodeGeneralInfo`, `NodeVentilationInfo`, and `NodeSensorInfo`.
|
|
76
|
+
|
|
77
|
+
## Development
|
|
78
|
+
|
|
79
|
+
Install the development dependencies and run the same checks as CI:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pip install ".[dev]"
|
|
83
|
+
pytest
|
|
84
|
+
ruff check src tests
|
|
85
|
+
ruff format --check src tests
|
|
86
|
+
mypy src
|
|
87
|
+
bandit -r src -ll
|
|
88
|
+
pip-audit --desc on
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Validation
|
|
92
|
+
|
|
93
|
+
The current API surface was validated against a real Duco box during the first
|
|
94
|
+
development pass, covering:
|
|
95
|
+
|
|
96
|
+
- `GET /api`
|
|
97
|
+
- `GET /info?module=General&submodule=Board`
|
|
98
|
+
- `GET /info?module=General&submodule=Lan`
|
|
99
|
+
- `GET /info/nodes`
|
|
100
|
+
- `GET /info?module=General&submodule=PublicApi`
|
|
101
|
+
- `POST /action/nodes/{node}` with a no-op `SetVentilationState`
|
|
102
|
+
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# python-duco-connectivity
|
|
2
|
+
|
|
3
|
+
Async Python client for the local Duco HTTP API.
|
|
4
|
+
|
|
5
|
+
`python-duco-connectivity` is a small async client for the unauthenticated
|
|
6
|
+
local Duco HTTP endpoints that were validated during initial development. The
|
|
7
|
+
library keeps its public models close to the API payload shape and is intended
|
|
8
|
+
to stay reusable outside Home Assistant.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
Until the first PyPI release is published, install directly from GitHub:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install git+https://github.com/ronaldvdmeer/python-duco-connectivity.git
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
After the package is published on PyPI, install it with:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install python-duco-connectivity
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Current scope
|
|
25
|
+
|
|
26
|
+
- HTTP only
|
|
27
|
+
- asynchronous communication via `aiohttp`
|
|
28
|
+
- typed models that stay close to the API response shape
|
|
29
|
+
|
|
30
|
+
## Public API surface
|
|
31
|
+
|
|
32
|
+
The current client exposes:
|
|
33
|
+
|
|
34
|
+
- `async_get_api_info()` for `GET /api`
|
|
35
|
+
- `async_get_board_info()` for `GET /info?module=General&submodule=Board`
|
|
36
|
+
- `async_get_lan_info()` for `GET /info?module=General&submodule=Lan`
|
|
37
|
+
- `async_get_nodes()` for `GET /info/nodes`
|
|
38
|
+
- `async_get_diagnostics()` for `GET /info?module=Diag`
|
|
39
|
+
- `async_get_write_requests_remaining()` for `GET /info?module=General&submodule=PublicApi`
|
|
40
|
+
- `async_set_ventilation_state()` for `POST /action/nodes/{node}` with `SetVentilationState`
|
|
41
|
+
|
|
42
|
+
The model layer includes `ApiInfo`, `BoardInfo`, `LanInfo`, `Node`,
|
|
43
|
+
`NodeGeneralInfo`, `NodeVentilationInfo`, and `NodeSensorInfo`.
|
|
44
|
+
|
|
45
|
+
## Development
|
|
46
|
+
|
|
47
|
+
Install the development dependencies and run the same checks as CI:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install ".[dev]"
|
|
51
|
+
pytest
|
|
52
|
+
ruff check src tests
|
|
53
|
+
ruff format --check src tests
|
|
54
|
+
mypy src
|
|
55
|
+
bandit -r src -ll
|
|
56
|
+
pip-audit --desc on
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Validation
|
|
60
|
+
|
|
61
|
+
The current API surface was validated against a real Duco box during the first
|
|
62
|
+
development pass, covering:
|
|
63
|
+
|
|
64
|
+
- `GET /api`
|
|
65
|
+
- `GET /info?module=General&submodule=Board`
|
|
66
|
+
- `GET /info?module=General&submodule=Lan`
|
|
67
|
+
- `GET /info/nodes`
|
|
68
|
+
- `GET /info?module=General&submodule=PublicApi`
|
|
69
|
+
- `POST /action/nodes/{node}` with a no-op `SetVentilationState`
|
|
70
|
+
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "python-duco-connectivity"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Async HTTP client for the local Duco Connectivity API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Ronald van der Meer" },
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Home Automation",
|
|
22
|
+
"Framework :: AsyncIO",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"aiohttp>=3.9.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
dev = [
|
|
31
|
+
"aioresponses>=0.7",
|
|
32
|
+
"bandit>=1.7",
|
|
33
|
+
"mypy>=1.8",
|
|
34
|
+
"pip-audit>=2.7",
|
|
35
|
+
"pytest>=8.0",
|
|
36
|
+
"pytest-asyncio>=0.23",
|
|
37
|
+
"pytest-cov>=5.0",
|
|
38
|
+
"ruff>=0.11",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://github.com/ronaldvdmeer/python-duco-connectivity"
|
|
43
|
+
Repository = "https://github.com/ronaldvdmeer/python-duco-connectivity"
|
|
44
|
+
Issues = "https://github.com/ronaldvdmeer/python-duco-connectivity/issues"
|
|
45
|
+
|
|
46
|
+
[tool.setuptools.packages.find]
|
|
47
|
+
where = ["src"]
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.package-data]
|
|
50
|
+
duco_connectivity = ["py.typed"]
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
testpaths = ["tests"]
|
|
54
|
+
asyncio_mode = "auto"
|
|
55
|
+
addopts = [
|
|
56
|
+
"--cov=duco_connectivity",
|
|
57
|
+
"--cov-report=term-missing",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
[tool.coverage.run]
|
|
61
|
+
source = ["duco_connectivity"]
|
|
62
|
+
branch = true
|
|
63
|
+
|
|
64
|
+
[tool.coverage.report]
|
|
65
|
+
exclude_lines = [
|
|
66
|
+
"pragma: no cover",
|
|
67
|
+
"if TYPE_CHECKING:",
|
|
68
|
+
"raise NotImplementedError",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
[tool.ruff]
|
|
72
|
+
line-length = 100
|
|
73
|
+
target-version = "py312"
|
|
74
|
+
|
|
75
|
+
[tool.ruff.lint]
|
|
76
|
+
select = ["E", "F", "I", "UP"]
|
|
77
|
+
|
|
78
|
+
[tool.mypy]
|
|
79
|
+
python_version = "3.12"
|
|
80
|
+
strict = true
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Public package exports for python-duco-connectivity."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .client import DucoClient
|
|
6
|
+
from .exceptions import DucoConnectionError, DucoError, DucoWriteLimitError
|
|
7
|
+
from .models import (
|
|
8
|
+
ApiEndpoint,
|
|
9
|
+
ApiInfo,
|
|
10
|
+
BoardInfo,
|
|
11
|
+
DiagComponent,
|
|
12
|
+
DiagStatus,
|
|
13
|
+
LanInfo,
|
|
14
|
+
NetworkType,
|
|
15
|
+
Node,
|
|
16
|
+
NodeGeneralInfo,
|
|
17
|
+
NodeSensorInfo,
|
|
18
|
+
NodeType,
|
|
19
|
+
NodeVentilationInfo,
|
|
20
|
+
VentilationMode,
|
|
21
|
+
VentilationState,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
__version__ = version("python-duco-connectivity")
|
|
26
|
+
except PackageNotFoundError:
|
|
27
|
+
__version__ = "0.0.0"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"ApiEndpoint",
|
|
31
|
+
"ApiInfo",
|
|
32
|
+
"BoardInfo",
|
|
33
|
+
"DucoClient",
|
|
34
|
+
"DucoConnectionError",
|
|
35
|
+
"DucoError",
|
|
36
|
+
"DucoWriteLimitError",
|
|
37
|
+
"DiagComponent",
|
|
38
|
+
"DiagStatus",
|
|
39
|
+
"LanInfo",
|
|
40
|
+
"NetworkType",
|
|
41
|
+
"Node",
|
|
42
|
+
"NodeGeneralInfo",
|
|
43
|
+
"NodeSensorInfo",
|
|
44
|
+
"NodeType",
|
|
45
|
+
"NodeVentilationInfo",
|
|
46
|
+
"VentilationMode",
|
|
47
|
+
"VentilationState",
|
|
48
|
+
"__version__",
|
|
49
|
+
]
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Async client for the local Duco HTTP API."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
|
|
8
|
+
from .exceptions import DucoConnectionError, DucoError, DucoWriteLimitError
|
|
9
|
+
from .models import (
|
|
10
|
+
ApiEndpoint,
|
|
11
|
+
ApiInfo,
|
|
12
|
+
BoardInfo,
|
|
13
|
+
DiagComponent,
|
|
14
|
+
DiagStatus,
|
|
15
|
+
LanInfo,
|
|
16
|
+
NetworkType,
|
|
17
|
+
Node,
|
|
18
|
+
NodeGeneralInfo,
|
|
19
|
+
NodeSensorInfo,
|
|
20
|
+
NodeType,
|
|
21
|
+
NodeVentilationInfo,
|
|
22
|
+
VentilationMode,
|
|
23
|
+
VentilationState,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DucoClient:
|
|
28
|
+
"""Client for a Duco box that exposes the local HTTP API."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
session: aiohttp.ClientSession,
|
|
33
|
+
host: str,
|
|
34
|
+
*,
|
|
35
|
+
port: int | None = None,
|
|
36
|
+
request_timeout: float = 10.0,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._session = session
|
|
39
|
+
self._timeout = aiohttp.ClientTimeout(total=request_timeout)
|
|
40
|
+
if host.startswith("https://"):
|
|
41
|
+
msg = "HTTPS is not supported by this client"
|
|
42
|
+
raise ValueError(msg)
|
|
43
|
+
normalized_host = host.removeprefix("http://").removeprefix("https://").rstrip("/")
|
|
44
|
+
if "://" in host and not host.startswith("http://"):
|
|
45
|
+
msg = f"Unsupported scheme in host value: {host}"
|
|
46
|
+
raise ValueError(msg)
|
|
47
|
+
if port is None:
|
|
48
|
+
self._base_url = f"http://{normalized_host}"
|
|
49
|
+
else:
|
|
50
|
+
self._base_url = f"http://{normalized_host}:{port}"
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def base_url(self) -> str:
|
|
54
|
+
"""Normalized base URL used for requests."""
|
|
55
|
+
return self._base_url
|
|
56
|
+
|
|
57
|
+
async def _request_json(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
58
|
+
if "json" in kwargs:
|
|
59
|
+
payload = kwargs.pop("json")
|
|
60
|
+
kwargs["data"] = json.dumps(payload, separators=(",", ":")).encode()
|
|
61
|
+
kwargs.setdefault("headers", {})["Content-Type"] = "application/json"
|
|
62
|
+
kwargs.setdefault("timeout", self._timeout)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
request = self._session.request(method, f"{self._base_url}{path}", **kwargs)
|
|
66
|
+
except (aiohttp.ClientError, TimeoutError) as err:
|
|
67
|
+
msg = f"Could not reach Duco device at {self._base_url}: {err}"
|
|
68
|
+
raise DucoConnectionError(msg) from err
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
async with request as response:
|
|
72
|
+
if response.status == 429:
|
|
73
|
+
raise DucoWriteLimitError()
|
|
74
|
+
|
|
75
|
+
if response.status >= 400:
|
|
76
|
+
body = await response.text()
|
|
77
|
+
msg = f"Unexpected response {response.status} for {path}: {body}"
|
|
78
|
+
raise DucoError(msg)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
return await response.json(content_type=None)
|
|
82
|
+
except ValueError as err:
|
|
83
|
+
msg = f"Expected JSON response from {path}: {err}"
|
|
84
|
+
raise DucoError(msg) from err
|
|
85
|
+
except DucoError:
|
|
86
|
+
raise
|
|
87
|
+
except (aiohttp.ClientError, TimeoutError) as err:
|
|
88
|
+
msg = f"Could not reach Duco device at {self._base_url}: {err}"
|
|
89
|
+
raise DucoConnectionError(msg) from err
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _read_wrapped_value(payload: dict[str, Any], key: str) -> Any:
|
|
93
|
+
return payload[key]["Val"]
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def _to_node_type(raw_value: str) -> NodeType:
|
|
97
|
+
try:
|
|
98
|
+
return NodeType(raw_value)
|
|
99
|
+
except ValueError:
|
|
100
|
+
return NodeType.UNKNOWN
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def _to_network_type(raw_value: str) -> NetworkType:
|
|
104
|
+
try:
|
|
105
|
+
return NetworkType(raw_value)
|
|
106
|
+
except ValueError:
|
|
107
|
+
return NetworkType.UNKNOWN
|
|
108
|
+
|
|
109
|
+
async def async_get_api_info(self) -> ApiInfo:
|
|
110
|
+
"""Return API metadata advertised by the box."""
|
|
111
|
+
payload = await self._request_json("GET", "/api")
|
|
112
|
+
public_api_version = self._read_wrapped_value(payload, "PublicApiVersion")
|
|
113
|
+
reported_api_version = None
|
|
114
|
+
if "ApiVersion" in payload:
|
|
115
|
+
reported_api_version = self._read_wrapped_value(payload, "ApiVersion")
|
|
116
|
+
endpoints = [
|
|
117
|
+
ApiEndpoint(
|
|
118
|
+
url=item["Url"],
|
|
119
|
+
methods=list(item.get("Methods", [])),
|
|
120
|
+
query_parameters=list(item.get("QueryParameters", [])),
|
|
121
|
+
modules=list(item.get("Modules", [])),
|
|
122
|
+
)
|
|
123
|
+
for item in payload.get("ApiInfo", [])
|
|
124
|
+
]
|
|
125
|
+
return ApiInfo(
|
|
126
|
+
public_api_version=public_api_version,
|
|
127
|
+
reported_api_version=reported_api_version,
|
|
128
|
+
endpoints=endpoints,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
async def async_get_board_info(self) -> BoardInfo:
|
|
132
|
+
"""Return identity and version details for the main unit."""
|
|
133
|
+
payload = await self._request_json(
|
|
134
|
+
"GET",
|
|
135
|
+
"/info",
|
|
136
|
+
params={"module": "General", "submodule": "Board"},
|
|
137
|
+
)
|
|
138
|
+
board = payload["General"]["Board"]
|
|
139
|
+
return BoardInfo(
|
|
140
|
+
box_name=self._read_wrapped_value(board, "BoxName"),
|
|
141
|
+
box_sub_type_name=self._read_wrapped_value(board, "BoxSubTypeName"),
|
|
142
|
+
serial_board_box=self._read_wrapped_value(board, "SerialBoardBox"),
|
|
143
|
+
serial_board_comm=self._read_wrapped_value(board, "SerialBoardComm"),
|
|
144
|
+
serial_duco_box=self._read_wrapped_value(board, "SerialDucoBox"),
|
|
145
|
+
serial_duco_comm=self._read_wrapped_value(board, "SerialDucoComm"),
|
|
146
|
+
time=self._read_wrapped_value(board, "Time"),
|
|
147
|
+
public_api_version=self._read_wrapped_value(board, "PublicApiVersion")
|
|
148
|
+
if "PublicApiVersion" in board
|
|
149
|
+
else None,
|
|
150
|
+
software_version=self._read_wrapped_value(board, "SwVersion")
|
|
151
|
+
if "SwVersion" in board
|
|
152
|
+
else None,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
async def async_get_lan_info(self) -> LanInfo:
|
|
156
|
+
"""Return LAN settings reported by the box."""
|
|
157
|
+
payload = await self._request_json(
|
|
158
|
+
"GET",
|
|
159
|
+
"/info",
|
|
160
|
+
params={"module": "General", "submodule": "Lan"},
|
|
161
|
+
)
|
|
162
|
+
lan = payload["General"]["Lan"]
|
|
163
|
+
return LanInfo(
|
|
164
|
+
mode=self._read_wrapped_value(lan, "Mode"),
|
|
165
|
+
ip=self._read_wrapped_value(lan, "Ip"),
|
|
166
|
+
net_mask=self._read_wrapped_value(lan, "NetMask"),
|
|
167
|
+
default_gateway=self._read_wrapped_value(lan, "DefaultGateway"),
|
|
168
|
+
dns=self._read_wrapped_value(lan, "Dns"),
|
|
169
|
+
mac=self._read_wrapped_value(lan, "Mac"),
|
|
170
|
+
host_name=self._read_wrapped_value(lan, "HostName"),
|
|
171
|
+
rssi_wifi=self._read_wrapped_value(lan, "RssiWifi") if "RssiWifi" in lan else None,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
async def async_get_diagnostics(self) -> list[DiagComponent]:
|
|
175
|
+
"""Return health states for diagnostic subsystems."""
|
|
176
|
+
payload = await self._request_json("GET", "/info", params={"module": "Diag"})
|
|
177
|
+
return [
|
|
178
|
+
DiagComponent(
|
|
179
|
+
component=item["Component"],
|
|
180
|
+
status=DiagStatus(item["Status"]),
|
|
181
|
+
)
|
|
182
|
+
for item in payload["Diag"]["SubSystems"]
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
async def async_get_nodes(self) -> list[Node]:
|
|
186
|
+
"""Return nodes reported by the local API."""
|
|
187
|
+
payload = await self._request_json("GET", "/info/nodes")
|
|
188
|
+
return [self._parse_node(item) for item in payload["Nodes"]]
|
|
189
|
+
|
|
190
|
+
async def async_get_write_requests_remaining(self) -> int:
|
|
191
|
+
"""Return the remaining write budget reported by the box."""
|
|
192
|
+
payload = await self._request_json(
|
|
193
|
+
"GET",
|
|
194
|
+
"/info",
|
|
195
|
+
params={"module": "General", "submodule": "PublicApi"},
|
|
196
|
+
)
|
|
197
|
+
return int(self._read_wrapped_value(payload["General"]["PublicApi"], "WriteReqCntRemain"))
|
|
198
|
+
|
|
199
|
+
async def async_set_ventilation_state(
|
|
200
|
+
self, node_id: int, state: VentilationState | str
|
|
201
|
+
) -> None:
|
|
202
|
+
"""Request a ventilation state change for a node."""
|
|
203
|
+
state_value = state.value if isinstance(state, VentilationState) else state
|
|
204
|
+
await self._request_json(
|
|
205
|
+
"POST",
|
|
206
|
+
f"/action/nodes/{node_id}",
|
|
207
|
+
json={"Action": "SetVentilationState", "Val": state_value},
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def _parse_node(self, payload: dict[str, Any]) -> Node:
|
|
211
|
+
general = payload["General"]
|
|
212
|
+
node_general = NodeGeneralInfo(
|
|
213
|
+
node_type=self._to_node_type(self._read_wrapped_value(general, "Type")),
|
|
214
|
+
sub_type=self._read_wrapped_value(general, "SubType"),
|
|
215
|
+
network_type=self._to_network_type(self._read_wrapped_value(general, "NetworkType")),
|
|
216
|
+
parent=self._read_wrapped_value(general, "Parent"),
|
|
217
|
+
asso=self._read_wrapped_value(general, "Asso"),
|
|
218
|
+
name=self._read_wrapped_value(general, "Name"),
|
|
219
|
+
identify=self._read_wrapped_value(general, "Identify"),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
ventilation = None
|
|
223
|
+
if "Ventilation" in payload:
|
|
224
|
+
vent = payload["Ventilation"]
|
|
225
|
+
ventilation = NodeVentilationInfo(
|
|
226
|
+
state=VentilationState(self._read_wrapped_value(vent, "State")),
|
|
227
|
+
mode=VentilationMode(self._read_wrapped_value(vent, "Mode")),
|
|
228
|
+
time_state_remain=self._read_wrapped_value(vent, "TimeStateRemain"),
|
|
229
|
+
time_state_end=self._read_wrapped_value(vent, "TimeStateEnd"),
|
|
230
|
+
flow_lvl_tgt=self._read_wrapped_value(vent, "FlowLvlTgt")
|
|
231
|
+
if "FlowLvlTgt" in vent
|
|
232
|
+
else None,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
sensor = None
|
|
236
|
+
if "Sensor" in payload:
|
|
237
|
+
sensor = payload["Sensor"]
|
|
238
|
+
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,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
return Node(
|
|
247
|
+
node_id=payload["Node"],
|
|
248
|
+
general=node_general,
|
|
249
|
+
ventilation=ventilation,
|
|
250
|
+
sensor=sensor,
|
|
251
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Exceptions raised by the Duco client."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DucoError(Exception):
|
|
5
|
+
"""Base class for client errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DucoConnectionError(DucoError):
|
|
9
|
+
"""Raised when the client cannot reach the box."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DucoWriteLimitError(DucoError):
|
|
13
|
+
"""Raised when the box rejects writes because its budget is exhausted."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, remaining: int | None = None) -> None:
|
|
16
|
+
self.remaining = remaining
|
|
17
|
+
detail = "Duco write capacity exhausted"
|
|
18
|
+
if remaining is not None:
|
|
19
|
+
detail = f"{detail} ({remaining} writes remaining)"
|
|
20
|
+
super().__init__(detail)
|