From c70e0697ab14cace256b497c4b4ed7e52a98114e Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Tue, 19 May 2026 20:48:59 +0200 Subject: [PATCH 1/5] Add minimal PPS climate support --- src/bsblan/bsblan.py | 136 +++++++++-- src/bsblan/constants.py | 26 ++ src/bsblan/models.py | 20 ++ tests/fixtures/pps_device.json | 16 ++ tests/fixtures/pps_state.json | 23 ++ tests/fixtures/pps_static_values.json | 16 ++ tests/test_device.py | 5 + tests/test_pps.py | 326 ++++++++++++++++++++++++++ 8 files changed, 551 insertions(+), 17 deletions(-) create mode 100644 tests/fixtures/pps_device.json create mode 100644 tests/fixtures/pps_state.json create mode 100644 tests/fixtures/pps_static_values.json create mode 100644 tests/test_pps.py diff --git a/src/bsblan/bsblan.py b/src/bsblan/bsblan.py index 1a005052..ea0153b9 100644 --- a/src/bsblan/bsblan.py +++ b/src/bsblan/bsblan.py @@ -19,6 +19,8 @@ from .constants import ( API_VERSIONS, + PPS_HEATING_PARAMS, + PPS_STATIC_VALUES_PARAMS, APIConfig, CircuitConfig, ErrorMsg, @@ -100,6 +102,7 @@ class BSBLAN: _firmware_version: str | None = None _api_version: str | None = None _api_data: APIConfig | None = None + _device: Device | None = None _initialized: bool = False _api_validator: APIValidator = field(init=False) _temperature_unit: str = "°C" @@ -170,6 +173,9 @@ async def get_available_circuits(self) -> list[int]: # circuits == [1, 2] for a dual-circuit controller """ + if self._uses_pps_bus: + return await self._get_available_pps_circuits() + available: list[int] = [] for circuit, param_id in CircuitConfig.PROBE_PARAMS.items(): try: @@ -195,6 +201,20 @@ async def get_available_circuits(self) -> list[int]: available.append(circuit) return sorted(available) + async def _get_available_pps_circuits(self) -> list[int]: + """Detect the single PPS room-unit climate circuit.""" + param_id = "15000" + try: + response = await self._request(params={"Parameter": param_id}) + except BSBLANError: + logger.debug("PPS climate circuit not available") + return [] + + if not response.get(param_id): + logger.debug("PPS climate circuit has no operating mode data") + return [] + return [1] + async def _setup_api_validator(self) -> None: """Set up the API validator without validating sections. @@ -208,9 +228,21 @@ async def _setup_api_validator(self) -> None: if self._api_data is None: self._api_data = self._copy_api_config() + self._apply_bus_specific_api_config() + # Initialize the API validator (but don't validate sections yet) self._api_validator = APIValidator(self._api_data) + def _apply_bus_specific_api_config(self) -> None: + """Apply bus-specific parameter maps to the current API config.""" + if self._api_data is None or not self._uses_pps_bus: + return + + self._api_data["heating"] = PPS_HEATING_PARAMS.copy() + self._api_data["staticValues"] = PPS_STATIC_VALUES_PARAMS.copy() + self._api_data["heating_circuit2"] = {} + self._api_data["staticValues_circuit2"] = {} + async def _ensure_section_validated( self, section: SectionLiteral, include: list[str] | None = None ) -> None: @@ -247,8 +279,7 @@ async def _ensure_section_validated( logger.debug("Lazy loading section: %s", section) response_data = await self._validate_api_section(section, include) - # Extract temperature unit from heating section validation - # (parameter 710 - target_temperature is always in heating section) + # Extract temperature unit from the target_temperature parameter. if section == "heating" and response_data: self._extract_temperature_unit_from_response(response_data) @@ -444,17 +475,16 @@ def _extract_temperature_unit_from_response( ) -> None: """Extract temperature unit from heating section response data. - Gets the unit from parameter 710 (target_temperature) which is always + Gets the unit from the target_temperature parameter, which is always present in the heating section. Args: response_data: The response data from heating section validation """ - # Look for parameter 710 (target_temperature) in the response + # Look for target_temperature in the response. for param_id, param_data in response_data.items(): - # Check if this is parameter 710 and has unit information - if param_id == "710" and isinstance(param_data, dict): + if param_id in {"710", "15004"} and isinstance(param_data, dict): unit = param_data.get("unit", "") if unit in ("°C", "°C"): self._temperature_unit = "°C" @@ -463,16 +493,15 @@ def _extract_temperature_unit_from_response( else: # Keep default if unit is empty or unknown logger.debug( - "Unknown or empty temperature unit from parameter 710: '%s'. " - "Using default (°C)", + "Unknown or empty temperature unit from heating target: " + "'%s'. Using default (°C)", unit, ) logger.debug("Temperature unit set to: %s", self._temperature_unit) return - # If we didn't find parameter 710, log a warning logger.warning( - "Could not find parameter 710 in heating section response. " + "Could not find target temperature in heating section response. " "Using default temperature unit (°C)" ) @@ -494,6 +523,31 @@ async def _fetch_firmware_version(self) -> None: logger.debug("BSBLAN version: %s", self._firmware_version) self._set_api_version() + @property + def device_info(self) -> Device | None: + """Return cached device metadata from the last /JI response.""" + return self._device + + @property + def supports_time_sync(self) -> bool: + """Return whether the normal BSB/LPB time sync command is safe.""" + return self._device is None or self._device.supports_time_sync + + @property + def _uses_pps_bus(self) -> bool: + """Return whether cached metadata identifies the device as PPS.""" + return self._device is not None and self._device.is_pps_bus + + @property + def _is_bus_writable(self) -> bool: + """Return whether cached metadata says writes are allowed.""" + return self._device is None or self._device.is_bus_writable + + async def _refresh_device_if_initialized(self) -> None: + """Fetch device metadata when initialization should have provided it.""" + if self._device is None and self._initialized: + await self.device() + def _set_api_version(self) -> None: """Set the API version based on the firmware version. @@ -572,8 +626,8 @@ async def _initialize_temperature_range( Args: circuit: The heating circuit number (1 or 2). - Note: Temperature unit is extracted during heating section validation - from the response (parameter 710), so no extra API call is needed here. + Note: Temperature unit is extracted during heating section validation, + so no extra API call is needed here. """ if circuit in self._circuit_temp_initialized: @@ -593,10 +647,20 @@ def _validate_circuit(self, circuit: int) -> None: BSBLANInvalidParameterError: If the circuit number is invalid. """ - if circuit not in CircuitConfig.VALID: + if circuit not in CircuitConfig.VALID or (self._uses_pps_bus and circuit != 1): msg = ErrorMsg.INVALID_CIRCUIT.format(circuit) raise BSBLANInvalidParameterError(msg) + def _validate_bus_write_supported(self) -> None: + """Validate that cached metadata permits writes.""" + if not self._is_bus_writable: + raise BSBLANError(ErrorMsg.BUS_WRITE_NOT_SUPPORTED) + + def _validate_time_sync_supported(self) -> None: + """Validate that normal parameter 0 time sync is safe.""" + if not self.supports_time_sync: + raise BSBLANError(ErrorMsg.TIME_SYNC_NOT_SUPPORTED) + @property def get_temperature_unit(self) -> str: """Get the unit of temperature. @@ -878,8 +942,26 @@ async def _fetch_section_data( params = self._extract_params_summary(section_params) data = await self._request(params={"Parameter": params["string_par"]}) data = dict(zip(params["list"], list(data.values()), strict=True)) + if section == "heating" and self._uses_pps_bus: + self._normalize_pps_state_data(data) return model_class.model_validate(data) + def _normalize_pps_state_data(self, data: dict[str, Any]) -> None: + """Normalize PPS climate values to the library's State model.""" + hvac_mode = data.get("hvac_mode") + if not isinstance(hvac_mode, dict): + return + + try: + raw_mode = int(hvac_mode["value"]) + except (KeyError, TypeError, ValueError): + return + + hvac_mode["value"] = Validation.PPS_HVAC_MODE_FROM_BSBLAN.get( + raw_mode, + raw_mode, + ) + async def state( self, include: list[str] | None = None, @@ -974,7 +1056,8 @@ async def device(self) -> Device: """ device_info = await self._request(base_path="/JI") - return Device.model_validate(device_info) + self._device = Device.model_validate(device_info) + return self._device async def info(self, include: list[str] | None = None) -> Info: """Get information about the current heating system config. @@ -1001,6 +1084,9 @@ async def time(self) -> DeviceTime: DeviceTime: The current time information from the BSB-LAN device. """ + await self._refresh_device_if_initialized() + self._validate_time_sync_supported() + # Get only parameter 0 for time data = await self._request(params={"Parameter": "0"}) # Create the data dictionary in the expected format @@ -1018,6 +1104,8 @@ async def set_time(self, time_value: str) -> None: BSBLANInvalidParameterError: If the time format is invalid. """ + await self._refresh_device_if_initialized() + self._validate_time_sync_supported() self._validate_time_format(time_value) state: dict[str, object] = { "Parameter": "0", @@ -1050,6 +1138,8 @@ async def thermostat( """ self._validate_circuit(circuit) + if self._uses_pps_bus: + self._validate_bus_write_supported() await self._initialize_temperature_range(circuit) self._validate_single_parameter( @@ -1082,7 +1172,7 @@ async def _prepare_thermostat_state( dict[str, Any]: The prepared state for the thermostat. """ - param_ids = CircuitConfig.THERMOSTAT_PARAMS[circuit] + param_ids = self._thermostat_params(circuit) state: dict[str, Any] = {} if target_temperature is not None: await self._validate_target_temperature( @@ -1098,15 +1188,24 @@ async def _prepare_thermostat_state( ) if hvac_mode is not None: self._validate_hvac_mode(hvac_mode) + hvac_value = str(hvac_mode) + if self._uses_pps_bus: + hvac_value = Validation.PPS_HVAC_MODE_TO_BSBLAN[hvac_mode] state.update( { "Parameter": param_ids["hvac_mode"], - "Value": str(hvac_mode), + "Value": hvac_value, "Type": "1", }, ) return state + def _thermostat_params(self, circuit: int) -> dict[str, str]: + """Return thermostat write parameters for the active bus type.""" + if self._uses_pps_bus: + return {"target_temperature": "15004", "hvac_mode": "15000"} + return CircuitConfig.THERMOSTAT_PARAMS[circuit] + async def _validate_target_temperature( self, target_temperature: str, @@ -1157,7 +1256,10 @@ def _validate_hvac_mode(self, hvac_mode: int) -> None: BSBLANInvalidParameterError: If the HVAC mode is invalid. """ - if hvac_mode not in Validation.HVAC_MODES: + valid_modes = ( + Validation.PPS_HVAC_MODES if self._uses_pps_bus else Validation.HVAC_MODES + ) + if hvac_mode not in valid_modes: raise BSBLANInvalidParameterError(str(hvac_mode)) def _validate_time_format(self, time_value: str) -> None: diff --git a/src/bsblan/constants.py b/src/bsblan/constants.py index 89988239..1a3f81a5 100644 --- a/src/bsblan/constants.py +++ b/src/bsblan/constants.py @@ -114,6 +114,19 @@ class APIConfig(TypedDict): "1014": "min_temp", } +# PPS bus supports one room-unit style climate circuit. These parameters are +# exposed by BSB-LAN in the 15000+ range and mirror the circuit 1 climate model. +PPS_HEATING_PARAMS: Final[dict[str, str]] = { + "15000": "hvac_mode", + "15004": "target_temperature", + "15008": "current_temperature", +} + +PPS_STATIC_VALUES_PARAMS: Final[dict[str, str]] = { + "15006": "min_temp", + "15007": "max_temp", +} + V1_STATIC_VALUES_CIRCUIT2_EXTENSIONS: Final[dict[str, str]] = { "1030": "max_temp", } @@ -207,6 +220,17 @@ class Validation: """Validation-related constants for BSBLAN.""" HVAC_MODES: Final[set[int]] = {0, 1, 2, 3} + PPS_HVAC_MODES: Final[set[int]] = {0, 1, 3} + PPS_HVAC_MODE_TO_BSBLAN: Final[dict[int, str]] = { + 0: "2", # off + 1: "0", # auto + 3: "1", # manual/comfort + } + PPS_HVAC_MODE_FROM_BSBLAN: Final[dict[int, int]] = { + 0: 1, # auto + 1: 3, # manual/comfort + 2: 0, # off + } MIN_YEAR: Final[int] = 1900 MAX_YEAR: Final[int] = 2100 @@ -492,6 +516,8 @@ class ErrorMsg: "Empty include list provided. Use None to fetch all parameters." ) NO_HEATING_SCHEDULE_PARAMS = "No heating schedule parameters available" + TIME_SYNC_NOT_SUPPORTED = "Time synchronization is not supported by this device" + BUS_WRITE_NOT_SUPPORTED = "Writing parameters is not supported by this device" # Handle both ASCII and Unicode degree symbols diff --git a/src/bsblan/models.py b/src/bsblan/models.py index 0c360a99..cb51e255 100644 --- a/src/bsblan/models.py +++ b/src/bsblan/models.py @@ -611,6 +611,26 @@ class Device(BaseModel): version: str MAC: str # pylint: disable=invalid-name uptime: int + bus: str | None = None + buswritable: int | bool | None = None + busaddr: int | None = None + busdest: int | None = None + busdevices: list[Any] | None = None + + @property + def is_pps_bus(self) -> bool: + """Return whether the device is connected to a PPS bus.""" + return self.bus is not None and self.bus.upper() == "PPS" + + @property + def is_bus_writable(self) -> bool: + """Return whether BSB-LAN reports the bus as writable.""" + return self.buswritable is None or bool(self.buswritable) + + @property + def supports_time_sync(self) -> bool: + """Return whether normal BSB/LPB time synchronization is supported.""" + return not self.is_pps_bus and self.is_bus_writable class Info(BaseModel): diff --git a/tests/fixtures/pps_device.json b/tests/fixtures/pps_device.json new file mode 100644 index 00000000..8dbe13b3 --- /dev/null +++ b/tests/fixtures/pps_device.json @@ -0,0 +1,16 @@ +{ + "name": "BSB-LAN", + "version": "5.1.0", + "freeram": 85479, + "uptime": 969402857, + "MAC": "00:80:41:19:69:91", + "freespace": 0, + "bus": "PPS", + "buswritable": 1, + "busaddr": 0, + "busdest": 0, + "monitor": 0, + "verbose": 1, + "protectedGPIO": [], + "averages": [] +} diff --git a/tests/fixtures/pps_state.json b/tests/fixtures/pps_state.json new file mode 100644 index 00000000..985ade41 --- /dev/null +++ b/tests/fixtures/pps_state.json @@ -0,0 +1,23 @@ +{ + "15000": { + "name": "Operating Mode", + "value": "0", + "unit": "", + "desc": "Automatic", + "dataType": 1 + }, + "15004": { + "name": "Comfort Setpoint", + "value": "20.5", + "unit": "°C", + "desc": "", + "dataType": 0 + }, + "15008": { + "name": "Room Temperature", + "value": "19.5", + "unit": "°C", + "desc": "", + "dataType": 0 + } +} diff --git a/tests/fixtures/pps_static_values.json b/tests/fixtures/pps_static_values.json new file mode 100644 index 00000000..606dc443 --- /dev/null +++ b/tests/fixtures/pps_static_values.json @@ -0,0 +1,16 @@ +{ + "15006": { + "name": "Frost Protection Setpoint", + "value": "8.0", + "unit": "°C", + "desc": "", + "dataType": 0 + }, + "15007": { + "name": "Setpoint Maximum", + "value": "30.0", + "unit": "°C", + "desc": "", + "dataType": 0 + } +} diff --git a/tests/test_device.py b/tests/test_device.py index bdec69ab..cd8aaada 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -40,3 +40,8 @@ async def test_device(aresponses: ResponsesMockServer) -> None: assert device.version == "1.0.38-20200730234859" assert device.MAC == "00:80:41:19:69:90" assert device.uptime == 969402857 + assert device.bus == "BSB" + assert device.buswritable == 1 + assert device.busaddr == 66 + assert device.busdest == 0 + assert device.supports_time_sync diff --git a/tests/test_pps.py b/tests/test_pps.py new file mode 100644 index 00000000..6a89fad5 --- /dev/null +++ b/tests/test_pps.py @@ -0,0 +1,326 @@ +"""Tests for minimal PPS bus support.""" + +# pylint: disable=protected-access + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any +from unittest.mock import AsyncMock + +import aiohttp +import pytest + +from bsblan import BSBLAN, BSBLANConfig, Device, State, StaticState +from bsblan.constants import ( + PPS_HEATING_PARAMS, + PPS_STATIC_VALUES_PARAMS, + ErrorMsg, + build_api_config, +) +from bsblan.exceptions import BSBLANError, BSBLANInvalidParameterError +from bsblan.utility import APIValidator + +from . import load_fixture + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + +@pytest.fixture +async def pps_bsblan() -> AsyncGenerator[BSBLAN, None]: + """Create a PPS BSBLAN instance with bus-specific API data.""" + config = BSBLANConfig(host="example.com") + async with aiohttp.ClientSession() as session: + bsblan = BSBLAN(config, session=session) + bsblan._firmware_version = "5.1.0" + bsblan._api_version = "v3" + bsblan._device = Device.model_validate( + json.loads(load_fixture("pps_device.json")) + ) + bsblan._api_data = build_api_config("v3") + bsblan._apply_bus_specific_api_config() + bsblan._api_validator = APIValidator(bsblan._api_data) + yield bsblan + + +def test_pps_device_capabilities() -> None: + """Test PPS metadata and derived capabilities.""" + device = Device.model_validate(json.loads(load_fixture("pps_device.json"))) + + assert device.bus == "PPS" + assert device.buswritable == 1 + assert device.busaddr == 0 + assert device.busdest == 0 + assert device.is_pps_bus + assert device.is_bus_writable + assert not device.supports_time_sync + + +def test_bsb_device_capabilities() -> None: + """Test BSB metadata keeps time sync available when writable.""" + device = Device.model_validate(json.loads(load_fixture("device.json"))) + + assert device.bus == "BSB" + assert not device.is_pps_bus + assert device.is_bus_writable + assert device.supports_time_sync + + +def test_device_with_read_only_bus_disables_time_sync() -> None: + """Test read-only bus metadata disables time sync.""" + device = Device( + name="BSB-LAN", + version="5.1.0", + MAC="00:80:41:19:69:92", + uptime=1, + bus="BSB", + buswritable=0, + ) + + assert not device.is_bus_writable + assert not device.supports_time_sync + + +def test_pps_api_config_uses_climate_params(pps_bsblan: BSBLAN) -> None: + """Test PPS devices use the 15000+ climate parameter map.""" + assert pps_bsblan.device_info is not None + assert pps_bsblan.supports_time_sync is False + assert pps_bsblan._api_data is not None + assert pps_bsblan._api_data["heating"] == PPS_HEATING_PARAMS + assert pps_bsblan._api_data["staticValues"] == PPS_STATIC_VALUES_PARAMS + assert pps_bsblan._api_data["heating_circuit2"] == {} + assert pps_bsblan._api_data["staticValues_circuit2"] == {} + + +@pytest.mark.asyncio +async def test_pps_set_time_raises_without_posting(pps_bsblan: BSBLAN) -> None: + """Test PPS devices refuse normal parameter 0 time sync writes.""" + request_mock = AsyncMock(return_value={"status": "ok"}) + pps_bsblan._request = request_mock # type: ignore[method-assign] + + with pytest.raises(BSBLANError, match=ErrorMsg.TIME_SYNC_NOT_SUPPORTED): + await pps_bsblan.set_time("01.01.2024 12:30:45") + + request_mock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_pps_time_raises_without_querying_param_zero( + pps_bsblan: BSBLAN, +) -> None: + """Test PPS devices refuse normal parameter 0 time reads.""" + request_mock = AsyncMock(return_value=json.loads(load_fixture("time.json"))) + pps_bsblan._request = request_mock # type: ignore[method-assign] + + with pytest.raises(BSBLANError, match=ErrorMsg.TIME_SYNC_NOT_SUPPORTED): + await pps_bsblan.time() + + request_mock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_time_refreshes_device_when_initialized() -> None: + """Test initialized clients refresh missing device metadata before time sync.""" + async with aiohttp.ClientSession() as session: + bsblan = BSBLAN(BSBLANConfig(host="example.com"), session=session) + bsblan._firmware_version = "5.1.0" + bsblan._api_version = "v3" + bsblan._initialized = True + request_mock = AsyncMock( + side_effect=[ + json.loads(load_fixture("device.json")), + json.loads(load_fixture("time.json")), + ] + ) + bsblan._request = request_mock # type: ignore[method-assign] + + await bsblan.time() + + assert request_mock.await_args_list[0].kwargs == {"base_path": "/JI"} + assert request_mock.await_args_list[1].kwargs == {"params": {"Parameter": "0"}} + + +@pytest.mark.asyncio +async def test_pps_state_uses_climate_params(pps_bsblan: BSBLAN) -> None: + """Test PPS climate state reads use and normalize PPS parameter values.""" + request_mock = AsyncMock(return_value=json.loads(load_fixture("pps_state.json"))) + pps_bsblan._request = request_mock # type: ignore[method-assign] + + state: State = await pps_bsblan.state() + + assert state.hvac_mode is not None + assert state.hvac_mode.value == 1 + assert state.target_temperature is not None + assert state.target_temperature.value == 20.5 + assert state.current_temperature is not None + assert state.current_temperature.value == 19.5 + assert pps_bsblan.get_temperature_unit == "°C" + assert [ + call.kwargs["params"]["Parameter"] for call in request_mock.await_args_list + ] == ["15000,15004,15008", "15000,15004,15008"] + + +@pytest.mark.asyncio +async def test_pps_static_values_use_climate_bounds(pps_bsblan: BSBLAN) -> None: + """Test PPS static values use frost and max setpoint parameters.""" + request_mock = AsyncMock( + return_value=json.loads(load_fixture("pps_static_values.json")) + ) + pps_bsblan._request = request_mock # type: ignore[method-assign] + + static_values: StaticState = await pps_bsblan.static_values() + + assert static_values.min_temp is not None + assert static_values.min_temp.value == 8.0 + assert static_values.max_temp is not None + assert static_values.max_temp.value == 30.0 + assert [ + call.kwargs["params"]["Parameter"] for call in request_mock.await_args_list + ] == ["15006,15007", "15006,15007"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("hvac_mode", "expected_value"), + [ + (0, "2"), + (1, "0"), + (3, "1"), + ], +) +async def test_pps_thermostat_hvac_mode_writes_translated_value( + pps_bsblan: BSBLAN, + hvac_mode: int, + expected_value: str, +) -> None: + """Test PPS mode writes translate library modes to PPS raw values.""" + pps_bsblan._circuit_temp_ranges[1] = {"min": 8.0, "max": 30.0} + pps_bsblan._circuit_temp_initialized.add(1) + request_mock = AsyncMock(return_value={"status": "ok"}) + pps_bsblan._request = request_mock # type: ignore[method-assign] + + await pps_bsblan.thermostat(hvac_mode=hvac_mode) + + request_mock.assert_awaited_with( + base_path="/JS", + data={"Parameter": "15000", "Value": expected_value, "Type": "1"}, + ) + + +@pytest.mark.asyncio +async def test_pps_thermostat_temperature_writes_comfort_setpoint( + pps_bsblan: BSBLAN, +) -> None: + """Test PPS target temperature writes use comfort setpoint.""" + pps_bsblan._circuit_temp_ranges[1] = {"min": 8.0, "max": 30.0} + pps_bsblan._circuit_temp_initialized.add(1) + request_mock = AsyncMock(return_value={"status": "ok"}) + pps_bsblan._request = request_mock # type: ignore[method-assign] + + await pps_bsblan.thermostat(target_temperature="20.5") + + request_mock.assert_awaited_with( + base_path="/JS", + data={"Parameter": "15004", "Value": "20.5", "Type": "1"}, + ) + + +@pytest.mark.asyncio +async def test_pps_thermostat_rejects_unsupported_eco_mode( + pps_bsblan: BSBLAN, +) -> None: + """Test PPS climate rejects the library eco mode value.""" + pps_bsblan._circuit_temp_ranges[1] = {"min": 8.0, "max": 30.0} + pps_bsblan._circuit_temp_initialized.add(1) + request_mock = AsyncMock(return_value={"status": "ok"}) + pps_bsblan._request = request_mock # type: ignore[method-assign] + + with pytest.raises(BSBLANInvalidParameterError): + await pps_bsblan.thermostat(hvac_mode=2) + + request_mock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_pps_thermostat_rejects_read_only_bus() -> None: + """Test PPS climate writes are blocked when the bus is read-only.""" + async with aiohttp.ClientSession() as session: + bsblan = BSBLAN(BSBLANConfig(host="example.com"), session=session) + bsblan._firmware_version = "5.1.0" + bsblan._api_version = "v3" + bsblan._device = Device( + name="BSB-LAN", + version="5.1.0", + MAC="00:80:41:19:69:93", + uptime=1, + bus="PPS", + buswritable=0, + ) + bsblan._api_data = build_api_config("v3") + bsblan._apply_bus_specific_api_config() + bsblan._api_validator = APIValidator(bsblan._api_data) + request_mock = AsyncMock(return_value={"status": "ok"}) + bsblan._request = request_mock # type: ignore[method-assign] + + with pytest.raises(BSBLANError, match=ErrorMsg.BUS_WRITE_NOT_SUPPORTED): + await bsblan.thermostat(target_temperature="20.5") + + request_mock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_pps_circuit_discovery_returns_single_climate( + pps_bsblan: BSBLAN, +) -> None: + """Test PPS circuit discovery only probes and returns circuit 1.""" + request_mock = AsyncMock( + return_value={"15000": {"value": "0", "unit": "", "desc": "Automatic"}} + ) + pps_bsblan._request = request_mock # type: ignore[method-assign] + + circuits = await pps_bsblan.get_available_circuits() + + assert circuits == [1] + request_mock.assert_awaited_once_with(params={"Parameter": "15000"}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("response", [{}, {"15000": {}}]) +async def test_pps_circuit_discovery_returns_empty_without_mode( + pps_bsblan: BSBLAN, + response: dict[str, Any], +) -> None: + """Test PPS circuit discovery handles missing operating mode data.""" + pps_bsblan._request = AsyncMock(return_value=response) # type: ignore[method-assign] + + circuits = await pps_bsblan.get_available_circuits() + + assert circuits == [] + + +@pytest.mark.asyncio +async def test_pps_circuit_discovery_returns_empty_on_error( + pps_bsblan: BSBLAN, +) -> None: + """Test PPS circuit discovery handles request errors.""" + pps_bsblan._request = AsyncMock( # type: ignore[method-assign] + side_effect=BSBLANError("failed") + ) + + circuits = await pps_bsblan.get_available_circuits() + + assert circuits == [] + + +@pytest.mark.asyncio +async def test_pps_rejects_second_circuit(pps_bsblan: BSBLAN) -> None: + """Test PPS devices expose only one climate circuit.""" + with pytest.raises(BSBLANInvalidParameterError): + await pps_bsblan.state(circuit=2) + + +def test_pps_normalization_ignores_missing_mode(pps_bsblan: BSBLAN) -> None: + """Test PPS normalization tolerates partial or malformed responses.""" + pps_bsblan._normalize_pps_state_data({}) + pps_bsblan._normalize_pps_state_data({"hvac_mode": {"value": "unknown"}}) From c4c93c7c9c881597f3ebbf4b77984b7adf33ea6d Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Tue, 19 May 2026 21:02:21 +0200 Subject: [PATCH 2/5] Enhance device metadata formatting and update time sync logic for BSB-LAN --- examples/control.py | 48 +++++++++++++++++++++++++++++++------------- src/bsblan/models.py | 4 ++-- tests/test_pps.py | 6 +++--- 3 files changed, 39 insertions(+), 19 deletions(-) diff --git a/examples/control.py b/examples/control.py index edb6c801..289ec9d2 100644 --- a/examples/control.py +++ b/examples/control.py @@ -68,6 +68,16 @@ def print_attributes(title: str, attributes: dict[str, str]) -> None: print(f"{label}: {value}") +def format_yes_no(*, value: bool) -> str: + """Format a boolean as a readable yes/no value.""" + return "yes" if value else "no" + + +def format_optional(value: Any) -> str: + """Format optional device metadata for display.""" + return "N/A" if value is None else str(value) + + def get_hvac_action_name(status_code: int) -> str: """Map BSB-LAN parameter 8000 status code to a human-readable HVAC action. @@ -180,6 +190,11 @@ async def print_device_info(device: Device, info: Info) -> None: "Device Name": device.name or "N/A", "Version": device.version or "N/A", "Device Identification": device_identification, + "Bus Type": format_optional(device.bus), + "Bus Writable Flag": format_optional(device.buswritable), + "Bus Address": format_optional(device.busaddr), + "Bus Destination": format_optional(device.busdest), + "Supports Time Sync": format_yes_no(value=device.supports_time_sync), } print_attributes("Device Information", attributes) @@ -319,6 +334,11 @@ async def main() -> None: # Initialize BSBLAN with the configuration object async with BSBLAN(config) as bsblan: + # Get and print device and general info, including bus metadata + device: Device = bsblan.device_info or await bsblan.device() + info: Info = await bsblan.info() + await print_device_info(device, info) + # Get and print state state: State = await bsblan.state() await print_state(state) @@ -335,14 +355,12 @@ async def main() -> None: sensor: Sensor = await bsblan.sensor() await print_sensor(sensor) - # Get and print device and general info - device: Device = await bsblan.device() - info: Info = await bsblan.info() - await print_device_info(device, info) - # Get and print device time - device_time: DeviceTime = await bsblan.time() - await print_device_time(device_time) + if bsblan.supports_time_sync: + device_time: DeviceTime = await bsblan.time() + await print_device_time(device_time) + else: + print("\nDevice time is not available for this bus type") # Get and print static state static_state: StaticState = await bsblan.static_values() @@ -375,13 +393,15 @@ async def main() -> None: await bsblan.set_hot_water(SetHotWaterParam(dhw_time_programs=dhw_programs)) # Example: Set device time - print("\nSetting device time to current system time") - # Get current local system time and format it for BSB-LAN (DD.MM.YYYY HH:MM:SS) - # Note: Using local time intentionally for this demo to sync BSB-LAN - current_time = datetime.now().replace(microsecond=0) # noqa: DTZ005 - Demo uses local time - formatted_time = current_time.strftime("%d.%m.%Y %H:%M:%S") - print(f"Current system time: {formatted_time}") - await bsblan.set_time(formatted_time) + if bsblan.supports_time_sync: + print("\nSetting device time to current system time") + # Get current local system time and format it for BSB-LAN. + current_time = datetime.now().replace(microsecond=0) # noqa: DTZ005 + formatted_time = current_time.strftime("%d.%m.%Y %H:%M:%S") + print(f"Current system time: {formatted_time}") + await bsblan.set_time(formatted_time) + else: + print("\nSkipping device time sync for this bus type") if __name__ == "__main__": diff --git a/src/bsblan/models.py b/src/bsblan/models.py index cb51e255..5d4216b7 100644 --- a/src/bsblan/models.py +++ b/src/bsblan/models.py @@ -624,13 +624,13 @@ def is_pps_bus(self) -> bool: @property def is_bus_writable(self) -> bool: - """Return whether BSB-LAN reports the bus as writable.""" + """Return whether BSB-LAN reports global bus writes as enabled.""" return self.buswritable is None or bool(self.buswritable) @property def supports_time_sync(self) -> bool: """Return whether normal BSB/LPB time synchronization is supported.""" - return not self.is_pps_bus and self.is_bus_writable + return not self.is_pps_bus class Info(BaseModel): diff --git a/tests/test_pps.py b/tests/test_pps.py index 6a89fad5..70804d76 100644 --- a/tests/test_pps.py +++ b/tests/test_pps.py @@ -67,8 +67,8 @@ def test_bsb_device_capabilities() -> None: assert device.supports_time_sync -def test_device_with_read_only_bus_disables_time_sync() -> None: - """Test read-only bus metadata disables time sync.""" +def test_bsb_device_with_buswritable_zero_supports_time_sync() -> None: + """Test BSB time sync does not depend on global bus write metadata.""" device = Device( name="BSB-LAN", version="5.1.0", @@ -79,7 +79,7 @@ def test_device_with_read_only_bus_disables_time_sync() -> None: ) assert not device.is_bus_writable - assert not device.supports_time_sync + assert device.supports_time_sync def test_pps_api_config_uses_climate_params(pps_bsblan: BSBLAN) -> None: From 9f0a70098cdd98d36eee5e93196f883adcfbb502 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Tue, 19 May 2026 21:21:43 +0200 Subject: [PATCH 3/5] Refactor device metadata fetching and enhance time sync tests --- src/bsblan/bsblan.py | 10 +++++----- tests/test_pps.py | 32 ++++++++++++++++++++++++++++++++ tests/test_time.py | 14 +++++++++++++- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/bsblan/bsblan.py b/src/bsblan/bsblan.py index ea0153b9..eaa04f32 100644 --- a/src/bsblan/bsblan.py +++ b/src/bsblan/bsblan.py @@ -543,9 +543,9 @@ def _is_bus_writable(self) -> bool: """Return whether cached metadata says writes are allowed.""" return self._device is None or self._device.is_bus_writable - async def _refresh_device_if_initialized(self) -> None: - """Fetch device metadata when initialization should have provided it.""" - if self._device is None and self._initialized: + async def _ensure_device_metadata(self) -> None: + """Fetch device metadata if it has not been loaded yet.""" + if self._device is None: await self.device() def _set_api_version(self) -> None: @@ -1084,7 +1084,7 @@ async def time(self) -> DeviceTime: DeviceTime: The current time information from the BSB-LAN device. """ - await self._refresh_device_if_initialized() + await self._ensure_device_metadata() self._validate_time_sync_supported() # Get only parameter 0 for time @@ -1104,7 +1104,7 @@ async def set_time(self, time_value: str) -> None: BSBLANInvalidParameterError: If the time format is invalid. """ - await self._refresh_device_if_initialized() + await self._ensure_device_metadata() self._validate_time_sync_supported() self._validate_time_format(time_value) state: dict[str, object] = { diff --git a/tests/test_pps.py b/tests/test_pps.py index 70804d76..a4c630a8 100644 --- a/tests/test_pps.py +++ b/tests/test_pps.py @@ -141,6 +141,38 @@ async def test_time_refreshes_device_when_initialized() -> None: assert request_mock.await_args_list[1].kwargs == {"params": {"Parameter": "0"}} +@pytest.mark.asyncio +async def test_set_time_fetches_device_metadata_before_sync() -> None: + """Test direct clients fetch metadata before deciding time sync support.""" + async with aiohttp.ClientSession() as session: + bsblan = BSBLAN(BSBLANConfig(host="example.com"), session=session) + request_mock = AsyncMock( + return_value=json.loads(load_fixture("pps_device.json")) + ) + bsblan._request = request_mock # type: ignore[method-assign] + + with pytest.raises(BSBLANError, match=ErrorMsg.TIME_SYNC_NOT_SUPPORTED): + await bsblan.set_time("01.01.2024 12:30:45") + + request_mock.assert_awaited_once_with(base_path="/JI") + + +@pytest.mark.asyncio +async def test_time_fetches_device_metadata_before_sync() -> None: + """Test direct time reads fetch metadata before querying parameter 0.""" + async with aiohttp.ClientSession() as session: + bsblan = BSBLAN(BSBLANConfig(host="example.com"), session=session) + request_mock = AsyncMock( + return_value=json.loads(load_fixture("pps_device.json")) + ) + bsblan._request = request_mock # type: ignore[method-assign] + + with pytest.raises(BSBLANError, match=ErrorMsg.TIME_SYNC_NOT_SUPPORTED): + await bsblan.time() + + request_mock.assert_awaited_once_with(base_path="/JI") + + @pytest.mark.asyncio async def test_pps_state_uses_climate_params(pps_bsblan: BSBLAN) -> None: """Test PPS climate state reads use and normalize PPS parameter values.""" diff --git a/tests/test_time.py b/tests/test_time.py index 3bf03301..bd534519 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -11,10 +11,15 @@ from bsblan import BSBLAN from bsblan.exceptions import BSBLANInvalidParameterError -from bsblan.models import DeviceTime, EntityInfo +from bsblan.models import Device, DeviceTime, EntityInfo from tests import load_fixture +def set_bsb_device_metadata(bsblan: BSBLAN) -> None: + """Seed cached BSB device metadata for time tests.""" + bsblan._device = Device.model_validate(json.loads(load_fixture("device.json"))) + + @pytest.mark.asyncio async def test_get_time(mock_bsblan: BSBLAN) -> None: """Test getting device time. @@ -28,6 +33,7 @@ async def test_get_time(mock_bsblan: BSBLAN) -> None: assert isinstance(mock_bsblan._request, AsyncMock) mock_bsblan._request.return_value = time_response + set_bsb_device_metadata(mock_bsblan) # Test getting device time device_time = await mock_bsblan.time() @@ -54,6 +60,7 @@ async def test_set_time(mock_bsblan: BSBLAN) -> None: """ # Test setting time assert isinstance(mock_bsblan._request, AsyncMock) + set_bsb_device_metadata(mock_bsblan) await mock_bsblan.set_time("01.01.2024 12:30:45") # Verify the request was made correctly @@ -77,6 +84,7 @@ async def test_set_time_different_format(mock_bsblan: BSBLAN) -> None: """ # Test setting time with correct format assert isinstance(mock_bsblan._request, AsyncMock) + set_bsb_device_metadata(mock_bsblan) await mock_bsblan.set_time("13.08.2025 10:25:55") # Verify the request was made correctly @@ -121,6 +129,7 @@ async def test_set_time_invalid_formats(mock_bsblan: BSBLAN) -> None: "", # Empty string "invalid format", # Completely wrong format ] + set_bsb_device_metadata(mock_bsblan) for invalid_format in invalid_formats: with pytest.raises(BSBLANInvalidParameterError): @@ -138,6 +147,7 @@ async def test_set_time_valid_formats(mock_bsblan: BSBLAN) -> None: """ assert isinstance(mock_bsblan._request, AsyncMock) + set_bsb_device_metadata(mock_bsblan) # Test various valid formats valid_formats = [ @@ -162,6 +172,8 @@ async def test_set_time_leap_year_validation(mock_bsblan: BSBLAN) -> None: mock_bsblan (BSBLAN): The mock BSBLAN instance. """ + set_bsb_device_metadata(mock_bsblan) + # 2024 is a leap year, so Feb 29 should be valid await mock_bsblan.set_time("29.02.2024 12:30:45") From 1e4aa2d4581eb895eacdb33eb9a8ed90be6bac89 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Wed, 20 May 2026 08:08:46 +0200 Subject: [PATCH 4/5] Enhance temperature unit extraction logic and update time sync support tests --- src/bsblan/bsblan.py | 36 +++++++++++++++++++++++++-------- tests/test_include_parameter.py | 31 ++++++++++++++++++++++++++++ tests/test_pps.py | 8 ++++++++ 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src/bsblan/bsblan.py b/src/bsblan/bsblan.py index eaa04f32..857e8bdf 100644 --- a/src/bsblan/bsblan.py +++ b/src/bsblan/bsblan.py @@ -279,10 +279,26 @@ async def _ensure_section_validated( logger.debug("Lazy loading section: %s", section) response_data = await self._validate_api_section(section, include) - # Extract temperature unit from the target_temperature parameter. - if section == "heating" and response_data: + if response_data and self._should_extract_temperature_unit( + section, include, response_data + ): self._extract_temperature_unit_from_response(response_data) + def _should_extract_temperature_unit( + self, + section: SectionLiteral, + include: list[str] | None, + response_data: dict[str, Any], + ) -> bool: + """Return whether the validation response should update temperature unit.""" + if section != "heating": + return False + + if include is None or "target_temperature" in include: + return True + + return any(param_id in response_data for param_id in ("710", "15004")) + async def _ensure_hot_water_group_validated( self, group_name: str, @@ -530,8 +546,8 @@ def device_info(self) -> Device | None: @property def supports_time_sync(self) -> bool: - """Return whether the normal BSB/LPB time sync command is safe.""" - return self._device is None or self._device.supports_time_sync + """Return cached support for the normal BSB/LPB time sync command.""" + return self._device is not None and self._device.supports_time_sync @property def _uses_pps_bus(self) -> bool: @@ -983,8 +999,9 @@ async def state( State: The current state of the BSBLAN device. Note: - The hvac_mode.value is returned as a raw integer from the device: - 0=off, 1=auto, 2=eco, 3=heat. + For BSB/LPB devices, hvac_mode.value is returned as a raw integer: + 0=off, 1=auto, 2=eco, 3=heat. PPS devices normalize their raw + operating modes to the same library values, but do not support eco. Example: # Fetch only hvac_mode and current_temperature @@ -1126,7 +1143,9 @@ async def thermostat( Args: target_temperature (str | None): The target temperature to set. hvac_mode (int | None): The HVAC mode to set as raw integer value. - Valid values: 0=off, 1=auto, 2=eco, 3=heat. + For BSB/LPB, valid values are 0=off, 1=auto, 2=eco, 3=heat. + 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. Example: @@ -1250,7 +1269,8 @@ def _validate_hvac_mode(self, hvac_mode: int) -> None: """Validate the HVAC mode. Args: - hvac_mode (int): The HVAC mode to validate (0-3). + hvac_mode (int): The HVAC mode to validate. BSB/LPB accepts 0-3; + PPS accepts 0, 1, and 3. Raises: BSBLANInvalidParameterError: If the HVAC mode is invalid. diff --git a/tests/test_include_parameter.py b/tests/test_include_parameter.py index 1960fa72..be071ce5 100644 --- a/tests/test_include_parameter.py +++ b/tests/test_include_parameter.py @@ -7,6 +7,7 @@ from __future__ import annotations import json +import logging from typing import Any from unittest.mock import AsyncMock @@ -278,6 +279,36 @@ async def test_state_with_include_multiple_params(monkeypatch: Any) -> None: assert state.current_temperature.value == 19.3 +@pytest.mark.asyncio +async def test_state_include_skips_temperature_unit_warning( + caplog: pytest.LogCaptureFixture, + monkeypatch: Any, +) -> None: + """Test include filters do not warn when target temperature is omitted.""" + async with aiohttp.ClientSession() as session: + config = BSBLANConfig(host="example.com") + bsblan = BSBLAN(config, session=session) + + monkeypatch.setattr(bsblan, "_firmware_version", "5.1.0") + monkeypatch.setattr(bsblan, "_api_version", "v3") + monkeypatch.setattr(bsblan, "_api_data", API_V3) + bsblan._api_validator = APIValidator(API_V3) + + state_data = json.loads(load_fixture("state.json")) + partial_response = {"700": state_data["700"]} + request_mock: AsyncMock = AsyncMock( + side_effect=[partial_response, partial_response] + ) + monkeypatch.setattr(bsblan, "_request", request_mock) + + with caplog.at_level(logging.WARNING, logger="bsblan.bsblan"): + state: State = await bsblan.state(include=["hvac_mode"]) + + assert state.hvac_mode is not None + assert "Could not find target temperature" not in caplog.text + assert bsblan.get_temperature_unit == "°C" + + @pytest.mark.asyncio async def test_state_with_include_mixed_valid_invalid_params(monkeypatch: Any) -> None: """Test state() with mixed valid and invalid parameters. diff --git a/tests/test_pps.py b/tests/test_pps.py index a4c630a8..64650798 100644 --- a/tests/test_pps.py +++ b/tests/test_pps.py @@ -82,6 +82,14 @@ def test_bsb_device_with_buswritable_zero_supports_time_sync() -> None: assert device.supports_time_sync +def test_client_time_sync_support_requires_device_metadata() -> None: + """Test clients report time sync support only after device metadata loads.""" + bsblan = BSBLAN(BSBLANConfig(host="example.com")) + + assert bsblan.device_info is None + assert not bsblan.supports_time_sync + + def test_pps_api_config_uses_climate_params(pps_bsblan: BSBLAN) -> None: """Test PPS devices use the 15000+ climate parameter map.""" assert pps_bsblan.device_info is not None From c83682698b89a22445a941ed35398a29ba4fb1f4 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Wed, 20 May 2026 09:07:33 +0200 Subject: [PATCH 5/5] Add test for handling older /JI responses without bus metadata --- tests/test_pps.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_pps.py b/tests/test_pps.py index 64650798..8697efda 100644 --- a/tests/test_pps.py +++ b/tests/test_pps.py @@ -82,6 +82,20 @@ def test_bsb_device_with_buswritable_zero_supports_time_sync() -> None: assert device.supports_time_sync +def test_device_without_bus_metadata_keeps_bsb_defaults() -> None: + """Test older /JI responses without bus metadata keep standard behavior.""" + device = Device( + name="BSB-LAN", + version="1.0.38-20200730234859", + MAC="00:80:41:19:69:90", + uptime=1, + ) + + assert not device.is_pps_bus + assert device.is_bus_writable + assert device.supports_time_sync + + def test_client_time_sync_support_requires_device_metadata() -> None: """Test clients report time sync support only after device metadata loads.""" bsblan = BSBLAN(BSBLANConfig(host="example.com"))