Skip to content

Commit 65669aa

Browse files
committed
Drop v1 API support and add heat/cool setpoint parameters
- Require BSB-LAN API v3 (firmware >= 3.0.0); remove v1 config and version branching, raising BSBLANVersionError for unsupported firmware - Add cooling setpoint bounds to StaticState (heating_protective_setpoint, cooling_comfort_setpoint_min, cooling_reduced_setpoint) and map params for HC1 (905/903) and HC2 (1205/1203) - Validate target_temperature_high against cooling bounds and fail fast on buses without a cooling setpoint parameter - Track available circuits to skip temperature-range fetches for unavailable circuits - Update fixtures, tests, and docs for the v3-only parameter set
1 parent 8a8232a commit 65669aa

24 files changed

Lines changed: 457 additions & 198 deletions

docs/getting-started.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,25 @@ async def main() -> None:
6363
asyncio.run(main())
6464
```
6565

66+
## Temperature bounds
67+
68+
For heating comfort setpoint writes (`target_temperature`), `min_temp` maps to
69+
the reduced setpoint (`712` for circuit 1, `1012` for circuit 2). The protective
70+
setpoints (`714` and `1014`) are exposed separately and are not used as the
71+
comfort setpoint lower bound. The upper heating bound is `716` for circuit 1 and
72+
`1016` for circuit 2 when the device exposes those parameters.
73+
6674
## Cooling setpoint support
6775

68-
Some BSB/LPB controllers expose a cooling comfort setpoint for heating circuit
69-
1. The client maps BSB-LAN parameter `902` to `target_temperature_high`; the
70-
duplicate decimal parameters `902.1` and `902.2` are not used.
76+
Some BSB/LPB controllers expose a cooling comfort setpoint for each heating
77+
circuit. The client maps BSB-LAN parameter `902` for circuit 1 and `1202` for
78+
circuit 2 to `target_temperature_high`; the duplicate decimal parameters
79+
`902.1` and `902.2` are not used.
80+
81+
When available, cooling setpoint validation uses `905`/`1205` (comfort setpoint
82+
minimum) as the lower bound and `903`/`1203` (room temperature reduced setpoint)
83+
as the upper bound. Parameters `908` and `1208` are flow setpoints and are not
84+
used for room setpoint validation.
7185

7286
Cooling support is optional. During section validation, unsupported parameters
7387
are removed from the active API map, so integrations can detect support by

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Asynchronous Python client for [BSB-LAN](https://github.com/fredlcore/bsb_lan) d
99
- Control thermostat settings and hot water parameters
1010
- Detect optional cooling setpoints for heat/cool range controls
1111
- Fully typed with PEP 561 support
12-
- Automatic API version detection (v1/v3)
12+
- API v3 parameter support
1313
- Lazy loading with per-section validation
1414

1515
## Quick example

src/bsblan/bsblan.py

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@
1818
from yarl import URL
1919

2020
from .constants import (
21-
API_VERSIONS,
21+
API_V3,
2222
PPS_HEATING_PARAMS,
2323
PPS_STATIC_VALUES_PARAMS,
24+
SUPPORTED_API_VERSION,
2425
APIConfig,
2526
CircuitConfig,
2627
ErrorMsg,
@@ -100,7 +101,7 @@ class BSBLAN:
100101
session: ClientSession | None = None
101102
_close_session: bool = False
102103
_firmware_version: str | None = None
103-
_api_version: str | None = None
104+
_api_version: str | None = SUPPORTED_API_VERSION
104105
_api_data: APIConfig | None = None
105106
_device: Device | None = None
106107
_initialized: bool = False
@@ -111,6 +112,7 @@ class BSBLAN:
111112
default_factory=dict,
112113
)
113114
_circuit_temp_initialized: set[int] = field(default_factory=set)
115+
_available_circuits: set[int] | None = None
114116
_hot_water_param_cache: dict[str, str] = field(default_factory=dict)
115117
# Track which hot water param groups have been validated
116118
_validated_hot_water_groups: set[str] = field(default_factory=set)
@@ -199,6 +201,7 @@ async def get_available_circuits(self) -> list[int]:
199201
continue
200202

201203
available.append(circuit)
204+
self._available_circuits = set(available)
202205
return sorted(available)
203206

204207
async def _get_available_pps_circuits(self) -> list[int]:
@@ -208,11 +211,14 @@ async def _get_available_pps_circuits(self) -> list[int]:
208211
response = await self._request(params={"Parameter": param_id})
209212
except BSBLANError:
210213
logger.debug("PPS climate circuit not available")
214+
self._available_circuits = set()
211215
return []
212216

213217
if not response.get(param_id):
214218
logger.debug("PPS climate circuit has no operating mode data")
219+
self._available_circuits = set()
215220
return []
221+
self._available_circuits = {1}
216222
return [1]
217223

218224
async def _setup_api_validator(self) -> None:
@@ -575,16 +581,10 @@ def _set_api_version(self) -> None:
575581
if not self._firmware_version:
576582
raise BSBLANError(ErrorMsg.FIRMWARE_VERSION)
577583

578-
version = pkg_version.parse(self._firmware_version)
579-
if version < pkg_version.parse("1.2.0"):
580-
self._api_version = "v1"
581-
elif version >= pkg_version.parse("5.0.0"):
582-
# BSB-LAN 5.0+ has breaking changes but uses v3-compatible API
583-
self._api_version = "v3"
584-
elif version >= pkg_version.parse("3.0.0"):
585-
self._api_version = "v3"
586-
else:
584+
firmware_version = pkg_version.parse(self._firmware_version)
585+
if firmware_version < pkg_version.parse("3.0.0"):
587586
raise BSBLANVersionError(ErrorMsg.VERSION)
587+
self._api_version = SUPPORTED_API_VERSION
588588

589589
async def _fetch_temperature_range(
590590
self,
@@ -596,10 +596,26 @@ async def _fetch_temperature_range(
596596
circuit: The heating circuit number (1 or 2).
597597
598598
Returns:
599-
dict with 'min' and 'max' keys (values may be None if unavailable).
599+
dict with heating and cooling min/max keys. Values may be None if
600+
unavailable.
600601
601602
"""
602-
temp_range: dict[str, float | None] = {"min": None, "max": None}
603+
temp_range: dict[str, float | None] = {
604+
"min": None,
605+
"max": None,
606+
"cooling_min": None,
607+
"cooling_max": None,
608+
}
609+
if (
610+
self._available_circuits is not None
611+
and circuit not in self._available_circuits
612+
):
613+
logger.debug(
614+
"Skipping temperature range fetch for unavailable circuit %d",
615+
circuit,
616+
)
617+
return temp_range
618+
603619
try:
604620
static_values = await self.static_values(circuit=circuit)
605621
except BSBLANError as err:
@@ -627,6 +643,22 @@ async def _fetch_temperature_range(
627643
temp_range["max"],
628644
)
629645

646+
if static_values.cooling_comfort_setpoint_min is not None:
647+
temp_range["cooling_min"] = static_values.cooling_comfort_setpoint_min.value
648+
logger.debug(
649+
"Circuit %d cooling min temp initialized: %s",
650+
circuit,
651+
temp_range["cooling_min"],
652+
)
653+
654+
if static_values.cooling_reduced_setpoint is not None:
655+
temp_range["cooling_max"] = static_values.cooling_reduced_setpoint.value
656+
logger.debug(
657+
"Circuit %d cooling max temp initialized: %s",
658+
circuit,
659+
temp_range["cooling_max"],
660+
)
661+
630662
return temp_range
631663

632664
async def _initialize_temperature_range(
@@ -703,7 +735,9 @@ def _copy_api_config(self) -> APIConfig:
703735
"""
704736
if self._api_version is None:
705737
raise BSBLANError(ErrorMsg.API_VERSION)
706-
source_config: APIConfig = API_VERSIONS[self._api_version]
738+
if self._api_version != SUPPORTED_API_VERSION:
739+
raise BSBLANVersionError(ErrorMsg.VERSION)
740+
source_config: APIConfig = API_V3
707741
return cast(
708742
"APIConfig",
709743
{
@@ -1215,11 +1249,14 @@ async def _prepare_thermostat_state(
12151249
},
12161250
)
12171251
if target_temperature_high is not None:
1218-
self._validate_target_temperature_high(target_temperature_high)
12191252
param_id = param_ids.get("target_temperature_high")
12201253
if param_id is None:
12211254
parameter_name = "target_temperature_high"
12221255
raise BSBLANInvalidParameterError(parameter_name)
1256+
await self._validate_target_temperature_high(
1257+
target_temperature_high,
1258+
circuit,
1259+
)
12231260
state.update(
12241261
{
12251262
"Parameter": param_id,
@@ -1247,16 +1284,30 @@ def _thermostat_params(self, circuit: int) -> dict[str, str]:
12471284
return {"target_temperature": "15004", "hvac_mode": "15000"}
12481285
return CircuitConfig.THERMOSTAT_PARAMS[circuit]
12491286

1250-
def _validate_target_temperature_high(
1287+
async def _validate_target_temperature_high(
12511288
self,
12521289
target_temperature_high: str | float,
1290+
circuit: int = 1,
12531291
) -> None:
12541292
"""Validate the cooling target temperature value."""
12551293
try:
1256-
float(target_temperature_high)
1294+
temp = float(target_temperature_high)
12571295
except ValueError as err:
12581296
raise BSBLANInvalidParameterError(str(target_temperature_high)) from err
12591297

1298+
if circuit not in self._circuit_temp_initialized:
1299+
await self._initialize_temperature_range(circuit)
1300+
1301+
temp_range = self._circuit_temp_ranges.get(circuit, {})
1302+
min_temp = temp_range.get("cooling_min")
1303+
max_temp = temp_range.get("cooling_max")
1304+
1305+
if min_temp is None or max_temp is None:
1306+
return
1307+
1308+
if not (min_temp <= temp <= max_temp):
1309+
raise BSBLANInvalidParameterError(str(target_temperature_high))
1310+
12601311
async def _validate_target_temperature(
12611312
self,
12621313
target_temperature: str,

src/bsblan/constants.py

Lines changed: 36 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
MAX_CIRCUIT: Final[int] = 2
1111

1212

13-
# API Versions
13+
# API version
14+
SUPPORTED_API_VERSION: Final[str] = "v3"
15+
16+
1417
class APIConfig(TypedDict):
1518
"""Type for API configuration."""
1619

@@ -24,7 +27,7 @@ class APIConfig(TypedDict):
2427
staticValues_circuit2: dict[str, str]
2528

2629

27-
# Base parameters that exist in all API versions
30+
# Supported BSB-LAN v3 API parameters
2831
BASE_HEATING_PARAMS: Final[dict[str, str]] = {
2932
"700": "hvac_mode",
3033
"710": "target_temperature",
@@ -33,10 +36,15 @@ class APIConfig(TypedDict):
3336
# -------
3437
"8000": "hvac_action",
3538
"8740": "current_temperature",
39+
"770": "room1_temp_setpoint_boost",
3640
}
3741

3842
BASE_STATIC_VALUES_PARAMS: Final[dict[str, str]] = {
39-
"714": "min_temp",
43+
"712": "min_temp",
44+
"714": "heating_protective_setpoint",
45+
"716": "max_temp",
46+
"905": "cooling_comfort_setpoint_min",
47+
"903": "cooling_reduced_setpoint",
4048
}
4149

4250
BASE_DEVICE_PARAMS: Final[dict[str, str]] = {
@@ -85,34 +93,25 @@ class APIConfig(TypedDict):
8593
"576": "dhw_time_program_standard_values",
8694
}
8795

88-
# V1-specific parameters
89-
V1_STATIC_VALUES_EXTENSIONS: Final[dict[str, str]] = {
90-
"730": "max_temp", # V1 uses 730 for max_temp
91-
}
92-
93-
# V3-specific additional parameters
94-
V3_HEATING_EXTENSIONS: Final[dict[str, str]] = {
95-
"770": "room1_temp_setpoint_boost",
96-
# Future V3 extensions like 701, 701.1, 701.2 can be added here
97-
}
98-
99-
V3_STATIC_VALUES_EXTENSIONS: Final[dict[str, str]] = {
100-
"716": "max_temp", # V3 uses 716 for max_temp
101-
}
102-
10396
# --- Heating Circuit 2 parameters (1000-series) ---
10497
# These mirror HC1 (700-series) with an offset of +300
10598
BASE_HEATING_CIRCUIT2_PARAMS: Final[dict[str, str]] = {
10699
"1000": "hvac_mode",
107100
"1010": "target_temperature",
108101
"1200": "hvac_mode_changeover",
102+
"1202": "target_temperature_high",
109103
# -------
110104
"8001": "hvac_action",
111105
"8770": "current_temperature",
106+
"1070": "room1_temp_setpoint_boost",
112107
}
113108

114109
BASE_STATIC_VALUES_CIRCUIT2_PARAMS: Final[dict[str, str]] = {
115-
"1014": "min_temp",
110+
"1012": "min_temp",
111+
"1014": "heating_protective_setpoint",
112+
"1016": "max_temp",
113+
"1205": "cooling_comfort_setpoint_min",
114+
"1203": "cooling_reduced_setpoint",
116115
}
117116

118117
# PPS bus supports one room-unit style climate circuit. These parameters are
@@ -128,18 +127,6 @@ class APIConfig(TypedDict):
128127
"15007": "max_temp",
129128
}
130129

131-
V1_STATIC_VALUES_CIRCUIT2_EXTENSIONS: Final[dict[str, str]] = {
132-
"1030": "max_temp",
133-
}
134-
135-
V3_HEATING_CIRCUIT2_EXTENSIONS: Final[dict[str, str]] = {
136-
"1070": "room1_temp_setpoint_boost",
137-
}
138-
139-
V3_STATIC_VALUES_CIRCUIT2_EXTENSIONS: Final[dict[str, str]] = {
140-
"1016": "max_temp",
141-
}
142-
143130

144131
# Circuit configuration
145132
class CircuitConfig:
@@ -160,7 +147,11 @@ class CircuitConfig:
160147
"target_temperature_high": "902",
161148
"hvac_mode": "700",
162149
},
163-
2: {"target_temperature": "1010", "hvac_mode": "1000"},
150+
2: {
151+
"target_temperature": "1010",
152+
"target_temperature_high": "1202",
153+
"hvac_mode": "1000",
154+
},
164155
}
165156
PROBE_PARAMS: Final[dict[int, str]] = {
166157
1: "700",
@@ -173,16 +164,23 @@ class CircuitConfig:
173164
INACTIVE_MARKER: Final[str] = "---"
174165

175166

176-
def build_api_config(version: str) -> APIConfig:
177-
"""Build API configuration dynamically based on version.
167+
def build_api_config(version: str = SUPPORTED_API_VERSION) -> APIConfig:
168+
"""Build the supported v3 API configuration.
178169
179170
Args:
180-
version: The API version ("v1" or "v3")
171+
version: The API version to build. Only ``"v3"`` is supported.
181172
182173
Returns:
183-
APIConfig: The complete API configuration for the specified version
174+
APIConfig: The complete API configuration.
175+
176+
Raises:
177+
ValueError: If a version other than ``"v3"`` is requested.
184178
185179
"""
180+
if version != SUPPORTED_API_VERSION:
181+
msg = f"Only API version {SUPPORTED_API_VERSION} is supported"
182+
raise ValueError(msg)
183+
186184
config: APIConfig = {
187185
"heating": BASE_HEATING_PARAMS.copy(),
188186
"staticValues": BASE_STATIC_VALUES_PARAMS.copy(),
@@ -193,31 +191,11 @@ def build_api_config(version: str) -> APIConfig:
193191
"heating_circuit2": BASE_HEATING_CIRCUIT2_PARAMS.copy(),
194192
"staticValues_circuit2": BASE_STATIC_VALUES_CIRCUIT2_PARAMS.copy(),
195193
}
196-
197-
if version == "v1":
198-
config["staticValues"].update(V1_STATIC_VALUES_EXTENSIONS)
199-
config["staticValues_circuit2"].update(
200-
V1_STATIC_VALUES_CIRCUIT2_EXTENSIONS,
201-
)
202-
elif version == "v3":
203-
config["heating"].update(V3_HEATING_EXTENSIONS)
204-
config["staticValues"].update(V3_STATIC_VALUES_EXTENSIONS)
205-
config["heating_circuit2"].update(V3_HEATING_CIRCUIT2_EXTENSIONS)
206-
config["staticValues_circuit2"].update(
207-
V3_STATIC_VALUES_CIRCUIT2_EXTENSIONS,
208-
)
209-
210194
return config
211195

212196

213-
# Pre-built API configurations
214-
API_V1: Final[APIConfig] = build_api_config("v1")
215-
API_V3: Final[APIConfig] = build_api_config("v3")
216-
217-
API_VERSIONS: Final[dict[str, APIConfig]] = {
218-
"v1": API_V1,
219-
"v3": API_V3,
220-
}
197+
# Pre-built API configuration
198+
API_V3: Final[APIConfig] = build_api_config()
221199

222200

223201
# Validation constants

0 commit comments

Comments
 (0)