From 641ba2c47928091c54d82e9d3c8fce1378dc3965 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Wed, 25 Feb 2026 10:58:09 +0100 Subject: [PATCH 01/14] Add dual heating circuit support with parameter mappings and initialization --- src/bsblan/bsblan.py | 189 +++++++++++++++++++++++++++++++++++----- src/bsblan/constants.py | 105 ++++++++++++++++++++++ 2 files changed, 273 insertions(+), 21 deletions(-) diff --git a/src/bsblan/bsblan.py b/src/bsblan/bsblan.py index b1dbe1d6..5b202848 100644 --- a/src/bsblan/bsblan.py +++ b/src/bsblan/bsblan.py @@ -22,6 +22,9 @@ API_VALIDATOR_NOT_INITIALIZED_ERROR_MSG, API_VERSION_ERROR_MSG, API_VERSIONS, + CIRCUIT_HEATING_SECTIONS, + CIRCUIT_STATIC_SECTIONS, + CIRCUIT_THERMOSTAT_PARAMS, DHW_TIME_PROGRAM_PARAMS, EMPTY_INCLUDE_LIST_ERROR_MSG, FIRMWARE_VERSION_ERROR_MSG, @@ -40,6 +43,7 @@ SESSION_NOT_INITIALIZED_ERROR_MSG, SETTABLE_HOT_WATER_PARAMS, TEMPERATURE_RANGE_ERROR_MSG, + VALID_CIRCUITS, VALID_HVAC_MODES, VERSION_ERROR_MSG, APIConfig, @@ -73,7 +77,17 @@ from aiohttp.client import ClientSession -SectionLiteral = Literal["heating", "staticValues", "device", "sensor", "hot_water"] +SectionLiteral = Literal[ + "heating", + "staticValues", + "device", + "sensor", + "hot_water", + "heating_circuit2", + "heating_circuit3", + "staticValues_circuit2", + "staticValues_circuit3", +] # TypeVar for hot water data models HotWaterDataT = TypeVar( @@ -114,6 +128,11 @@ class BSBLAN: _initialized: bool = False _api_validator: APIValidator = field(init=False) _temperature_unit: str = "°C" + # Per-circuit temperature ranges: circuit_number -> (min, max, initialized) + _circuit_temp_ranges: dict[int, dict[str, float | None]] = field( + default_factory=dict, + ) + _circuit_temp_initialized: set[int] = field(default_factory=set) _hot_water_param_cache: dict[str, str] = field(default_factory=dict) # Track which hot water param groups have been validated _validated_hot_water_groups: set[str] = field(default_factory=set) @@ -509,18 +528,25 @@ def _set_api_version(self) -> None: else: raise BSBLANVersionError(VERSION_ERROR_MSG) - async def _initialize_temperature_range(self) -> None: + async def _initialize_temperature_range( + self, + circuit: int = 1, + ) -> None: """Initialize the temperature range from static values (lazy loaded). This method is called on-demand when temperature range is needed. It uses lazy loading through static_values() which will validate the staticValues section if not already done. + Args: + circuit: The heating circuit number (1, 2, or 3). + Note: Temperature unit is extracted during heating section validation from the response (parameter 710), so no extra API call is needed here. + """ - if not self._temperature_range_initialized: - # Try to get temperature range from static values (lazy loaded) + if circuit == 1 and not self._temperature_range_initialized: + # HC1 uses legacy fields for backwards compatibility try: static_values = await self.static_values() if static_values.min_temp is not None: @@ -547,6 +573,58 @@ async def _initialize_temperature_range(self) -> None: ) self._temperature_range_initialized = True + elif circuit != 1 and circuit not in self._circuit_temp_initialized: + # HC2/HC3 use per-circuit storage + try: + static_values = await self.static_values(circuit=circuit) + temp_range: dict[str, float | None] = { + "min": None, + "max": None, + } + if static_values.min_temp is not None: + temp_range["min"] = static_values.min_temp.value + logger.debug( + "Circuit %d min temp initialized: %s", + circuit, + temp_range["min"], + ) + if static_values.max_temp is not None: + temp_range["max"] = static_values.max_temp.value + logger.debug( + "Circuit %d max temp initialized: %s", + circuit, + temp_range["max"], + ) + self._circuit_temp_ranges[circuit] = temp_range + except BSBLANError as err: + logger.warning( + "Failed to get static values for circuit %d: %s. " + "Temperature range will be None", + circuit, + str(err), + ) + self._circuit_temp_ranges[circuit] = { + "min": None, + "max": None, + } + + self._circuit_temp_initialized.add(circuit) + + def _validate_circuit(self, circuit: int) -> None: + """Validate the circuit number. + + Args: + circuit: The heating circuit number to validate. + + Raises: + BSBLANInvalidParameterError: If the circuit number is invalid. + + """ + if circuit not in VALID_CIRCUITS: + msg = f"Invalid circuit number: {circuit}. Must be 1, 2, or 3." + raise BSBLANInvalidParameterError(msg) + + self._temperature_range_initialized = True @property def get_temperature_unit(self) -> str: @@ -843,7 +921,11 @@ async def _fetch_section_data( data = dict(zip(params["list"], list(data.values()), strict=True)) return model_class.model_validate(data) - async def state(self, include: list[str] | None = None) -> State: + async def state( + self, + include: list[str] | None = None, + circuit: int = 1, + ) -> State: """Get the current state from BSBLAN device. Args: @@ -852,6 +934,9 @@ async def state(self, include: list[str] | None = None) -> State: hvac_mode, target_temperature, hvac_action, hvac_mode_changeover, current_temperature, room1_thermostat_mode, room1_temp_setpoint_boost. + circuit: The heating circuit number (1, 2, or 3). Defaults to 1. + Circuit 2 and 3 use separate parameter IDs but return the + same State model with the same field names. Returns: State: The current state of the BSBLAN device. @@ -864,8 +949,15 @@ async def state(self, include: list[str] | None = None) -> State: # Fetch only hvac_mode and current_temperature state = await client.state(include=["hvac_mode", "current_temperature"]) + # Fetch state for heating circuit 2 + state_hc2 = await client.state(circuit=2) + """ - return await self._fetch_section_data("heating", State, include) + self._validate_circuit(circuit) + section: SectionLiteral = cast( + SectionLiteral, CIRCUIT_HEATING_SECTIONS[circuit] + ) + return await self._fetch_section_data(section, State, include) async def sensor(self, include: list[str] | None = None) -> Sensor: """Get the sensor information from BSBLAN device. @@ -885,13 +977,18 @@ async def sensor(self, include: list[str] | None = None) -> Sensor: """ return await self._fetch_section_data("sensor", Sensor, include) - async def static_values(self, include: list[str] | None = None) -> StaticState: + async def static_values( + self, + include: list[str] | None = None, + circuit: int = 1, + ) -> StaticState: """Get the static information from BSBLAN device. Args: include: Optional list of parameter names to fetch. If None, fetches all static parameters. Valid names include: min_temp, max_temp. + circuit: The heating circuit number (1, 2, or 3). Defaults to 1. Returns: StaticState: The static information from the BSBLAN device. @@ -900,8 +997,15 @@ async def static_values(self, include: list[str] | None = None) -> StaticState: # Fetch only min_temp static = await client.static_values(include=["min_temp"]) + # Fetch static values for heating circuit 2 + static_hc2 = await client.static_values(circuit=2) + """ - return await self._fetch_section_data("staticValues", StaticState, include) + self._validate_circuit(circuit) + section: SectionLiteral = cast( + SectionLiteral, CIRCUIT_STATIC_SECTIONS[circuit] + ) + return await self._fetch_section_data(section, StaticState, include) async def device(self) -> Device: """Get BSBLAN device info. @@ -968,6 +1072,7 @@ async def thermostat( self, target_temperature: str | None = None, hvac_mode: int | None = None, + circuit: int = 1, ) -> None: """Change the state of the thermostat through BSB-Lan. @@ -975,9 +1080,18 @@ async def thermostat( 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. + circuit: The heating circuit number (1, 2, or 3). Defaults to 1. + + Example: + # Set HC1 temperature + await client.thermostat(target_temperature="21.0") + + # Set HC2 mode + await client.thermostat(hvac_mode=1, circuit=2) """ - await self._initialize_temperature_range() + self._validate_circuit(circuit) + await self._initialize_temperature_range(circuit) self._validate_single_parameter( target_temperature, @@ -985,65 +1099,98 @@ async def thermostat( error_msg=MULTI_PARAMETER_ERROR_MSG, ) - state = await self._prepare_thermostat_state(target_temperature, hvac_mode) + state = await self._prepare_thermostat_state( + target_temperature, + hvac_mode, + circuit, + ) await self._set_device_state(state) async def _prepare_thermostat_state( self, target_temperature: str | None, hvac_mode: int | None, + circuit: int = 1, ) -> 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, 2, or 3). Returns: dict[str, Any]: The prepared state for the thermostat. """ + param_ids = CIRCUIT_THERMOSTAT_PARAMS[circuit] state: dict[str, Any] = {} if target_temperature is not None: - await self._validate_target_temperature(target_temperature) + await self._validate_target_temperature( + target_temperature, + circuit, + ) state.update( - {"Parameter": "710", "Value": target_temperature, "Type": "1"}, + { + "Parameter": param_ids["target_temperature"], + "Value": target_temperature, + "Type": "1", + }, ) if hvac_mode is not None: self._validate_hvac_mode(hvac_mode) state.update( { - "Parameter": "700", + "Parameter": param_ids["hvac_mode"], "Value": str(hvac_mode), "Type": "1", }, ) return state - async def _validate_target_temperature(self, target_temperature: str) -> None: + async def _validate_target_temperature( + self, + target_temperature: str, + circuit: int = 1, + ) -> None: """Validate the target temperature. This method lazy-loads the temperature range if not already initialized. Args: target_temperature (str): The target temperature to validate. + circuit: The heating circuit number (1, 2, or 3). Raises: BSBLANError: If the temperature range cannot be initialized. BSBLANInvalidParameterError: If the target temperature is invalid. """ - # Lazy load temperature range if needed - if self._min_temp is None or self._max_temp is None: - await self._initialize_temperature_range() + if circuit == 1: + # HC1 uses legacy fields for backwards compatibility + if self._min_temp is None or self._max_temp is None: + await self._initialize_temperature_range(circuit) + + if self._min_temp is None or self._max_temp is None: + raise BSBLANError(TEMPERATURE_RANGE_ERROR_MSG) + + min_temp = self._min_temp + max_temp = self._max_temp + else: + # HC2/HC3 use per-circuit storage + if circuit not in self._circuit_temp_initialized: + await self._initialize_temperature_range(circuit) + + temp_range = self._circuit_temp_ranges.get(circuit, {}) + min_temp = temp_range.get("min") + max_temp = temp_range.get("max") - # After initialization attempt, check if we have the range - if self._min_temp is None or self._max_temp is None: - raise BSBLANError(TEMPERATURE_RANGE_ERROR_MSG) + if min_temp is None or max_temp is None: + raise BSBLANError(TEMPERATURE_RANGE_ERROR_MSG) try: temp = float(target_temperature) - if not (self._min_temp <= temp <= self._max_temp): + if not (min_temp <= temp <= max_temp): raise BSBLANInvalidParameterError(target_temperature) except ValueError as err: raise BSBLANInvalidParameterError(target_temperature) from err diff --git a/src/bsblan/constants.py b/src/bsblan/constants.py index c0a90be8..cf9bf474 100644 --- a/src/bsblan/constants.py +++ b/src/bsblan/constants.py @@ -5,6 +5,11 @@ from enum import IntEnum from typing import Final, TypedDict +# Supported heating circuits (1-based) +MIN_CIRCUIT: Final[int] = 1 +MAX_CIRCUIT: Final[int] = 3 +VALID_CIRCUITS: Final[set[int]] = {1, 2, 3} + # API Versions class APIConfig(TypedDict): @@ -15,6 +20,11 @@ class APIConfig(TypedDict): device: dict[str, str] sensor: dict[str, str] hot_water: dict[str, str] + # Multi-circuit sections (heating circuit 2 and 3) + heating_circuit2: dict[str, str] + heating_circuit3: dict[str, str] + staticValues_circuit2: dict[str, str] + staticValues_circuit3: dict[str, str] # Base parameters that exist in all API versions @@ -93,6 +103,82 @@ class APIConfig(TypedDict): "716": "max_temp", # V3 uses 716 for max_temp } +# --- Heating Circuit 2 parameters (1000-series) --- +# These mirror HC1 (700-series) with an offset of +300 +BASE_HEATING_CIRCUIT2_PARAMS: Final[dict[str, str]] = { + "1000": "hvac_mode", + "1010": "target_temperature", + "1200": "hvac_mode_changeover", + # ------- + "8001": "hvac_action", + "8741": "current_temperature", + "8750": "room1_thermostat_mode", +} + +BASE_STATIC_VALUES_CIRCUIT2_PARAMS: Final[dict[str, str]] = { + "1014": "min_temp", +} + +V1_STATIC_VALUES_CIRCUIT2_EXTENSIONS: Final[dict[str, str]] = { + "1030": "max_temp", +} + +V3_HEATING_CIRCUIT2_EXTENSIONS: Final[dict[str, str]] = { + "1070": "room1_temp_setpoint_boost", +} + +V3_STATIC_VALUES_CIRCUIT2_EXTENSIONS: Final[dict[str, str]] = { + "1016": "max_temp", +} + +# --- Heating Circuit 3 parameters (1300-series) --- +# These mirror HC1 (700-series) with an offset of +600 +BASE_HEATING_CIRCUIT3_PARAMS: Final[dict[str, str]] = { + "1300": "hvac_mode", + "1310": "target_temperature", + "1500": "hvac_mode_changeover", + # ------- + "8002": "hvac_action", + "8742": "current_temperature", + "8751": "room1_thermostat_mode", +} + +BASE_STATIC_VALUES_CIRCUIT3_PARAMS: Final[dict[str, str]] = { + "1314": "min_temp", +} + +V1_STATIC_VALUES_CIRCUIT3_EXTENSIONS: Final[dict[str, str]] = { + "1330": "max_temp", +} + +V3_HEATING_CIRCUIT3_EXTENSIONS: Final[dict[str, str]] = { + "1370": "room1_temp_setpoint_boost", +} + +V3_STATIC_VALUES_CIRCUIT3_EXTENSIONS: Final[dict[str, str]] = { + "1316": "max_temp", +} + +# Mapping from circuit number to section names +CIRCUIT_HEATING_SECTIONS: Final[dict[int, str]] = { + 1: "heating", + 2: "heating_circuit2", + 3: "heating_circuit3", +} + +CIRCUIT_STATIC_SECTIONS: Final[dict[int, str]] = { + 1: "staticValues", + 2: "staticValues_circuit2", + 3: "staticValues_circuit3", +} + +# Mapping from circuit number to thermostat parameter IDs +CIRCUIT_THERMOSTAT_PARAMS: Final[dict[int, dict[str, str]]] = { + 1: {"target_temperature": "710", "hvac_mode": "700"}, + 2: {"target_temperature": "1010", "hvac_mode": "1000"}, + 3: {"target_temperature": "1310", "hvac_mode": "1300"}, +} + def build_api_config(version: str) -> APIConfig: """Build API configuration dynamically based on version. @@ -110,13 +196,32 @@ def build_api_config(version: str) -> APIConfig: "device": BASE_DEVICE_PARAMS.copy(), "sensor": BASE_SENSOR_PARAMS.copy(), "hot_water": BASE_HOT_WATER_PARAMS.copy(), + # Multi-circuit sections + "heating_circuit2": BASE_HEATING_CIRCUIT2_PARAMS.copy(), + "heating_circuit3": BASE_HEATING_CIRCUIT3_PARAMS.copy(), + "staticValues_circuit2": BASE_STATIC_VALUES_CIRCUIT2_PARAMS.copy(), + "staticValues_circuit3": BASE_STATIC_VALUES_CIRCUIT3_PARAMS.copy(), } if version == "v1": config["staticValues"].update(V1_STATIC_VALUES_EXTENSIONS) + config["staticValues_circuit2"].update( + V1_STATIC_VALUES_CIRCUIT2_EXTENSIONS, + ) + config["staticValues_circuit3"].update( + V1_STATIC_VALUES_CIRCUIT3_EXTENSIONS, + ) elif version == "v3": config["heating"].update(V3_HEATING_EXTENSIONS) config["staticValues"].update(V3_STATIC_VALUES_EXTENSIONS) + config["heating_circuit2"].update(V3_HEATING_CIRCUIT2_EXTENSIONS) + config["staticValues_circuit2"].update( + V3_STATIC_VALUES_CIRCUIT2_EXTENSIONS, + ) + config["heating_circuit3"].update(V3_HEATING_CIRCUIT3_EXTENSIONS) + config["staticValues_circuit3"].update( + V3_STATIC_VALUES_CIRCUIT3_EXTENSIONS, + ) return config From 3282ae668ce0a757bb4fae0cd9ef45fae29b1abc Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Wed, 25 Feb 2026 10:58:17 +0100 Subject: [PATCH 02/14] Add method to detect available heating circuits and define probe parameters --- src/bsblan/bsblan.py | 35 +++++++++++++++++++++++++++++++++++ src/bsblan/constants.py | 8 ++++++++ 2 files changed, 43 insertions(+) diff --git a/src/bsblan/bsblan.py b/src/bsblan/bsblan.py index 5b202848..9633f227 100644 --- a/src/bsblan/bsblan.py +++ b/src/bsblan/bsblan.py @@ -23,6 +23,7 @@ API_VERSION_ERROR_MSG, API_VERSIONS, CIRCUIT_HEATING_SECTIONS, + CIRCUIT_PROBE_PARAMS, CIRCUIT_STATIC_SECTIONS, CIRCUIT_THERMOSTAT_PARAMS, DHW_TIME_PROGRAM_PARAMS, @@ -174,6 +175,40 @@ async def initialize(self) -> None: await self._setup_api_validator() self._initialized = True + async def get_available_circuits(self) -> list[int]: + """Detect which heating circuits are available on the device. + + Probes the operating mode parameter for each circuit (1, 2, 3). + A circuit is considered available if the device returns a non-empty + response with a valid value (not empty ``{}``). + + This is useful for integration setup flows (e.g., Home Assistant + config flow) to discover how many circuits the user's controller + supports. + + Returns: + list[int]: Sorted list of available circuit numbers (e.g., [1, 2]). + + Example: + async with BSBLAN(config) as client: + circuits = await client.get_available_circuits() + # circuits == [1, 2] for a dual-circuit controller + + """ + available: list[int] = [] + for circuit, param_id in CIRCUIT_PROBE_PARAMS.items(): + try: + response = await self._request( + params={"Parameter": param_id}, + ) + # A circuit exists if the response contains the param_id key + # with actual data (not an empty dict) + if param_id in response and response[param_id]: + available.append(circuit) + except BSBLANError: + logger.debug("Circuit %d not available (request failed)", circuit) + return sorted(available) + async def _setup_api_validator(self) -> None: """Set up the API validator without validating sections. diff --git a/src/bsblan/constants.py b/src/bsblan/constants.py index cf9bf474..352e31f3 100644 --- a/src/bsblan/constants.py +++ b/src/bsblan/constants.py @@ -179,6 +179,14 @@ class APIConfig(TypedDict): 3: {"target_temperature": "1310", "hvac_mode": "1300"}, } +# Parameter IDs used to probe whether a heating circuit exists on the device. +# We query the operating mode (hvac_mode) for each circuit. +CIRCUIT_PROBE_PARAMS: Final[dict[int, str]] = { + 1: "700", + 2: "1000", + 3: "1300", +} + def build_api_config(version: str) -> APIConfig: """Build API configuration dynamically based on version. From 2946115cd694e838818218e7f053b9851872a855 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Wed, 25 Feb 2026 10:58:27 +0100 Subject: [PATCH 03/14] Add support for dual heating circuits and update related tests --- tests/fixtures/state_circuit2.json | 89 +++ tests/fixtures/static_state_circuit2.json | 16 + tests/test_api_validation.py | 4 + tests/test_circuit.py | 680 ++++++++++++++++++++++ tests/test_constants.py | 12 +- tests/test_hotwater_state.py | 4 + tests/test_initialization.py | 4 + tests/test_temperature_validation.py | 2 +- 8 files changed, 809 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/state_circuit2.json create mode 100644 tests/fixtures/static_state_circuit2.json create mode 100644 tests/test_circuit.py diff --git a/tests/fixtures/state_circuit2.json b/tests/fixtures/state_circuit2.json new file mode 100644 index 00000000..bb343f67 --- /dev/null +++ b/tests/fixtures/state_circuit2.json @@ -0,0 +1,89 @@ +{ + "1000": { + "name": "Operating mode", + "dataType_name": "ENUM", + "dataType_family": "ENUM", + "error": 0, + "value": "1", + "desc": "Automatic", + "dataType": 1, + "readonly": 0, + "readwrite": 0, + "unit": "" + }, + "1010": { + "name": "Comfort setpoint ", + "dataType_name": "TEMP", + "dataType_family": "VALS", + "error": 0, + "value": "20.0", + "desc": "", + "precision": 0.1, + "dataType": 0, + "readonly": 0, + "readwrite": 0, + "unit": "°C" + }, + "1200": { + "name": "Operating mode changeover", + "dataType_name": "ENUM", + "dataType_family": "ENUM", + "error": 0, + "value": "2", + "desc": "Reduced", + "dataType": 1, + "readonly": 0, + "readwrite": 0, + "unit": "" + }, + "8001": { + "name": "Status heating circuit 2", + "dataType_name": "ENUM", + "dataType_family": "ENUM", + "error": 0, + "value": "0", + "desc": "---", + "dataType": 1, + "readonly": 1, + "readwrite": 1, + "unit": "" + }, + "8741": { + "name": "Room temperature setpoint 1", + "dataType_name": "TEMP", + "dataType_family": "VALS", + "error": 0, + "value": "18.5", + "desc": "", + "precision": 0.1, + "dataType": 0, + "readonly": 1, + "readwrite": 1, + "unit": "°C" + }, + "8750": { + "name": "Room thermostat heating circuit 2", + "dataType_name": "ENUM", + "dataType_family": "ENUM", + "error": 0, + "value": "0", + "desc": "No demand", + "dataType": 1, + "readonly": 1, + "readwrite": 1, + "unit": "" + }, + "1070": { + "name": "Room temp setpoint boost (boost heating)", + "dataType_name": "TEMP", + "dataType_family": "VALS", + "error": 0, + "value": "---", + "desc": "", + "precision": 0.1, + "dataType": 0, + "readonly": 0, + "readwrite": 0, + "unit": "°C" + } +} diff --git a/tests/fixtures/static_state_circuit2.json b/tests/fixtures/static_state_circuit2.json new file mode 100644 index 00000000..c9e21531 --- /dev/null +++ b/tests/fixtures/static_state_circuit2.json @@ -0,0 +1,16 @@ +{ + "min_temp": { + "name": "Room temp protective setpoint cooling circuit 2", + "unit": "°C", + "desc": "", + "value": "8.0", + "dataType": 0 + }, + "max_temp": { + "name": "Comfort setpoint max", + "unit": "°C", + "desc": "", + "value": "28.0", + "dataType": 0 + } +} diff --git a/tests/test_api_validation.py b/tests/test_api_validation.py index c4b40ad6..b4c4a55e 100644 --- a/tests/test_api_validation.py +++ b/tests/test_api_validation.py @@ -371,6 +371,10 @@ async def test_validate_api_section_hot_water_cache() -> None: "staticValues": {}, "device": {}, "hot_water": {"1600": "operating_mode", "1610": "nominal_setpoint"}, + "heating_circuit2": {}, + "heating_circuit3": {}, + "staticValues_circuit2": {}, + "staticValues_circuit3": {}, } bsblan._api_validator = APIValidator(bsblan._api_data) diff --git a/tests/test_circuit.py b/tests/test_circuit.py new file mode 100644 index 00000000..2ba6ef7e --- /dev/null +++ b/tests/test_circuit.py @@ -0,0 +1,680 @@ +"""Tests for multi-circuit (HC1/HC2/HC3) heating support.""" + +# pylint: disable=protected-access + +from __future__ import annotations + +import json +from collections.abc import AsyncGenerator, Awaitable, Callable +from typing import Any +from unittest.mock import AsyncMock + +import aiohttp +import pytest +from aiohttp.web_request import Request +from aresponses import Response, ResponsesMockServer + +from bsblan import BSBLAN, BSBLANConfig, State, StaticState +from bsblan.constants import API_V3, MULTI_PARAMETER_ERROR_MSG +from bsblan.exceptions import BSBLANError, BSBLANInvalidParameterError +from bsblan.utility import APIValidator + +from . import load_fixture + + +# --- Fixtures --- + + +@pytest.fixture +async def mock_bsblan_circuit() -> AsyncGenerator[BSBLAN, None]: + """Fixture to create a mocked BSBLAN instance for circuit tests.""" + config = BSBLANConfig(host="example.com") + async with aiohttp.ClientSession() as session: + bsblan = BSBLAN(config, session=session) + bsblan._firmware_version = "1.0.38-20200730234859" + bsblan._api_version = "v3" + bsblan._api_data = { + k: v.copy() for k, v in API_V3.items() + } + bsblan._min_temp = 8.0 + bsblan._max_temp = 30.0 + bsblan._temperature_range_initialized = True + + api_validator = APIValidator(bsblan._api_data) + api_validator.validated_sections.add("heating") + api_validator.validated_sections.add("heating_circuit2") + api_validator.validated_sections.add("heating_circuit3") + api_validator.validated_sections.add("staticValues") + api_validator.validated_sections.add("staticValues_circuit2") + api_validator.validated_sections.add("staticValues_circuit3") + bsblan._api_validator = api_validator + + yield bsblan + + +def create_response_handler( + expected_data: dict[str, Any], +) -> Callable[[Request], Awaitable[Response]]: + """Create a response handler that checks the request data.""" + + async def response_handler(request: Request) -> Response: + """Check the request data.""" + actual_data = json.loads(await request.text()) + for key, value in expected_data.items(): + assert key in actual_data + if key == "Value": + assert str(actual_data[key]) == str(value) + else: + assert actual_data[key] == value + return Response( + text=json.dumps({"status": "success"}), + content_type="application/json", + ) + + return response_handler + + +# --- State tests --- + + +@pytest.mark.asyncio +async def test_state_circuit1_default(monkeypatch: Any) -> None: + """Test state() defaults to circuit 1.""" + 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 + + request_mock = AsyncMock( + return_value=json.loads(load_fixture("state.json")), + ) + monkeypatch.setattr(bsblan, "_request", request_mock) + + state: State = await bsblan.state() + + assert isinstance(state, State) + assert state.hvac_mode is not None + assert state.hvac_mode.value == 3 + assert state.target_temperature is not None + assert state.target_temperature.value == 18.0 + + +@pytest.mark.asyncio +async def test_state_circuit2(monkeypatch: Any) -> None: + """Test state() with circuit=2 returns HC2 data.""" + 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", + {k: v.copy() for k, v in API_V3.items()}, + ) + + api_validator = APIValidator(bsblan._api_data) + api_validator.validated_sections.add("heating_circuit2") + bsblan._api_validator = api_validator + + request_mock = AsyncMock( + return_value=json.loads( + load_fixture("state_circuit2.json"), + ), + ) + monkeypatch.setattr(bsblan, "_request", request_mock) + + state: State = await bsblan.state(circuit=2) + + assert isinstance(state, State) + assert state.hvac_mode is not None + assert state.hvac_mode.value == 1 + assert state.hvac_mode.desc == "Automatic" + assert state.current_temperature is not None + assert state.current_temperature.value == 18.5 + + +@pytest.mark.asyncio +async def test_state_circuit2_with_include(monkeypatch: Any) -> None: + """Test state() with circuit=2 and include filter.""" + 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", + {k: v.copy() for k, v in API_V3.items()}, + ) + + api_validator = APIValidator(bsblan._api_data) + api_validator.validated_sections.add("heating_circuit2") + bsblan._api_validator = api_validator + + # Only return hvac_mode data + request_mock = AsyncMock( + return_value={ + "1000": json.loads( + load_fixture("state_circuit2.json"), + )["1000"], + }, + ) + monkeypatch.setattr(bsblan, "_request", request_mock) + + state: State = await bsblan.state( + circuit=2, include=["hvac_mode"], + ) + + assert isinstance(state, State) + assert state.hvac_mode is not None + assert state.hvac_mode.value == 1 + + +# --- Static values tests --- + + +@pytest.mark.asyncio +async def test_static_values_circuit2(monkeypatch: Any) -> None: + """Test static_values() with circuit=2.""" + 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", + {k: v.copy() for k, v in API_V3.items()}, + ) + + api_validator = APIValidator(bsblan._api_data) + api_validator.validated_sections.add("staticValues_circuit2") + bsblan._api_validator = api_validator + + request_mock = AsyncMock( + return_value=json.loads( + load_fixture("static_state_circuit2.json"), + ), + ) + monkeypatch.setattr(bsblan, "_request", request_mock) + + static: StaticState = await bsblan.static_values(circuit=2) + + assert isinstance(static, StaticState) + assert static.min_temp is not None + assert static.min_temp.value == 8.0 + assert static.max_temp is not None + assert static.max_temp.value == 28.0 + + +# --- Thermostat tests --- + + +@pytest.mark.asyncio +async def test_thermostat_circuit2_temperature( + mock_bsblan_circuit: BSBLAN, + aresponses: ResponsesMockServer, +) -> None: + """Test setting temperature on circuit 2.""" + # Set up HC2 temp range + mock_bsblan_circuit._circuit_temp_ranges[2] = { + "min": 8.0, + "max": 28.0, + } + mock_bsblan_circuit._circuit_temp_initialized.add(2) + + expected_data = { + "Parameter": "1010", + "Value": "20", + "Type": "1", + } + aresponses.add( + "example.com", + "/JS", + "POST", + create_response_handler(expected_data), + ) + await mock_bsblan_circuit.thermostat( + target_temperature="20", circuit=2, + ) + + +@pytest.mark.asyncio +async def test_thermostat_circuit2_hvac_mode( + mock_bsblan_circuit: BSBLAN, + aresponses: ResponsesMockServer, +) -> None: + """Test setting HVAC mode on circuit 2.""" + # Set up HC2 temp range + mock_bsblan_circuit._circuit_temp_ranges[2] = { + "min": 8.0, + "max": 28.0, + } + mock_bsblan_circuit._circuit_temp_initialized.add(2) + + expected_data = { + "Parameter": "1000", + "Value": "1", + "Type": "1", + } + aresponses.add( + "example.com", + "/JS", + "POST", + create_response_handler(expected_data), + ) + await mock_bsblan_circuit.thermostat(hvac_mode=1, circuit=2) + + +@pytest.mark.asyncio +async def test_thermostat_circuit3_temperature( + mock_bsblan_circuit: BSBLAN, + aresponses: ResponsesMockServer, +) -> None: + """Test setting temperature on circuit 3.""" + mock_bsblan_circuit._circuit_temp_ranges[3] = { + "min": 8.0, + "max": 35.0, + } + mock_bsblan_circuit._circuit_temp_initialized.add(3) + + expected_data = { + "Parameter": "1310", + "Value": "25", + "Type": "1", + } + aresponses.add( + "example.com", + "/JS", + "POST", + create_response_handler(expected_data), + ) + await mock_bsblan_circuit.thermostat( + target_temperature="25", circuit=3, + ) + + +@pytest.mark.asyncio +async def test_thermostat_circuit3_hvac_mode( + mock_bsblan_circuit: BSBLAN, + aresponses: ResponsesMockServer, +) -> None: + """Test setting HVAC mode on circuit 3.""" + mock_bsblan_circuit._circuit_temp_ranges[3] = { + "min": 8.0, + "max": 35.0, + } + mock_bsblan_circuit._circuit_temp_initialized.add(3) + + expected_data = { + "Parameter": "1300", + "Value": "2", + "Type": "1", + } + aresponses.add( + "example.com", + "/JS", + "POST", + create_response_handler(expected_data), + ) + await mock_bsblan_circuit.thermostat(hvac_mode=2, circuit=3) + + +@pytest.mark.asyncio +async def test_thermostat_circuit1_still_works( + mock_bsblan_circuit: BSBLAN, + aresponses: ResponsesMockServer, +) -> None: + """Test that circuit=1 (default) still uses HC1 parameters.""" + expected_data = { + "Parameter": "710", + "Value": "20", + "Type": "1", + } + aresponses.add( + "example.com", + "/JS", + "POST", + create_response_handler(expected_data), + ) + await mock_bsblan_circuit.thermostat(target_temperature="20") + + +@pytest.mark.asyncio +async def test_thermostat_circuit2_invalid_temperature( + mock_bsblan_circuit: BSBLAN, +) -> None: + """Test setting out-of-range temperature on circuit 2.""" + 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="35", circuit=2, + ) + + +@pytest.mark.asyncio +async def test_thermostat_circuit2_no_temp_range( + mock_bsblan_circuit: BSBLAN, +) -> None: + """Test error when HC2 temp range not available.""" + # Set HC2 as initialized but with None range + mock_bsblan_circuit._circuit_temp_ranges[2] = { + "min": None, + "max": None, + } + mock_bsblan_circuit._circuit_temp_initialized.add(2) + + with pytest.raises(BSBLANError, match="Temperature range"): + await mock_bsblan_circuit.thermostat( + target_temperature="20", circuit=2, + ) + + +# --- Validation tests --- + + +@pytest.mark.asyncio +async def test_invalid_circuit_number( + mock_bsblan_circuit: BSBLAN, +) -> None: + """Test that invalid circuit numbers are rejected.""" + with pytest.raises(BSBLANInvalidParameterError, match="Invalid circuit"): + await mock_bsblan_circuit.state(circuit=0) + + with pytest.raises(BSBLANInvalidParameterError, match="Invalid circuit"): + await mock_bsblan_circuit.state(circuit=4) + + with pytest.raises(BSBLANInvalidParameterError, match="Invalid circuit"): + await mock_bsblan_circuit.static_values(circuit=99) + + +@pytest.mark.asyncio +async def test_invalid_circuit_thermostat( + mock_bsblan_circuit: BSBLAN, +) -> None: + """Test that invalid circuit numbers are rejected for thermostat.""" + with pytest.raises(BSBLANInvalidParameterError, match="Invalid circuit"): + await mock_bsblan_circuit.thermostat( + target_temperature="20", circuit=0, + ) + + +@pytest.mark.asyncio +async def test_thermostat_circuit2_no_params( + mock_bsblan_circuit: BSBLAN, +) -> None: + """Test that multi-parameter error still works with circuit.""" + mock_bsblan_circuit._circuit_temp_ranges[2] = { + "min": 8.0, + "max": 28.0, + } + mock_bsblan_circuit._circuit_temp_initialized.add(2) + + with pytest.raises(BSBLANError) as exc_info: + await mock_bsblan_circuit.thermostat(circuit=2) + assert str(exc_info.value) == MULTI_PARAMETER_ERROR_MSG + + +# --- Temperature range initialization tests --- + + +@pytest.mark.asyncio +async def test_circuit2_temp_range_initialization( + monkeypatch: Any, +) -> None: + """Test that HC2 temperature range initializes from static values.""" + 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", + {k: v.copy() for k, v in API_V3.items()}, + ) + + api_validator = APIValidator(bsblan._api_data) + api_validator.validated_sections.add("staticValues_circuit2") + bsblan._api_validator = api_validator + + static_fixture = json.loads( + load_fixture("static_state_circuit2.json"), + ) + request_mock = AsyncMock(return_value=static_fixture) + monkeypatch.setattr(bsblan, "_request", request_mock) + + await bsblan._initialize_temperature_range(circuit=2) + + assert 2 in bsblan._circuit_temp_initialized + assert bsblan._circuit_temp_ranges[2]["min"] == 8.0 + assert bsblan._circuit_temp_ranges[2]["max"] == 28.0 + + +@pytest.mark.asyncio +async def test_circuit1_temp_range_unchanged( + monkeypatch: Any, +) -> None: + """Test that HC1 temp range still uses legacy fields.""" + 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("staticValues") + bsblan._api_validator = api_validator + + static_fixture = json.loads( + load_fixture("static_state.json"), + ) + request_mock = AsyncMock(return_value=static_fixture) + monkeypatch.setattr(bsblan, "_request", request_mock) + + await bsblan._initialize_temperature_range(circuit=1) + + assert bsblan._temperature_range_initialized + assert bsblan._min_temp == 8.0 + assert bsblan._max_temp == 20.0 + # HC1 should NOT be in per-circuit storage + assert 1 not in bsblan._circuit_temp_initialized + + +@pytest.mark.asyncio +async def test_thermostat_circuit2_lazy_temp_init( + monkeypatch: Any, +) -> None: + """Test that HC2 thermostat lazy-initializes temp range.""" + 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", + {k: v.copy() for k, v in API_V3.items()}, + ) + + api_validator = APIValidator(bsblan._api_data) + api_validator.validated_sections.add("staticValues_circuit2") + api_validator.validated_sections.add("heating_circuit2") + bsblan._api_validator = api_validator + + # First: mock _request to return static values for temp range + static_fixture = json.loads( + load_fixture("static_state_circuit2.json"), + ) + + call_count = 0 + + async def mock_request(**kwargs: Any) -> dict[str, Any]: + nonlocal call_count + call_count += 1 + if call_count == 1: + # First call: static values for temp range init + return static_fixture + # Second call: thermostat set + return {"status": "success"} + + monkeypatch.setattr(bsblan, "_request", mock_request) + + # HC2 temp range is NOT initialized yet + assert 2 not in bsblan._circuit_temp_initialized + + # This should trigger lazy init of HC2 temp range + await bsblan._validate_target_temperature("20.0", circuit=2) + + # Verify it was initialized + assert 2 in bsblan._circuit_temp_initialized + assert bsblan._circuit_temp_ranges[2]["min"] == 8.0 + assert bsblan._circuit_temp_ranges[2]["max"] == 28.0 + + +# --- Tests for get_available_circuits --- + + +@pytest.mark.asyncio +async def test_get_available_circuits_two_circuits( + mock_bsblan_circuit: BSBLAN, +) -> None: + """Test detecting two available heating circuits.""" + bsblan = mock_bsblan_circuit + + async def mock_request( + **kwargs: Any, + ) -> dict[str, Any]: + params = kwargs.get("params", {}) + param_id = params.get("Parameter", "") + if param_id == "700": + return {"700": {"value": "1", "unit": "", "desc": "Automatic"}} + if param_id == "1000": + return {"1000": {"value": "1", "unit": "", "desc": "Automatic"}} + # HC3 returns empty + return {"1300": {}} + + bsblan._request = AsyncMock(side_effect=mock_request) # type: ignore[method-assign] + + circuits = await bsblan.get_available_circuits() + assert circuits == [1, 2] + + +@pytest.mark.asyncio +async def test_get_available_circuits_all_three( + mock_bsblan_circuit: BSBLAN, +) -> None: + """Test detecting all three available heating circuits.""" + bsblan = mock_bsblan_circuit + + async def mock_request( + **kwargs: Any, + ) -> dict[str, Any]: + params = kwargs.get("params", {}) + param_id = params.get("Parameter", "") + return {param_id: {"value": "1", "unit": "", "desc": "Automatic"}} + + bsblan._request = AsyncMock(side_effect=mock_request) # type: ignore[method-assign] + + circuits = await bsblan.get_available_circuits() + assert circuits == [1, 2, 3] + + +@pytest.mark.asyncio +async def test_get_available_circuits_only_one( + mock_bsblan_circuit: BSBLAN, +) -> None: + """Test detecting only one available heating circuit.""" + bsblan = mock_bsblan_circuit + + async def mock_request( + **kwargs: Any, + ) -> dict[str, Any]: + params = kwargs.get("params", {}) + param_id = params.get("Parameter", "") + if param_id == "700": + return {"700": {"value": "3", "unit": "", "desc": "Comfort"}} + return {param_id: {}} + + bsblan._request = AsyncMock(side_effect=mock_request) # type: ignore[method-assign] + + circuits = await bsblan.get_available_circuits() + assert circuits == [1] + + +@pytest.mark.asyncio +async def test_get_available_circuits_request_failure( + mock_bsblan_circuit: BSBLAN, +) -> None: + """Test circuit detection when some requests fail.""" + bsblan = mock_bsblan_circuit + + call_count = 0 + + async def mock_request( + **kwargs: Any, + ) -> dict[str, Any]: + nonlocal call_count + call_count += 1 + params = kwargs.get("params", {}) + param_id = params.get("Parameter", "") + if param_id == "700": + return {"700": {"value": "1", "unit": "", "desc": "Automatic"}} + # HC2 and HC3 fail with connection error + msg = "Connection failed" + raise BSBLANError(msg) + + bsblan._request = AsyncMock(side_effect=mock_request) # type: ignore[method-assign] + + circuits = await bsblan.get_available_circuits() + assert circuits == [1] + + +@pytest.mark.asyncio +async def test_get_available_circuits_param_not_in_response( + mock_bsblan_circuit: BSBLAN, +) -> None: + """Test circuit detection when param ID is missing from response.""" + bsblan = mock_bsblan_circuit + + async def mock_request( + **kwargs: Any, + ) -> dict[str, Any]: + params = kwargs.get("params", {}) + param_id = params.get("Parameter", "") + if param_id == "700": + return {"700": {"value": "1", "unit": "", "desc": "Automatic"}} + # Returns a response but without the expected param key + return {"other_key": {"value": "1"}} + + bsblan._request = AsyncMock(side_effect=mock_request) # type: ignore[method-assign] + + circuits = await bsblan.get_available_circuits() + assert circuits == [1] diff --git a/tests/test_constants.py b/tests/test_constants.py index 00d9ebef..12a16642 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -146,7 +146,17 @@ def test_api_config_structure(version: str) -> None: config = build_api_config(version) # Check all required sections exist - required_sections = {"heating", "staticValues", "device", "sensor", "hot_water"} + required_sections = { + "heating", + "staticValues", + "device", + "sensor", + "hot_water", + "heating_circuit2", + "heating_circuit3", + "staticValues_circuit2", + "staticValues_circuit3", + } assert set(config.keys()) == required_sections # Check that each section is a dict with string keys and values diff --git a/tests/test_hotwater_state.py b/tests/test_hotwater_state.py index a7d92e72..07d5f87f 100644 --- a/tests/test_hotwater_state.py +++ b/tests/test_hotwater_state.py @@ -42,6 +42,10 @@ async def test_hot_water_state( for k, v in API_V3["hot_water"].items() if k not in ["561", "562", "563", "564", "565", "566", "567", "576"] }, + "heating_circuit2": API_V3["heating_circuit2"].copy(), + "heating_circuit3": API_V3["heating_circuit3"].copy(), + "staticValues_circuit2": API_V3["staticValues_circuit2"].copy(), + "staticValues_circuit3": API_V3["staticValues_circuit3"].copy(), } monkeypatch.setattr(bsblan, "_api_data", test_api_v3) diff --git a/tests/test_initialization.py b/tests/test_initialization.py index d0590fda..a8fd637e 100644 --- a/tests/test_initialization.py +++ b/tests/test_initialization.py @@ -212,6 +212,10 @@ async def test_initialize_api_validator() -> None: "staticValues": {}, "device": {}, "hot_water": {}, + "heating_circuit2": {}, + "heating_circuit3": {}, + "staticValues_circuit2": {}, + "staticValues_circuit3": {}, } # Create a coroutine mock for _validate_api_section that returns response data diff --git a/tests/test_temperature_validation.py b/tests/test_temperature_validation.py index 1b8b6a3d..bfe48610 100644 --- a/tests/test_temperature_validation.py +++ b/tests/test_temperature_validation.py @@ -21,7 +21,7 @@ async def test_validate_target_temperature_no_range() -> None: bsblan = BSBLAN(config) # Mock _initialize_temperature_range to do nothing (simulate failure) - async def mock_init_temp_range() -> None: + async def mock_init_temp_range(circuit: int = 1) -> None: pass bsblan._initialize_temperature_range = mock_init_temp_range # type: ignore[method-assign] From 7fc316ceefaa24d3a00a55011a3de3c48ed46291 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Wed, 25 Feb 2026 10:59:10 +0100 Subject: [PATCH 04/14] Enhance documentation for adding parameters by including setup and usage instructions for fetching data from a real BSB-LAN device --- .github/copilot-instructions.md | 17 ++++++++ .github/skills/bsblan-parameters/SKILL.md | 51 +++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e121a41a..bb58e01a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -211,8 +211,25 @@ Test fixtures (JSON responses) are in `tests/fixtures/` ## Common Tasks +### Fetching Parameters from a Real Device + +Use `examples/fetch_param.py` to query raw parameter data from a real BSB-LAN device before adding new parameters: + +```bash +# Set your device connection details +export BSBLAN_HOST=blank to use auto-discovery +export BSBLAN_PASSKEY=your_passkey # if needed + +# Fetch one or more parameters +cd examples && python fetch_param.py 1645 +cd examples && python fetch_param.py 1645 1641 1642 1644 1646 +``` + +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. + ### Adding a New Settable Parameter +0. Fetch the parameter from a real device using `examples/fetch_param.py` to inspect the raw response 1. Add parameter ID mapping in `constants.py` 2. Add field to appropriate model in `models.py` 3. Add parameter to method signature in `bsblan.py` diff --git a/.github/skills/bsblan-parameters/SKILL.md b/.github/skills/bsblan-parameters/SKILL.md index e49d5897..95c803a1 100644 --- a/.github/skills/bsblan-parameters/SKILL.md +++ b/.github/skills/bsblan-parameters/SKILL.md @@ -14,6 +14,57 @@ This skill guides you through adding new parameters to the python-bsblan library - Legionella-related parameters use `legionella_function_*` prefix - DHW (Domestic Hot Water) parameters use `dhw_*` prefix +## Discovering Parameters from a Real System + +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. + +### Setup + +```bash +# Set environment variables for your device +export BSBLAN_HOST=your_host or blank to use autodiscovery # Your BSB-LAN IP address +export BSBLAN_PASSKEY=your_passkey # Optional: if your device requires a passkey +export BSBLAN_USER=username # Optional: if authentication is enabled +export BSBLAN_PASS=password # Optional: if authentication is enabled +export BSBLAN_PORT=80 # Optional: defaults to 80 +``` + +### Fetching Parameters + +```bash +# Fetch a single parameter +cd examples && python fetch_param.py 1645 + +# Fetch multiple parameters at once +cd examples && python fetch_param.py 1645 1641 1642 1644 1646 +``` + +### Example Output + +The raw API response shows the exact structure you need to model: + +```json +{ + "1645": { + "name": "Legionella function setpoint", + "value": "70.0", + "unit": "°C", + "desc": "", + "dataType": 0 + } +} +``` + +Use this output to determine: +- **Field type**: `float`, `int`, or `str` based on the `value` format +- **Unit**: The `unit` field (e.g., `°C`, `%`, `-`) +- **Description**: The `name` field for docstrings +- **Data type**: The `dataType` field for `EntityInfo` typing + +### Device Discovery + +`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. + ## Steps to Add a New Parameter ### 1. Add to `constants.py` From b8968688f3729c701121dce35aaa17f9f3f67521 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Wed, 25 Feb 2026 10:59:31 +0100 Subject: [PATCH 05/14] Update pre-commit configuration to rename pre-commit stage from hooks (prek) --- .pre-commit-config.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 88eb7694..92c9f970 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,14 +8,14 @@ repos: types: [python] entry: uv run ruff check --fix require_serial: true - stages: [pre-commit, pre-push, manual] + stages: [pre-push, manual] - id: ruff-format name: 🐶 Ruff Formatter language: system types: [python] entry: uv run ruff format require_serial: true - stages: [pre-commit, pre-push, manual] + stages: [pre-push, manual] - id: check-ast name: 🐍 Check Python AST language: system @@ -35,7 +35,7 @@ repos: language: system types: [text, executable] entry: uv run check-executables-have-shebangs - stages: [pre-commit, pre-push, manual] + stages: [pre-push, manual] - id: check-json name: { Check JSON files language: system @@ -82,7 +82,7 @@ repos: language: system types: [text] entry: uv run end-of-file-fixer - stages: [pre-commit, pre-push, manual] + stages: [pre-push, manual] - id: ty name: 🆎 Static type checking using ty language: system @@ -114,7 +114,7 @@ repos: language: system types: [text] entry: uv run trailing-whitespace-fixer - stages: [pre-commit, pre-push, manual] + stages: [pre-push, manual] - id: yamllint name: 🎗 Check YAML files with yamllint language: system From 1a3f84281063feb5ba710302b2c88c83dc3e2872 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Wed, 25 Feb 2026 11:11:55 +0100 Subject: [PATCH 06/14] Update skills Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/skills/bsblan-parameters/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/skills/bsblan-parameters/SKILL.md b/.github/skills/bsblan-parameters/SKILL.md index 95c803a1..275bc22a 100644 --- a/.github/skills/bsblan-parameters/SKILL.md +++ b/.github/skills/bsblan-parameters/SKILL.md @@ -22,7 +22,7 @@ Before adding a new parameter, use `examples/fetch_param.py` to retrieve the raw ```bash # Set environment variables for your device -export BSBLAN_HOST=your_host or blank to use autodiscovery # Your BSB-LAN IP address +export BSBLAN_HOST= # Your BSB-LAN IP address; leave unset to use autodiscovery export BSBLAN_PASSKEY=your_passkey # Optional: if your device requires a passkey export BSBLAN_USER=username # Optional: if authentication is enabled export BSBLAN_PASS=password # Optional: if authentication is enabled From 5b7ffacf63b65d11cf57f82c078ae70f8bd38e31 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Wed, 25 Feb 2026 11:12:10 +0100 Subject: [PATCH 07/14] Update .github/copilot-instructions.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bb58e01a..189a6a48 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -217,7 +217,8 @@ Use `examples/fetch_param.py` to query raw parameter data from a real BSB-LAN de ```bash # Set your device connection details -export BSBLAN_HOST=blank to use auto-discovery +# Leave BSBLAN_HOST unset to use auto-discovery, or set it explicitly: +export BSBLAN_HOST="192.168.1.100" export BSBLAN_PASSKEY=your_passkey # if needed # Fetch one or more parameters From 73f82942fa5224f75627c7647c09712377e55ea9 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Wed, 25 Feb 2026 11:13:26 +0100 Subject: [PATCH 08/14] Revert "Update pre-commit configuration to rename pre-commit stage from hooks (prek)" This reverts commit b8968688f3729c701121dce35aaa17f9f3f67521. --- .pre-commit-config.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 92c9f970..88eb7694 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,14 +8,14 @@ repos: types: [python] entry: uv run ruff check --fix require_serial: true - stages: [pre-push, manual] + stages: [pre-commit, pre-push, manual] - id: ruff-format name: 🐶 Ruff Formatter language: system types: [python] entry: uv run ruff format require_serial: true - stages: [pre-push, manual] + stages: [pre-commit, pre-push, manual] - id: check-ast name: 🐍 Check Python AST language: system @@ -35,7 +35,7 @@ repos: language: system types: [text, executable] entry: uv run check-executables-have-shebangs - stages: [pre-push, manual] + stages: [pre-commit, pre-push, manual] - id: check-json name: { Check JSON files language: system @@ -82,7 +82,7 @@ repos: language: system types: [text] entry: uv run end-of-file-fixer - stages: [pre-push, manual] + stages: [pre-commit, pre-push, manual] - id: ty name: 🆎 Static type checking using ty language: system @@ -114,7 +114,7 @@ repos: language: system types: [text] entry: uv run trailing-whitespace-fixer - stages: [pre-push, manual] + stages: [pre-commit, pre-push, manual] - id: yamllint name: 🎗 Check YAML files with yamllint language: system From ba1250d3451e06cacfc7add045952df015d6a00c Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Wed, 25 Feb 2026 11:17:21 +0100 Subject: [PATCH 09/14] Fix circuit availability check and update type casting for heating and static sections --- src/bsblan/bsblan.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/bsblan/bsblan.py b/src/bsblan/bsblan.py index 9633f227..76dc5586 100644 --- a/src/bsblan/bsblan.py +++ b/src/bsblan/bsblan.py @@ -203,7 +203,7 @@ async def get_available_circuits(self) -> list[int]: ) # A circuit exists if the response contains the param_id key # with actual data (not an empty dict) - if param_id in response and response[param_id]: + if response.get(param_id): available.append(circuit) except BSBLANError: logger.debug("Circuit %d not available (request failed)", circuit) @@ -990,7 +990,7 @@ async def state( """ self._validate_circuit(circuit) section: SectionLiteral = cast( - SectionLiteral, CIRCUIT_HEATING_SECTIONS[circuit] + "SectionLiteral", CIRCUIT_HEATING_SECTIONS[circuit] ) return await self._fetch_section_data(section, State, include) @@ -1038,7 +1038,7 @@ async def static_values( """ self._validate_circuit(circuit) section: SectionLiteral = cast( - SectionLiteral, CIRCUIT_STATIC_SECTIONS[circuit] + "SectionLiteral", CIRCUIT_STATIC_SECTIONS[circuit] ) return await self._fetch_section_data(section, StaticState, include) From 321c04d4c0edcaaf371a3672b7e1eea6637623a5 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Wed, 25 Feb 2026 11:17:38 +0100 Subject: [PATCH 10/14] Refactor test circuit code to use build_api_config for API data initialization --- tests/test_circuit.py | 82 +++++++++++++++++++++++++++---------------- 1 file changed, 52 insertions(+), 30 deletions(-) diff --git a/tests/test_circuit.py b/tests/test_circuit.py index 2ba6ef7e..13d9fd24 100644 --- a/tests/test_circuit.py +++ b/tests/test_circuit.py @@ -5,22 +5,24 @@ from __future__ import annotations import json -from collections.abc import AsyncGenerator, Awaitable, Callable -from typing import Any +from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock import aiohttp import pytest -from aiohttp.web_request import Request from aresponses import Response, ResponsesMockServer from bsblan import BSBLAN, BSBLANConfig, State, StaticState -from bsblan.constants import API_V3, MULTI_PARAMETER_ERROR_MSG +from bsblan.constants import MULTI_PARAMETER_ERROR_MSG, 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, Awaitable, Callable + + from aiohttp.web_request import Request # --- Fixtures --- @@ -33,9 +35,7 @@ async def mock_bsblan_circuit() -> AsyncGenerator[BSBLAN, None]: bsblan = BSBLAN(config, session=session) bsblan._firmware_version = "1.0.38-20200730234859" bsblan._api_version = "v3" - bsblan._api_data = { - k: v.copy() for k, v in API_V3.items() - } + bsblan._api_data = build_api_config("v3") bsblan._min_temp = 8.0 bsblan._max_temp = 30.0 bsblan._temperature_range_initialized = True @@ -84,12 +84,15 @@ async def test_state_circuit1_default(monkeypatch: Any) -> None: config = BSBLANConfig(host="example.com") bsblan = BSBLAN(config, session=session) monkeypatch.setattr( - bsblan, "_firmware_version", "1.0.38-20200730234859", + bsblan, + "_firmware_version", + "1.0.38-20200730234859", ) monkeypatch.setattr(bsblan, "_api_version", "v3") - monkeypatch.setattr(bsblan, "_api_data", API_V3) + api_data = build_api_config("v3") + monkeypatch.setattr(bsblan, "_api_data", api_data) - api_validator = APIValidator(API_V3) + api_validator = APIValidator(api_data) api_validator.validated_sections.add("heating") bsblan._api_validator = api_validator @@ -114,13 +117,15 @@ async def test_state_circuit2(monkeypatch: Any) -> None: config = BSBLANConfig(host="example.com") bsblan = BSBLAN(config, session=session) monkeypatch.setattr( - bsblan, "_firmware_version", "1.0.38-20200730234859", + bsblan, + "_firmware_version", + "1.0.38-20200730234859", ) monkeypatch.setattr(bsblan, "_api_version", "v3") monkeypatch.setattr( bsblan, "_api_data", - {k: v.copy() for k, v in API_V3.items()}, + build_api_config("v3"), ) api_validator = APIValidator(bsblan._api_data) @@ -151,13 +156,15 @@ async def test_state_circuit2_with_include(monkeypatch: Any) -> None: config = BSBLANConfig(host="example.com") bsblan = BSBLAN(config, session=session) monkeypatch.setattr( - bsblan, "_firmware_version", "1.0.38-20200730234859", + bsblan, + "_firmware_version", + "1.0.38-20200730234859", ) monkeypatch.setattr(bsblan, "_api_version", "v3") monkeypatch.setattr( bsblan, "_api_data", - {k: v.copy() for k, v in API_V3.items()}, + build_api_config("v3"), ) api_validator = APIValidator(bsblan._api_data) @@ -175,7 +182,8 @@ async def test_state_circuit2_with_include(monkeypatch: Any) -> None: monkeypatch.setattr(bsblan, "_request", request_mock) state: State = await bsblan.state( - circuit=2, include=["hvac_mode"], + circuit=2, + include=["hvac_mode"], ) assert isinstance(state, State) @@ -193,13 +201,15 @@ async def test_static_values_circuit2(monkeypatch: Any) -> None: config = BSBLANConfig(host="example.com") bsblan = BSBLAN(config, session=session) monkeypatch.setattr( - bsblan, "_firmware_version", "1.0.38-20200730234859", + bsblan, + "_firmware_version", + "1.0.38-20200730234859", ) monkeypatch.setattr(bsblan, "_api_version", "v3") monkeypatch.setattr( bsblan, "_api_data", - {k: v.copy() for k, v in API_V3.items()}, + build_api_config("v3"), ) api_validator = APIValidator(bsblan._api_data) @@ -250,7 +260,8 @@ async def test_thermostat_circuit2_temperature( create_response_handler(expected_data), ) await mock_bsblan_circuit.thermostat( - target_temperature="20", circuit=2, + target_temperature="20", + circuit=2, ) @@ -305,7 +316,8 @@ async def test_thermostat_circuit3_temperature( create_response_handler(expected_data), ) await mock_bsblan_circuit.thermostat( - target_temperature="25", circuit=3, + target_temperature="25", + circuit=3, ) @@ -368,7 +380,8 @@ async def test_thermostat_circuit2_invalid_temperature( with pytest.raises(BSBLANInvalidParameterError): await mock_bsblan_circuit.thermostat( - target_temperature="35", circuit=2, + target_temperature="35", + circuit=2, ) @@ -386,7 +399,8 @@ async def test_thermostat_circuit2_no_temp_range( with pytest.raises(BSBLANError, match="Temperature range"): await mock_bsblan_circuit.thermostat( - target_temperature="20", circuit=2, + target_temperature="20", + circuit=2, ) @@ -415,7 +429,8 @@ async def test_invalid_circuit_thermostat( """Test that invalid circuit numbers are rejected for thermostat.""" with pytest.raises(BSBLANInvalidParameterError, match="Invalid circuit"): await mock_bsblan_circuit.thermostat( - target_temperature="20", circuit=0, + target_temperature="20", + circuit=0, ) @@ -447,13 +462,15 @@ async def test_circuit2_temp_range_initialization( config = BSBLANConfig(host="example.com") bsblan = BSBLAN(config, session=session) monkeypatch.setattr( - bsblan, "_firmware_version", "1.0.38-20200730234859", + bsblan, + "_firmware_version", + "1.0.38-20200730234859", ) monkeypatch.setattr(bsblan, "_api_version", "v3") monkeypatch.setattr( bsblan, "_api_data", - {k: v.copy() for k, v in API_V3.items()}, + build_api_config("v3"), ) api_validator = APIValidator(bsblan._api_data) @@ -482,12 +499,15 @@ async def test_circuit1_temp_range_unchanged( config = BSBLANConfig(host="example.com") bsblan = BSBLAN(config, session=session) monkeypatch.setattr( - bsblan, "_firmware_version", "1.0.38-20200730234859", + bsblan, + "_firmware_version", + "1.0.38-20200730234859", ) monkeypatch.setattr(bsblan, "_api_version", "v3") - monkeypatch.setattr(bsblan, "_api_data", API_V3) + api_data = build_api_config("v3") + monkeypatch.setattr(bsblan, "_api_data", api_data) - api_validator = APIValidator(API_V3) + api_validator = APIValidator(api_data) api_validator.validated_sections.add("staticValues") bsblan._api_validator = api_validator @@ -515,13 +535,15 @@ async def test_thermostat_circuit2_lazy_temp_init( config = BSBLANConfig(host="example.com") bsblan = BSBLAN(config, session=session) monkeypatch.setattr( - bsblan, "_firmware_version", "1.0.38-20200730234859", + bsblan, + "_firmware_version", + "1.0.38-20200730234859", ) monkeypatch.setattr(bsblan, "_api_version", "v3") monkeypatch.setattr( bsblan, "_api_data", - {k: v.copy() for k, v in API_V3.items()}, + build_api_config("v3"), ) api_validator = APIValidator(bsblan._api_data) @@ -536,7 +558,7 @@ async def test_thermostat_circuit2_lazy_temp_init( call_count = 0 - async def mock_request(**kwargs: Any) -> dict[str, Any]: + async def mock_request(**_kwargs: Any) -> dict[str, Any]: nonlocal call_count call_count += 1 if call_count == 1: From abd4095766ee1c733274216863b86641f50b30be Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Wed, 25 Feb 2026 11:19:47 +0100 Subject: [PATCH 11/14] Remove unnecessary temperature range initialization in BSBLAN class --- src/bsblan/bsblan.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/bsblan/bsblan.py b/src/bsblan/bsblan.py index 76dc5586..437a093d 100644 --- a/src/bsblan/bsblan.py +++ b/src/bsblan/bsblan.py @@ -659,8 +659,6 @@ def _validate_circuit(self, circuit: int) -> None: msg = f"Invalid circuit number: {circuit}. Must be 1, 2, or 3." raise BSBLANInvalidParameterError(msg) - self._temperature_range_initialized = True - @property def get_temperature_unit(self) -> str: """Get the unit of temperature. From 46fbc009ab28dcb2da8914d84dad26ba89867a6d Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Wed, 25 Feb 2026 11:27:14 +0100 Subject: [PATCH 12/14] Add error message constants for section validation and circuit checks --- src/bsblan/bsblan.py | 19 ++++++++++++++----- src/bsblan/constants.py | 11 +++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/bsblan/bsblan.py b/src/bsblan/bsblan.py index 437a093d..f792755a 100644 --- a/src/bsblan/bsblan.py +++ b/src/bsblan/bsblan.py @@ -28,11 +28,14 @@ CIRCUIT_THERMOSTAT_PARAMS, DHW_TIME_PROGRAM_PARAMS, EMPTY_INCLUDE_LIST_ERROR_MSG, + EMPTY_SECTION_PARAMS_ERROR_MSG, FIRMWARE_VERSION_ERROR_MSG, HOT_WATER_CONFIG_PARAMS, HOT_WATER_ESSENTIAL_PARAMS, HOT_WATER_SCHEDULE_PARAMS, + INVALID_CIRCUIT_ERROR_MSG, INVALID_INCLUDE_PARAMS_ERROR_MSG, + INVALID_RESPONSE_ERROR_MSG, MAX_VALID_YEAR, MIN_VALID_YEAR, MULTI_PARAMETER_ERROR_MSG, @@ -41,6 +44,7 @@ NO_SCHEDULE_ERROR_MSG, NO_STATE_ERROR_MSG, PARAMETER_NAMES_NOT_RESOLVED_ERROR_MSG, + SECTION_NOT_FOUND_ERROR_MSG, SESSION_NOT_INITIALIZED_ERROR_MSG, SETTABLE_HOT_WATER_PARAMS, TEMPERATURE_RANGE_ERROR_MSG, @@ -441,8 +445,8 @@ async def _validate_api_section( try: section_data = self._api_data[section] except KeyError as err: - error_msg = f"Section '{section}' not found in API data" - raise BSBLANError(error_msg) from err + msg = SECTION_NOT_FOUND_ERROR_MSG.format(section) + raise BSBLANError(msg) from err # Filter to only included params if specified if include is not None: @@ -656,7 +660,7 @@ def _validate_circuit(self, circuit: int) -> None: """ if circuit not in VALID_CIRCUITS: - msg = f"Invalid circuit number: {circuit}. Must be 1, 2, or 3." + msg = INVALID_CIRCUIT_ERROR_MSG.format(circuit) raise BSBLANInvalidParameterError(msg) @property @@ -801,8 +805,8 @@ async def _request_with_retry( raise except (ValueError, UnicodeDecodeError) as e: # Handle JSON decode errors and other parsing issues - error_msg = f"Invalid response format from BSB-LAN device: {e!s}" - raise BSBLANError(error_msg) from e + msg = INVALID_RESPONSE_ERROR_MSG.format(e) + raise BSBLANError(msg) from e def _process_response( self, response_data: dict[str, Any], base_path: str @@ -937,6 +941,11 @@ async def _fetch_section_data( section_params = self._api_validator.get_section_params(section) + # Guard: if validation removed all params, the section is not available + if not section_params: + msg = EMPTY_SECTION_PARAMS_ERROR_MSG.format(section) + raise BSBLANError(msg) + # Filter parameters if include list is specified if include is not None: if not include: diff --git a/src/bsblan/constants.py b/src/bsblan/constants.py index 352e31f3..c8e8ffcd 100644 --- a/src/bsblan/constants.py +++ b/src/bsblan/constants.py @@ -508,6 +508,17 @@ def get_hvac_action_category(status_code: int) -> HVACActionCategory: SESSION_NOT_INITIALIZED_ERROR_MSG: Final[str] = "Session not initialized" API_DATA_NOT_INITIALIZED_ERROR_MSG: Final[str] = "API data not initialized" API_VALIDATOR_NOT_INITIALIZED_ERROR_MSG: Final[str] = "API validator not initialized" +SECTION_NOT_FOUND_ERROR_MSG: Final[str] = "Section '{}' not found in API data" +INVALID_CIRCUIT_ERROR_MSG: Final[str] = ( + "Invalid circuit number: {}. Must be 1, 2, or 3." +) +INVALID_RESPONSE_ERROR_MSG: Final[str] = ( + "Invalid response format from BSB-LAN device: {}" +) +EMPTY_SECTION_PARAMS_ERROR_MSG: Final[str] = ( + "No valid parameters found for section '{}'. " + "The device may not support this circuit or section." +) # Time validation constants MIN_VALID_YEAR: Final[int] = 1900 # Reasonable minimum year for BSB-LAN devices From c9620a20f8e933c2c9dea46eaf483a1cb553dea1 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Wed, 25 Feb 2026 11:27:18 +0100 Subject: [PATCH 13/14] Add test for empty section validation in circuit state retrieval --- tests/test_circuit.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_circuit.py b/tests/test_circuit.py index 13d9fd24..6cf55d38 100644 --- a/tests/test_circuit.py +++ b/tests/test_circuit.py @@ -700,3 +700,19 @@ async def mock_request( circuits = await bsblan.get_available_circuits() assert circuits == [1] + + +@pytest.mark.asyncio +async def test_state_empty_section_after_validation( + mock_bsblan_circuit: BSBLAN, +) -> None: + """Test that fetching state for a circuit with all params removed raises error.""" + bsblan = mock_bsblan_circuit + + # Simulate validation removing all params for heating_circuit2 + assert bsblan._api_validator is not None + bsblan._api_validator.api_config["heating_circuit2"] = {} # type: ignore[index] + bsblan._api_validator.validated_sections.add("heating_circuit2") + + with pytest.raises(BSBLANError, match="No valid parameters found"): + await bsblan.state(circuit=2) From 2e3bba3c69a847fb2a98ebb2347b503c6a8a93d0 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Wed, 25 Feb 2026 11:32:23 +0100 Subject: [PATCH 14/14] Add dual heating support by fetching temperature ranges for circuits --- src/bsblan/bsblan.py | 113 +++++++++++++++++++++---------------------- 1 file changed, 54 insertions(+), 59 deletions(-) diff --git a/src/bsblan/bsblan.py b/src/bsblan/bsblan.py index f792755a..ac59b187 100644 --- a/src/bsblan/bsblan.py +++ b/src/bsblan/bsblan.py @@ -567,6 +567,49 @@ def _set_api_version(self) -> None: else: raise BSBLANVersionError(VERSION_ERROR_MSG) + async def _fetch_temperature_range( + self, + circuit: int, + ) -> dict[str, float | None]: + """Fetch min/max temperature range for a circuit from the device. + + Args: + circuit: The heating circuit number (1, 2, or 3). + + Returns: + dict with 'min' and 'max' keys (values may be None if unavailable). + + """ + temp_range: dict[str, float | None] = {"min": None, "max": None} + try: + static_values = await self.static_values(circuit=circuit) + except BSBLANError as err: + logger.warning( + "Failed to get static values for circuit %d: %s. " + "Temperature range will be None", + circuit, + str(err), + ) + return temp_range + + if static_values.min_temp is not None: + temp_range["min"] = static_values.min_temp.value + logger.debug( + "Circuit %d min temp initialized: %s", + circuit, + temp_range["min"], + ) + + if static_values.max_temp is not None: + temp_range["max"] = static_values.max_temp.value + logger.debug( + "Circuit %d max temp initialized: %s", + circuit, + temp_range["max"], + ) + + return temp_range + async def _initialize_temperature_range( self, circuit: int = 1, @@ -584,69 +627,21 @@ async def _initialize_temperature_range( from the response (parameter 710), so no extra API call is needed here. """ - if circuit == 1 and not self._temperature_range_initialized: - # HC1 uses legacy fields for backwards compatibility - try: - static_values = await self.static_values() - if static_values.min_temp is not None: - self._min_temp = static_values.min_temp.value - logger.debug("Min temperature initialized: %s", self._min_temp) - else: - logger.warning( - "min_temp not available from device, " - "temperature range will be None" - ) + if circuit == 1 and self._temperature_range_initialized: + return + if circuit != 1 and circuit in self._circuit_temp_initialized: + return - if static_values.max_temp is not None: - self._max_temp = static_values.max_temp.value - logger.debug("Max temperature initialized: %s", self._max_temp) - else: - logger.warning( - "max_temp not available from device, " - "temperature range will be None" - ) - except BSBLANError as err: - logger.warning( - "Failed to get static values: %s. Temperature range will be None", - str(err), - ) + temp_range = await self._fetch_temperature_range(circuit) + if circuit == 1: + # HC1 uses legacy fields for backwards compatibility + self._min_temp = temp_range["min"] + self._max_temp = temp_range["max"] self._temperature_range_initialized = True - elif circuit != 1 and circuit not in self._circuit_temp_initialized: + else: # HC2/HC3 use per-circuit storage - try: - static_values = await self.static_values(circuit=circuit) - temp_range: dict[str, float | None] = { - "min": None, - "max": None, - } - if static_values.min_temp is not None: - temp_range["min"] = static_values.min_temp.value - logger.debug( - "Circuit %d min temp initialized: %s", - circuit, - temp_range["min"], - ) - if static_values.max_temp is not None: - temp_range["max"] = static_values.max_temp.value - logger.debug( - "Circuit %d max temp initialized: %s", - circuit, - temp_range["max"], - ) - self._circuit_temp_ranges[circuit] = temp_range - except BSBLANError as err: - logger.warning( - "Failed to get static values for circuit %d: %s. " - "Temperature range will be None", - circuit, - str(err), - ) - self._circuit_temp_ranges[circuit] = { - "min": None, - "max": None, - } - + self._circuit_temp_ranges[circuit] = temp_range self._circuit_temp_initialized.add(circuit) def _validate_circuit(self, circuit: int) -> None: