python-bsblan 0.6.2__tar.gz → 0.6.3__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.2 → python_bsblan-0.6.3}/PKG-INFO +2 -1
- {python_bsblan-0.6.2 → python_bsblan-0.6.3}/pyproject.toml +5 -4
- {python_bsblan-0.6.2 → python_bsblan-0.6.3}/src/bsblan/__init__.py +2 -1
- python_bsblan-0.6.3/src/bsblan/bsblan.py +363 -0
- python_bsblan-0.6.3/src/bsblan/constants.py +128 -0
- python_bsblan-0.6.3/src/bsblan/exceptions.py +42 -0
- {python_bsblan-0.6.2 → python_bsblan-0.6.3}/src/bsblan/models.py +16 -0
- python_bsblan-0.6.2/src/bsblan/bsblan.py +0 -403
- python_bsblan-0.6.2/src/bsblan/constants.py +0 -81
- python_bsblan-0.6.2/src/bsblan/exceptions.py +0 -78
- {python_bsblan-0.6.2 → python_bsblan-0.6.3}/README.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: python-bsblan
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.3
|
|
4
4
|
Summary: Asynchronous Python client for BSBLAN
|
|
5
5
|
Home-page: https://github.com/liudger/python-bsblan
|
|
6
6
|
License: MIT
|
|
@@ -23,6 +23,7 @@ Requires-Dist: aiohttp (>=3.8.1)
|
|
|
23
23
|
Requires-Dist: async-timeout (>=4.0.3,<5.0.0)
|
|
24
24
|
Requires-Dist: backoff (>=2.2.1,<3.0.0)
|
|
25
25
|
Requires-Dist: mashumaro (>=3.13.1,<4.0.0)
|
|
26
|
+
Requires-Dist: orjson (>=3.9.10,<4.0.0)
|
|
26
27
|
Requires-Dist: packaging (>=21.3)
|
|
27
28
|
Requires-Dist: yarl (>=1.7.2)
|
|
28
29
|
Project-URL: Bug Tracker, https://github.com/liudger/python-bsblan/issues
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "python-bsblan"
|
|
3
|
-
version = "0.6.
|
|
3
|
+
version = "0.6.3"
|
|
4
4
|
description = "Asynchronous Python client for BSBLAN"
|
|
5
5
|
authors = ["Willem-Jan van Rootselaar <liudgervr@gmail.com>"]
|
|
6
6
|
maintainers = ["Willem-Jan van Rootselaar <liudgervr@gmail.com>"]
|
|
@@ -32,10 +32,11 @@ packaging = ">=21.3"
|
|
|
32
32
|
backoff = "^2.2.1"
|
|
33
33
|
async-timeout = "^4.0.3"
|
|
34
34
|
mashumaro = "^3.13.1"
|
|
35
|
+
orjson = "^3.9.10"
|
|
35
36
|
|
|
36
37
|
[tool.poetry.dev-dependencies]
|
|
37
38
|
covdefaults = "^2.3.0"
|
|
38
|
-
ruff = "^0.
|
|
39
|
+
ruff = "^0.6.0"
|
|
39
40
|
aresponses = "^3.0.0"
|
|
40
41
|
black = "^24.0.0"
|
|
41
42
|
blacken-docs = "^1.13.0"
|
|
@@ -47,8 +48,8 @@ pre-commit = "^3.0.0"
|
|
|
47
48
|
pre-commit-hooks = "^4.3.0"
|
|
48
49
|
pylint = "^3.0.0"
|
|
49
50
|
pytest = "^8.0.0"
|
|
50
|
-
pytest-asyncio = "^0.
|
|
51
|
-
pytest-cov = "^
|
|
51
|
+
pytest-asyncio = "^0.24.0"
|
|
52
|
+
pytest-cov = "^5.0.0"
|
|
52
53
|
yamllint = "^1.29.0"
|
|
53
54
|
pyupgrade = "^3.3.1"
|
|
54
55
|
flake8-simplify = "^0.21.0"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from .bsblan import BSBLAN, BSBLANConfig
|
|
4
4
|
from .exceptions import BSBLANConnectionError, BSBLANError
|
|
5
|
-
from .models import Device, Info, Sensor, State, StaticState
|
|
5
|
+
from .models import Device, HotWaterState, Info, Sensor, State, StaticState
|
|
6
6
|
|
|
7
7
|
__all__ = [
|
|
8
8
|
"BSBLAN",
|
|
@@ -14,4 +14,5 @@ __all__ = [
|
|
|
14
14
|
"Device",
|
|
15
15
|
"Sensor",
|
|
16
16
|
"StaticState",
|
|
17
|
+
"HotWaterState",
|
|
17
18
|
]
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Asynchronous Python client for BSB-Lan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Mapping, cast
|
|
9
|
+
|
|
10
|
+
import aiohttp
|
|
11
|
+
from aiohttp.hdrs import METH_POST
|
|
12
|
+
from aiohttp.helpers import BasicAuth
|
|
13
|
+
from packaging import version as pkg_version
|
|
14
|
+
from typing_extensions import Self
|
|
15
|
+
from yarl import URL
|
|
16
|
+
|
|
17
|
+
from .constants import (
|
|
18
|
+
API_DATA_NOT_INITIALIZED_ERROR_MSG,
|
|
19
|
+
API_VERSION_ERROR_MSG,
|
|
20
|
+
API_VERSIONS,
|
|
21
|
+
FIRMWARE_VERSION_ERROR_MSG,
|
|
22
|
+
HVAC_MODE_DICT,
|
|
23
|
+
HVAC_MODE_DICT_REVERSE,
|
|
24
|
+
MULTI_PARAMETER_ERROR_MSG,
|
|
25
|
+
NO_STATE_ERROR_MSG,
|
|
26
|
+
SESSION_NOT_INITIALIZED_ERROR_MSG,
|
|
27
|
+
TEMPERATURE_RANGE_ERROR_MSG,
|
|
28
|
+
VERSION_ERROR_MSG,
|
|
29
|
+
APIConfig,
|
|
30
|
+
)
|
|
31
|
+
from .exceptions import (
|
|
32
|
+
BSBLANConnectionError,
|
|
33
|
+
BSBLANError,
|
|
34
|
+
BSBLANInvalidParameterError,
|
|
35
|
+
BSBLANVersionError,
|
|
36
|
+
)
|
|
37
|
+
from .models import Device, HotWaterState, Info, Sensor, State, StaticState
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from aiohttp.client import ClientSession
|
|
41
|
+
|
|
42
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class BSBLANConfig:
|
|
48
|
+
"""Configuration for BSBLAN."""
|
|
49
|
+
|
|
50
|
+
host: str
|
|
51
|
+
username: str | None = None
|
|
52
|
+
password: str | None = None
|
|
53
|
+
passkey: str | None = None
|
|
54
|
+
port: int = 80
|
|
55
|
+
request_timeout: int = 10
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class BSBLAN:
|
|
60
|
+
"""Main class for handling connections with BSBLAN."""
|
|
61
|
+
|
|
62
|
+
config: BSBLANConfig
|
|
63
|
+
session: ClientSession | None = None
|
|
64
|
+
_close_session: bool = False
|
|
65
|
+
_firmware_version: str | None = None
|
|
66
|
+
_api_version: str | None = None
|
|
67
|
+
_min_temp: float | None = None
|
|
68
|
+
_max_temp: float | None = None
|
|
69
|
+
_temperature_range_initialized: bool = False
|
|
70
|
+
_api_data: APIConfig | None = None
|
|
71
|
+
|
|
72
|
+
def __post_init__(self) -> None:
|
|
73
|
+
"""Initialize the session if not provided."""
|
|
74
|
+
if self.session is None:
|
|
75
|
+
self.session = aiohttp.ClientSession()
|
|
76
|
+
self._close_session = True
|
|
77
|
+
|
|
78
|
+
async def __aenter__(self) -> Self:
|
|
79
|
+
"""Enter the context manager."""
|
|
80
|
+
if self.session is None:
|
|
81
|
+
self.session = aiohttp.ClientSession()
|
|
82
|
+
self._close_session = True
|
|
83
|
+
await self._initialize()
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
async def __aexit__(self, *args: object) -> None:
|
|
87
|
+
"""Exit the context manager."""
|
|
88
|
+
if self._close_session and self.session:
|
|
89
|
+
await self.session.close()
|
|
90
|
+
|
|
91
|
+
async def _initialize(self) -> None:
|
|
92
|
+
"""Initialize the BSBLAN client."""
|
|
93
|
+
await self._fetch_firmware_version()
|
|
94
|
+
await self._initialize_temperature_range()
|
|
95
|
+
await self._initialize_api_data()
|
|
96
|
+
|
|
97
|
+
async def _fetch_firmware_version(self) -> None:
|
|
98
|
+
"""Fetch the firmware version if not already available."""
|
|
99
|
+
if self._firmware_version is None:
|
|
100
|
+
device = await self.device()
|
|
101
|
+
self._firmware_version = device.version
|
|
102
|
+
logger.debug("BSBLAN version: %s", self._firmware_version)
|
|
103
|
+
self._set_api_version()
|
|
104
|
+
|
|
105
|
+
def _set_api_version(self) -> None:
|
|
106
|
+
"""Set the API version based on the firmware version."""
|
|
107
|
+
if not self._firmware_version:
|
|
108
|
+
raise BSBLANError(FIRMWARE_VERSION_ERROR_MSG)
|
|
109
|
+
|
|
110
|
+
version = pkg_version.parse(self._firmware_version)
|
|
111
|
+
if version < pkg_version.parse("1.2.0"):
|
|
112
|
+
self._api_version = "v1"
|
|
113
|
+
elif version >= pkg_version.parse("3.0.0"):
|
|
114
|
+
self._api_version = "v3"
|
|
115
|
+
else:
|
|
116
|
+
raise BSBLANVersionError(VERSION_ERROR_MSG)
|
|
117
|
+
|
|
118
|
+
async def _initialize_temperature_range(self) -> None:
|
|
119
|
+
"""Initialize the temperature range from static values."""
|
|
120
|
+
if not self._temperature_range_initialized:
|
|
121
|
+
static_values = await self.static_values()
|
|
122
|
+
self._min_temp = float(static_values.min_temp.value)
|
|
123
|
+
self._max_temp = float(static_values.max_temp.value)
|
|
124
|
+
self._temperature_range_initialized = True
|
|
125
|
+
logger.debug(
|
|
126
|
+
"Temperature range initialized: min=%f, max=%f",
|
|
127
|
+
self._min_temp,
|
|
128
|
+
self._max_temp,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
async def _initialize_api_data(self) -> APIConfig:
|
|
132
|
+
"""Initialize and cache the API data."""
|
|
133
|
+
if self._api_data is None:
|
|
134
|
+
if self._api_version is None:
|
|
135
|
+
raise BSBLANError(API_VERSION_ERROR_MSG)
|
|
136
|
+
self._api_data = API_VERSIONS[self._api_version]
|
|
137
|
+
logger.debug("API data initialized for version: %s", self._api_version)
|
|
138
|
+
if self._api_data is None:
|
|
139
|
+
raise BSBLANError(API_DATA_NOT_INITIALIZED_ERROR_MSG)
|
|
140
|
+
return self._api_data
|
|
141
|
+
|
|
142
|
+
async def _request(
|
|
143
|
+
self,
|
|
144
|
+
method: str = METH_POST,
|
|
145
|
+
base_path: str = "/JQ",
|
|
146
|
+
data: dict[str, object] | None = None,
|
|
147
|
+
params: Mapping[str, str | int] | str | None = None,
|
|
148
|
+
) -> dict[str, Any]:
|
|
149
|
+
"""Handle a request to a BSBLAN device."""
|
|
150
|
+
if self.session is None:
|
|
151
|
+
raise BSBLANError(SESSION_NOT_INITIALIZED_ERROR_MSG)
|
|
152
|
+
url = self._build_url(base_path)
|
|
153
|
+
auth = self._get_auth()
|
|
154
|
+
headers = self._get_headers()
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
async with asyncio.timeout(self.config.request_timeout):
|
|
158
|
+
async with self.session.request(
|
|
159
|
+
method,
|
|
160
|
+
url,
|
|
161
|
+
auth=auth,
|
|
162
|
+
params=params,
|
|
163
|
+
json=data,
|
|
164
|
+
headers=headers,
|
|
165
|
+
) as response:
|
|
166
|
+
response.raise_for_status()
|
|
167
|
+
return cast(dict[str, Any], await response.json())
|
|
168
|
+
except asyncio.TimeoutError as e:
|
|
169
|
+
raise BSBLANConnectionError(BSBLANConnectionError.message_timeout) from e
|
|
170
|
+
except aiohttp.ClientError as e:
|
|
171
|
+
raise BSBLANConnectionError(BSBLANConnectionError.message_error) from e
|
|
172
|
+
except ValueError as e:
|
|
173
|
+
raise BSBLANError(str(e)) from e
|
|
174
|
+
|
|
175
|
+
def _build_url(self, base_path: str) -> URL:
|
|
176
|
+
"""Build the URL for the request."""
|
|
177
|
+
if self.config.passkey:
|
|
178
|
+
base_path = f"/{self.config.passkey}{base_path}"
|
|
179
|
+
return URL.build(
|
|
180
|
+
scheme="http",
|
|
181
|
+
host=self.config.host,
|
|
182
|
+
port=self.config.port,
|
|
183
|
+
path=base_path,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def _get_auth(self) -> BasicAuth | None:
|
|
187
|
+
"""Get the authentication for the request."""
|
|
188
|
+
if self.config.username and self.config.password:
|
|
189
|
+
return BasicAuth(self.config.username, self.config.password)
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
def _get_headers(self) -> dict[str, str]:
|
|
193
|
+
"""Get the headers for the request."""
|
|
194
|
+
return {
|
|
195
|
+
"User-Agent": f"PythonBSBLAN/{self._firmware_version}",
|
|
196
|
+
"Accept": "application/json, */*",
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
def _validate_single_parameter(self, *params: Any, error_msg: str) -> None:
|
|
200
|
+
"""Validate that exactly one parameter is provided."""
|
|
201
|
+
if sum(param is not None for param in params) != 1:
|
|
202
|
+
raise BSBLANError(error_msg)
|
|
203
|
+
|
|
204
|
+
async def _get_parameters(self, params: dict[Any, Any]) -> dict[Any, Any]:
|
|
205
|
+
"""Get the parameters info from BSBLAN device."""
|
|
206
|
+
string_params = ",".join(map(str, params))
|
|
207
|
+
return {"string_par": string_params, "list": list(params.values())}
|
|
208
|
+
|
|
209
|
+
async def state(self) -> State:
|
|
210
|
+
"""Get the current state from BSBLAN device."""
|
|
211
|
+
api_data = await self._initialize_api_data()
|
|
212
|
+
params = await self._get_parameters(api_data["heating"])
|
|
213
|
+
data = await self._request(params={"Parameter": params["string_par"]})
|
|
214
|
+
data = dict(zip(params["list"], list(data.values()), strict=True))
|
|
215
|
+
data["hvac_mode"]["value"] = HVAC_MODE_DICT[int(data["hvac_mode"]["value"])]
|
|
216
|
+
return State.from_dict(data)
|
|
217
|
+
|
|
218
|
+
async def sensor(self) -> Sensor:
|
|
219
|
+
"""Get the sensor information from BSBLAN device."""
|
|
220
|
+
api_data = await self._initialize_api_data()
|
|
221
|
+
params = await self._get_parameters(api_data["sensor"])
|
|
222
|
+
data = await self._request(params={"Parameter": params["string_par"]})
|
|
223
|
+
data = dict(zip(params["list"], list(data.values()), strict=True))
|
|
224
|
+
return Sensor.from_dict(data)
|
|
225
|
+
|
|
226
|
+
async def static_values(self) -> StaticState:
|
|
227
|
+
"""Get the static information from BSBLAN device."""
|
|
228
|
+
api_data = await self._initialize_api_data()
|
|
229
|
+
params = await self._get_parameters(api_data["staticValues"])
|
|
230
|
+
data = await self._request(params={"Parameter": params["string_par"]})
|
|
231
|
+
data = dict(zip(params["list"], list(data.values()), strict=True))
|
|
232
|
+
return StaticState.from_dict(data)
|
|
233
|
+
|
|
234
|
+
async def device(self) -> Device:
|
|
235
|
+
"""Get BSBLAN device info."""
|
|
236
|
+
device_info = await self._request(base_path="/JI")
|
|
237
|
+
return Device.from_dict(device_info)
|
|
238
|
+
|
|
239
|
+
async def info(self) -> Info:
|
|
240
|
+
"""Get information about the current heating system config."""
|
|
241
|
+
api_data = await self._initialize_api_data()
|
|
242
|
+
params = await self._get_parameters(api_data["device"])
|
|
243
|
+
data = await self._request(params={"Parameter": params["string_par"]})
|
|
244
|
+
data = dict(zip(params["list"], list(data.values()), strict=True))
|
|
245
|
+
return Info.from_dict(data)
|
|
246
|
+
|
|
247
|
+
async def thermostat(
|
|
248
|
+
self,
|
|
249
|
+
target_temperature: str | None = None,
|
|
250
|
+
hvac_mode: str | None = None,
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Change the state of the thermostat through BSB-Lan."""
|
|
253
|
+
await self._initialize_temperature_range()
|
|
254
|
+
|
|
255
|
+
self._validate_single_parameter(
|
|
256
|
+
target_temperature,
|
|
257
|
+
hvac_mode,
|
|
258
|
+
error_msg=MULTI_PARAMETER_ERROR_MSG,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
state = self._prepare_thermostat_state(target_temperature, hvac_mode)
|
|
262
|
+
await self._set_thermostat_state(state)
|
|
263
|
+
|
|
264
|
+
def _prepare_thermostat_state(
|
|
265
|
+
self,
|
|
266
|
+
target_temperature: str | None,
|
|
267
|
+
hvac_mode: str | None,
|
|
268
|
+
) -> dict[str, Any]:
|
|
269
|
+
"""Prepare the thermostat state for setting."""
|
|
270
|
+
state: dict[str, Any] = {}
|
|
271
|
+
if target_temperature is not None:
|
|
272
|
+
self._validate_target_temperature(target_temperature)
|
|
273
|
+
state.update({"Parameter": "710", "Value": target_temperature, "Type": "1"})
|
|
274
|
+
if hvac_mode is not None:
|
|
275
|
+
self._validate_hvac_mode(hvac_mode)
|
|
276
|
+
state.update(
|
|
277
|
+
{
|
|
278
|
+
"Parameter": "700",
|
|
279
|
+
"EnumValue": HVAC_MODE_DICT_REVERSE[hvac_mode],
|
|
280
|
+
"Type": "1",
|
|
281
|
+
},
|
|
282
|
+
)
|
|
283
|
+
return state
|
|
284
|
+
|
|
285
|
+
def _validate_target_temperature(self, target_temperature: str) -> None:
|
|
286
|
+
"""Validate the target temperature."""
|
|
287
|
+
if self._min_temp is None or self._max_temp is None:
|
|
288
|
+
raise BSBLANError(TEMPERATURE_RANGE_ERROR_MSG)
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
temp = float(target_temperature)
|
|
292
|
+
if not (self._min_temp <= temp <= self._max_temp):
|
|
293
|
+
raise BSBLANInvalidParameterError(target_temperature)
|
|
294
|
+
except ValueError as err:
|
|
295
|
+
raise BSBLANInvalidParameterError(target_temperature) from err
|
|
296
|
+
|
|
297
|
+
def _validate_hvac_mode(self, hvac_mode: str) -> None:
|
|
298
|
+
"""Validate the HVAC mode."""
|
|
299
|
+
if hvac_mode not in HVAC_MODE_DICT_REVERSE:
|
|
300
|
+
raise BSBLANInvalidParameterError(hvac_mode)
|
|
301
|
+
|
|
302
|
+
async def _set_thermostat_state(self, state: dict[str, Any]) -> None:
|
|
303
|
+
"""Set the thermostat state."""
|
|
304
|
+
response = await self._request(base_path="/JS", data=state)
|
|
305
|
+
logger.debug("Response for setting: %s", response)
|
|
306
|
+
|
|
307
|
+
async def hot_water_state(self) -> HotWaterState:
|
|
308
|
+
"""Get the current hot water state from BSBLAN device."""
|
|
309
|
+
api_data = await self._initialize_api_data()
|
|
310
|
+
params = await self._get_parameters(api_data["hot_water"])
|
|
311
|
+
data = await self._request(params={"Parameter": params["string_par"]})
|
|
312
|
+
data = dict(zip(params["list"], list(data.values()), strict=True))
|
|
313
|
+
return HotWaterState.from_dict(data)
|
|
314
|
+
|
|
315
|
+
async def set_hot_water(
|
|
316
|
+
self,
|
|
317
|
+
operating_mode: str | None = None,
|
|
318
|
+
nominal_setpoint: float | None = None,
|
|
319
|
+
reduced_setpoint: float | None = None,
|
|
320
|
+
) -> None:
|
|
321
|
+
"""Change the state of the hot water system through BSB-Lan."""
|
|
322
|
+
self._validate_single_parameter(
|
|
323
|
+
operating_mode,
|
|
324
|
+
nominal_setpoint,
|
|
325
|
+
reduced_setpoint,
|
|
326
|
+
error_msg=MULTI_PARAMETER_ERROR_MSG,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
state = self._prepare_hot_water_state(
|
|
330
|
+
operating_mode,
|
|
331
|
+
nominal_setpoint,
|
|
332
|
+
reduced_setpoint,
|
|
333
|
+
)
|
|
334
|
+
await self._set_hot_water_state(state)
|
|
335
|
+
|
|
336
|
+
def _prepare_hot_water_state(
|
|
337
|
+
self,
|
|
338
|
+
operating_mode: str | None,
|
|
339
|
+
nominal_setpoint: float | None,
|
|
340
|
+
reduced_setpoint: float | None,
|
|
341
|
+
) -> dict[str, Any]:
|
|
342
|
+
"""Prepare the hot water state for setting."""
|
|
343
|
+
state: dict[str, Any] = {}
|
|
344
|
+
if operating_mode is not None:
|
|
345
|
+
state.update(
|
|
346
|
+
{"Parameter": "1600", "EnumValue": operating_mode, "Type": "1"},
|
|
347
|
+
)
|
|
348
|
+
if nominal_setpoint is not None:
|
|
349
|
+
state.update(
|
|
350
|
+
{"Parameter": "1610", "Value": str(nominal_setpoint), "Type": "1"},
|
|
351
|
+
)
|
|
352
|
+
if reduced_setpoint is not None:
|
|
353
|
+
state.update(
|
|
354
|
+
{"Parameter": "1612", "Value": str(reduced_setpoint), "Type": "1"},
|
|
355
|
+
)
|
|
356
|
+
if not state:
|
|
357
|
+
raise BSBLANError(NO_STATE_ERROR_MSG)
|
|
358
|
+
return state
|
|
359
|
+
|
|
360
|
+
async def _set_hot_water_state(self, state: dict[str, Any]) -> None:
|
|
361
|
+
"""Set the hot water state."""
|
|
362
|
+
response = await self._request(base_path="/JS", data=state)
|
|
363
|
+
logger.debug("Response for setting: %s", response)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""BSBLAN constants."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Final, TypedDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# API Versions
|
|
9
|
+
class APIConfig(TypedDict):
|
|
10
|
+
"""Type for API configuration."""
|
|
11
|
+
|
|
12
|
+
heating: dict[str, str]
|
|
13
|
+
staticValues: dict[str, str]
|
|
14
|
+
device: dict[str, str]
|
|
15
|
+
sensor: dict[str, str]
|
|
16
|
+
hot_water: dict[str, str]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
API_V1: Final[APIConfig] = {
|
|
20
|
+
"heating": {
|
|
21
|
+
"700": "hvac_mode",
|
|
22
|
+
"710": "target_temperature",
|
|
23
|
+
"900": "hvac_mode2",
|
|
24
|
+
"8000": "hvac_action",
|
|
25
|
+
"8740": "current_temperature",
|
|
26
|
+
"8749": "room1_thermostat_mode",
|
|
27
|
+
},
|
|
28
|
+
"staticValues": {
|
|
29
|
+
"714": "min_temp",
|
|
30
|
+
"730": "max_temp",
|
|
31
|
+
},
|
|
32
|
+
"device": {
|
|
33
|
+
"6224": "device_identification",
|
|
34
|
+
"6225": "controller_family",
|
|
35
|
+
"6226": "controller_variant",
|
|
36
|
+
},
|
|
37
|
+
"sensor": {
|
|
38
|
+
"8700": "outside_temperature",
|
|
39
|
+
"8740": "current_temperature",
|
|
40
|
+
},
|
|
41
|
+
"hot_water": {
|
|
42
|
+
"1600": "operating_mode",
|
|
43
|
+
"1610": "nominal_setpoint",
|
|
44
|
+
"1612": "reduced_setpoint",
|
|
45
|
+
"1620": "release",
|
|
46
|
+
"1640": "legionella_function",
|
|
47
|
+
"1645": "legionella_setpoint",
|
|
48
|
+
"1641": "legionella_periodically",
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
API_V3: Final[APIConfig] = {
|
|
53
|
+
"heating": {
|
|
54
|
+
"700": "hvac_mode",
|
|
55
|
+
"710": "target_temperature",
|
|
56
|
+
"900": "hvac_mode2",
|
|
57
|
+
"8000": "hvac_action",
|
|
58
|
+
"8740": "current_temperature",
|
|
59
|
+
"8749": "room1_thermostat_mode",
|
|
60
|
+
"770": "room1_temp_setpoint_boost",
|
|
61
|
+
},
|
|
62
|
+
"staticValues": {
|
|
63
|
+
"714": "min_temp",
|
|
64
|
+
"716": "max_temp",
|
|
65
|
+
},
|
|
66
|
+
"device": {
|
|
67
|
+
"6224": "device_identification",
|
|
68
|
+
"6225": "controller_family",
|
|
69
|
+
"6226": "controller_variant",
|
|
70
|
+
},
|
|
71
|
+
"sensor": {
|
|
72
|
+
"8700": "outside_temperature",
|
|
73
|
+
"8740": "current_temperature",
|
|
74
|
+
},
|
|
75
|
+
"hot_water": {
|
|
76
|
+
"1600": "operating_mode",
|
|
77
|
+
"1610": "nominal_setpoint",
|
|
78
|
+
"1612": "reduced_setpoint",
|
|
79
|
+
"1620": "release",
|
|
80
|
+
"1640": "legionella_function",
|
|
81
|
+
"1645": "legionella_setpoint",
|
|
82
|
+
"1641": "legionella_periodically",
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
API_VERSIONS: Final[dict[str, APIConfig]] = {
|
|
87
|
+
"v1": API_V1,
|
|
88
|
+
"v3": API_V3,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# HVAC Modes
|
|
92
|
+
HVAC_MODE_DICT: Final[dict[int, str]] = {
|
|
93
|
+
0: "off",
|
|
94
|
+
1: "auto",
|
|
95
|
+
2: "eco",
|
|
96
|
+
3: "heat",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
HVAC_MODE_DICT_REVERSE: Final[dict[str, int]] = {
|
|
100
|
+
"off": 0,
|
|
101
|
+
"auto": 1,
|
|
102
|
+
"eco": 2,
|
|
103
|
+
"heat": 3,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# Error Messages
|
|
107
|
+
INVALID_VALUES_ERROR_MSG: Final[str] = "Invalid values provided."
|
|
108
|
+
NO_STATE_ERROR_MSG: Final[str] = "No state provided."
|
|
109
|
+
VERSION_ERROR_MSG: Final[str] = "Version not supported"
|
|
110
|
+
FIRMWARE_VERSION_ERROR_MSG: Final[str] = "Firmware version not available"
|
|
111
|
+
TEMPERATURE_RANGE_ERROR_MSG: Final[str] = "Temperature range not initialized"
|
|
112
|
+
API_VERSION_ERROR_MSG: Final[str] = "API version not set"
|
|
113
|
+
MULTI_PARAMETER_ERROR_MSG: Final[str] = "Only one parameter can be set at a time"
|
|
114
|
+
SESSION_NOT_INITIALIZED_ERROR_MSG: Final[str] = "Session not initialized"
|
|
115
|
+
API_DATA_NOT_INITIALIZED_ERROR_MSG: Final[str] = "API data not initialized"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# Other Constants
|
|
119
|
+
DEFAULT_PORT: Final[int] = 80
|
|
120
|
+
SCAN_INTERVAL: Final[int] = 12 # seconds
|
|
121
|
+
|
|
122
|
+
# Configuration Keys
|
|
123
|
+
CONF_PASSKEY: Final[str] = "passkey"
|
|
124
|
+
|
|
125
|
+
# Attributes
|
|
126
|
+
ATTR_TARGET_TEMPERATURE: Final[str] = "target_temperature"
|
|
127
|
+
ATTR_INSIDE_TEMPERATURE: Final[str] = "inside_temperature"
|
|
128
|
+
ATTR_OUTSIDE_TEMPERATURE: Final[str] = "outside_temperature"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Exceptions for BSB-Lan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BSBLANError(Exception):
|
|
7
|
+
"""Generic BSBLAN exception."""
|
|
8
|
+
|
|
9
|
+
message: str = "Unexpected response from the BSBLAN device."
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str | None = None) -> None:
|
|
12
|
+
"""Initialize a new instance of the BSBLANError class."""
|
|
13
|
+
if message is not None:
|
|
14
|
+
self.message = message
|
|
15
|
+
super().__init__(self.message)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BSBLANConnectionError(BSBLANError):
|
|
19
|
+
"""BSBLAN connection exception."""
|
|
20
|
+
|
|
21
|
+
message_timeout: str = "Timeout occurred while connecting to BSBLAN device."
|
|
22
|
+
message_error: str = "Error occurred while connecting to BSBLAN device."
|
|
23
|
+
|
|
24
|
+
def __init__(self, response: str | None = None) -> None:
|
|
25
|
+
"""Initialize a new instance of the BSBLANConnectionError class."""
|
|
26
|
+
self.response = response
|
|
27
|
+
super().__init__(self.message)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BSBLANVersionError(BSBLANError):
|
|
31
|
+
"""Raised when the BSBLAN device has an unsupported version."""
|
|
32
|
+
|
|
33
|
+
message: str = "The BSBLAN device has an unsupported version."
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BSBLANInvalidParameterError(BSBLANError):
|
|
37
|
+
"""Raised when an invalid parameter is provided."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, parameter: str) -> None:
|
|
40
|
+
"""Initialize a new instance of the BSBLANInvalidParameterError class."""
|
|
41
|
+
self.message = f"Invalid values provided: {parameter}"
|
|
42
|
+
super().__init__(self.message)
|
|
@@ -57,6 +57,8 @@ class State(DataClassJSONMixin):
|
|
|
57
57
|
The current temperature of the climate system.
|
|
58
58
|
room1_thermostat_mode : EntityInfo
|
|
59
59
|
The thermostat mode of the climate system.
|
|
60
|
+
room1_temp_setpoint_boost : EntityInfo
|
|
61
|
+
The temperature setpoint boost of the climate system.
|
|
60
62
|
|
|
61
63
|
"""
|
|
62
64
|
|
|
@@ -66,6 +68,7 @@ class State(DataClassJSONMixin):
|
|
|
66
68
|
hvac_action: EntityInfo
|
|
67
69
|
current_temperature: EntityInfo
|
|
68
70
|
room1_thermostat_mode: EntityInfo
|
|
71
|
+
room1_temp_setpoint_boost: EntityInfo
|
|
69
72
|
|
|
70
73
|
|
|
71
74
|
@dataclass
|
|
@@ -84,6 +87,19 @@ class Sensor(DataClassJSONMixin):
|
|
|
84
87
|
outside_temperature: EntityInfo
|
|
85
88
|
|
|
86
89
|
|
|
90
|
+
@dataclass
|
|
91
|
+
class HotWaterState(DataClassJSONMixin):
|
|
92
|
+
"""Object holds info about object for hot water climate."""
|
|
93
|
+
|
|
94
|
+
operating_mode: EntityInfo
|
|
95
|
+
nominal_setpoint: EntityInfo
|
|
96
|
+
reduced_setpoint: EntityInfo
|
|
97
|
+
release: EntityInfo
|
|
98
|
+
legionella_function: EntityInfo
|
|
99
|
+
legionella_setpoint: EntityInfo
|
|
100
|
+
legionella_periodically: EntityInfo
|
|
101
|
+
|
|
102
|
+
|
|
87
103
|
@dataclass
|
|
88
104
|
class Device(DataClassJSONMixin):
|
|
89
105
|
"""Object holds bsblan device information.
|
|
@@ -1,403 +0,0 @@
|
|
|
1
|
-
"""Asynchronous Python client for BSB-Lan."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import asyncio
|
|
6
|
-
import logging
|
|
7
|
-
from asyncio.log import logger
|
|
8
|
-
from dataclasses import dataclass, field
|
|
9
|
-
from typing import Any, Mapping, TypedDict, cast
|
|
10
|
-
|
|
11
|
-
import aiohttp
|
|
12
|
-
from aiohttp.client import ClientSession
|
|
13
|
-
from aiohttp.hdrs import METH_POST
|
|
14
|
-
from aiohttp.helpers import BasicAuth
|
|
15
|
-
from packaging import version as pkg_version
|
|
16
|
-
from typing_extensions import Self
|
|
17
|
-
from yarl import URL
|
|
18
|
-
|
|
19
|
-
from .constants import (
|
|
20
|
-
DEVICE_INFO_API_V1,
|
|
21
|
-
DEVICE_INFO_API_V3,
|
|
22
|
-
HEATING_CIRCUIT1_API_V1,
|
|
23
|
-
HEATING_CIRCUIT1_API_V3,
|
|
24
|
-
HVAC_MODE_DICT,
|
|
25
|
-
HVAC_MODE_DICT_REVERSE,
|
|
26
|
-
INVALID_VALUES_ERROR_MSG,
|
|
27
|
-
NO_STATE_ERROR_MSG,
|
|
28
|
-
SENSORS_API_V1,
|
|
29
|
-
SENSORS_API_V3,
|
|
30
|
-
STATIC_VALUES_API_V1,
|
|
31
|
-
STATIC_VALUES_API_V3,
|
|
32
|
-
VERSION_ERROR_MSG,
|
|
33
|
-
)
|
|
34
|
-
from .exceptions import (
|
|
35
|
-
BSBLANConnectionError,
|
|
36
|
-
BSBLANError,
|
|
37
|
-
BSBLANInvalidParameterError,
|
|
38
|
-
BSBLANVersionError,
|
|
39
|
-
)
|
|
40
|
-
from .models import Device, Info, Sensor, State, StaticState
|
|
41
|
-
|
|
42
|
-
logging.basicConfig(level=logging.DEBUG)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
@dataclass
|
|
46
|
-
class BSBLANConfig:
|
|
47
|
-
"""Configuration for BSBLAN."""
|
|
48
|
-
|
|
49
|
-
host: str
|
|
50
|
-
username: str | None = None
|
|
51
|
-
password: str | None = None
|
|
52
|
-
passkey: str | None = None
|
|
53
|
-
port: int = 80
|
|
54
|
-
request_timeout: int = 10
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
@dataclass
|
|
58
|
-
class BSBLAN:
|
|
59
|
-
"""Main class for handling connections with BSBLAN."""
|
|
60
|
-
|
|
61
|
-
_heating_params: list[str] | None = None
|
|
62
|
-
_string_circuit1: str | None = None
|
|
63
|
-
_sensor_params: list[str] | None = None
|
|
64
|
-
_sensor_list: str | None = None
|
|
65
|
-
_static_params: list[str] | None = None
|
|
66
|
-
_static_list: str | None = None
|
|
67
|
-
_device_params: list[str] = field(default_factory=list)
|
|
68
|
-
_min_temp: float = 7.0
|
|
69
|
-
_max_temp: float = 25.0
|
|
70
|
-
_info: str | None = None
|
|
71
|
-
_auth: BasicAuth | None = None
|
|
72
|
-
_close_session: bool = False
|
|
73
|
-
|
|
74
|
-
def __init__(
|
|
75
|
-
self,
|
|
76
|
-
config: BSBLANConfig,
|
|
77
|
-
session: ClientSession | None = None,
|
|
78
|
-
) -> None:
|
|
79
|
-
"""Initialize the BSBLAN object.
|
|
80
|
-
|
|
81
|
-
Args:
|
|
82
|
-
----
|
|
83
|
-
config: Configuration for the BSBLAN object.
|
|
84
|
-
session: The aiohttp session to use for the connection.
|
|
85
|
-
|
|
86
|
-
"""
|
|
87
|
-
self.config = config
|
|
88
|
-
self.session = session
|
|
89
|
-
self._close_session = session is None
|
|
90
|
-
self._firmware_version: str | None = None
|
|
91
|
-
|
|
92
|
-
async def _fetch_firmware_version(self) -> None:
|
|
93
|
-
"""Fetch the firmware version if not already available."""
|
|
94
|
-
if self._firmware_version is None:
|
|
95
|
-
device = await self.device()
|
|
96
|
-
self._firmware_version = device.version
|
|
97
|
-
logger.debug("BSBLAN version: %s", self._firmware_version)
|
|
98
|
-
|
|
99
|
-
async def __aenter__(self) -> Self:
|
|
100
|
-
"""Enter method for the context manager.
|
|
101
|
-
|
|
102
|
-
Returns
|
|
103
|
-
-------
|
|
104
|
-
Self: The current instance of the context manager.
|
|
105
|
-
|
|
106
|
-
"""
|
|
107
|
-
if self.session is None:
|
|
108
|
-
self.session = aiohttp.ClientSession()
|
|
109
|
-
self._close_session = True
|
|
110
|
-
return self
|
|
111
|
-
|
|
112
|
-
async def __aexit__(self, *args: object) -> None:
|
|
113
|
-
"""Exit method for the context manager.
|
|
114
|
-
|
|
115
|
-
Args:
|
|
116
|
-
----
|
|
117
|
-
*args: Arguments passed to the exit method.
|
|
118
|
-
|
|
119
|
-
"""
|
|
120
|
-
if self._close_session and self.session:
|
|
121
|
-
await self.session.close()
|
|
122
|
-
|
|
123
|
-
# cSpell:ignore BSBLAN
|
|
124
|
-
async def _request(
|
|
125
|
-
self,
|
|
126
|
-
method: str = METH_POST,
|
|
127
|
-
base_path: str = "/JQ",
|
|
128
|
-
data: dict[str, object] | None = None,
|
|
129
|
-
params: Mapping[str, str | int] | str | None = None,
|
|
130
|
-
) -> dict[str, Any]:
|
|
131
|
-
"""Handle a request to a BSBLAN device.
|
|
132
|
-
|
|
133
|
-
A generic method for sending/handling HTTP requests done against
|
|
134
|
-
the BSBLAN API.
|
|
135
|
-
|
|
136
|
-
Args:
|
|
137
|
-
----
|
|
138
|
-
method: HTTP method to use.
|
|
139
|
-
base_path: Base path to use.
|
|
140
|
-
data: Dictionary of data to send to the BSBLAN device.
|
|
141
|
-
params: string of parameters to send to the BSBLAN device to
|
|
142
|
-
retrieve certain data.
|
|
143
|
-
|
|
144
|
-
Returns:
|
|
145
|
-
-------
|
|
146
|
-
A Python dictionary (JSON decoded) with the response from
|
|
147
|
-
the BSBLAN API.
|
|
148
|
-
|
|
149
|
-
Raises:
|
|
150
|
-
------
|
|
151
|
-
BSBLANConnectionError: If the connection to the BSBLAN device
|
|
152
|
-
fails.
|
|
153
|
-
BSBLANError: If receiving from the BSBLAN device an unexpected
|
|
154
|
-
response.
|
|
155
|
-
|
|
156
|
-
"""
|
|
157
|
-
# retrieve passkey for custom url
|
|
158
|
-
if self.config.passkey:
|
|
159
|
-
base_path = f"/{self.config.passkey}{base_path}"
|
|
160
|
-
|
|
161
|
-
url = URL.build(
|
|
162
|
-
scheme="http",
|
|
163
|
-
host=self.config.host,
|
|
164
|
-
port=self.config.port,
|
|
165
|
-
path=base_path,
|
|
166
|
-
)
|
|
167
|
-
|
|
168
|
-
auth = None
|
|
169
|
-
if self.config.username and self.config.password:
|
|
170
|
-
auth = BasicAuth(self.config.username, self.config.password)
|
|
171
|
-
|
|
172
|
-
headers = {
|
|
173
|
-
"User-Agent": f"PythonBSBLAN/{self._firmware_version}",
|
|
174
|
-
"Accept": "application/json, */*",
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
if self.session is None:
|
|
178
|
-
self.session = ClientSession()
|
|
179
|
-
self._close_session = True
|
|
180
|
-
|
|
181
|
-
try:
|
|
182
|
-
async with asyncio.timeout(self.config.request_timeout):
|
|
183
|
-
async with self.session.request(
|
|
184
|
-
method,
|
|
185
|
-
url,
|
|
186
|
-
auth=auth,
|
|
187
|
-
params=params,
|
|
188
|
-
json=data,
|
|
189
|
-
headers=headers,
|
|
190
|
-
) as response:
|
|
191
|
-
response.raise_for_status()
|
|
192
|
-
return cast(dict[str, Any], await response.json())
|
|
193
|
-
except asyncio.TimeoutError as e:
|
|
194
|
-
raise BSBLANConnectionError(BSBLANConnectionError.message_timeout) from e
|
|
195
|
-
except aiohttp.ClientError as e:
|
|
196
|
-
raise BSBLANConnectionError(BSBLANConnectionError.message_error) from e
|
|
197
|
-
except ValueError as e:
|
|
198
|
-
raise BSBLANError(str(e)) from e
|
|
199
|
-
|
|
200
|
-
async def state(self) -> State:
|
|
201
|
-
"""Get the current state from BSBLAN device.
|
|
202
|
-
|
|
203
|
-
Returns
|
|
204
|
-
-------
|
|
205
|
-
A BSBLAN state object.
|
|
206
|
-
|
|
207
|
-
"""
|
|
208
|
-
if not self._string_circuit1 or not self._heating_params:
|
|
209
|
-
# retrieve heating circuit 1
|
|
210
|
-
data = await self._get_dict_version()
|
|
211
|
-
data = await self._get_parameters(data["heating"])
|
|
212
|
-
self._string_circuit1 = str(data["string_par"])
|
|
213
|
-
self._heating_params = list(data["list"])
|
|
214
|
-
|
|
215
|
-
# retrieve heating circuit 1 and heating params so we can build the
|
|
216
|
-
# data structure (its circuit 1 because it can support 2 circuits)
|
|
217
|
-
data = await self._request(params={"Parameter": f"{self._string_circuit1}"})
|
|
218
|
-
data = dict(zip(self._heating_params, list(data.values()), strict=True))
|
|
219
|
-
|
|
220
|
-
# set hvac_mode with correct value
|
|
221
|
-
data["hvac_mode"]["value"] = HVAC_MODE_DICT[int(data["hvac_mode"]["value"])]
|
|
222
|
-
return State.from_dict(data)
|
|
223
|
-
|
|
224
|
-
async def sensor(self) -> Sensor:
|
|
225
|
-
"""Get the sensor information from BSBLAN device.
|
|
226
|
-
|
|
227
|
-
Returns
|
|
228
|
-
-------
|
|
229
|
-
A BSBLAN sensor object.
|
|
230
|
-
|
|
231
|
-
"""
|
|
232
|
-
if not self._sensor_params:
|
|
233
|
-
data = await self._get_dict_version()
|
|
234
|
-
data = await self._get_parameters(data["sensor"])
|
|
235
|
-
self._sensor_list = str(data["string_par"])
|
|
236
|
-
self._sensor_params = list(data["list"])
|
|
237
|
-
|
|
238
|
-
# retrieve sensor params so we can build the data structure
|
|
239
|
-
data = await self._request(params={"Parameter": f"{self._sensor_list}"})
|
|
240
|
-
data = dict(zip(self._sensor_params, list(data.values()), strict=True))
|
|
241
|
-
return Sensor.from_dict(data)
|
|
242
|
-
|
|
243
|
-
async def static_values(self) -> StaticState:
|
|
244
|
-
"""Get the static information from BSBLAN device.
|
|
245
|
-
|
|
246
|
-
Returns
|
|
247
|
-
-------
|
|
248
|
-
A BSBLAN staticState object.
|
|
249
|
-
|
|
250
|
-
"""
|
|
251
|
-
if not self._static_params:
|
|
252
|
-
data = await self._get_dict_version()
|
|
253
|
-
data = await self._get_parameters(data["staticValues"])
|
|
254
|
-
self._static_list = str(data["string_par"])
|
|
255
|
-
self._static_params = list(data["list"])
|
|
256
|
-
|
|
257
|
-
# retrieve sensor params so we can build the data structure
|
|
258
|
-
data = await self._request(params={"Parameter": f"{self._static_list}"})
|
|
259
|
-
data = dict(zip(self._static_params, list(data.values()), strict=True))
|
|
260
|
-
self._min_temp = data["min_temp"]["value"]
|
|
261
|
-
self._max_temp = data["max_temp"]["value"]
|
|
262
|
-
return StaticState.from_dict(data)
|
|
263
|
-
|
|
264
|
-
async def _get_dict_version(self) -> dict[Any, Any]:
|
|
265
|
-
"""Get the version from device.
|
|
266
|
-
|
|
267
|
-
Returns
|
|
268
|
-
-------
|
|
269
|
-
A dictionary with dicts
|
|
270
|
-
|
|
271
|
-
"""
|
|
272
|
-
await self._fetch_firmware_version()
|
|
273
|
-
|
|
274
|
-
if self._firmware_version is None:
|
|
275
|
-
msg = "Unable to fetch firmware version"
|
|
276
|
-
raise BSBLANError(msg)
|
|
277
|
-
|
|
278
|
-
if pkg_version.parse(self._firmware_version) < pkg_version.parse("1.2.0"):
|
|
279
|
-
return {
|
|
280
|
-
"heating": HEATING_CIRCUIT1_API_V1,
|
|
281
|
-
"staticValues": STATIC_VALUES_API_V1,
|
|
282
|
-
"device": DEVICE_INFO_API_V1,
|
|
283
|
-
"sensor": SENSORS_API_V1,
|
|
284
|
-
}
|
|
285
|
-
if pkg_version.parse(self._firmware_version) > pkg_version.parse("3.0.0"):
|
|
286
|
-
return {
|
|
287
|
-
"heating": HEATING_CIRCUIT1_API_V3,
|
|
288
|
-
"staticValues": STATIC_VALUES_API_V3,
|
|
289
|
-
"device": DEVICE_INFO_API_V3,
|
|
290
|
-
"sensor": SENSORS_API_V3,
|
|
291
|
-
}
|
|
292
|
-
raise BSBLANVersionError(VERSION_ERROR_MSG)
|
|
293
|
-
|
|
294
|
-
async def device(self) -> Device:
|
|
295
|
-
"""Get BSBLAN device info.
|
|
296
|
-
|
|
297
|
-
Returns
|
|
298
|
-
-------
|
|
299
|
-
A BSBLAN device info object.
|
|
300
|
-
|
|
301
|
-
"""
|
|
302
|
-
device_info = await self._request(base_path="/JI")
|
|
303
|
-
return Device.from_dict(device_info)
|
|
304
|
-
|
|
305
|
-
async def info(self) -> Info:
|
|
306
|
-
"""Get information about the current heating system config.
|
|
307
|
-
|
|
308
|
-
Returns
|
|
309
|
-
-------
|
|
310
|
-
A BSBLAN info object about the heating system.
|
|
311
|
-
|
|
312
|
-
"""
|
|
313
|
-
if not self._info or not self._device_params:
|
|
314
|
-
device_dict = await self._get_dict_version()
|
|
315
|
-
data = await self._get_parameters(device_dict["device"])
|
|
316
|
-
self._info = str(data["string_par"])
|
|
317
|
-
self._device_params = data["list"]
|
|
318
|
-
|
|
319
|
-
data = await self._request(params={"Parameter": f"{self._info}"})
|
|
320
|
-
data = dict(zip(self._device_params, list(data.values()), strict=True))
|
|
321
|
-
return Info.from_dict(data)
|
|
322
|
-
|
|
323
|
-
async def _get_parameters(self, params: dict[Any, Any]) -> dict[Any, Any]:
|
|
324
|
-
"""Get the parameters info from BSBLAN device.
|
|
325
|
-
|
|
326
|
-
Args:
|
|
327
|
-
----
|
|
328
|
-
params: A dictionary with the parameters to get.
|
|
329
|
-
|
|
330
|
-
Returns:
|
|
331
|
-
-------
|
|
332
|
-
A dict of 2 objects [str, list].
|
|
333
|
-
|
|
334
|
-
"""
|
|
335
|
-
_string_params = [*params]
|
|
336
|
-
list_params = list(params.values())
|
|
337
|
-
# convert list of string to string
|
|
338
|
-
string_params = ",".join(map(str, _string_params))
|
|
339
|
-
|
|
340
|
-
return {"string_par": string_params, "list": list_params}
|
|
341
|
-
|
|
342
|
-
async def thermostat(
|
|
343
|
-
self,
|
|
344
|
-
target_temperature: str | None = None,
|
|
345
|
-
hvac_mode: str | None = None,
|
|
346
|
-
) -> None:
|
|
347
|
-
"""Change the state of the thermostat through BSB-Lan.
|
|
348
|
-
|
|
349
|
-
Args:
|
|
350
|
-
----
|
|
351
|
-
target_temperature: Target temperature to set.
|
|
352
|
-
hvac_mode: Preset mode to set.
|
|
353
|
-
|
|
354
|
-
Raises:
|
|
355
|
-
------
|
|
356
|
-
BSBLANError: The provided values are invalid.
|
|
357
|
-
|
|
358
|
-
"""
|
|
359
|
-
|
|
360
|
-
class ThermostatState( # lgtm [py/unused-local-variable]
|
|
361
|
-
TypedDict,
|
|
362
|
-
total=False,
|
|
363
|
-
):
|
|
364
|
-
"""Describe state dictionary that can be set on the thermostat."""
|
|
365
|
-
|
|
366
|
-
target_temperature: str
|
|
367
|
-
hvac_mode: str
|
|
368
|
-
Parameter: str
|
|
369
|
-
Value: str
|
|
370
|
-
Type: str
|
|
371
|
-
EnumValue: int
|
|
372
|
-
|
|
373
|
-
state: ThermostatState = {}
|
|
374
|
-
|
|
375
|
-
if target_temperature is not None:
|
|
376
|
-
if not (
|
|
377
|
-
float(self._min_temp)
|
|
378
|
-
<= float(target_temperature)
|
|
379
|
-
<= float(self._max_temp)
|
|
380
|
-
):
|
|
381
|
-
raise BSBLANInvalidParameterError(
|
|
382
|
-
INVALID_VALUES_ERROR_MSG + ": " + str(target_temperature),
|
|
383
|
-
)
|
|
384
|
-
state["Parameter"] = "710"
|
|
385
|
-
state["Value"] = target_temperature
|
|
386
|
-
state["Type"] = "1"
|
|
387
|
-
|
|
388
|
-
if hvac_mode is not None:
|
|
389
|
-
if hvac_mode not in HVAC_MODE_DICT_REVERSE:
|
|
390
|
-
raise BSBLANInvalidParameterError(
|
|
391
|
-
INVALID_VALUES_ERROR_MSG + ": " + str(hvac_mode),
|
|
392
|
-
)
|
|
393
|
-
state["Parameter"] = "700"
|
|
394
|
-
state["EnumValue"] = HVAC_MODE_DICT_REVERSE[hvac_mode]
|
|
395
|
-
state["Type"] = "1"
|
|
396
|
-
|
|
397
|
-
if not state:
|
|
398
|
-
raise BSBLANError(NO_STATE_ERROR_MSG)
|
|
399
|
-
|
|
400
|
-
# Type needs to be 1 to really set value.
|
|
401
|
-
# Now it only checks if it could set value.
|
|
402
|
-
response = await self._request(base_path="/JS", data=dict(state))
|
|
403
|
-
logger.debug("response for setting: %s", response)
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
"""BSBLAN constants."""
|
|
2
|
-
|
|
3
|
-
DEVICE_INFO_API_V1 = {
|
|
4
|
-
"6224": "device_identification",
|
|
5
|
-
"6225": "controller_family",
|
|
6
|
-
"6226": "controller_variant",
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
DEVICE_INFO_API_V3 = {
|
|
10
|
-
"6224.0": "device_identification",
|
|
11
|
-
"6225.0": "controller_family",
|
|
12
|
-
"6226.0": "controller_variant",
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
HEATING_CIRCUIT1_API_V1 = {
|
|
16
|
-
"700": "hvac_mode",
|
|
17
|
-
"710": "target_temperature",
|
|
18
|
-
"900": "hvac_mode2",
|
|
19
|
-
"8000": "hvac_action",
|
|
20
|
-
"8740": "current_temperature",
|
|
21
|
-
"8749": "room1_thermostat_mode",
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
HEATING_CIRCUIT1_API_V3 = {
|
|
25
|
-
"700.0": "hvac_mode",
|
|
26
|
-
"710.0": "target_temperature",
|
|
27
|
-
"900.0": "hvac_mode2",
|
|
28
|
-
"8000.0": "hvac_action",
|
|
29
|
-
"8740.0": "current_temperature",
|
|
30
|
-
"8749.0": "room1_thermostat_mode",
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
STATIC_VALUES_API_V1 = {
|
|
34
|
-
"714": "min_temp",
|
|
35
|
-
"730": "max_temp",
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
STATIC_VALUES_API_V3 = {
|
|
39
|
-
"714.0": "min_temp",
|
|
40
|
-
"716.0": "max_temp",
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
SENSORS_API_V1 = {
|
|
44
|
-
"8700": "outside_temperature",
|
|
45
|
-
"8740": "current_temperature",
|
|
46
|
-
}
|
|
47
|
-
SENSORS_API_V3 = {
|
|
48
|
-
"8700.0": "outside_temperature",
|
|
49
|
-
"8740.0": "current_temperature",
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
HEATING_CIRCUIT2 = [
|
|
53
|
-
"1000",
|
|
54
|
-
"1010",
|
|
55
|
-
"1011",
|
|
56
|
-
"1012",
|
|
57
|
-
"1014",
|
|
58
|
-
"1030",
|
|
59
|
-
"1200",
|
|
60
|
-
"8001", # status_heating_circuit2
|
|
61
|
-
"8770",
|
|
62
|
-
]
|
|
63
|
-
|
|
64
|
-
# homeassistant values
|
|
65
|
-
HVAC_MODE_DICT = {
|
|
66
|
-
0: "off",
|
|
67
|
-
1: "auto",
|
|
68
|
-
2: "eco",
|
|
69
|
-
3: "heat",
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
HVAC_MODE_DICT_REVERSE = {
|
|
73
|
-
"off": 0,
|
|
74
|
-
"auto": 1,
|
|
75
|
-
"eco": 2,
|
|
76
|
-
"heat": 3,
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
INVALID_VALUES_ERROR_MSG = "Invalid values provided."
|
|
80
|
-
NO_STATE_ERROR_MSG = "No state provided."
|
|
81
|
-
VERSION_ERROR_MSG = "Version not supported"
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
"""Exceptions for for BSB-Lan."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class BSBLANError(Exception):
|
|
7
|
-
"""Generic BSBLAN exception."""
|
|
8
|
-
|
|
9
|
-
message: str = "Unexpected response from the BSBLAN device."
|
|
10
|
-
|
|
11
|
-
def __init__(self, message: str | None = None) -> None:
|
|
12
|
-
"""Initialize a new instance of the BSBLANError class.
|
|
13
|
-
|
|
14
|
-
Args:
|
|
15
|
-
----
|
|
16
|
-
message: Optional error message to include in the exception.
|
|
17
|
-
|
|
18
|
-
Returns:
|
|
19
|
-
-------
|
|
20
|
-
None.
|
|
21
|
-
|
|
22
|
-
"""
|
|
23
|
-
if message is not None:
|
|
24
|
-
self.message = message
|
|
25
|
-
super().__init__(self.message)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class BSBLANConnectionError(BSBLANError):
|
|
29
|
-
"""BSBLAN connection exception.
|
|
30
|
-
|
|
31
|
-
Attributes
|
|
32
|
-
----------
|
|
33
|
-
response: The response received from the BSBLAN device.
|
|
34
|
-
|
|
35
|
-
"""
|
|
36
|
-
|
|
37
|
-
message_timeout = "Timeout occurred while connecting to BSBLAN device."
|
|
38
|
-
message_error = "Error occurred while connecting to BSBLAN device."
|
|
39
|
-
|
|
40
|
-
def __init__(self, response: str | None = None) -> None:
|
|
41
|
-
"""Initialize a new instance of the BSBLANConnectionError class.
|
|
42
|
-
|
|
43
|
-
Args:
|
|
44
|
-
----
|
|
45
|
-
response: Optional error message to include in the exception.
|
|
46
|
-
|
|
47
|
-
Returns:
|
|
48
|
-
-------
|
|
49
|
-
None.
|
|
50
|
-
|
|
51
|
-
"""
|
|
52
|
-
self.response = response
|
|
53
|
-
super().__init__(self.message)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
class BSBLANVersionError(BSBLANError):
|
|
57
|
-
"""Raised when the BSBLAN device has an unsupported version."""
|
|
58
|
-
|
|
59
|
-
message = "The BSBLAN device has an unsupported version."
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
class BSBLANInvalidParameterError(BSBLANError):
|
|
63
|
-
"""Raised when an invalid parameter is provided."""
|
|
64
|
-
|
|
65
|
-
def __init__(self, parameter: str) -> None:
|
|
66
|
-
"""Initialize a new instance of the BSBLANInvalidParameterError class.
|
|
67
|
-
|
|
68
|
-
Args:
|
|
69
|
-
----
|
|
70
|
-
parameter: The invalid parameter.
|
|
71
|
-
|
|
72
|
-
Returns:
|
|
73
|
-
-------
|
|
74
|
-
None.
|
|
75
|
-
|
|
76
|
-
"""
|
|
77
|
-
self.message = f"Invalid parameter: {parameter}"
|
|
78
|
-
super().__init__(self.message)
|
|
File without changes
|