diff --git a/docs/getting-started.md b/docs/getting-started.md index 40f0e371..0db3ba4e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -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 diff --git a/docs/index.md b/docs/index.md index c6eb3f34..824f7638 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/src/bsblan/bsblan.py b/src/bsblan/bsblan.py index 857e8bdf..fba26e55 100644 --- a/src/bsblan/bsblan.py +++ b/src/bsblan/bsblan.py @@ -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. @@ -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) @@ -1164,6 +1169,7 @@ async def thermostat( self._validate_single_parameter( target_temperature, hvac_mode, + target_temperature_high, error_msg=ErrorMsg.MULTI_PARAMETER, ) @@ -1171,6 +1177,7 @@ async def thermostat( target_temperature, hvac_mode, circuit, + target_temperature_high, ) await self._set_device_state(state) @@ -1179,6 +1186,7 @@ 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. @@ -1186,6 +1194,7 @@ async def _prepare_thermostat_state( 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. @@ -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", + }, + ) if hvac_mode is not None: self._validate_hvac_mode(hvac_mode) hvac_value = str(hvac_mode) @@ -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, diff --git a/src/bsblan/constants.py b/src/bsblan/constants.py index c2d0cd18..6663570f 100644 --- a/src/bsblan/constants.py +++ b/src/bsblan/constants.py @@ -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", @@ -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]] = { diff --git a/src/bsblan/models.py b/src/bsblan/models.py index 5d4216b7..3ddb5d6c 100644 --- a/src/bsblan/models.py +++ b/src/bsblan/models.py @@ -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 diff --git a/tests/fixtures/state.json b/tests/fixtures/state.json index 3bad093f..4b946672 100644 --- a/tests/fixtures/state.json +++ b/tests/fixtures/state.json @@ -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", diff --git a/tests/test_circuit.py b/tests/test_circuit.py index c0c1dd9b..eba03533 100644 --- a/tests/test_circuit.py +++ b/tests/test_circuit.py @@ -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 --- diff --git a/tests/test_constants.py b/tests/test_constants.py index 337b0ec0..d8b21ca6 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -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 ), ], @@ -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.""" diff --git a/tests/test_state.py b/tests/test_state.py index 83cc8d4f..40082b01 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -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 @@ -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"] diff --git a/tests/test_thermostat.py b/tests/test_thermostat.py index 246600f8..4c40f0c4 100644 --- a/tests/test_thermostat.py +++ b/tests/test_thermostat.py @@ -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, @@ -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", @@ -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