python-bsblan 0.6.4__tar.gz → 1.0.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_bsblan-0.6.4 → python_bsblan-1.0.0}/PKG-INFO +9 -8
- {python_bsblan-0.6.4 → python_bsblan-1.0.0}/README.md +3 -3
- {python_bsblan-0.6.4 → python_bsblan-1.0.0}/pyproject.toml +27 -23
- {python_bsblan-0.6.4 → python_bsblan-1.0.0}/src/bsblan/bsblan.py +284 -46
- {python_bsblan-0.6.4 → python_bsblan-1.0.0}/src/bsblan/constants.py +25 -3
- {python_bsblan-0.6.4 → python_bsblan-1.0.0}/src/bsblan/exceptions.py +18 -3
- {python_bsblan-0.6.4 → python_bsblan-1.0.0}/src/bsblan/models.py +19 -12
- python_bsblan-1.0.0/src/bsblan/utility.py +101 -0
- {python_bsblan-0.6.4 → python_bsblan-1.0.0}/src/bsblan/__init__.py +0 -0
|
@@ -1,23 +1,24 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: python-bsblan
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Asynchronous Python client for BSBLAN
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Asynchronous Python client for BSBLAN API
|
|
5
5
|
Home-page: https://github.com/liudger/python-bsblan
|
|
6
6
|
License: MIT
|
|
7
|
-
Keywords: bsblan,thermostat,client,api
|
|
7
|
+
Keywords: bsblan,thermostat,client,api,async
|
|
8
8
|
Author: Willem-Jan van Rootselaar
|
|
9
9
|
Author-email: liudgervr@gmail.com
|
|
10
10
|
Maintainer: Willem-Jan van Rootselaar
|
|
11
11
|
Maintainer-email: liudgervr@gmail.com
|
|
12
|
-
Requires-Python: >=3.
|
|
12
|
+
Requires-Python: >=3.11,<4.0
|
|
13
13
|
Classifier: Development Status :: 3 - Alpha
|
|
14
14
|
Classifier: Framework :: AsyncIO
|
|
15
15
|
Classifier: Intended Audience :: Developers
|
|
16
16
|
Classifier: License :: OSI Approved :: MIT License
|
|
17
17
|
Classifier: Natural Language :: English
|
|
18
18
|
Classifier: Programming Language :: Python :: 3
|
|
19
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
20
19
|
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
22
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
23
|
Requires-Dist: aiohttp (>=3.8.1)
|
|
23
24
|
Requires-Dist: async-timeout (>=4.0.3,<5.0.0)
|
|
@@ -38,7 +39,7 @@ Description-Content-Type: text/markdown
|
|
|
38
39
|
[![Python Versions][python-versions-shield]][pypi]
|
|
39
40
|
![Project Stage][project-stage-shield]
|
|
40
41
|
![Project Maintenance][maintenance-shield]
|
|
41
|
-
[![License][license-shield]](LICENSE.md)
|
|
42
|
+
[![License][license-shield]](.github/LICENSE.md)
|
|
42
43
|
|
|
43
44
|
[![Build Status][build-shield]][build]
|
|
44
45
|
[![Code Coverage][codecov-shield]][codecov]
|
|
@@ -212,7 +213,7 @@ check [the contributor's page][contributors].
|
|
|
212
213
|
|
|
213
214
|
MIT License
|
|
214
215
|
|
|
215
|
-
Copyright (c) 2024 WJ van Rootselaar
|
|
216
|
+
Copyright (c) 2023-2024 WJ van Rootselaar
|
|
216
217
|
|
|
217
218
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
218
219
|
of this software and associated documentation files (the "Software"), to deal
|
|
@@ -242,7 +243,7 @@ SOFTWARE.
|
|
|
242
243
|
[contributors]: https://github.com/liudger/python-bsblan/graphs/contributors
|
|
243
244
|
[frenck]: https://github.com/frenck
|
|
244
245
|
[keepchangelog]: http://keepachangelog.com/en/1.0.0/
|
|
245
|
-
[license-shield]: https://img.shields.io/
|
|
246
|
+
[license-shield]: https://img.shields.io/badge/license-MIT-blue.svg
|
|
246
247
|
[liudger]: https://github.com/liudger
|
|
247
248
|
[maintenance-shield]: https://img.shields.io/maintenance/yes/2024.svg
|
|
248
249
|
[poetry]: https://python-poetry.org
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[![Python Versions][python-versions-shield]][pypi]
|
|
5
5
|
![Project Stage][project-stage-shield]
|
|
6
6
|
![Project Maintenance][maintenance-shield]
|
|
7
|
-
[![License][license-shield]](LICENSE.md)
|
|
7
|
+
[![License][license-shield]](.github/LICENSE.md)
|
|
8
8
|
|
|
9
9
|
[![Build Status][build-shield]][build]
|
|
10
10
|
[![Code Coverage][codecov-shield]][codecov]
|
|
@@ -178,7 +178,7 @@ check [the contributor's page][contributors].
|
|
|
178
178
|
|
|
179
179
|
MIT License
|
|
180
180
|
|
|
181
|
-
Copyright (c) 2024 WJ van Rootselaar
|
|
181
|
+
Copyright (c) 2023-2024 WJ van Rootselaar
|
|
182
182
|
|
|
183
183
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
184
184
|
of this software and associated documentation files (the "Software"), to deal
|
|
@@ -208,7 +208,7 @@ SOFTWARE.
|
|
|
208
208
|
[contributors]: https://github.com/liudger/python-bsblan/graphs/contributors
|
|
209
209
|
[frenck]: https://github.com/frenck
|
|
210
210
|
[keepchangelog]: http://keepachangelog.com/en/1.0.0/
|
|
211
|
-
[license-shield]: https://img.shields.io/
|
|
211
|
+
[license-shield]: https://img.shields.io/badge/license-MIT-blue.svg
|
|
212
212
|
[liudger]: https://github.com/liudger
|
|
213
213
|
[maintenance-shield]: https://img.shields.io/maintenance/yes/2024.svg
|
|
214
214
|
[poetry]: https://python-poetry.org
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "python-bsblan"
|
|
3
|
-
version = "0.
|
|
4
|
-
description = "Asynchronous Python client for BSBLAN"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Asynchronous Python client for BSBLAN API"
|
|
5
5
|
authors = ["Willem-Jan van Rootselaar <liudgervr@gmail.com>"]
|
|
6
6
|
maintainers = ["Willem-Jan van Rootselaar <liudgervr@gmail.com>"]
|
|
7
7
|
license = "MIT"
|
|
@@ -9,8 +9,9 @@ readme = "README.md"
|
|
|
9
9
|
homepage = "https://github.com/liudger/python-bsblan"
|
|
10
10
|
repository = "https://github.com/liudger/python-bsblan"
|
|
11
11
|
documentation = "https://github.com/liudger/python-bsblan"
|
|
12
|
-
keywords = ["bsblan", "thermostat", "client" , "api"]
|
|
12
|
+
keywords = ["bsblan", "thermostat", "client" , "api", "async"]
|
|
13
13
|
classifiers = [
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
14
15
|
"Development Status :: 3 - Alpha",
|
|
15
16
|
"Framework :: AsyncIO",
|
|
16
17
|
"Intended Audience :: Developers",
|
|
@@ -25,7 +26,7 @@ packages = [
|
|
|
25
26
|
]
|
|
26
27
|
|
|
27
28
|
[tool.poetry.dependencies]
|
|
28
|
-
python = "^3.
|
|
29
|
+
python = "^3.11"
|
|
29
30
|
aiohttp = ">=3.8.1"
|
|
30
31
|
yarl = ">=1.7.2"
|
|
31
32
|
packaging = ">=21.3"
|
|
@@ -34,9 +35,13 @@ async-timeout = "^4.0.3"
|
|
|
34
35
|
mashumaro = "^3.13.1"
|
|
35
36
|
orjson = "^3.9.10"
|
|
36
37
|
|
|
38
|
+
[tool.poetry.urls]
|
|
39
|
+
"Bug Tracker" = "https://github.com/liudger/python-bsblan/issues"
|
|
40
|
+
Changelog = "https://github.com/liudger/python-bsblan/releases"
|
|
41
|
+
|
|
37
42
|
[tool.poetry.dev-dependencies]
|
|
38
43
|
covdefaults = "^2.3.0"
|
|
39
|
-
ruff = "^0.
|
|
44
|
+
ruff = "^0.7.0"
|
|
40
45
|
aresponses = "^3.0.0"
|
|
41
46
|
black = "^24.0.0"
|
|
42
47
|
blacken-docs = "^1.13.0"
|
|
@@ -44,8 +49,8 @@ coverage = "^7.0.5"
|
|
|
44
49
|
flake8 = "^7.0.0"
|
|
45
50
|
isort = "^5.11.4"
|
|
46
51
|
mypy = "^1.0.0"
|
|
47
|
-
pre-commit = "^
|
|
48
|
-
pre-commit-hooks = "^
|
|
52
|
+
pre-commit = "^4.0.0"
|
|
53
|
+
pre-commit-hooks = "^5.0.0"
|
|
49
54
|
pylint = "^3.0.0"
|
|
50
55
|
pytest = "^8.0.0"
|
|
51
56
|
pytest-asyncio = "^0.24.0"
|
|
@@ -59,27 +64,21 @@ safety = "^3.0.0"
|
|
|
59
64
|
codespell = "^2.2.2"
|
|
60
65
|
bandit = "^1.7.4"
|
|
61
66
|
|
|
62
|
-
[tool.poetry.urls]
|
|
63
|
-
"Bug Tracker" = "https://github.com/liudger/python-bsblan/issues"
|
|
64
|
-
Changelog = "https://github.com/liudger/python-bsblan/releases"
|
|
65
|
-
|
|
66
|
-
[tool.coverage.report]
|
|
67
|
-
show_missing = true
|
|
68
|
-
fail_under = 53
|
|
69
67
|
|
|
70
68
|
[tool.coverage.run]
|
|
71
69
|
plugins = ["covdefaults"]
|
|
72
70
|
source = ["bsblan"]
|
|
73
71
|
|
|
74
|
-
[tool.
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
[tool.coverage.report]
|
|
73
|
+
show_missing = true
|
|
74
|
+
fail_under = 53
|
|
77
75
|
|
|
78
76
|
[tool.mypy]
|
|
79
77
|
# Specify the target platform details in config, so your developers are
|
|
80
78
|
# free to run mypy on Windows, Linux, or macOS and get consistent
|
|
81
79
|
# results.
|
|
82
80
|
platform = "linux"
|
|
81
|
+
python_version = "3.11"
|
|
83
82
|
|
|
84
83
|
# show error messages from unrelated files
|
|
85
84
|
follow_imports = "normal"
|
|
@@ -106,9 +105,6 @@ warn_unused_configs = true
|
|
|
106
105
|
warn_unused_ignores = true
|
|
107
106
|
|
|
108
107
|
[tool.pylint.MASTER]
|
|
109
|
-
extension-pkg-whitelist = [
|
|
110
|
-
"pydantic"
|
|
111
|
-
]
|
|
112
108
|
ignore= [
|
|
113
109
|
"tests"
|
|
114
110
|
]
|
|
@@ -128,11 +124,11 @@ good-names = [
|
|
|
128
124
|
]
|
|
129
125
|
|
|
130
126
|
[tool.pylint.DESIGN]
|
|
131
|
-
max-attributes =
|
|
127
|
+
max-attributes = 12
|
|
132
128
|
|
|
133
129
|
[tool.pylint."MESSAGES CONTROL"]
|
|
134
130
|
disable= [
|
|
135
|
-
"too-few-public-methods",
|
|
131
|
+
# "too-few-public-methods",
|
|
136
132
|
"duplicate-code",
|
|
137
133
|
"format",
|
|
138
134
|
"unsubscriptable-object",
|
|
@@ -153,11 +149,16 @@ asyncio_mode = "auto"
|
|
|
153
149
|
select = ["ALL"]
|
|
154
150
|
ignore = [
|
|
155
151
|
"ANN101", # Self... explanatory
|
|
152
|
+
"ANN102", # cls... Not classy enough
|
|
156
153
|
"ANN401", # Opinioated warning on disallowing dynamically typed expressions
|
|
157
154
|
"D203", # Conflicts with other rules
|
|
158
155
|
"D213", # Conflicts with other rules
|
|
159
156
|
"D417", # False positives in some occasions
|
|
160
157
|
"PLR2004", # Just annoying, not really useful
|
|
158
|
+
|
|
159
|
+
# Conflicts with the Ruff formatter
|
|
160
|
+
"COM812",
|
|
161
|
+
"ISC001",
|
|
161
162
|
]
|
|
162
163
|
|
|
163
164
|
[tool.ruff.lint.flake8-pytest-style]
|
|
@@ -167,9 +168,12 @@ fixture-parentheses = false
|
|
|
167
168
|
[tool.ruff.lint.isort]
|
|
168
169
|
known-first-party = ["bsblan"]
|
|
169
170
|
|
|
171
|
+
[tool.ruff.lint.flake8-type-checking]
|
|
172
|
+
runtime-evaluated-base-classes = ["mashumaro.mixins.orjson.DataClassORJSONMixin"]
|
|
173
|
+
|
|
170
174
|
[tool.ruff.lint.mccabe]
|
|
171
175
|
max-complexity = 25
|
|
172
176
|
|
|
173
177
|
[build-system]
|
|
174
|
-
build-backend = "poetry.core.masonry.api"
|
|
175
178
|
requires = ["poetry-core>=1.0.0"]
|
|
179
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -4,18 +4,18 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import logging
|
|
7
|
-
from dataclasses import dataclass
|
|
8
|
-
from typing import TYPE_CHECKING, Any, Mapping, cast
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Literal, Mapping, cast
|
|
9
9
|
|
|
10
10
|
import aiohttp
|
|
11
11
|
from aiohttp.hdrs import METH_POST
|
|
12
12
|
from aiohttp.helpers import BasicAuth
|
|
13
13
|
from packaging import version as pkg_version
|
|
14
|
-
from typing_extensions import Self
|
|
15
14
|
from yarl import URL
|
|
16
15
|
|
|
17
16
|
from .constants import (
|
|
18
17
|
API_DATA_NOT_INITIALIZED_ERROR_MSG,
|
|
18
|
+
API_VALIDATOR_NOT_INITIALIZED_ERROR_MSG,
|
|
19
19
|
API_VERSION_ERROR_MSG,
|
|
20
20
|
API_VERSIONS,
|
|
21
21
|
FIRMWARE_VERSION_ERROR_MSG,
|
|
@@ -35,9 +35,13 @@ from .exceptions import (
|
|
|
35
35
|
BSBLANVersionError,
|
|
36
36
|
)
|
|
37
37
|
from .models import Device, HotWaterState, Info, Sensor, State, StaticState
|
|
38
|
+
from .utility import APIValidator
|
|
38
39
|
|
|
39
40
|
if TYPE_CHECKING:
|
|
40
41
|
from aiohttp.client import ClientSession
|
|
42
|
+
from typing_extensions import Self
|
|
43
|
+
|
|
44
|
+
SectionLiteral = Literal["heating", "staticValues", "device", "sensor", "hot_water"]
|
|
41
45
|
|
|
42
46
|
logging.basicConfig(level=logging.DEBUG)
|
|
43
47
|
logger = logging.getLogger(__name__)
|
|
@@ -69,9 +73,15 @@ class BSBLAN:
|
|
|
69
73
|
_temperature_range_initialized: bool = False
|
|
70
74
|
_api_data: APIConfig | None = None
|
|
71
75
|
_initialized: bool = False
|
|
76
|
+
_api_validator: APIValidator = field(init=False)
|
|
72
77
|
|
|
73
78
|
async def __aenter__(self) -> Self:
|
|
74
|
-
"""Enter the context manager.
|
|
79
|
+
"""Enter the context manager.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Self: The initialized BSBLAN instance.
|
|
83
|
+
|
|
84
|
+
"""
|
|
75
85
|
if self.session is None:
|
|
76
86
|
self.session = aiohttp.ClientSession()
|
|
77
87
|
self._close_session = True
|
|
@@ -79,7 +89,12 @@ class BSBLAN:
|
|
|
79
89
|
return self
|
|
80
90
|
|
|
81
91
|
async def __aexit__(self, *args: object) -> None:
|
|
82
|
-
"""Exit the context manager.
|
|
92
|
+
"""Exit the context manager.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
*args: Variable length argument list.
|
|
96
|
+
|
|
97
|
+
"""
|
|
83
98
|
if self._close_session and self.session:
|
|
84
99
|
await self.session.close()
|
|
85
100
|
|
|
@@ -87,10 +102,81 @@ class BSBLAN:
|
|
|
87
102
|
"""Initialize the BSBLAN client."""
|
|
88
103
|
if not self._initialized:
|
|
89
104
|
await self._fetch_firmware_version()
|
|
105
|
+
await self._initialize_api_validator()
|
|
90
106
|
await self._initialize_temperature_range()
|
|
91
107
|
await self._initialize_api_data()
|
|
92
108
|
self._initialized = True
|
|
93
109
|
|
|
110
|
+
async def _initialize_api_validator(self) -> None:
|
|
111
|
+
"""Initialize and validate API data against device capabilities."""
|
|
112
|
+
if self._api_version is None:
|
|
113
|
+
raise BSBLANError(API_VERSION_ERROR_MSG)
|
|
114
|
+
|
|
115
|
+
# Initialize API data if not already done
|
|
116
|
+
if self._api_data is None:
|
|
117
|
+
self._api_data = API_VERSIONS[self._api_version]
|
|
118
|
+
|
|
119
|
+
# Initialize the API validator
|
|
120
|
+
self._api_validator = APIValidator(self._api_data)
|
|
121
|
+
|
|
122
|
+
# Perform initial validation of each section
|
|
123
|
+
sections: list[SectionLiteral] = [
|
|
124
|
+
"heating",
|
|
125
|
+
"sensor",
|
|
126
|
+
"staticValues",
|
|
127
|
+
"device",
|
|
128
|
+
"hot_water",
|
|
129
|
+
]
|
|
130
|
+
for section in sections:
|
|
131
|
+
await self._validate_api_section(section)
|
|
132
|
+
|
|
133
|
+
async def _validate_api_section(self, section: SectionLiteral) -> None:
|
|
134
|
+
"""Validate a specific section of the API configuration.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
section: The section name to validate
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
BSBLANError: If the API validator is not initialized
|
|
141
|
+
|
|
142
|
+
"""
|
|
143
|
+
if not self._api_validator:
|
|
144
|
+
raise BSBLANError(API_VALIDATOR_NOT_INITIALIZED_ERROR_MSG)
|
|
145
|
+
|
|
146
|
+
if not self._api_data:
|
|
147
|
+
raise BSBLANError(API_DATA_NOT_INITIALIZED_ERROR_MSG)
|
|
148
|
+
|
|
149
|
+
# Assign to local variable after asserting it's not None
|
|
150
|
+
api_validator = self._api_validator
|
|
151
|
+
|
|
152
|
+
if api_validator.is_section_validated(section):
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
# Get parameters for the section
|
|
156
|
+
try:
|
|
157
|
+
section_data = self._api_data[section]
|
|
158
|
+
except KeyError as err:
|
|
159
|
+
error_msg = f"Section '{section}' not found in API data"
|
|
160
|
+
raise BSBLANError(error_msg) from err
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
# Request data from device for validation
|
|
164
|
+
params = await self._extract_params_summary(section_data)
|
|
165
|
+
response_data = await self._request(
|
|
166
|
+
params={"Parameter": params["string_par"]}
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Validate the section against actual device response
|
|
170
|
+
api_validator.validate_section(section, response_data)
|
|
171
|
+
# Update API data with validated configuration
|
|
172
|
+
if self._api_data:
|
|
173
|
+
self._api_data[section] = api_validator.get_section_params(section)
|
|
174
|
+
except BSBLANError as err:
|
|
175
|
+
logger.warning("Failed to validate section %s: %s", section, str(err))
|
|
176
|
+
# Reset validation state for this section
|
|
177
|
+
api_validator.reset_validation(section)
|
|
178
|
+
raise
|
|
179
|
+
|
|
94
180
|
async def _fetch_firmware_version(self) -> None:
|
|
95
181
|
"""Fetch the firmware version if not already available."""
|
|
96
182
|
if self._firmware_version is None:
|
|
@@ -100,7 +186,13 @@ class BSBLAN:
|
|
|
100
186
|
self._set_api_version()
|
|
101
187
|
|
|
102
188
|
def _set_api_version(self) -> None:
|
|
103
|
-
"""Set the API version based on the firmware version.
|
|
189
|
+
"""Set the API version based on the firmware version.
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
BSBLANError: If the firmware version is not set.
|
|
193
|
+
BSBLANVersionError: If the firmware version is not supported.
|
|
194
|
+
|
|
195
|
+
"""
|
|
104
196
|
if not self._firmware_version:
|
|
105
197
|
raise BSBLANError(FIRMWARE_VERSION_ERROR_MSG)
|
|
106
198
|
|
|
@@ -126,7 +218,15 @@ class BSBLAN:
|
|
|
126
218
|
)
|
|
127
219
|
|
|
128
220
|
async def _initialize_api_data(self) -> APIConfig:
|
|
129
|
-
"""Initialize and cache the API data.
|
|
221
|
+
"""Initialize and cache the API data.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
APIConfig: The API configuration data.
|
|
225
|
+
|
|
226
|
+
Raises:
|
|
227
|
+
BSBLANError: If the API version or data is not initialized.
|
|
228
|
+
|
|
229
|
+
"""
|
|
130
230
|
if self._api_data is None:
|
|
131
231
|
if self._api_version is None:
|
|
132
232
|
raise BSBLANError(API_VERSION_ERROR_MSG)
|
|
@@ -143,7 +243,23 @@ class BSBLAN:
|
|
|
143
243
|
data: dict[str, object] | None = None,
|
|
144
244
|
params: Mapping[str, str | int] | str | None = None,
|
|
145
245
|
) -> dict[str, Any]:
|
|
146
|
-
"""Handle a request to a BSBLAN device.
|
|
246
|
+
"""Handle a request to a BSBLAN device.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
method (str): The HTTP method to use for the request.
|
|
250
|
+
base_path (str): The base path for the URL.
|
|
251
|
+
data (dict[str, object] | None): The data to send in the request body.
|
|
252
|
+
params (Mapping[str, str | int] | str | None): The query parameters
|
|
253
|
+
to include in the request.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
dict[str, Any]: The JSON response from the BSBLAN device.
|
|
257
|
+
|
|
258
|
+
Raises:
|
|
259
|
+
BSBLANConnectionError: If there is a connection error.
|
|
260
|
+
BSBLANError: If there is an error with the request.
|
|
261
|
+
|
|
262
|
+
"""
|
|
147
263
|
if self.session is None:
|
|
148
264
|
raise BSBLANError(SESSION_NOT_INITIALIZED_ERROR_MSG)
|
|
149
265
|
url = self._build_url(base_path)
|
|
@@ -170,7 +286,15 @@ class BSBLAN:
|
|
|
170
286
|
raise BSBLANError(str(e)) from e
|
|
171
287
|
|
|
172
288
|
def _build_url(self, base_path: str) -> URL:
|
|
173
|
-
"""Build the URL for the request.
|
|
289
|
+
"""Build the URL for the request.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
base_path (str): The base path for the URL.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
URL: The constructed URL.
|
|
296
|
+
|
|
297
|
+
"""
|
|
174
298
|
if self.config.passkey:
|
|
175
299
|
base_path = f"/{self.config.passkey}{base_path}"
|
|
176
300
|
return URL.build(
|
|
@@ -181,62 +305,117 @@ class BSBLAN:
|
|
|
181
305
|
)
|
|
182
306
|
|
|
183
307
|
def _get_auth(self) -> BasicAuth | None:
|
|
184
|
-
"""Get the authentication for the request.
|
|
308
|
+
"""Get the authentication for the request.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
BasicAuth | None: The authentication object or None if no authentication
|
|
312
|
+
is required.
|
|
313
|
+
|
|
314
|
+
"""
|
|
185
315
|
if self.config.username and self.config.password:
|
|
186
316
|
return BasicAuth(self.config.username, self.config.password)
|
|
187
317
|
return None
|
|
188
318
|
|
|
189
319
|
def _get_headers(self) -> dict[str, str]:
|
|
190
|
-
"""Get the headers for the request.
|
|
320
|
+
"""Get the headers for the request.
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
dict[str, str]: The headers for the request.
|
|
324
|
+
|
|
325
|
+
"""
|
|
191
326
|
return {
|
|
192
327
|
"User-Agent": f"PythonBSBLAN/{self._firmware_version}",
|
|
193
328
|
"Accept": "application/json, */*",
|
|
194
329
|
}
|
|
195
330
|
|
|
196
331
|
def _validate_single_parameter(self, *params: Any, error_msg: str) -> None:
|
|
197
|
-
"""Validate that exactly one parameter is provided.
|
|
332
|
+
"""Validate that exactly one parameter is provided.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
*params: Variable length argument list of parameters to validate.
|
|
336
|
+
error_msg (str): The error message to raise if validation fails.
|
|
337
|
+
|
|
338
|
+
Raises:
|
|
339
|
+
BSBLANError: If the validation fails.
|
|
340
|
+
|
|
341
|
+
"""
|
|
198
342
|
if sum(param is not None for param in params) != 1:
|
|
199
343
|
raise BSBLANError(error_msg)
|
|
200
344
|
|
|
201
|
-
async def
|
|
202
|
-
"""Get the parameters info from BSBLAN device.
|
|
345
|
+
async def _extract_params_summary(self, params: dict[Any, Any]) -> dict[Any, Any]:
|
|
346
|
+
"""Get the parameters info from BSBLAN device.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
params (dict[Any, Any]): The parameters to get info for.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
dict[Any, Any]: The parameters info from the BSBLAN device.
|
|
353
|
+
|
|
354
|
+
"""
|
|
203
355
|
string_params = ",".join(map(str, params))
|
|
204
356
|
return {"string_par": string_params, "list": list(params.values())}
|
|
205
357
|
|
|
206
358
|
async def state(self) -> State:
|
|
207
|
-
"""Get the current state from BSBLAN device.
|
|
208
|
-
|
|
209
|
-
|
|
359
|
+
"""Get the current state from BSBLAN device.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
State: The current state of the BSBLAN device.
|
|
363
|
+
|
|
364
|
+
"""
|
|
365
|
+
# Get validated parameters for heating section
|
|
366
|
+
heating_params = self._api_validator.get_section_params("heating")
|
|
367
|
+
params = await self._extract_params_summary(heating_params)
|
|
210
368
|
data = await self._request(params={"Parameter": params["string_par"]})
|
|
211
369
|
data = dict(zip(params["list"], list(data.values()), strict=True))
|
|
370
|
+
# we should convert this in homeassistant integration?
|
|
212
371
|
data["hvac_mode"]["value"] = HVAC_MODE_DICT[int(data["hvac_mode"]["value"])]
|
|
213
372
|
return State.from_dict(data)
|
|
214
373
|
|
|
215
374
|
async def sensor(self) -> Sensor:
|
|
216
|
-
"""Get the sensor information from BSBLAN device.
|
|
217
|
-
|
|
218
|
-
|
|
375
|
+
"""Get the sensor information from BSBLAN device.
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
Sensor: The sensor information from the BSBLAN device.
|
|
379
|
+
|
|
380
|
+
"""
|
|
381
|
+
sensor_params = self._api_validator.get_section_params("sensor")
|
|
382
|
+
params = await self._extract_params_summary(sensor_params)
|
|
219
383
|
data = await self._request(params={"Parameter": params["string_par"]})
|
|
220
384
|
data = dict(zip(params["list"], list(data.values()), strict=True))
|
|
221
385
|
return Sensor.from_dict(data)
|
|
222
386
|
|
|
223
387
|
async def static_values(self) -> StaticState:
|
|
224
|
-
"""Get the static information from BSBLAN device.
|
|
225
|
-
|
|
226
|
-
|
|
388
|
+
"""Get the static information from BSBLAN device.
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
StaticState: The static information from the BSBLAN device.
|
|
392
|
+
|
|
393
|
+
"""
|
|
394
|
+
static_params = self._api_validator.get_section_params("staticValues")
|
|
395
|
+
params = await self._extract_params_summary(static_params)
|
|
227
396
|
data = await self._request(params={"Parameter": params["string_par"]})
|
|
228
397
|
data = dict(zip(params["list"], list(data.values()), strict=True))
|
|
229
398
|
return StaticState.from_dict(data)
|
|
230
399
|
|
|
231
400
|
async def device(self) -> Device:
|
|
232
|
-
"""Get BSBLAN device info.
|
|
401
|
+
"""Get BSBLAN device info.
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
Device: The BSBLAN device information.
|
|
405
|
+
|
|
406
|
+
"""
|
|
233
407
|
device_info = await self._request(base_path="/JI")
|
|
234
408
|
return Device.from_dict(device_info)
|
|
235
409
|
|
|
236
410
|
async def info(self) -> Info:
|
|
237
|
-
"""Get information about the current heating system config.
|
|
411
|
+
"""Get information about the current heating system config.
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
Info: The information about the current heating system config.
|
|
415
|
+
|
|
416
|
+
"""
|
|
238
417
|
api_data = await self._initialize_api_data()
|
|
239
|
-
params = await self.
|
|
418
|
+
params = await self._extract_params_summary(api_data["device"])
|
|
240
419
|
data = await self._request(params={"Parameter": params["string_par"]})
|
|
241
420
|
data = dict(zip(params["list"], list(data.values()), strict=True))
|
|
242
421
|
return Info.from_dict(data)
|
|
@@ -246,7 +425,13 @@ class BSBLAN:
|
|
|
246
425
|
target_temperature: str | None = None,
|
|
247
426
|
hvac_mode: str | None = None,
|
|
248
427
|
) -> None:
|
|
249
|
-
"""Change the state of the thermostat through BSB-Lan.
|
|
428
|
+
"""Change the state of the thermostat through BSB-Lan.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
target_temperature (str | None): The target temperature to set.
|
|
432
|
+
hvac_mode (str | None): The HVAC mode to set.
|
|
433
|
+
|
|
434
|
+
"""
|
|
250
435
|
await self._initialize_temperature_range()
|
|
251
436
|
|
|
252
437
|
self._validate_single_parameter(
|
|
@@ -263,11 +448,22 @@ class BSBLAN:
|
|
|
263
448
|
target_temperature: str | None,
|
|
264
449
|
hvac_mode: str | None,
|
|
265
450
|
) -> dict[str, Any]:
|
|
266
|
-
"""Prepare the thermostat state for setting.
|
|
451
|
+
"""Prepare the thermostat state for setting.
|
|
452
|
+
|
|
453
|
+
Args:
|
|
454
|
+
target_temperature (str | None): The target temperature to set.
|
|
455
|
+
hvac_mode (str | None): The HVAC mode to set.
|
|
456
|
+
|
|
457
|
+
Returns:
|
|
458
|
+
dict[str, Any]: The prepared state for the thermostat.
|
|
459
|
+
|
|
460
|
+
"""
|
|
267
461
|
state: dict[str, Any] = {}
|
|
268
462
|
if target_temperature is not None:
|
|
269
463
|
self._validate_target_temperature(target_temperature)
|
|
270
|
-
state.update(
|
|
464
|
+
state.update(
|
|
465
|
+
{"Parameter": "710", "Value": target_temperature, "Type": "1"},
|
|
466
|
+
)
|
|
271
467
|
if hvac_mode is not None:
|
|
272
468
|
self._validate_hvac_mode(hvac_mode)
|
|
273
469
|
state.update(
|
|
@@ -280,7 +476,16 @@ class BSBLAN:
|
|
|
280
476
|
return state
|
|
281
477
|
|
|
282
478
|
def _validate_target_temperature(self, target_temperature: str) -> None:
|
|
283
|
-
"""Validate the target temperature.
|
|
479
|
+
"""Validate the target temperature.
|
|
480
|
+
|
|
481
|
+
Args:
|
|
482
|
+
target_temperature (str): The target temperature to validate.
|
|
483
|
+
|
|
484
|
+
Raises:
|
|
485
|
+
BSBLANError: If the temperature range is not initialized.
|
|
486
|
+
BSBLANInvalidParameterError: If the target temperature is invalid.
|
|
487
|
+
|
|
488
|
+
"""
|
|
284
489
|
if self._min_temp is None or self._max_temp is None:
|
|
285
490
|
raise BSBLANError(TEMPERATURE_RANGE_ERROR_MSG)
|
|
286
491
|
|
|
@@ -292,39 +497,60 @@ class BSBLAN:
|
|
|
292
497
|
raise BSBLANInvalidParameterError(target_temperature) from err
|
|
293
498
|
|
|
294
499
|
def _validate_hvac_mode(self, hvac_mode: str) -> None:
|
|
295
|
-
"""Validate the HVAC mode.
|
|
500
|
+
"""Validate the HVAC mode.
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
hvac_mode (str): The HVAC mode to validate.
|
|
504
|
+
|
|
505
|
+
Raises:
|
|
506
|
+
BSBLANInvalidParameterError: If the HVAC mode is invalid.
|
|
507
|
+
|
|
508
|
+
"""
|
|
296
509
|
if hvac_mode not in HVAC_MODE_DICT_REVERSE:
|
|
297
510
|
raise BSBLANInvalidParameterError(hvac_mode)
|
|
298
511
|
|
|
299
512
|
async def _set_thermostat_state(self, state: dict[str, Any]) -> None:
|
|
300
|
-
"""Set the thermostat state.
|
|
513
|
+
"""Set the thermostat state.
|
|
514
|
+
|
|
515
|
+
Args:
|
|
516
|
+
state (dict[str, Any]): The state to set for the thermostat.
|
|
517
|
+
|
|
518
|
+
"""
|
|
301
519
|
response = await self._request(base_path="/JS", data=state)
|
|
302
520
|
logger.debug("Response for setting: %s", response)
|
|
303
521
|
|
|
304
522
|
async def hot_water_state(self) -> HotWaterState:
|
|
305
|
-
"""Get the current hot water state from BSBLAN device.
|
|
306
|
-
|
|
307
|
-
|
|
523
|
+
"""Get the current hot water state from BSBLAN device.
|
|
524
|
+
|
|
525
|
+
Returns:
|
|
526
|
+
HotWaterState: The current hot water state.
|
|
527
|
+
|
|
528
|
+
"""
|
|
529
|
+
hotwater_params = self._api_validator.get_section_params("hot_water")
|
|
530
|
+
params = await self._extract_params_summary(hotwater_params)
|
|
308
531
|
data = await self._request(params={"Parameter": params["string_par"]})
|
|
309
532
|
data = dict(zip(params["list"], list(data.values()), strict=True))
|
|
310
533
|
return HotWaterState.from_dict(data)
|
|
311
534
|
|
|
312
535
|
async def set_hot_water(
|
|
313
536
|
self,
|
|
314
|
-
operating_mode: str | None = None,
|
|
315
537
|
nominal_setpoint: float | None = None,
|
|
316
538
|
reduced_setpoint: float | None = None,
|
|
317
539
|
) -> None:
|
|
318
|
-
"""Change the state of the hot water system through BSB-Lan.
|
|
540
|
+
"""Change the state of the hot water system through BSB-Lan.
|
|
541
|
+
|
|
542
|
+
Args:
|
|
543
|
+
nominal_setpoint (float | None): The nominal setpoint temperature to set.
|
|
544
|
+
reduced_setpoint (float | None): The reduced setpoint temperature to set.
|
|
545
|
+
|
|
546
|
+
"""
|
|
319
547
|
self._validate_single_parameter(
|
|
320
|
-
operating_mode,
|
|
321
548
|
nominal_setpoint,
|
|
322
549
|
reduced_setpoint,
|
|
323
550
|
error_msg=MULTI_PARAMETER_ERROR_MSG,
|
|
324
551
|
)
|
|
325
552
|
|
|
326
553
|
state = self._prepare_hot_water_state(
|
|
327
|
-
operating_mode,
|
|
328
554
|
nominal_setpoint,
|
|
329
555
|
reduced_setpoint,
|
|
330
556
|
)
|
|
@@ -332,16 +558,23 @@ class BSBLAN:
|
|
|
332
558
|
|
|
333
559
|
def _prepare_hot_water_state(
|
|
334
560
|
self,
|
|
335
|
-
operating_mode: str | None,
|
|
336
561
|
nominal_setpoint: float | None,
|
|
337
562
|
reduced_setpoint: float | None,
|
|
338
563
|
) -> dict[str, Any]:
|
|
339
|
-
"""Prepare the hot water state for setting.
|
|
564
|
+
"""Prepare the hot water state for setting.
|
|
565
|
+
|
|
566
|
+
Args:
|
|
567
|
+
nominal_setpoint (float | None): The nominal setpoint temperature to set.
|
|
568
|
+
reduced_setpoint (float | None): The reduced setpoint temperature to set.
|
|
569
|
+
|
|
570
|
+
Returns:
|
|
571
|
+
dict[str, Any]: The prepared state for the hot water.
|
|
572
|
+
|
|
573
|
+
Raises:
|
|
574
|
+
BSBLANError: If no state is provided.
|
|
575
|
+
|
|
576
|
+
"""
|
|
340
577
|
state: dict[str, Any] = {}
|
|
341
|
-
if operating_mode is not None:
|
|
342
|
-
state.update(
|
|
343
|
-
{"Parameter": "1600", "EnumValue": operating_mode, "Type": "1"},
|
|
344
|
-
)
|
|
345
578
|
if nominal_setpoint is not None:
|
|
346
579
|
state.update(
|
|
347
580
|
{"Parameter": "1610", "Value": str(nominal_setpoint), "Type": "1"},
|
|
@@ -355,6 +588,11 @@ class BSBLAN:
|
|
|
355
588
|
return state
|
|
356
589
|
|
|
357
590
|
async def _set_hot_water_state(self, state: dict[str, Any]) -> None:
|
|
358
|
-
"""Set the hot water state.
|
|
591
|
+
"""Set the hot water state.
|
|
592
|
+
|
|
593
|
+
Args:
|
|
594
|
+
state (dict[str, Any]): The state to set for the hot water.
|
|
595
|
+
|
|
596
|
+
"""
|
|
359
597
|
response = await self._request(base_path="/JS", data=state)
|
|
360
598
|
logger.debug("Response for setting: %s", response)
|
|
@@ -2,7 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import Final, TypedDict
|
|
5
|
+
from typing import Final, NotRequired, TypedDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# API Config Types
|
|
9
|
+
class APIConfigSection(TypedDict):
|
|
10
|
+
"""Type for API configuration section."""
|
|
11
|
+
|
|
12
|
+
heating: NotRequired[dict[str, str]]
|
|
13
|
+
staticValues: NotRequired[dict[str, str]]
|
|
14
|
+
device: NotRequired[dict[str, str]]
|
|
15
|
+
sensor: NotRequired[dict[str, str]]
|
|
16
|
+
hot_water: NotRequired[dict[str, str]]
|
|
6
17
|
|
|
7
18
|
|
|
8
19
|
# API Versions
|
|
@@ -41,11 +52,16 @@ API_V1: Final[APIConfig] = {
|
|
|
41
52
|
"hot_water": {
|
|
42
53
|
"1600": "operating_mode",
|
|
43
54
|
"1610": "nominal_setpoint",
|
|
55
|
+
"1614": "nominal_setpoint_max",
|
|
44
56
|
"1612": "reduced_setpoint",
|
|
45
57
|
"1620": "release",
|
|
46
58
|
"1640": "legionella_function",
|
|
47
59
|
"1645": "legionella_setpoint",
|
|
48
|
-
"1641": "
|
|
60
|
+
"1641": "legionella_periodicity",
|
|
61
|
+
"1642": "legionella_function_day",
|
|
62
|
+
"1643": "legionella_function_time",
|
|
63
|
+
"8830": "dhw_actual_value_top_temperature",
|
|
64
|
+
"8820": "state_dhw_pump",
|
|
49
65
|
},
|
|
50
66
|
}
|
|
51
67
|
|
|
@@ -75,11 +91,16 @@ API_V3: Final[APIConfig] = {
|
|
|
75
91
|
"hot_water": {
|
|
76
92
|
"1600": "operating_mode",
|
|
77
93
|
"1610": "nominal_setpoint",
|
|
94
|
+
"1614": "nominal_setpoint_max",
|
|
78
95
|
"1612": "reduced_setpoint",
|
|
79
96
|
"1620": "release",
|
|
80
97
|
"1640": "legionella_function",
|
|
81
98
|
"1645": "legionella_setpoint",
|
|
82
|
-
"1641": "
|
|
99
|
+
"1641": "legionella_periodicity",
|
|
100
|
+
"1642": "legionella_function_day",
|
|
101
|
+
"1644": "legionella_function_time",
|
|
102
|
+
"8830": "dhw_actual_value_top_temperature",
|
|
103
|
+
"8820": "state_dhw_pump",
|
|
83
104
|
},
|
|
84
105
|
}
|
|
85
106
|
|
|
@@ -113,6 +134,7 @@ API_VERSION_ERROR_MSG: Final[str] = "API version not set"
|
|
|
113
134
|
MULTI_PARAMETER_ERROR_MSG: Final[str] = "Only one parameter can be set at a time"
|
|
114
135
|
SESSION_NOT_INITIALIZED_ERROR_MSG: Final[str] = "Session not initialized"
|
|
115
136
|
API_DATA_NOT_INITIALIZED_ERROR_MSG: Final[str] = "API data not initialized"
|
|
137
|
+
API_VALIDATOR_NOT_INITIALIZED_ERROR_MSG: Final[str] = "API validator not initialized"
|
|
116
138
|
|
|
117
139
|
|
|
118
140
|
# Other Constants
|
|
@@ -9,7 +9,12 @@ class BSBLANError(Exception):
|
|
|
9
9
|
message: str = "Unexpected response from the BSBLAN device."
|
|
10
10
|
|
|
11
11
|
def __init__(self, message: str | None = None) -> None:
|
|
12
|
-
"""Initialize a new instance of the BSBLANError class.
|
|
12
|
+
"""Initialize a new instance of the BSBLANError class.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
message: Optional custom error message.
|
|
16
|
+
|
|
17
|
+
"""
|
|
13
18
|
if message is not None:
|
|
14
19
|
self.message = message
|
|
15
20
|
super().__init__(self.message)
|
|
@@ -22,7 +27,12 @@ class BSBLANConnectionError(BSBLANError):
|
|
|
22
27
|
message_error: str = "Error occurred while connecting to BSBLAN device."
|
|
23
28
|
|
|
24
29
|
def __init__(self, response: str | None = None) -> None:
|
|
25
|
-
"""Initialize a new instance of the BSBLANConnectionError class.
|
|
30
|
+
"""Initialize a new instance of the BSBLANConnectionError class.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
response: Optional response message.
|
|
34
|
+
|
|
35
|
+
"""
|
|
26
36
|
self.response = response
|
|
27
37
|
super().__init__(self.message)
|
|
28
38
|
|
|
@@ -37,6 +47,11 @@ class BSBLANInvalidParameterError(BSBLANError):
|
|
|
37
47
|
"""Raised when an invalid parameter is provided."""
|
|
38
48
|
|
|
39
49
|
def __init__(self, parameter: str) -> None:
|
|
40
|
-
"""Initialize a new instance of the BSBLANInvalidParameterError class.
|
|
50
|
+
"""Initialize a new instance of the BSBLANInvalidParameterError class.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
parameter: The invalid parameter that caused the error.
|
|
54
|
+
|
|
55
|
+
"""
|
|
41
56
|
self.message = f"Invalid values provided: {parameter}"
|
|
42
57
|
super().__init__(self.message)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Models for BSB-Lan."""
|
|
2
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
3
5
|
from dataclasses import dataclass, field
|
|
4
6
|
|
|
5
7
|
from mashumaro.mixins.json import DataClassJSONMixin
|
|
@@ -63,12 +65,12 @@ class State(DataClassJSONMixin):
|
|
|
63
65
|
"""
|
|
64
66
|
|
|
65
67
|
hvac_mode: EntityInfo
|
|
66
|
-
hvac_mode2: EntityInfo
|
|
67
68
|
target_temperature: EntityInfo
|
|
68
69
|
hvac_action: EntityInfo
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
hvac_mode2: EntityInfo | None = None
|
|
71
|
+
current_temperature: EntityInfo | None = None
|
|
72
|
+
room1_thermostat_mode: EntityInfo | None = None
|
|
73
|
+
room1_temp_setpoint_boost: EntityInfo | None = None
|
|
72
74
|
|
|
73
75
|
|
|
74
76
|
@dataclass
|
|
@@ -83,21 +85,26 @@ class StaticState(DataClassJSONMixin):
|
|
|
83
85
|
class Sensor(DataClassJSONMixin):
|
|
84
86
|
"""Object holds info about object for sensor climate."""
|
|
85
87
|
|
|
86
|
-
current_temperature: EntityInfo
|
|
87
88
|
outside_temperature: EntityInfo
|
|
89
|
+
current_temperature: EntityInfo | None = None
|
|
88
90
|
|
|
89
91
|
|
|
90
92
|
@dataclass
|
|
91
93
|
class HotWaterState(DataClassJSONMixin):
|
|
92
94
|
"""Object holds info about object for hot water climate."""
|
|
93
95
|
|
|
94
|
-
operating_mode: EntityInfo
|
|
95
|
-
nominal_setpoint: EntityInfo
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
96
|
+
operating_mode: EntityInfo | None = None
|
|
97
|
+
nominal_setpoint: EntityInfo | None = None # 1610
|
|
98
|
+
nominal_setpoint_max: EntityInfo | None = None # 1614
|
|
99
|
+
reduced_setpoint: EntityInfo | None = None # 1612
|
|
100
|
+
release: EntityInfo | None = None # 1620 - programme
|
|
101
|
+
legionella_function: EntityInfo | None = None # 1640 - Fixed weekday
|
|
102
|
+
legionella_setpoint: EntityInfo | None = None # 1645
|
|
103
|
+
legionella_periodicity: EntityInfo | None = None # 1641 - 7 (days)
|
|
104
|
+
legionella_function_day: EntityInfo | None = None # 1642 - Saturday
|
|
105
|
+
legionella_function_time: EntityInfo | None = None # 1644 - 12:00
|
|
106
|
+
dhw_actual_value_top_temperature: EntityInfo | None = None # 8830
|
|
107
|
+
state_dhw_pump: EntityInfo | None = None # 8820
|
|
101
108
|
|
|
102
109
|
|
|
103
110
|
@dataclass
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Utility functions for BSB-LAN integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from constants import APIConfig
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class APIValidator:
|
|
17
|
+
"""Validates and maintains BSB-LAN API configuration."""
|
|
18
|
+
|
|
19
|
+
api_config: APIConfig
|
|
20
|
+
validated_sections: set[str] = field(default_factory=set)
|
|
21
|
+
|
|
22
|
+
def validate_section(self, section: str, request_data: dict[str, Any]) -> None:
|
|
23
|
+
"""Validate and update a section of API config based on actual device support.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
section: The section of the API config to validate
|
|
27
|
+
(e.g., 'heating', 'hot_water')
|
|
28
|
+
request_data: Response data from the device for validation
|
|
29
|
+
|
|
30
|
+
"""
|
|
31
|
+
# Check if the section exists in the APIConfig object
|
|
32
|
+
section_config = getattr(self.api_config, section, None)
|
|
33
|
+
if section not in self.api_config:
|
|
34
|
+
logger.warning("Unknown section '%s' in API configuration", section)
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
# Skip if section was already validated
|
|
38
|
+
if section in self.validated_sections:
|
|
39
|
+
logger.debug("Section '%s' was already validated", section)
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
section_config = self.api_config[section]
|
|
43
|
+
params_to_remove = []
|
|
44
|
+
|
|
45
|
+
# Check each parameter in the section
|
|
46
|
+
for param_id, param_name in section_config.items():
|
|
47
|
+
if param_id not in request_data:
|
|
48
|
+
logger.info(
|
|
49
|
+
"Parameter %s (%s) not found in device response",
|
|
50
|
+
param_id,
|
|
51
|
+
param_name,
|
|
52
|
+
)
|
|
53
|
+
params_to_remove.append(param_id)
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
param_data = request_data[param_id]
|
|
57
|
+
if not self._is_valid_param(param_data):
|
|
58
|
+
logger.info(
|
|
59
|
+
"Parameter %s (%s) returned invalid value: %s",
|
|
60
|
+
param_id,
|
|
61
|
+
param_name,
|
|
62
|
+
param_data.get("value"),
|
|
63
|
+
)
|
|
64
|
+
params_to_remove.append(param_id)
|
|
65
|
+
|
|
66
|
+
# Remove unsupported parameters from the configuration
|
|
67
|
+
for param_id in params_to_remove:
|
|
68
|
+
section_config.pop(param_id)
|
|
69
|
+
|
|
70
|
+
# Mark section as validated
|
|
71
|
+
self.validated_sections.add(section)
|
|
72
|
+
|
|
73
|
+
logger.debug(
|
|
74
|
+
"Validated section '%s': removed %d unsupported parameters",
|
|
75
|
+
section,
|
|
76
|
+
len(params_to_remove),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def _is_valid_param(self, param: dict[str, Any]) -> bool:
|
|
80
|
+
"""Check if parameter data is valid."""
|
|
81
|
+
return not (not param or param.get("value") in (None, "---"))
|
|
82
|
+
|
|
83
|
+
def get_section_params(self, section: str) -> Any:
|
|
84
|
+
"""Get the parameter mapping for a section."""
|
|
85
|
+
return self.api_config.get(section, {}).copy()
|
|
86
|
+
|
|
87
|
+
def is_section_validated(self, section: str) -> bool:
|
|
88
|
+
"""Check if a section has been validated."""
|
|
89
|
+
return section in self.validated_sections
|
|
90
|
+
|
|
91
|
+
def reset_validation(self, section: str | None = None) -> None:
|
|
92
|
+
"""Reset validation state for a section or all sections.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
section: Specific section to reset, or None to reset all
|
|
96
|
+
|
|
97
|
+
"""
|
|
98
|
+
if section is None:
|
|
99
|
+
self.validated_sections.clear()
|
|
100
|
+
elif section in self.validated_sections:
|
|
101
|
+
self.validated_sections.remove(section)
|
|
File without changes
|