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 +9 -1
- bsblan/_temperature.py +324 -0
- bsblan/_transport.py +221 -0
- bsblan/_validation.py +402 -0
- bsblan/_version.py +77 -0
- bsblan/bsblan.py +201 -604
- bsblan/constants.py +55 -19
- bsblan/exceptions.py +17 -0
- bsblan/models.py +18 -0
- {python_bsblan-6.1.0.dist-info → python_bsblan-6.1.2.dist-info}/METADATA +22 -1
- python_bsblan-6.1.2.dist-info/RECORD +15 -0
- python_bsblan-6.1.0.dist-info/RECORD +0 -11
- {python_bsblan-6.1.0.dist-info → python_bsblan-6.1.2.dist-info}/WHEEL +0 -0
- {python_bsblan-6.1.0.dist-info → python_bsblan-6.1.2.dist-info}/licenses/LICENSE.md +0 -0
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
|
|
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
|
+
}
|