diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e121a41a..189a6a48 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -211,8 +211,26 @@ 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 +# 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 +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..275bc22a 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 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 +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` diff --git a/src/bsblan/bsblan.py b/src/bsblan/bsblan.py index b1dbe1d6..ac59b187 100644 --- a/src/bsblan/bsblan.py +++ b/src/bsblan/bsblan.py @@ -22,13 +22,20 @@ API_VALIDATOR_NOT_INITIALIZED_ERROR_MSG, API_VERSION_ERROR_MSG, API_VERSIONS, + CIRCUIT_HEATING_SECTIONS, + CIRCUIT_PROBE_PARAMS, + CIRCUIT_STATIC_SECTIONS, + 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, @@ -37,9 +44,11 @@ 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, + VALID_CIRCUITS, VALID_HVAC_MODES, VERSION_ERROR_MSG, APIConfig, @@ -73,7 +82,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 +133,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) @@ -155,6 +179,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 response.get(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. @@ -387,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: @@ -509,44 +567,96 @@ def _set_api_version(self) -> None: else: raise BSBLANVersionError(VERSION_ERROR_MSG) - async def _initialize_temperature_range(self) -> None: + 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, + ) -> 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) - 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 + else: + # HC2/HC3 use per-circuit storage + self._circuit_temp_ranges[circuit] = temp_range + 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 = INVALID_CIRCUIT_ERROR_MSG.format(circuit) + raise BSBLANInvalidParameterError(msg) @property def get_temperature_unit(self) -> str: @@ -690,8 +800,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 @@ -826,6 +936,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: @@ -843,7 +958,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 +971,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 +986,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 +1014,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 +1034,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 +1109,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 +1117,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 +1136,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..c8e8ffcd 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,90 @@ 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"}, +} + +# 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. @@ -110,13 +204,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 @@ -395,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 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..6cf55d38 --- /dev/null +++ b/tests/test_circuit.py @@ -0,0 +1,718 @@ +"""Tests for multi-circuit (HC1/HC2/HC3) heating 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 aresponses import Response, ResponsesMockServer + +from bsblan import BSBLAN, BSBLANConfig, State, StaticState +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 --- + + +@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 = build_api_config("v3") + 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") + api_data = build_api_config("v3") + monkeypatch.setattr(bsblan, "_api_data", api_data) + + api_validator = APIValidator(api_data) + 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", + build_api_config("v3"), + ) + + 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", + build_api_config("v3"), + ) + + 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", + build_api_config("v3"), + ) + + 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", + build_api_config("v3"), + ) + + 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") + api_data = build_api_config("v3") + monkeypatch.setattr(bsblan, "_api_data", api_data) + + api_validator = APIValidator(api_data) + 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", + build_api_config("v3"), + ) + + 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] + + +@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) 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]