python-bsblan 6.1.0__py3-none-any.whl → 6.1.2__py3-none-any.whl

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.
bsblan/__init__.py CHANGED
@@ -8,8 +8,14 @@ from .constants import (
8
8
  HVACActionCategory,
9
9
  get_hvac_action_category,
10
10
  )
11
- from .exceptions import BSBLANAuthError, BSBLANConnectionError, BSBLANError
11
+ from .exceptions import (
12
+ BSBLANAuthError,
13
+ BSBLANConnectionError,
14
+ BSBLANError,
15
+ BSBLANVersionError,
16
+ )
12
17
  from .models import (
18
+ ApiVersion,
13
19
  DaySchedule,
14
20
  Device,
15
21
  DeviceTime,
@@ -34,10 +40,12 @@ __all__ = [
34
40
  "BSBLAN",
35
41
  "UNIT_DEVICE_CLASS_MAP",
36
42
  "UNIT_STATE_CLASS_MAP",
43
+ "ApiVersion",
37
44
  "BSBLANAuthError",
38
45
  "BSBLANConfig",
39
46
  "BSBLANConnectionError",
40
47
  "BSBLANError",
48
+ "BSBLANVersionError",
41
49
  "DHWSchedule",
42
50
  "DHWTimeSwitchPrograms",
43
51
  "DaySchedule",
bsblan/_temperature.py ADDED
@@ -0,0 +1,324 @@
1
+ """Temperature range, unit, and bounds management for the BSBLAN client.
2
+
3
+ Owns the per-circuit temperature range cache, the set of circuits whose range
4
+ has been initialized, and the active temperature unit. Ranges are lazily
5
+ fetched from the device static values and target temperatures are validated
6
+ against the heating and cooling bounds. The owning client keeps thin
7
+ delegations that forward here.
8
+
9
+ The manager reads discovered circuits and fetches static values through
10
+ callables supplied by the owning client so it does not hold a back-reference
11
+ to the facade.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from typing import TYPE_CHECKING
18
+
19
+ from .exceptions import BSBLANError, BSBLANInvalidParameterError
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Awaitable, Callable
23
+ from typing import Any
24
+
25
+ from .bsblan import SectionLiteral
26
+ from .models import StaticState
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class TemperatureManager:
32
+ """Manage per-circuit temperature ranges, the unit, and bounds checks.
33
+
34
+ The manager owns the temperature state (per-circuit range cache, the set of
35
+ circuits whose range has been initialized, and the active unit). Static
36
+ values are read through ``static_values`` and discovered circuits through
37
+ ``get_available_circuits`` so the manager never holds a back-reference to
38
+ the owning client.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ *,
44
+ static_values: Callable[..., Awaitable[StaticState]],
45
+ get_available_circuits: Callable[[], set[int] | None],
46
+ ) -> None:
47
+ """Initialize the manager with the client's collaborators.
48
+
49
+ Args:
50
+ static_values: Callable returning the static values model for a
51
+ circuit.
52
+ get_available_circuits: Callable returning the set of circuits known
53
+ to be available, or ``None`` when discovery has not run.
54
+
55
+ """
56
+ self._static_values = static_values
57
+ self._get_available_circuits = get_available_circuits
58
+ self._temperature_unit: str = "°C"
59
+ # Per-circuit temperature ranges: circuit_number -> range dict.
60
+ self._circuit_temp_ranges: dict[int, dict[str, float | None]] = {}
61
+ self._circuit_temp_initialized: set[int] = set()
62
+
63
+ @property
64
+ def unit(self) -> str:
65
+ """Return the active temperature unit (°C or °F)."""
66
+ return self._temperature_unit
67
+
68
+ def should_extract_temperature_unit(
69
+ self,
70
+ section: SectionLiteral,
71
+ include: list[str] | None,
72
+ response_data: dict[str, Any],
73
+ ) -> bool:
74
+ """Return whether the validation response should update temperature unit."""
75
+ if section != "heating":
76
+ return False
77
+
78
+ if include is None or "target_temperature" in include:
79
+ return True
80
+
81
+ return any(param_id in response_data for param_id in ("710", "15004"))
82
+
83
+ def extract_temperature_unit_from_response(
84
+ self, response_data: dict[str, Any]
85
+ ) -> None:
86
+ """Extract temperature unit from heating section response data.
87
+
88
+ Gets the unit from the target_temperature parameter, which is always
89
+ present in the heating section.
90
+
91
+ Args:
92
+ response_data: The response data from heating section validation
93
+
94
+ """
95
+ # Look for target_temperature in the response.
96
+ for param_id, param_data in response_data.items():
97
+ if param_id in {"710", "15004"} and isinstance(param_data, dict):
98
+ unit = param_data.get("unit", "")
99
+ if unit in ("°C", "°C"):
100
+ self._temperature_unit = "°C"
101
+ elif unit == "°F":
102
+ self._temperature_unit = "°F"
103
+ else:
104
+ # Keep default if unit is empty or unknown
105
+ logger.debug(
106
+ "Unknown or empty temperature unit from heating target: "
107
+ "'%s'. Using default (°C)",
108
+ unit,
109
+ )
110
+ logger.debug("Temperature unit set to: %s", self._temperature_unit)
111
+ return
112
+
113
+ logger.warning(
114
+ "Could not find target temperature in heating section response. "
115
+ "Using default temperature unit (°C)"
116
+ )
117
+
118
+ async def _fetch_temperature_range(
119
+ self,
120
+ circuit: int,
121
+ ) -> dict[str, float | None]:
122
+ """Fetch min/max temperature range for a circuit from the device.
123
+
124
+ Args:
125
+ circuit: The heating circuit number (1 or 2).
126
+
127
+ Returns:
128
+ dict with heating and cooling min/max keys. Values may be None if
129
+ unavailable.
130
+
131
+ """
132
+ temp_range: dict[str, float | None] = {
133
+ "min": None,
134
+ "max": None,
135
+ "cooling_min": None,
136
+ "cooling_max": None,
137
+ }
138
+ available_circuits = self._get_available_circuits()
139
+ if available_circuits is not None and circuit not in available_circuits:
140
+ logger.debug(
141
+ "Skipping temperature range fetch for unavailable circuit %d",
142
+ circuit,
143
+ )
144
+ return temp_range
145
+
146
+ try:
147
+ static_values = await self._static_values(circuit=circuit)
148
+ except BSBLANError as err:
149
+ logger.warning(
150
+ "Failed to get static values for circuit %d: %s. "
151
+ "Temperature range will be None",
152
+ circuit,
153
+ str(err),
154
+ )
155
+ return temp_range
156
+
157
+ # Prefer heating_protective_setpoint (714/1014) as the true lower bound
158
+ # for standard circuits. Fall back to min_temp for PPS circuits (15006)
159
+ # which have no separate protective setpoint. Skip sources whose value is
160
+ # inactive (BSB-LAN may return "---" which becomes value=None).
161
+ min_source = next(
162
+ (
163
+ source
164
+ for source in (
165
+ static_values.heating_protective_setpoint,
166
+ static_values.min_temp,
167
+ )
168
+ if source is not None and source.value is not None
169
+ ),
170
+ None,
171
+ )
172
+ if min_source is not None:
173
+ temp_range["min"] = min_source.value
174
+ logger.debug(
175
+ "Circuit %d min temp initialized: %s",
176
+ circuit,
177
+ temp_range["min"],
178
+ )
179
+
180
+ # Prefer comfort_setpoint_max (716/1016) as the upper bound for standard
181
+ # circuits. Fall back to max_temp for PPS circuits (15007) which expose
182
+ # only a generic max. Skip sources whose value is inactive.
183
+ max_source = next(
184
+ (
185
+ source
186
+ for source in (
187
+ static_values.comfort_setpoint_max,
188
+ static_values.max_temp,
189
+ )
190
+ if source is not None and source.value is not None
191
+ ),
192
+ None,
193
+ )
194
+ if max_source is not None:
195
+ temp_range["max"] = max_source.value
196
+ logger.debug(
197
+ "Circuit %d max temp initialized: %s",
198
+ circuit,
199
+ temp_range["max"],
200
+ )
201
+
202
+ if static_values.cooling_comfort_setpoint_min is not None:
203
+ temp_range["cooling_min"] = static_values.cooling_comfort_setpoint_min.value
204
+ logger.debug(
205
+ "Circuit %d cooling min temp initialized: %s",
206
+ circuit,
207
+ temp_range["cooling_min"],
208
+ )
209
+
210
+ if static_values.cooling_reduced_setpoint is not None:
211
+ temp_range["cooling_max"] = static_values.cooling_reduced_setpoint.value
212
+ logger.debug(
213
+ "Circuit %d cooling max temp initialized: %s",
214
+ circuit,
215
+ temp_range["cooling_max"],
216
+ )
217
+
218
+ return temp_range
219
+
220
+ async def initialize_temperature_range(
221
+ self,
222
+ circuit: int = 1,
223
+ ) -> None:
224
+ """Initialize the temperature range from static values (lazy loaded).
225
+
226
+ This method is called on-demand when temperature range is needed.
227
+ It uses lazy loading through static_values() which will validate
228
+ the staticValues section if not already done.
229
+
230
+ Args:
231
+ circuit: The heating circuit number (1 or 2).
232
+
233
+ Note: Temperature unit is extracted during heating section validation,
234
+ so no extra API call is needed here.
235
+
236
+ """
237
+ if circuit in self._circuit_temp_initialized:
238
+ return
239
+
240
+ temp_range = await self._fetch_temperature_range(circuit)
241
+ self._circuit_temp_ranges[circuit] = temp_range
242
+ self._circuit_temp_initialized.add(circuit)
243
+
244
+ async def _validate_in_range(
245
+ self,
246
+ value: str | float,
247
+ circuit: int,
248
+ *,
249
+ min_key: str,
250
+ max_key: str,
251
+ ) -> None:
252
+ """Validate a temperature value against a circuit's configured bounds.
253
+
254
+ Lazy-loads the circuit temperature range when needed. If the device
255
+ does not expose the relevant bounds, only the float conversion is
256
+ validated.
257
+
258
+ Args:
259
+ value (str | float): The temperature value to validate.
260
+ circuit (int): The heating circuit number (1 or 2).
261
+ min_key (str): Range key holding the lower bound.
262
+ max_key (str): Range key holding the upper bound.
263
+
264
+ Raises:
265
+ BSBLANInvalidParameterError: If the value is not a valid float or
266
+ falls outside the configured bounds.
267
+
268
+ """
269
+ try:
270
+ temp = float(value)
271
+ except ValueError as err:
272
+ raise BSBLANInvalidParameterError(str(value)) from err
273
+
274
+ if circuit not in self._circuit_temp_initialized:
275
+ await self.initialize_temperature_range(circuit)
276
+
277
+ temp_range = self._circuit_temp_ranges.get(circuit, {})
278
+ min_temp = temp_range.get(min_key)
279
+ max_temp = temp_range.get(max_key)
280
+
281
+ if min_temp is None or max_temp is None:
282
+ return
283
+
284
+ if not (min_temp <= temp <= max_temp):
285
+ raise BSBLANInvalidParameterError(str(value))
286
+
287
+ async def validate_target_temperature_high(
288
+ self,
289
+ target_temperature_high: str | float,
290
+ circuit: int = 1,
291
+ ) -> None:
292
+ """Validate the cooling target temperature value."""
293
+ await self._validate_in_range(
294
+ target_temperature_high,
295
+ circuit,
296
+ min_key="cooling_min",
297
+ max_key="cooling_max",
298
+ )
299
+
300
+ async def validate_target_temperature(
301
+ self,
302
+ target_temperature: str,
303
+ circuit: int = 1,
304
+ ) -> None:
305
+ """Validate the target temperature.
306
+
307
+ This method lazy-loads the temperature range if not already initialized.
308
+ If the device does not provide min/max temperature parameters,
309
+ only validates that the value is a valid float.
310
+
311
+ Args:
312
+ target_temperature (str): The target temperature to validate.
313
+ circuit: The heating circuit number (1 or 2).
314
+
315
+ Raises:
316
+ BSBLANInvalidParameterError: If the target temperature is invalid.
317
+
318
+ """
319
+ await self._validate_in_range(
320
+ target_temperature,
321
+ circuit,
322
+ min_key="min",
323
+ max_key="max",
324
+ )
bsblan/_transport.py ADDED
@@ -0,0 +1,221 @@
1
+ """HTTP transport layer for the BSBLAN client.
2
+
3
+ Owns the low-level concerns of talking to a BSB-LAN device: URL building,
4
+ authentication, headers, request execution with exponential-backoff retries,
5
+ and firmware-specific response post-processing. The owning client keeps a
6
+ stable ``_request`` facade that delegates here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import logging
13
+ from typing import TYPE_CHECKING, Any, cast
14
+
15
+ import aiohttp
16
+ import backoff
17
+ from aiohttp.helpers import BasicAuth
18
+ from packaging import version as pkg_version
19
+ from yarl import URL
20
+
21
+ from .constants import ErrorMsg
22
+ from .exceptions import BSBLANAuthError, BSBLANError
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Callable, Mapping
26
+
27
+ from aiohttp.client import ClientSession
28
+
29
+ from .bsblan import BSBLANConfig
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # HTTP status that must not be retried (resource genuinely absent).
34
+ HTTP_NOT_FOUND = 404
35
+ # HTTP statuses that indicate authentication failure (not retried).
36
+ HTTP_AUTH_STATUSES = (401, 403)
37
+ # Firmware version at/after which /JQ responses include a debug `payload` field.
38
+ PAYLOAD_FIELD_MIN_VERSION = "5.0.0"
39
+
40
+
41
+ def _should_give_up_retry(error: Exception) -> bool:
42
+ """Return whether a failed request must not be retried.
43
+
44
+ Args:
45
+ error: The exception raised while performing the request.
46
+
47
+ Returns:
48
+ bool: True for HTTP 404 responses, which are not transient.
49
+
50
+ """
51
+ return (
52
+ isinstance(error, aiohttp.ClientResponseError)
53
+ and error.status == HTTP_NOT_FOUND
54
+ )
55
+
56
+
57
+ class BSBLANTransport:
58
+ """Handle HTTP transport for a BSB-LAN device.
59
+
60
+ The session and firmware version are read through callables because both
61
+ are assigned on the owning client after it is constructed (the session in
62
+ ``__aenter__`` and the firmware version during ``initialize``).
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ config: BSBLANConfig,
68
+ session_getter: Callable[[], ClientSession | None],
69
+ firmware_getter: Callable[[], str | None],
70
+ ) -> None:
71
+ """Initialize the transport.
72
+
73
+ Args:
74
+ config: Connection configuration (host, port, credentials, timeout).
75
+ session_getter: Callable returning the current client session.
76
+ firmware_getter: Callable returning the current firmware version.
77
+
78
+ """
79
+ self._config = config
80
+ self._session_getter = session_getter
81
+ self._firmware_getter = firmware_getter
82
+
83
+ @backoff.on_exception(
84
+ backoff.expo,
85
+ (TimeoutError, aiohttp.ClientError),
86
+ max_tries=3,
87
+ max_time=30,
88
+ giveup=_should_give_up_retry,
89
+ logger=logger,
90
+ )
91
+ async def request_with_retry(
92
+ self,
93
+ method: str,
94
+ base_path: str,
95
+ data: dict[str, object] | None,
96
+ params: Mapping[str, str | int] | str | None,
97
+ ) -> dict[str, Any]:
98
+ """Execute an HTTP request with automatic retries.
99
+
100
+ Decorated with backoff for automatic retries on transient failures.
101
+
102
+ Args:
103
+ method: The HTTP method to use.
104
+ base_path: The base path for the URL.
105
+ data: The data to send in the request body.
106
+ params: The query parameters to include.
107
+
108
+ Returns:
109
+ dict[str, Any]: The JSON response from the BSBLAN device.
110
+
111
+ Raises:
112
+ BSBLANError: If the session is missing or the response is invalid.
113
+ BSBLANAuthError: If authentication fails (401/403, not retried).
114
+
115
+ """
116
+ session = self._session_getter()
117
+ if session is None:
118
+ raise BSBLANError(ErrorMsg.SESSION_NOT_INITIALIZED)
119
+ url = self._build_url(base_path)
120
+ auth = self._get_auth()
121
+ headers = self._get_headers()
122
+
123
+ try:
124
+ async with asyncio.timeout(self._config.request_timeout):
125
+ async with session.request(
126
+ method,
127
+ url,
128
+ auth=auth,
129
+ params=params,
130
+ json=data,
131
+ headers=headers,
132
+ ) as response:
133
+ response.raise_for_status()
134
+ response_data = cast("dict[str, Any]", await response.json())
135
+ return self._process_response(response_data, base_path)
136
+ except aiohttp.ClientResponseError as e:
137
+ if e.status in HTTP_AUTH_STATUSES:
138
+ raise BSBLANAuthError from e
139
+ raise
140
+ except (ValueError, UnicodeDecodeError) as e:
141
+ # Handle JSON decode errors and other parsing issues
142
+ msg = ErrorMsg.INVALID_RESPONSE.format(e)
143
+ raise BSBLANError(msg) from e
144
+
145
+ def _process_response(
146
+ self, response_data: dict[str, Any], base_path: str
147
+ ) -> dict[str, Any]:
148
+ """Process response data based on firmware version.
149
+
150
+ BSB-LAN 5.0+ includes an additional 'payload' field in /JQ responses
151
+ that needs to be handled for compatibility.
152
+
153
+ Args:
154
+ response_data: Raw response data from BSB-LAN.
155
+ base_path: The API endpoint that was called.
156
+
157
+ Returns:
158
+ Processed response data compatible with existing code.
159
+
160
+ """
161
+ # For non-JQ endpoints, return response as-is
162
+ if base_path != "/JQ":
163
+ return response_data
164
+
165
+ # Check if we have a firmware version to determine processing
166
+ firmware_version = self._firmware_getter()
167
+ if not firmware_version:
168
+ return response_data
169
+
170
+ # For BSB-LAN 5.0+, remove 'payload' field if present (debugging only)
171
+ version = pkg_version.parse(firmware_version)
172
+ if (
173
+ version >= pkg_version.parse(PAYLOAD_FIELD_MIN_VERSION)
174
+ and "payload" in response_data
175
+ ):
176
+ return {k: v for k, v in response_data.items() if k != "payload"}
177
+
178
+ return response_data
179
+
180
+ def _build_url(self, base_path: str) -> URL:
181
+ """Build the URL for the request.
182
+
183
+ Args:
184
+ base_path (str): The base path for the URL.
185
+
186
+ Returns:
187
+ URL: The constructed URL.
188
+
189
+ """
190
+ if self._config.passkey:
191
+ base_path = f"/{self._config.passkey}{base_path}"
192
+ return URL.build(
193
+ scheme="http",
194
+ host=self._config.host,
195
+ port=self._config.port,
196
+ path=base_path,
197
+ )
198
+
199
+ def _get_auth(self) -> BasicAuth | None:
200
+ """Get the authentication for the request.
201
+
202
+ Returns:
203
+ BasicAuth | None: The authentication object or None if no
204
+ authentication is required.
205
+
206
+ """
207
+ if self._config.username and self._config.password:
208
+ return BasicAuth(self._config.username, self._config.password)
209
+ return None
210
+
211
+ def _get_headers(self) -> dict[str, str]:
212
+ """Get the headers for the request.
213
+
214
+ Returns:
215
+ dict[str, str]: The headers for the request.
216
+
217
+ """
218
+ return {
219
+ "User-Agent": f"PythonBSBLAN/{self._firmware_getter()}",
220
+ "Accept": "application/json, */*",
221
+ }