python-bsblan 5.0.0__tar.gz → 5.1.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.
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/copilot-instructions.md +18 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/skills/bsblan-parameters/SKILL.md +51 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/PKG-INFO +1 -1
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/pyproject.toml +2 -2
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/src/bsblan/bsblan.py +232 -48
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/src/bsblan/constants.py +124 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/src/bsblan/models.py +11 -0
- python_bsblan-5.1.0/tests/fixtures/state_circuit2.json +89 -0
- python_bsblan-5.1.0/tests/fixtures/static_state_circuit2.json +16 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_api_validation.py +4 -0
- python_bsblan-5.1.0/tests/test_circuit.py +718 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_constants.py +11 -1
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_entity_info.py +41 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_hotwater_state.py +4 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_initialization.py +4 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_temperature_validation.py +1 -1
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/uv.lock +4 -4
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.editorconfig +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.gitattributes +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/CODE_OF_CONDUCT.md +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/CONTRIBUTING.md +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE.md +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/labels.yml +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/prompts/add-parameter.prompt.md +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/prompts/code-review.prompt.md +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/release-drafter.yml +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/renovate.json +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/skills/bsblan-testing/SKILL.md +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/workflows/auto-approve-renovate.yml +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/workflows/codeql.yaml +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/workflows/labels.yaml +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/workflows/linting.yaml +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/workflows/lock.yaml +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/workflows/pr-labels.yaml +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/workflows/release-drafter.yaml +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/workflows/release.yaml +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/workflows/stale.yaml +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.github/workflows/tests.yaml +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.gitignore +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.nvmrc +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.pre-commit-config.yaml +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.prettierignore +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/.yamllint +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/AGENTS.md +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/CLAUDE.md +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/LICENSE.md +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/README.md +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/examples/control.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/examples/discovery.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/examples/fetch_param.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/examples/profile_init.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/examples/ruff.toml +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/examples/speed_test.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/package-lock.json +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/package.json +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/sonar-project.properties +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/src/bsblan/__init__.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/src/bsblan/exceptions.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/src/bsblan/py.typed +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/src/bsblan/utility.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/__init__.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/conftest.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/fixtures/device.json +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/fixtures/dict_version.json +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/fixtures/hot_water_state.json +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/fixtures/info.json +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/fixtures/password.txt +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/fixtures/sensor.json +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/fixtures/state.json +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/fixtures/static_state.json +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/fixtures/thermostat_hvac.json +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/fixtures/thermostat_temp.json +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/fixtures/time.json +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/ruff.toml +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_api_initialization.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_auth.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_backoff_retry.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_bsblan.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_bsblan_edge_cases.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_configuration.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_context_manager.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_device.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_dhw_time_switch.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_entity_info_ha.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_hot_water_additional.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_include_parameter.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_info.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_read_parameters.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_reset_validation.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_schedule_models.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_sensor.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_set_hot_water_schedule.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_set_hotwater.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_state.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_static_state.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_temperature_unit.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_thermostat.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_time.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_utility.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_utility_additional.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_utility_edge_cases.py +0 -0
- {python_bsblan-5.0.0 → python_bsblan-5.1.0}/tests/test_version_errors.py +0 -0
|
@@ -211,8 +211,26 @@ Test fixtures (JSON responses) are in `tests/fixtures/`
|
|
|
211
211
|
|
|
212
212
|
## Common Tasks
|
|
213
213
|
|
|
214
|
+
### Fetching Parameters from a Real Device
|
|
215
|
+
|
|
216
|
+
Use `examples/fetch_param.py` to query raw parameter data from a real BSB-LAN device before adding new parameters:
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
# Set your device connection details
|
|
220
|
+
# Leave BSBLAN_HOST unset to use auto-discovery, or set it explicitly:
|
|
221
|
+
export BSBLAN_HOST="192.168.1.100"
|
|
222
|
+
export BSBLAN_PASSKEY=your_passkey # if needed
|
|
223
|
+
|
|
224
|
+
# Fetch one or more parameters
|
|
225
|
+
cd examples && python fetch_param.py 1645
|
|
226
|
+
cd examples && python fetch_param.py 1645 1641 1642 1644 1646
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
The output shows the raw JSON response including value, unit, description, and data type — use this to determine the correct model field type and naming.
|
|
230
|
+
|
|
214
231
|
### Adding a New Settable Parameter
|
|
215
232
|
|
|
233
|
+
0. Fetch the parameter from a real device using `examples/fetch_param.py` to inspect the raw response
|
|
216
234
|
1. Add parameter ID mapping in `constants.py`
|
|
217
235
|
2. Add field to appropriate model in `models.py`
|
|
218
236
|
3. Add parameter to method signature in `bsblan.py`
|
|
@@ -14,6 +14,57 @@ This skill guides you through adding new parameters to the python-bsblan library
|
|
|
14
14
|
- Legionella-related parameters use `legionella_function_*` prefix
|
|
15
15
|
- DHW (Domestic Hot Water) parameters use `dhw_*` prefix
|
|
16
16
|
|
|
17
|
+
## Discovering Parameters from a Real System
|
|
18
|
+
|
|
19
|
+
Before adding a new parameter, use `examples/fetch_param.py` to retrieve the raw API response from a real BSB-LAN device. This shows the exact structure, data types, units, and descriptions returned by the device.
|
|
20
|
+
|
|
21
|
+
### Setup
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Set environment variables for your device
|
|
25
|
+
export BSBLAN_HOST=<ip-or-host> # Your BSB-LAN IP address; leave unset to use autodiscovery
|
|
26
|
+
export BSBLAN_PASSKEY=your_passkey # Optional: if your device requires a passkey
|
|
27
|
+
export BSBLAN_USER=username # Optional: if authentication is enabled
|
|
28
|
+
export BSBLAN_PASS=password # Optional: if authentication is enabled
|
|
29
|
+
export BSBLAN_PORT=80 # Optional: defaults to 80
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Fetching Parameters
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# Fetch a single parameter
|
|
36
|
+
cd examples && python fetch_param.py 1645
|
|
37
|
+
|
|
38
|
+
# Fetch multiple parameters at once
|
|
39
|
+
cd examples && python fetch_param.py 1645 1641 1642 1644 1646
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Example Output
|
|
43
|
+
|
|
44
|
+
The raw API response shows the exact structure you need to model:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"1645": {
|
|
49
|
+
"name": "Legionella function setpoint",
|
|
50
|
+
"value": "70.0",
|
|
51
|
+
"unit": "°C",
|
|
52
|
+
"desc": "",
|
|
53
|
+
"dataType": 0
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Use this output to determine:
|
|
59
|
+
- **Field type**: `float`, `int`, or `str` based on the `value` format
|
|
60
|
+
- **Unit**: The `unit` field (e.g., `°C`, `%`, `-`)
|
|
61
|
+
- **Description**: The `name` field for docstrings
|
|
62
|
+
- **Data type**: The `dataType` field for `EntityInfo` typing
|
|
63
|
+
|
|
64
|
+
### Device Discovery
|
|
65
|
+
|
|
66
|
+
`fetch_param.py` uses mDNS/Zeroconf discovery (via `examples/discovery.py`) to find your BSB-LAN device automatically when `BSBLAN_HOST` is not set. If mDNS is unavailable, set the `BSBLAN_HOST` environment variable directly.
|
|
67
|
+
|
|
17
68
|
## Steps to Add a New Parameter
|
|
18
69
|
|
|
19
70
|
### 1. Add to `constants.py`
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "python-bsblan"
|
|
3
|
-
version = "5.
|
|
3
|
+
version = "5.1.0"
|
|
4
4
|
description = "Asynchronous Python client for BSBLAN API"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "Willem-Jan van Rootselaar", email = "liudgervr@gmail.com"}
|
|
@@ -187,7 +187,7 @@ dev = [
|
|
|
187
187
|
"flake8-simplify==0.30.0",
|
|
188
188
|
# hatch is required to support type hinting and proper packaging of the py.typed file.
|
|
189
189
|
"hatch>=1.14.1",
|
|
190
|
-
"isort==
|
|
190
|
+
"isort==8.0.0",
|
|
191
191
|
"ty==0.0.18",
|
|
192
192
|
"prek>=0.3.3",
|
|
193
193
|
"pre-commit-hooks==6.0.0",
|
|
@@ -22,13 +22,20 @@ from .constants import (
|
|
|
22
22
|
API_VALIDATOR_NOT_INITIALIZED_ERROR_MSG,
|
|
23
23
|
API_VERSION_ERROR_MSG,
|
|
24
24
|
API_VERSIONS,
|
|
25
|
+
CIRCUIT_HEATING_SECTIONS,
|
|
26
|
+
CIRCUIT_PROBE_PARAMS,
|
|
27
|
+
CIRCUIT_STATIC_SECTIONS,
|
|
28
|
+
CIRCUIT_THERMOSTAT_PARAMS,
|
|
25
29
|
DHW_TIME_PROGRAM_PARAMS,
|
|
26
30
|
EMPTY_INCLUDE_LIST_ERROR_MSG,
|
|
31
|
+
EMPTY_SECTION_PARAMS_ERROR_MSG,
|
|
27
32
|
FIRMWARE_VERSION_ERROR_MSG,
|
|
28
33
|
HOT_WATER_CONFIG_PARAMS,
|
|
29
34
|
HOT_WATER_ESSENTIAL_PARAMS,
|
|
30
35
|
HOT_WATER_SCHEDULE_PARAMS,
|
|
36
|
+
INVALID_CIRCUIT_ERROR_MSG,
|
|
31
37
|
INVALID_INCLUDE_PARAMS_ERROR_MSG,
|
|
38
|
+
INVALID_RESPONSE_ERROR_MSG,
|
|
32
39
|
MAX_VALID_YEAR,
|
|
33
40
|
MIN_VALID_YEAR,
|
|
34
41
|
MULTI_PARAMETER_ERROR_MSG,
|
|
@@ -37,9 +44,11 @@ from .constants import (
|
|
|
37
44
|
NO_SCHEDULE_ERROR_MSG,
|
|
38
45
|
NO_STATE_ERROR_MSG,
|
|
39
46
|
PARAMETER_NAMES_NOT_RESOLVED_ERROR_MSG,
|
|
47
|
+
SECTION_NOT_FOUND_ERROR_MSG,
|
|
40
48
|
SESSION_NOT_INITIALIZED_ERROR_MSG,
|
|
41
49
|
SETTABLE_HOT_WATER_PARAMS,
|
|
42
50
|
TEMPERATURE_RANGE_ERROR_MSG,
|
|
51
|
+
VALID_CIRCUITS,
|
|
43
52
|
VALID_HVAC_MODES,
|
|
44
53
|
VERSION_ERROR_MSG,
|
|
45
54
|
APIConfig,
|
|
@@ -73,7 +82,17 @@ if TYPE_CHECKING:
|
|
|
73
82
|
|
|
74
83
|
from aiohttp.client import ClientSession
|
|
75
84
|
|
|
76
|
-
SectionLiteral = Literal[
|
|
85
|
+
SectionLiteral = Literal[
|
|
86
|
+
"heating",
|
|
87
|
+
"staticValues",
|
|
88
|
+
"device",
|
|
89
|
+
"sensor",
|
|
90
|
+
"hot_water",
|
|
91
|
+
"heating_circuit2",
|
|
92
|
+
"heating_circuit3",
|
|
93
|
+
"staticValues_circuit2",
|
|
94
|
+
"staticValues_circuit3",
|
|
95
|
+
]
|
|
77
96
|
|
|
78
97
|
# TypeVar for hot water data models
|
|
79
98
|
HotWaterDataT = TypeVar(
|
|
@@ -114,6 +133,11 @@ class BSBLAN:
|
|
|
114
133
|
_initialized: bool = False
|
|
115
134
|
_api_validator: APIValidator = field(init=False)
|
|
116
135
|
_temperature_unit: str = "°C"
|
|
136
|
+
# Per-circuit temperature ranges: circuit_number -> (min, max, initialized)
|
|
137
|
+
_circuit_temp_ranges: dict[int, dict[str, float | None]] = field(
|
|
138
|
+
default_factory=dict,
|
|
139
|
+
)
|
|
140
|
+
_circuit_temp_initialized: set[int] = field(default_factory=set)
|
|
117
141
|
_hot_water_param_cache: dict[str, str] = field(default_factory=dict)
|
|
118
142
|
# Track which hot water param groups have been validated
|
|
119
143
|
_validated_hot_water_groups: set[str] = field(default_factory=set)
|
|
@@ -155,6 +179,40 @@ class BSBLAN:
|
|
|
155
179
|
await self._setup_api_validator()
|
|
156
180
|
self._initialized = True
|
|
157
181
|
|
|
182
|
+
async def get_available_circuits(self) -> list[int]:
|
|
183
|
+
"""Detect which heating circuits are available on the device.
|
|
184
|
+
|
|
185
|
+
Probes the operating mode parameter for each circuit (1, 2, 3).
|
|
186
|
+
A circuit is considered available if the device returns a non-empty
|
|
187
|
+
response with a valid value (not empty ``{}``).
|
|
188
|
+
|
|
189
|
+
This is useful for integration setup flows (e.g., Home Assistant
|
|
190
|
+
config flow) to discover how many circuits the user's controller
|
|
191
|
+
supports.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
list[int]: Sorted list of available circuit numbers (e.g., [1, 2]).
|
|
195
|
+
|
|
196
|
+
Example:
|
|
197
|
+
async with BSBLAN(config) as client:
|
|
198
|
+
circuits = await client.get_available_circuits()
|
|
199
|
+
# circuits == [1, 2] for a dual-circuit controller
|
|
200
|
+
|
|
201
|
+
"""
|
|
202
|
+
available: list[int] = []
|
|
203
|
+
for circuit, param_id in CIRCUIT_PROBE_PARAMS.items():
|
|
204
|
+
try:
|
|
205
|
+
response = await self._request(
|
|
206
|
+
params={"Parameter": param_id},
|
|
207
|
+
)
|
|
208
|
+
# A circuit exists if the response contains the param_id key
|
|
209
|
+
# with actual data (not an empty dict)
|
|
210
|
+
if response.get(param_id):
|
|
211
|
+
available.append(circuit)
|
|
212
|
+
except BSBLANError:
|
|
213
|
+
logger.debug("Circuit %d not available (request failed)", circuit)
|
|
214
|
+
return sorted(available)
|
|
215
|
+
|
|
158
216
|
async def _setup_api_validator(self) -> None:
|
|
159
217
|
"""Set up the API validator without validating sections.
|
|
160
218
|
|
|
@@ -387,8 +445,8 @@ class BSBLAN:
|
|
|
387
445
|
try:
|
|
388
446
|
section_data = self._api_data[section]
|
|
389
447
|
except KeyError as err:
|
|
390
|
-
|
|
391
|
-
raise BSBLANError(
|
|
448
|
+
msg = SECTION_NOT_FOUND_ERROR_MSG.format(section)
|
|
449
|
+
raise BSBLANError(msg) from err
|
|
392
450
|
|
|
393
451
|
# Filter to only included params if specified
|
|
394
452
|
if include is not None:
|
|
@@ -509,44 +567,96 @@ class BSBLAN:
|
|
|
509
567
|
else:
|
|
510
568
|
raise BSBLANVersionError(VERSION_ERROR_MSG)
|
|
511
569
|
|
|
512
|
-
async def
|
|
570
|
+
async def _fetch_temperature_range(
|
|
571
|
+
self,
|
|
572
|
+
circuit: int,
|
|
573
|
+
) -> dict[str, float | None]:
|
|
574
|
+
"""Fetch min/max temperature range for a circuit from the device.
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
circuit: The heating circuit number (1, 2, or 3).
|
|
578
|
+
|
|
579
|
+
Returns:
|
|
580
|
+
dict with 'min' and 'max' keys (values may be None if unavailable).
|
|
581
|
+
|
|
582
|
+
"""
|
|
583
|
+
temp_range: dict[str, float | None] = {"min": None, "max": None}
|
|
584
|
+
try:
|
|
585
|
+
static_values = await self.static_values(circuit=circuit)
|
|
586
|
+
except BSBLANError as err:
|
|
587
|
+
logger.warning(
|
|
588
|
+
"Failed to get static values for circuit %d: %s. "
|
|
589
|
+
"Temperature range will be None",
|
|
590
|
+
circuit,
|
|
591
|
+
str(err),
|
|
592
|
+
)
|
|
593
|
+
return temp_range
|
|
594
|
+
|
|
595
|
+
if static_values.min_temp is not None:
|
|
596
|
+
temp_range["min"] = static_values.min_temp.value
|
|
597
|
+
logger.debug(
|
|
598
|
+
"Circuit %d min temp initialized: %s",
|
|
599
|
+
circuit,
|
|
600
|
+
temp_range["min"],
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
if static_values.max_temp is not None:
|
|
604
|
+
temp_range["max"] = static_values.max_temp.value
|
|
605
|
+
logger.debug(
|
|
606
|
+
"Circuit %d max temp initialized: %s",
|
|
607
|
+
circuit,
|
|
608
|
+
temp_range["max"],
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
return temp_range
|
|
612
|
+
|
|
613
|
+
async def _initialize_temperature_range(
|
|
614
|
+
self,
|
|
615
|
+
circuit: int = 1,
|
|
616
|
+
) -> None:
|
|
513
617
|
"""Initialize the temperature range from static values (lazy loaded).
|
|
514
618
|
|
|
515
619
|
This method is called on-demand when temperature range is needed.
|
|
516
620
|
It uses lazy loading through static_values() which will validate
|
|
517
621
|
the staticValues section if not already done.
|
|
518
622
|
|
|
623
|
+
Args:
|
|
624
|
+
circuit: The heating circuit number (1, 2, or 3).
|
|
625
|
+
|
|
519
626
|
Note: Temperature unit is extracted during heating section validation
|
|
520
627
|
from the response (parameter 710), so no extra API call is needed here.
|
|
628
|
+
|
|
521
629
|
"""
|
|
522
|
-
if
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
if static_values.min_temp is not None:
|
|
527
|
-
self._min_temp = static_values.min_temp.value
|
|
528
|
-
logger.debug("Min temperature initialized: %s", self._min_temp)
|
|
529
|
-
else:
|
|
530
|
-
logger.warning(
|
|
531
|
-
"min_temp not available from device, "
|
|
532
|
-
"temperature range will be None"
|
|
533
|
-
)
|
|
630
|
+
if circuit == 1 and self._temperature_range_initialized:
|
|
631
|
+
return
|
|
632
|
+
if circuit != 1 and circuit in self._circuit_temp_initialized:
|
|
633
|
+
return
|
|
534
634
|
|
|
535
|
-
|
|
536
|
-
self._max_temp = static_values.max_temp.value
|
|
537
|
-
logger.debug("Max temperature initialized: %s", self._max_temp)
|
|
538
|
-
else:
|
|
539
|
-
logger.warning(
|
|
540
|
-
"max_temp not available from device, "
|
|
541
|
-
"temperature range will be None"
|
|
542
|
-
)
|
|
543
|
-
except BSBLANError as err:
|
|
544
|
-
logger.warning(
|
|
545
|
-
"Failed to get static values: %s. Temperature range will be None",
|
|
546
|
-
str(err),
|
|
547
|
-
)
|
|
635
|
+
temp_range = await self._fetch_temperature_range(circuit)
|
|
548
636
|
|
|
637
|
+
if circuit == 1:
|
|
638
|
+
# HC1 uses legacy fields for backwards compatibility
|
|
639
|
+
self._min_temp = temp_range["min"]
|
|
640
|
+
self._max_temp = temp_range["max"]
|
|
549
641
|
self._temperature_range_initialized = True
|
|
642
|
+
else:
|
|
643
|
+
# HC2/HC3 use per-circuit storage
|
|
644
|
+
self._circuit_temp_ranges[circuit] = temp_range
|
|
645
|
+
self._circuit_temp_initialized.add(circuit)
|
|
646
|
+
|
|
647
|
+
def _validate_circuit(self, circuit: int) -> None:
|
|
648
|
+
"""Validate the circuit number.
|
|
649
|
+
|
|
650
|
+
Args:
|
|
651
|
+
circuit: The heating circuit number to validate.
|
|
652
|
+
|
|
653
|
+
Raises:
|
|
654
|
+
BSBLANInvalidParameterError: If the circuit number is invalid.
|
|
655
|
+
|
|
656
|
+
"""
|
|
657
|
+
if circuit not in VALID_CIRCUITS:
|
|
658
|
+
msg = INVALID_CIRCUIT_ERROR_MSG.format(circuit)
|
|
659
|
+
raise BSBLANInvalidParameterError(msg)
|
|
550
660
|
|
|
551
661
|
@property
|
|
552
662
|
def get_temperature_unit(self) -> str:
|
|
@@ -690,8 +800,8 @@ class BSBLAN:
|
|
|
690
800
|
raise
|
|
691
801
|
except (ValueError, UnicodeDecodeError) as e:
|
|
692
802
|
# Handle JSON decode errors and other parsing issues
|
|
693
|
-
|
|
694
|
-
raise BSBLANError(
|
|
803
|
+
msg = INVALID_RESPONSE_ERROR_MSG.format(e)
|
|
804
|
+
raise BSBLANError(msg) from e
|
|
695
805
|
|
|
696
806
|
def _process_response(
|
|
697
807
|
self, response_data: dict[str, Any], base_path: str
|
|
@@ -826,6 +936,11 @@ class BSBLAN:
|
|
|
826
936
|
|
|
827
937
|
section_params = self._api_validator.get_section_params(section)
|
|
828
938
|
|
|
939
|
+
# Guard: if validation removed all params, the section is not available
|
|
940
|
+
if not section_params:
|
|
941
|
+
msg = EMPTY_SECTION_PARAMS_ERROR_MSG.format(section)
|
|
942
|
+
raise BSBLANError(msg)
|
|
943
|
+
|
|
829
944
|
# Filter parameters if include list is specified
|
|
830
945
|
if include is not None:
|
|
831
946
|
if not include:
|
|
@@ -843,7 +958,11 @@ class BSBLAN:
|
|
|
843
958
|
data = dict(zip(params["list"], list(data.values()), strict=True))
|
|
844
959
|
return model_class.model_validate(data)
|
|
845
960
|
|
|
846
|
-
async def state(
|
|
961
|
+
async def state(
|
|
962
|
+
self,
|
|
963
|
+
include: list[str] | None = None,
|
|
964
|
+
circuit: int = 1,
|
|
965
|
+
) -> State:
|
|
847
966
|
"""Get the current state from BSBLAN device.
|
|
848
967
|
|
|
849
968
|
Args:
|
|
@@ -852,6 +971,9 @@ class BSBLAN:
|
|
|
852
971
|
hvac_mode, target_temperature, hvac_action,
|
|
853
972
|
hvac_mode_changeover, current_temperature,
|
|
854
973
|
room1_thermostat_mode, room1_temp_setpoint_boost.
|
|
974
|
+
circuit: The heating circuit number (1, 2, or 3). Defaults to 1.
|
|
975
|
+
Circuit 2 and 3 use separate parameter IDs but return the
|
|
976
|
+
same State model with the same field names.
|
|
855
977
|
|
|
856
978
|
Returns:
|
|
857
979
|
State: The current state of the BSBLAN device.
|
|
@@ -864,8 +986,15 @@ class BSBLAN:
|
|
|
864
986
|
# Fetch only hvac_mode and current_temperature
|
|
865
987
|
state = await client.state(include=["hvac_mode", "current_temperature"])
|
|
866
988
|
|
|
989
|
+
# Fetch state for heating circuit 2
|
|
990
|
+
state_hc2 = await client.state(circuit=2)
|
|
991
|
+
|
|
867
992
|
"""
|
|
868
|
-
|
|
993
|
+
self._validate_circuit(circuit)
|
|
994
|
+
section: SectionLiteral = cast(
|
|
995
|
+
"SectionLiteral", CIRCUIT_HEATING_SECTIONS[circuit]
|
|
996
|
+
)
|
|
997
|
+
return await self._fetch_section_data(section, State, include)
|
|
869
998
|
|
|
870
999
|
async def sensor(self, include: list[str] | None = None) -> Sensor:
|
|
871
1000
|
"""Get the sensor information from BSBLAN device.
|
|
@@ -885,13 +1014,18 @@ class BSBLAN:
|
|
|
885
1014
|
"""
|
|
886
1015
|
return await self._fetch_section_data("sensor", Sensor, include)
|
|
887
1016
|
|
|
888
|
-
async def static_values(
|
|
1017
|
+
async def static_values(
|
|
1018
|
+
self,
|
|
1019
|
+
include: list[str] | None = None,
|
|
1020
|
+
circuit: int = 1,
|
|
1021
|
+
) -> StaticState:
|
|
889
1022
|
"""Get the static information from BSBLAN device.
|
|
890
1023
|
|
|
891
1024
|
Args:
|
|
892
1025
|
include: Optional list of parameter names to fetch. If None,
|
|
893
1026
|
fetches all static parameters. Valid names include:
|
|
894
1027
|
min_temp, max_temp.
|
|
1028
|
+
circuit: The heating circuit number (1, 2, or 3). Defaults to 1.
|
|
895
1029
|
|
|
896
1030
|
Returns:
|
|
897
1031
|
StaticState: The static information from the BSBLAN device.
|
|
@@ -900,8 +1034,15 @@ class BSBLAN:
|
|
|
900
1034
|
# Fetch only min_temp
|
|
901
1035
|
static = await client.static_values(include=["min_temp"])
|
|
902
1036
|
|
|
1037
|
+
# Fetch static values for heating circuit 2
|
|
1038
|
+
static_hc2 = await client.static_values(circuit=2)
|
|
1039
|
+
|
|
903
1040
|
"""
|
|
904
|
-
|
|
1041
|
+
self._validate_circuit(circuit)
|
|
1042
|
+
section: SectionLiteral = cast(
|
|
1043
|
+
"SectionLiteral", CIRCUIT_STATIC_SECTIONS[circuit]
|
|
1044
|
+
)
|
|
1045
|
+
return await self._fetch_section_data(section, StaticState, include)
|
|
905
1046
|
|
|
906
1047
|
async def device(self) -> Device:
|
|
907
1048
|
"""Get BSBLAN device info.
|
|
@@ -968,6 +1109,7 @@ class BSBLAN:
|
|
|
968
1109
|
self,
|
|
969
1110
|
target_temperature: str | None = None,
|
|
970
1111
|
hvac_mode: int | None = None,
|
|
1112
|
+
circuit: int = 1,
|
|
971
1113
|
) -> None:
|
|
972
1114
|
"""Change the state of the thermostat through BSB-Lan.
|
|
973
1115
|
|
|
@@ -975,9 +1117,18 @@ class BSBLAN:
|
|
|
975
1117
|
target_temperature (str | None): The target temperature to set.
|
|
976
1118
|
hvac_mode (int | None): The HVAC mode to set as raw integer value.
|
|
977
1119
|
Valid values: 0=off, 1=auto, 2=eco, 3=heat.
|
|
1120
|
+
circuit: The heating circuit number (1, 2, or 3). Defaults to 1.
|
|
1121
|
+
|
|
1122
|
+
Example:
|
|
1123
|
+
# Set HC1 temperature
|
|
1124
|
+
await client.thermostat(target_temperature="21.0")
|
|
1125
|
+
|
|
1126
|
+
# Set HC2 mode
|
|
1127
|
+
await client.thermostat(hvac_mode=1, circuit=2)
|
|
978
1128
|
|
|
979
1129
|
"""
|
|
980
|
-
|
|
1130
|
+
self._validate_circuit(circuit)
|
|
1131
|
+
await self._initialize_temperature_range(circuit)
|
|
981
1132
|
|
|
982
1133
|
self._validate_single_parameter(
|
|
983
1134
|
target_temperature,
|
|
@@ -985,65 +1136,98 @@ class BSBLAN:
|
|
|
985
1136
|
error_msg=MULTI_PARAMETER_ERROR_MSG,
|
|
986
1137
|
)
|
|
987
1138
|
|
|
988
|
-
state = await self._prepare_thermostat_state(
|
|
1139
|
+
state = await self._prepare_thermostat_state(
|
|
1140
|
+
target_temperature,
|
|
1141
|
+
hvac_mode,
|
|
1142
|
+
circuit,
|
|
1143
|
+
)
|
|
989
1144
|
await self._set_device_state(state)
|
|
990
1145
|
|
|
991
1146
|
async def _prepare_thermostat_state(
|
|
992
1147
|
self,
|
|
993
1148
|
target_temperature: str | None,
|
|
994
1149
|
hvac_mode: int | None,
|
|
1150
|
+
circuit: int = 1,
|
|
995
1151
|
) -> dict[str, Any]:
|
|
996
1152
|
"""Prepare the thermostat state for setting.
|
|
997
1153
|
|
|
998
1154
|
Args:
|
|
999
1155
|
target_temperature (str | None): The target temperature to set.
|
|
1000
1156
|
hvac_mode (int | None): The HVAC mode to set as raw integer.
|
|
1157
|
+
circuit: The heating circuit number (1, 2, or 3).
|
|
1001
1158
|
|
|
1002
1159
|
Returns:
|
|
1003
1160
|
dict[str, Any]: The prepared state for the thermostat.
|
|
1004
1161
|
|
|
1005
1162
|
"""
|
|
1163
|
+
param_ids = CIRCUIT_THERMOSTAT_PARAMS[circuit]
|
|
1006
1164
|
state: dict[str, Any] = {}
|
|
1007
1165
|
if target_temperature is not None:
|
|
1008
|
-
await self._validate_target_temperature(
|
|
1166
|
+
await self._validate_target_temperature(
|
|
1167
|
+
target_temperature,
|
|
1168
|
+
circuit,
|
|
1169
|
+
)
|
|
1009
1170
|
state.update(
|
|
1010
|
-
{
|
|
1171
|
+
{
|
|
1172
|
+
"Parameter": param_ids["target_temperature"],
|
|
1173
|
+
"Value": target_temperature,
|
|
1174
|
+
"Type": "1",
|
|
1175
|
+
},
|
|
1011
1176
|
)
|
|
1012
1177
|
if hvac_mode is not None:
|
|
1013
1178
|
self._validate_hvac_mode(hvac_mode)
|
|
1014
1179
|
state.update(
|
|
1015
1180
|
{
|
|
1016
|
-
"Parameter": "
|
|
1181
|
+
"Parameter": param_ids["hvac_mode"],
|
|
1017
1182
|
"Value": str(hvac_mode),
|
|
1018
1183
|
"Type": "1",
|
|
1019
1184
|
},
|
|
1020
1185
|
)
|
|
1021
1186
|
return state
|
|
1022
1187
|
|
|
1023
|
-
async def _validate_target_temperature(
|
|
1188
|
+
async def _validate_target_temperature(
|
|
1189
|
+
self,
|
|
1190
|
+
target_temperature: str,
|
|
1191
|
+
circuit: int = 1,
|
|
1192
|
+
) -> None:
|
|
1024
1193
|
"""Validate the target temperature.
|
|
1025
1194
|
|
|
1026
1195
|
This method lazy-loads the temperature range if not already initialized.
|
|
1027
1196
|
|
|
1028
1197
|
Args:
|
|
1029
1198
|
target_temperature (str): The target temperature to validate.
|
|
1199
|
+
circuit: The heating circuit number (1, 2, or 3).
|
|
1030
1200
|
|
|
1031
1201
|
Raises:
|
|
1032
1202
|
BSBLANError: If the temperature range cannot be initialized.
|
|
1033
1203
|
BSBLANInvalidParameterError: If the target temperature is invalid.
|
|
1034
1204
|
|
|
1035
1205
|
"""
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1206
|
+
if circuit == 1:
|
|
1207
|
+
# HC1 uses legacy fields for backwards compatibility
|
|
1208
|
+
if self._min_temp is None or self._max_temp is None:
|
|
1209
|
+
await self._initialize_temperature_range(circuit)
|
|
1210
|
+
|
|
1211
|
+
if self._min_temp is None or self._max_temp is None:
|
|
1212
|
+
raise BSBLANError(TEMPERATURE_RANGE_ERROR_MSG)
|
|
1213
|
+
|
|
1214
|
+
min_temp = self._min_temp
|
|
1215
|
+
max_temp = self._max_temp
|
|
1216
|
+
else:
|
|
1217
|
+
# HC2/HC3 use per-circuit storage
|
|
1218
|
+
if circuit not in self._circuit_temp_initialized:
|
|
1219
|
+
await self._initialize_temperature_range(circuit)
|
|
1220
|
+
|
|
1221
|
+
temp_range = self._circuit_temp_ranges.get(circuit, {})
|
|
1222
|
+
min_temp = temp_range.get("min")
|
|
1223
|
+
max_temp = temp_range.get("max")
|
|
1039
1224
|
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
raise BSBLANError(TEMPERATURE_RANGE_ERROR_MSG)
|
|
1225
|
+
if min_temp is None or max_temp is None:
|
|
1226
|
+
raise BSBLANError(TEMPERATURE_RANGE_ERROR_MSG)
|
|
1043
1227
|
|
|
1044
1228
|
try:
|
|
1045
1229
|
temp = float(target_temperature)
|
|
1046
|
-
if not (
|
|
1230
|
+
if not (min_temp <= temp <= max_temp):
|
|
1047
1231
|
raise BSBLANInvalidParameterError(target_temperature)
|
|
1048
1232
|
except ValueError as err:
|
|
1049
1233
|
raise BSBLANInvalidParameterError(target_temperature) from err
|