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.
@@ -1,23 +1,24 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-bsblan
3
- Version: 0.6.4
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,<4.0
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/github/license/liudger/python-bsblan.svg
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/github/license/liudger/python-bsblan.svg
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.6.4"
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.12"
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.6.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 = "^3.0.0"
48
- pre-commit-hooks = "^4.3.0"
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.isort]
75
- profile = "black"
76
- multi_line_output = 3
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 = 20
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 _get_parameters(self, params: dict[Any, Any]) -> dict[Any, Any]:
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
- api_data = await self._initialize_api_data()
209
- params = await self._get_parameters(api_data["heating"])
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
- api_data = await self._initialize_api_data()
218
- params = await self._get_parameters(api_data["sensor"])
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
- api_data = await self._initialize_api_data()
226
- params = await self._get_parameters(api_data["staticValues"])
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._get_parameters(api_data["device"])
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({"Parameter": "710", "Value": target_temperature, "Type": "1"})
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
- api_data = await self._initialize_api_data()
307
- params = await self._get_parameters(api_data["hot_water"])
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": "legionella_periodically",
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": "legionella_periodically",
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
- current_temperature: EntityInfo
70
- room1_thermostat_mode: EntityInfo
71
- room1_temp_setpoint_boost: EntityInfo
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
- reduced_setpoint: EntityInfo
97
- release: EntityInfo
98
- legionella_function: EntityInfo
99
- legionella_setpoint: EntityInfo
100
- legionella_periodically: EntityInfo
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)