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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-bsblan
3
- Version: 0.6.2
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.2"
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.5.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.23.0"
51
- pytest-cov = "^4.0.0"
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