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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 14 additions & 86 deletions src/bsblan/bsblan.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,11 @@ class BSBLAN:
_close_session: bool = False
_firmware_version: str | None = None
_api_version: str | None = None
_min_temp: float | None = None
_max_temp: float | None = None
_temperature_range_initialized: bool = False
_api_data: APIConfig | None = None
_initialized: bool = False
_api_validator: APIValidator = field(init=False)
_temperature_unit: str = "°C"
# Per-circuit temperature ranges: circuit_number -> (min, max, initialized)
# Per-circuit temperature ranges: circuit_number -> {min, max}
_circuit_temp_ranges: dict[int, dict[str, float | None]] = field(
default_factory=dict,
)
Expand Down Expand Up @@ -345,7 +342,7 @@ async def _ensure_hot_water_group_validated(
return

# Request only these specific parameters from the device
params = await self._extract_params_summary(group_params)
params = self._extract_params_summary(group_params)
response_data = await self._request(
params={"Parameter": params["string_par"]}
)
Expand Down Expand Up @@ -386,39 +383,6 @@ async def _ensure_hot_water_group_validated(
len(params_to_remove),
)

async def _initialize_api_validator(self) -> None:
"""Initialize and validate API data against device capabilities.

DEPRECATED: This method validates all sections upfront.
Use _setup_api_validator() + _ensure_section_validated() for lazy loading.
This method is kept for backwards compatibility.
"""
if self._api_version is None:
raise BSBLANError(ErrorMsg.API_VERSION)

# Initialize API data if not already done
if self._api_data is None:
self._api_data = self._copy_api_config()

# Initialize the API validator
self._api_validator = APIValidator(self._api_data)

# Perform initial validation of each section (eager loading)
sections: list[SectionLiteral] = [
"heating",
"sensor",
"staticValues",
"device",
"hot_water",
]
for section in sections:
response_data = await self._validate_api_section(section)

# Extract temperature unit from heating section validation
# (parameter 710 - target_temperature is always in heating section)
if section == "heating" and response_data:
self._extract_temperature_unit_from_response(response_data)

async def _validate_api_section(
self, section: SectionLiteral, include: list[str] | None = None
) -> dict[str, Any] | None:
Expand Down Expand Up @@ -466,7 +430,7 @@ async def _validate_api_section(

try:
# Request data from device for validation
params = await self._extract_params_summary(section_data)
params = self._extract_params_summary(section_data)
response_data = await self._request(
params={"Parameter": params["string_par"]}
)
Expand Down Expand Up @@ -635,22 +599,12 @@ async def _initialize_temperature_range(
from the response (parameter 710), so no extra API call is needed here.

"""
if circuit == 1 and self._temperature_range_initialized:
return
if circuit != 1 and circuit in self._circuit_temp_initialized:
if circuit in self._circuit_temp_initialized:
return

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 uses per-circuit storage
self._circuit_temp_ranges[circuit] = temp_range
self._circuit_temp_initialized.add(circuit)
self._circuit_temp_ranges[circuit] = temp_range
self._circuit_temp_initialized.add(circuit)

def _validate_circuit(self, circuit: int) -> None:
"""Validate the circuit number.
Expand Down Expand Up @@ -680,23 +634,6 @@ def get_temperature_unit(self) -> str:
"""
return self._temperature_unit

async def _initialize_api_data(self) -> APIConfig:
"""Initialize and cache the API data.

Returns:
APIConfig: The API configuration data.

Raises:
BSBLANError: If the API version or data is not initialized.

"""
if self._api_data is None:
self._api_data = self._copy_api_config()
logger.debug("API data initialized for version: %s", self._api_version)
if self._api_data is None:
raise BSBLANError(ErrorMsg.API_DATA_NOT_INITIALIZED)
return self._api_data

def _copy_api_config(self) -> APIConfig:
"""Create a copy of the API configuration for the current version.

Expand Down Expand Up @@ -900,7 +837,7 @@ def _validate_single_parameter(self, *params: Any, error_msg: str) -> None:
if sum(param is not None for param in params) != 1:
raise BSBLANError(error_msg)

async def _extract_params_summary(self, params: dict[Any, Any]) -> dict[Any, Any]:
def _extract_params_summary(self, params: dict[Any, Any]) -> dict[Any, Any]:
"""Get the parameters info from BSBLAN device.

Args:
Expand Down Expand Up @@ -961,7 +898,7 @@ async def _fetch_section_data(
if not section_params:
raise BSBLANError(ErrorMsg.INVALID_INCLUDE_PARAMS)

params = await self._extract_params_summary(section_params)
params = self._extract_params_summary(section_params)
data = await self._request(params={"Parameter": params["string_par"]})
data = dict(zip(params["list"], list(data.values()), strict=True))
return model_class.model_validate(data)
Expand Down Expand Up @@ -1219,21 +1156,12 @@ async def _validate_target_temperature(
raise BSBLANInvalidParameterError(target_temperature) from err

# Try to load temperature range for bounds checking
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)

min_temp = self._min_temp
max_temp = self._max_temp
else:
# HC2 uses per-circuit storage
if circuit not in self._circuit_temp_initialized:
await self._initialize_temperature_range(circuit)
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")
temp_range = self._circuit_temp_ranges.get(circuit, {})
min_temp = temp_range.get("min")
max_temp = temp_range.get("max")

# Skip range validation if device doesn't provide min/max
if min_temp is None or max_temp is None:
Expand Down Expand Up @@ -1337,7 +1265,7 @@ async def _fetch_hot_water_data(
if not filtered_params:
raise BSBLANError(error_msg)

params = await self._extract_params_summary(filtered_params)
params = self._extract_params_summary(filtered_params)
data = await self._request(params={"Parameter": params["string_par"]})
data = dict(zip(params["list"], list(data.values()), strict=True))
return model_class.model_validate(data)
Expand Down
7 changes: 3 additions & 4 deletions src/bsblan/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,14 @@ class BSBLANConnectionError(BSBLANError):
message_timeout: str = "Timeout occurred while connecting to BSBLAN device."
message_error: str = "Error occurred while connecting to BSBLAN device."

def __init__(self, response: str | None = None) -> None:
def __init__(self, message: str | None = None) -> None:
"""Initialize a new instance of the BSBLANConnectionError class.

Args:
response: Optional response message.
message: Optional error message.

"""
self.response = response
super().__init__(self.message)
super().__init__(message)
Comment on lines +29 to +36
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renaming the constructor parameter from response to message is a breaking public API change for callers that instantiate BSBLANConnectionError with response= (it’s exported in bsblan.__init__). Consider keeping backwards compatibility by accepting the old keyword as an alias (or using a deprecated response kwarg that maps to message).

Copilot uses AI. Check for mistakes.


class BSBLANVersionError(BSBLANError):
Expand Down
3 changes: 0 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,6 @@ async def mock_bsblan(
monkeypatch.setattr(bsblan, "_firmware_version", "1.0.38-20200730234859")
monkeypatch.setattr(bsblan, "_api_version", "v3")
monkeypatch.setattr(bsblan, "_api_data", API_V3)
initialize_api_data_mock: AsyncMock = AsyncMock()
# return the constant dictionary
monkeypatch.setattr(bsblan, "_initialize_api_data", initialize_api_data_mock)
request_mock: AsyncMock = AsyncMock(return_value={"status": "ok"})
monkeypatch.setattr(bsblan, "_request", request_mock)
yield bsblan
58 changes: 7 additions & 51 deletions tests/test_api_initialization.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,17 @@
"""Tests for API data initialization error handling."""
# pylint: disable=protected-access

from typing import Any
from unittest.mock import AsyncMock

import aiohttp
import pytest

from bsblan import BSBLAN
from bsblan.bsblan import BSBLANConfig
from bsblan.constants import (
API_VERSIONS,
ErrorMsg,
)
from bsblan.exceptions import BSBLANError


@pytest.mark.asyncio
async def test_initialize_api_data_no_api_version() -> None:
"""Test initializing API data with no API version set."""
config = BSBLANConfig(host="example.com")
bsblan = BSBLAN(config)

# API version is None by default
with pytest.raises(BSBLANError, match=ErrorMsg.API_VERSION):
await bsblan._initialize_api_data()


@pytest.mark.asyncio
async def test_request_no_session() -> None:
"""Test request method with no session initialized."""
Expand All @@ -39,8 +24,8 @@ async def test_request_no_session() -> None:


@pytest.mark.asyncio
async def test_api_data_initialized_from_versions(monkeypatch: Any) -> None:
"""Test that API data is initialized from API_VERSIONS when None (line 141)."""
async def test_api_data_initialized_from_versions() -> None:
"""Test that API data is initialized via _setup_api_validator."""
async with aiohttp.ClientSession() as session:
config = BSBLANConfig(host="example.com")
client = BSBLAN(config, session=session)
Expand All @@ -49,23 +34,18 @@ async def test_api_data_initialized_from_versions(monkeypatch: Any) -> None:
client._api_version = "v1"
client._api_data = None # This should be initialized

# Mock request to avoid real network calls
request_mock: AsyncMock = AsyncMock(return_value={})
monkeypatch.setattr(client, "_request", request_mock)

# Call _initialize_api_validator which should initialize _api_data
await client._initialize_api_validator()
# Call _setup_api_validator which should initialize _api_data
await client._setup_api_validator()

# Verify API data was initialized (should be a copy, not the same object)
assert client._api_data is not None
# Verify it started with the same keys as API_VERSIONS["v1"]
assert set(client._api_data.keys()) == set(API_VERSIONS["v1"].keys())
# Note: Values will differ after validation since validator modifies the copy


@pytest.mark.asyncio
async def test_api_data_property_raises_without_version() -> None:
"""Test _initialize_api_data raises error when API version is None (line 368)."""
async def test_copy_api_config_raises_without_version() -> None:
"""Test _copy_api_config raises error when API version is None."""
async with aiohttp.ClientSession() as session:
config = BSBLANConfig(host="example.com")
client = BSBLAN(config, session=session)
Expand All @@ -76,28 +56,4 @@ async def test_api_data_property_raises_without_version() -> None:

# This should raise BSBLANError
with pytest.raises(BSBLANError, match="API version not set"):
await client._initialize_api_data()


@pytest.mark.asyncio
async def test_initialize_api_data_returns_existing() -> None:
"""Test _initialize_api_data returns existing data when already initialized."""
async with aiohttp.ClientSession() as session:
config = BSBLANConfig(host="example.com")
client = BSBLAN(config, session=session)

# Set up with pre-initialized data
client._api_version = "v1"
existing_data = {
"heating": {"710": "Test"},
"staticValues": {"714": "Test"},
"device": {},
"sensor": {},
"hot_water": {},
}
client._api_data = existing_data # type: ignore[assignment]

# This should return the existing data without re-initializing
result = await client._initialize_api_data()
assert result is existing_data
assert result == existing_data
client._copy_api_config()
2 changes: 1 addition & 1 deletion tests/test_api_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ def mock_validate(

try:
# _api_validator is already set on bsblan
async def mock_extract_params(*_args: Any) -> dict[str, Any]:
def mock_extract_params(*_args: Any) -> dict[str, Any]:
# Not using the parameters
return {"string_par": "5870", "list": ["Device Parameter"]}

Expand Down
48 changes: 0 additions & 48 deletions tests/test_bsblan_edge_cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,9 @@
import pytest

from bsblan import BSBLAN, BSBLANConfig
from bsblan.constants import ErrorMsg
from bsblan.exceptions import BSBLANConnectionError, BSBLANError


@pytest.mark.asyncio
async def test_initialize_api_data_edge_case() -> None:
"""Test _initialize_api_data when API data is None after version setting."""
config = BSBLANConfig(host="example.com")
bsblan = BSBLAN(config)

# Force API version to be set but data to be None
bsblan._api_version = "v3"
bsblan._api_data = None

# This should trigger the defensive check in _initialize_api_data
api_data = await bsblan._initialize_api_data()
assert api_data is not None


@pytest.mark.asyncio
async def test_validate_api_section_key_error(monkeypatch: Any) -> None:
"""Test validate_api_section when section is not found in API data."""
Expand Down Expand Up @@ -107,35 +91,3 @@ def test_bsblan_config_initialization_edge_cases() -> None:
assert bsblan.session is None
assert bsblan._initialized is False
assert len(bsblan._hot_water_param_cache) == 0


@pytest.mark.asyncio
async def test_initialize_api_data_none_after_init(monkeypatch: Any) -> None:
"""Test _initialize_api_data raises error when api_data remains None.

This covers the defensive check at line 391 in bsblan.py.
"""
config = BSBLANConfig(host="example.com")
bsblan = BSBLAN(config)

# Set API version so we pass the first check
bsblan._api_version = "v3"

# Monkeypatch dict comprehension to return None (simulating failure)
original_items = dict.items

def mock_items(self: dict[str, Any]) -> Any:
# Return empty to prevent assignment
return original_items(self)

# Force _api_data to stay None by patching the assignment
def mock_setattr(obj: Any, name: str, value: Any) -> None:
if name == "_api_data":
object.__setattr__(obj, name, None)
else:
object.__setattr__(obj, name, value)

monkeypatch.setattr(BSBLAN, "__setattr__", mock_setattr)

with pytest.raises(BSBLANError, match=ErrorMsg.API_DATA_NOT_INITIALIZED):
await bsblan._initialize_api_data()
Loading
Loading