Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,29 @@ async def main() -> None:
asyncio.run(main())
```

## Cooling setpoint support

Some BSB/LPB controllers expose a cooling comfort setpoint for heating circuit
1. The client maps BSB-LAN parameter `902` to `target_temperature_high`; the
duplicate decimal parameters `902.1` and `902.2` are not used.

Cooling support is optional. During section validation, unsupported parameters
are removed from the active API map, so integrations can detect support by
checking whether `state.target_temperature_high` is present.

```python
async with BSBLAN(config) as client:
state = await client.state(include=["target_temperature_high"])

if state.target_temperature_high is not None:
print(f"Cooling setpoint: {state.target_temperature_high.value}")
await client.thermostat(target_temperature_high="24.0")
```

BSB-LAN writes one parameter at a time. If an application exposes a heat/cool
temperature range, write `target_temperature` and `target_temperature_high` with
separate `thermostat()` calls.

## PPS bus support

PPS bus devices are detected from the device metadata returned by BSB-LAN. The
Expand Down
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Asynchronous Python client for [BSB-LAN](https://github.com/fredlcore/bsb_lan) d
- Async/await support using `aiohttp`
- Read heating state, sensor data, and device information
- Control thermostat settings and hot water parameters
- Detect optional cooling setpoints for heat/cool range controls
- Fully typed with PEP 561 support
- Automatic API version detection (v1/v3)
- Lazy loading with per-section validation
Expand Down
32 changes: 32 additions & 0 deletions src/bsblan/bsblan.py
Original file line number Diff line number Diff line change
Expand Up @@ -1137,6 +1137,7 @@ async def thermostat(
target_temperature: str | None = None,
hvac_mode: int | None = None,
circuit: int = 1,
target_temperature_high: str | float | None = None,
) -> None:
"""Change the state of the thermostat through BSB-Lan.

Expand All @@ -1147,11 +1148,15 @@ async def thermostat(
For PPS, valid values are 0=off, 1=auto, and 3=heat/manual;
they are translated to PPS raw values before posting.
circuit: The heating circuit number (1 or 2). Defaults to 1.
target_temperature_high: The cooling comfort setpoint to set.

Example:
# Set HC1 temperature
await client.thermostat(target_temperature="21.0")

# Set HC1 cooling comfort setpoint
await client.thermostat(target_temperature_high="24.0")

# Set HC2 mode
await client.thermostat(hvac_mode=1, circuit=2)

Expand All @@ -1164,13 +1169,15 @@ async def thermostat(
self._validate_single_parameter(
target_temperature,
hvac_mode,
target_temperature_high,
error_msg=ErrorMsg.MULTI_PARAMETER,
)

state = await self._prepare_thermostat_state(
target_temperature,
hvac_mode,
circuit,
target_temperature_high,
)
await self._set_device_state(state)

Expand All @@ -1179,13 +1186,15 @@ async def _prepare_thermostat_state(
target_temperature: str | None,
hvac_mode: int | None,
circuit: int = 1,
target_temperature_high: str | float | None = None,
) -> dict[str, Any]:
"""Prepare the thermostat state for setting.

Args:
target_temperature (str | None): The target temperature to set.
hvac_mode (int | None): The HVAC mode to set as raw integer.
circuit: The heating circuit number (1 or 2).
target_temperature_high: The cooling comfort setpoint to set.

Returns:
dict[str, Any]: The prepared state for the thermostat.
Expand All @@ -1205,6 +1214,19 @@ async def _prepare_thermostat_state(
"Type": "1",
},
)
if target_temperature_high is not None:
self._validate_target_temperature_high(target_temperature_high)
param_id = param_ids.get("target_temperature_high")
if param_id is None:
parameter_name = "target_temperature_high"
raise BSBLANInvalidParameterError(parameter_name)
state.update(
{
"Parameter": param_id,
"Value": str(target_temperature_high),
"Type": "1",
},
)
Comment on lines +1217 to +1229
if hvac_mode is not None:
self._validate_hvac_mode(hvac_mode)
hvac_value = str(hvac_mode)
Expand All @@ -1225,6 +1247,16 @@ def _thermostat_params(self, circuit: int) -> dict[str, str]:
return {"target_temperature": "15004", "hvac_mode": "15000"}
return CircuitConfig.THERMOSTAT_PARAMS[circuit]

def _validate_target_temperature_high(
self,
target_temperature_high: str | float,
) -> None:
"""Validate the cooling target temperature value."""
try:
float(target_temperature_high)
except ValueError as err:
raise BSBLANInvalidParameterError(str(target_temperature_high)) from err

async def _validate_target_temperature(
self,
target_temperature: str,
Expand Down
7 changes: 6 additions & 1 deletion src/bsblan/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class APIConfig(TypedDict):
"700": "hvac_mode",
"710": "target_temperature",
"900": "hvac_mode_changeover",
"902": "target_temperature_high",
# -------
"8000": "hvac_action",
"8740": "current_temperature",
Expand Down Expand Up @@ -154,7 +155,11 @@ class CircuitConfig:
2: "staticValues_circuit2",
}
THERMOSTAT_PARAMS: Final[dict[int, dict[str, str]]] = {
1: {"target_temperature": "710", "hvac_mode": "700"},
1: {
"target_temperature": "710",
"target_temperature_high": "902",
"hvac_mode": "700",
},
2: {"target_temperature": "1010", "hvac_mode": "1000"},
}
PROBE_PARAMS: Final[dict[int, str]] = {
Expand Down
1 change: 1 addition & 0 deletions src/bsblan/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,7 @@ class State(BaseModel):

hvac_mode: EntityInfo[int] | None = None
target_temperature: EntityInfo[float] | None = None
target_temperature_high: EntityInfo[float] | None = None
hvac_action: EntityInfo[int] | None = None
hvac_mode_changeover: EntityInfo[int] | None = None
current_temperature: EntityInfo[float] | None = None
Expand Down
13 changes: 13 additions & 0 deletions tests/fixtures/state.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@
"readwrite": 0,
"unit": ""
},
"902": {
"name": "Cooling circuit 1 - Comfort setpoint",
"dataType_name": "TEMP",
"dataType_family": "VALS",
"error": 0,
"value": "21.0",
"desc": "",
"precision": 0.1,
"dataType": 0,
"readonly": 0,
"readwrite": 0,
"unit": "°C"
},
"8000": {
"name": "Status heating circuit 1",
"dataType_name": "ENUM",
Expand Down
18 changes: 18 additions & 0 deletions tests/test_circuit.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,24 @@ async def test_thermostat_circuit2_no_params(
assert str(exc_info.value) == ErrorMsg.MULTI_PARAMETER


@pytest.mark.asyncio
async def test_thermostat_circuit2_rejects_cooling_temperature(
mock_bsblan_circuit: BSBLAN,
) -> None:
"""Test cooling target writes are only mapped for circuit 1."""
mock_bsblan_circuit._circuit_temp_ranges[2] = {
"min": 8.0,
"max": 28.0,
}
mock_bsblan_circuit._circuit_temp_initialized.add(2)

with pytest.raises(BSBLANInvalidParameterError):
await mock_bsblan_circuit.thermostat(
target_temperature_high="24",
circuit=2,
)


# --- Temperature range initialization tests ---


Expand Down
15 changes: 12 additions & 3 deletions tests/test_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,17 @@
[
(
"v1",
{"700", "710", "714", "730"}, # hvac_mode, target_temp, min_temp, v1_max
{"700", "710", "902", "714", "730"},
{"770", "716"}, # v3_boost, v3_max_temp
),
(
"v3",
{"700", "710", "714", "770", "716"}, # base + v3 extensions
{"700", "710", "902", "714", "770", "716"},
{"730"}, # v1_max_temp
),
(
"v5", # Unknown version
{"700", "710", "714"}, # only base parameters
{"700", "710", "902", "714"}, # only base parameters
{"770", "730", "716"}, # no extensions
),
],
Expand Down Expand Up @@ -138,6 +138,15 @@ def test_hot_water_parameter_groups_total_count() -> None:
assert total_grouped == len(BASE_HOT_WATER_PARAMS)


def test_cooling_target_uses_single_base_parameter() -> None:
"""Test cooling setpoint uses 902, not duplicate decimal parameters."""
config = build_api_config("v3")

assert config["heating"]["902"] == "target_temperature_high"
assert "902.1" not in config["heating"]
assert "902.2" not in config["heating"]


@pytest.mark.parametrize("version", ["v1", "v3"])
def test_api_config_structure(version: str) -> None:
"""Test that API config has required structure."""
Expand Down
71 changes: 70 additions & 1 deletion tests/test_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ async def test_state(monkeypatch: Any) -> None:
assert state.target_temperature.desc == ""
assert state.target_temperature.unit == "°C"

# Cooling target temperature assertions
assert state.target_temperature_high is not None
assert state.target_temperature_high.value == 21.0
assert state.target_temperature_high.desc == ""
assert state.target_temperature_high.unit == "°C"

# HVAC mode changeover assertions
assert state.hvac_mode_changeover is not None
assert state.hvac_mode_changeover.value == 2
Expand All @@ -81,5 +87,68 @@ async def test_state(monkeypatch: Any) -> None:

# Verify API call
request_mock.assert_called_once_with(
params={"Parameter": "700,710,900,8000,8740,770"}
params={"Parameter": "700,710,900,902,8000,8740,770"}
)


@pytest.mark.asyncio
async def test_state_with_cooling_include(monkeypatch: Any) -> None:
"""Test fetching only the cooling target temperature."""
async with aiohttp.ClientSession() as session:
config = BSBLANConfig(host="example.com")
bsblan = BSBLAN(config, session=session)

monkeypatch.setattr(bsblan, "_firmware_version", "1.0.38-20200730234859")
monkeypatch.setattr(bsblan, "_api_version", "v3")
monkeypatch.setattr(bsblan, "_api_data", API_V3)

api_validator = APIValidator(API_V3)
api_validator.validated_sections.add("heating")
bsblan._api_validator = api_validator

state_data = json.loads(load_fixture("state.json"))
request_mock: AsyncMock = AsyncMock(return_value={"902": state_data["902"]})
monkeypatch.setattr(bsblan, "_request", request_mock)

state: State = await bsblan.state(include=["target_temperature_high"])

assert state.target_temperature_high is not None
assert state.target_temperature_high.value == 21.0
assert state.target_temperature is None
request_mock.assert_awaited_once_with(params={"Parameter": "902"})


@pytest.mark.asyncio
async def test_state_without_cooling_strips_target_temperature_high(
monkeypatch: Any,
) -> None:
"""Test unsupported cooling setpoint is stripped during validation."""
async with aiohttp.ClientSession() as session:
config = BSBLANConfig(host="example.com")
bsblan = BSBLAN(config, session=session)

monkeypatch.setattr(bsblan, "_firmware_version", "1.0.38-20200730234859")
monkeypatch.setattr(bsblan, "_api_version", "v3")
bsblan._api_data = {
section: params.copy() for section, params in API_V3.items()
}
bsblan._api_validator = APIValidator(bsblan._api_data)

state_data = json.loads(load_fixture("state.json"))
validation_response = {"700": state_data["700"]}
fetch_response = {"700": state_data["700"]}
request_mock: AsyncMock = AsyncMock(
side_effect=[validation_response, fetch_response]
)
monkeypatch.setattr(bsblan, "_request", request_mock)

state: State = await bsblan.state(
include=["hvac_mode", "target_temperature_high"]
)

assert state.hvac_mode is not None
assert state.target_temperature_high is None
assert bsblan.get_parameter_id("target_temperature_high") is None
assert [
call.kwargs["params"]["Parameter"] for call in request_mock.await_args_list
] == ["700,902", "700"]
40 changes: 40 additions & 0 deletions tests/test_thermostat.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,26 @@ async def test_change_temperature(
await mock_bsblan.thermostat(target_temperature="20")


@pytest.mark.asyncio
async def test_change_cooling_temperature(
mock_bsblan: BSBLAN,
mock_aresponses: ResponsesMockServer,
) -> None:
"""Test changing BSBLAN cooling setpoint."""
expected_data = {
"Parameter": "902",
"Value": "32.0",
"Type": "1",
}
mock_aresponses.add(
"example.com",
"/JS",
"POST",
create_response_handler(expected_data),
)
await mock_bsblan.thermostat(target_temperature_high=32.0)


@pytest.mark.asyncio
async def test_change_hvac_mode(
mock_bsblan: BSBLAN,
Expand Down Expand Up @@ -144,6 +164,13 @@ async def test_invalid_temperature(mock_bsblan: BSBLAN) -> None:
await mock_bsblan.thermostat(target_temperature="35")


@pytest.mark.asyncio
async def test_invalid_cooling_temperature(mock_bsblan: BSBLAN) -> None:
"""Test setting an invalid cooling temperature."""
with pytest.raises(BSBLANInvalidParameterError):
await mock_bsblan.thermostat(target_temperature_high="invalid")


@pytest.mark.asyncio
@pytest.mark.parametrize(
"invalid_mode",
Expand Down Expand Up @@ -191,3 +218,16 @@ async def test_no_parameters(mock_bsblan: BSBLAN) -> None:
with pytest.raises(BSBLANError) as exc_info:
await mock_bsblan.thermostat()
assert str(exc_info.value) == ErrorMsg.MULTI_PARAMETER


@pytest.mark.asyncio
async def test_cooling_temperature_with_other_parameter_raises(
mock_bsblan: BSBLAN,
) -> None:
"""Test cooling setpoint respects one-parameter-per-call rule."""
with pytest.raises(BSBLANError) as exc_info:
await mock_bsblan.thermostat(
target_temperature="20",
target_temperature_high="24",
)
assert str(exc_info.value) == ErrorMsg.MULTI_PARAMETER