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
58 changes: 16 additions & 42 deletions src/bsblan/bsblan.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,13 +152,10 @@ async def initialize(self) -> None:
async def get_available_circuits(self) -> list[int]:
"""Detect which heating circuits are available on the device.

Uses a two-step probe for each circuit (1, 2):
1. Query the operating mode parameter — the response must be
non-empty and contain actual data.
2. Query the status parameter (8000/8001) — an inactive
circuit returns ``value="0"`` with ``desc="---"``.

A circuit is only considered available when both checks pass.
Uses the configured operating mode probe parameters from
CircuitConfig.PROBE_PARAMS as the only discovery signal. Status
parameters are not queried during discovery to keep setup lightweight
and avoid excluding valid circuits when status data is unavailable.

This is useful for integration setup flows (e.g., Home Assistant
config flow) to discover how many circuits the user's controller
Expand All @@ -179,46 +176,23 @@ async def get_available_circuits(self) -> list[int]:
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 not response.get(param_id):
continue

# Secondary check: query the status parameter.
# Inactive circuits either:
# - return value="0" and desc="---"
# - return an empty dict {} (param not supported)
status_id = CircuitConfig.STATUS_PARAMS[circuit]
status_resp = await self._request(
params={"Parameter": status_id},
except BSBLANError:
logger.debug(
"Circuit %d not available (operating mode request failed)",
circuit,
)
status_data = status_resp.get(status_id, {})

# Empty response means the parameter doesn't exist
if not status_data or not isinstance(status_data, dict):
logger.debug(
"Circuit %d has no status data (not supported)",
circuit,
)
continue

# value="0" + desc="---" means inactive
if (
status_data.get("desc") == CircuitConfig.INACTIVE_MARKER
and str(status_data.get("value", "")) == "0"
):
logger.debug(
"Circuit %d has status '---' (inactive)",
circuit,
)
continue
continue

available.append(circuit)
except BSBLANError:
# A circuit exists if the response contains the operating mode key
# with actual data (not an empty dict).
if not response.get(param_id):
logger.debug(
"Circuit %d not available (request failed)",
"Circuit %d has no operating mode data (not supported)",
circuit,
)
continue

available.append(circuit)
return sorted(available)

async def _setup_api_validator(self) -> None:
Expand Down
169 changes: 49 additions & 120 deletions tests/test_circuit.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from aresponses import Response, ResponsesMockServer

from bsblan import BSBLAN, BSBLANConfig, State, StaticState
from bsblan.constants import ErrorMsg, build_api_config
from bsblan.constants import CircuitConfig, ErrorMsg, build_api_config
from bsblan.exceptions import BSBLANError, BSBLANInvalidParameterError
from bsblan.utility import APIValidator

Expand Down Expand Up @@ -544,26 +544,20 @@ async def mock_request(
# HC1 operating mode
if param_id == "700":
return {"700": {"value": "1", "unit": "", "desc": "Automatic"}}
# HC1 status - active
if param_id == "8000":
return {
"8000": {
"value": "114",
"desc": "Heating mode Comfort",
}
}
# HC2 operating mode
if param_id == "1000":
return {"1000": {"value": "1", "unit": "", "desc": "Automatic"}}
# HC2 status - active
if param_id == "8001":
return {"8001": {"value": "114", "desc": "Heating mode Comfort"}}
return {}
msg = f"Unexpected parameter probe: {param_id}"
raise AssertionError(msg)

bsblan._request = AsyncMock(side_effect=mock_request) # type: ignore[method-assign]
request_mock = AsyncMock(side_effect=mock_request)
bsblan._request = request_mock # type: ignore[method-assign]

circuits = await bsblan.get_available_circuits()
assert circuits == [1, 2]
assert [
call.kwargs["params"]["Parameter"] for call in request_mock.await_args_list
] == ["700", "1000"]


@pytest.mark.asyncio
Expand All @@ -580,33 +574,27 @@ async def mock_request(
param_id = params.get("Parameter", "")
if param_id == "700":
return {"700": {"value": "3", "unit": "", "desc": "Comfort"}}
if param_id == "8000":
return {
"8000": {
"value": "114",
"desc": "Heating mode Comfort",
}
}
# HC2 operating mode - return empty
if param_id == "1000":
return {param_id: {}}
return {}
msg = f"Unexpected parameter probe: {param_id}"
raise AssertionError(msg)

bsblan._request = AsyncMock(side_effect=mock_request) # type: ignore[method-assign]
request_mock = AsyncMock(side_effect=mock_request)
bsblan._request = request_mock # type: ignore[method-assign]

circuits = await bsblan.get_available_circuits()
assert circuits == [1]
assert [
call.kwargs["params"]["Parameter"] for call in request_mock.await_args_list
] == ["700", "1000"]


@pytest.mark.asyncio
async def test_get_available_circuits_inactive_by_status(
async def test_get_available_circuits_does_not_probe_status_params(
mock_bsblan_circuit: BSBLAN,
) -> None:
"""Test that circuits with status '---' are detected as inactive.

This is the real-world scenario: the device returns a valid operating
mode for all circuits, but status param shows '---' for HC2.
"""
"""Test discovery only queries probe params, not status params."""
bsblan = mock_bsblan_circuit

async def mock_request(
Expand All @@ -617,61 +605,20 @@ async def mock_request(
# All circuits return valid operating mode
if param_id in {"700", "1000"}:
return {param_id: {"value": "1", "unit": "", "desc": "Automatic"}}
# HC1 status - active
if param_id == "8000":
return {
"8000": {
"value": "114",
"desc": "Heating mode Comfort",
}
}
# HC2 status - inactive (value=0, desc=---)
if param_id == "8001":
return {"8001": {"value": "0", "desc": "---"}}
return {}
msg = f"Unexpected status parameter probe: {param_id}"
raise AssertionError(msg)

bsblan._request = AsyncMock(side_effect=mock_request) # type: ignore[method-assign]
request_mock = AsyncMock(side_effect=mock_request)
bsblan._request = request_mock # type: ignore[method-assign]

circuits = await bsblan.get_available_circuits()
assert circuits == [1]


@pytest.mark.asyncio
async def test_get_available_circuits_inactive_empty_status(
mock_bsblan_circuit: BSBLAN,
) -> None:
"""Test that circuits with empty status response are inactive.

Some controllers return an empty dict for status params of circuits
that don't exist (e.g., HC2 status 8001 returns {}).
"""
bsblan = mock_bsblan_circuit

async def mock_request(
**kwargs: Any,
) -> dict[str, Any]:
params = kwargs.get("params", {})
param_id = params.get("Parameter", "")
# All circuits return valid operating mode
if param_id in {"700", "1000"}:
return {param_id: {"value": "1", "unit": "", "desc": "Automatic"}}
# HC1 status - active
if param_id == "8000":
return {
"8000": {
"value": "114",
"desc": "Heating mode Comfort",
}
}
# HC2 status - empty response (param not supported)
if param_id == "8001":
return {}
return {}

bsblan._request = AsyncMock(side_effect=mock_request) # type: ignore[method-assign]

circuits = await bsblan.get_available_circuits()
assert circuits == [1]
assert circuits == [1, 2]
assert {
call.kwargs["params"]["Parameter"] for call in request_mock.await_args_list
} == {
"700",
"1000",
}


@pytest.mark.asyncio
Expand All @@ -681,27 +628,19 @@ async def test_get_available_circuits_request_failure(
"""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"}}
if param_id == "8000":
return {
"8000": {
"value": "114",
"desc": "Heating mode Comfort",
}
}
# HC2 fail with connection error
msg = "Connection failed"
raise BSBLANError(msg)
if param_id == "1000":
msg = "Connection failed"
raise BSBLANError(msg)
msg = f"Unexpected parameter probe: {param_id}"
raise AssertionError(msg)

bsblan._request = AsyncMock(side_effect=mock_request) # type: ignore[method-assign]

Expand All @@ -723,15 +662,11 @@ async def mock_request(
param_id = params.get("Parameter", "")
if param_id == "700":
return {"700": {"value": "1", "unit": "", "desc": "Automatic"}}
if param_id == "8000":
return {
"8000": {
"value": "114",
"desc": "Heating mode Comfort",
}
}
# Returns a response but without the expected param key
return {"other_key": {"value": "1"}}
if param_id == "1000":
return {"other_key": {"value": "1"}}
msg = f"Unexpected parameter probe: {param_id}"
raise AssertionError(msg)

bsblan._request = AsyncMock(side_effect=mock_request) # type: ignore[method-assign]

Expand All @@ -740,37 +675,31 @@ async def mock_request(


@pytest.mark.asyncio
async def test_get_available_circuits_status_failure_excludes_circuit(
async def test_get_available_circuits_all_probes_missing(
mock_bsblan_circuit: BSBLAN,
) -> None:
"""Test that a circuit is excluded if the status request fails.

If the operating mode returns valid data but the status request fails,
the circuit should be excluded (fail-safe).
"""
"""Test no circuits are detected when operating mode probes are missing."""
bsblan = mock_bsblan_circuit
expected_params = list(CircuitConfig.PROBE_PARAMS.values())

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"}}
# Status request fails
if param_id == "8000":
msg = "Connection failed"
raise BSBLANError(msg)
# HC2 return empty
if param_id == "1000":
return {param_id: {}}
return {}
if param_id in expected_params:
return {}
msg = f"Unexpected parameter probe: {param_id}"
raise AssertionError(msg)

bsblan._request = AsyncMock(side_effect=mock_request) # type: ignore[method-assign]
request_mock = AsyncMock(side_effect=mock_request)
bsblan._request = request_mock # type: ignore[method-assign]

# HC1 status request fails -> entire circuit probe fails -> excluded
circuits = await bsblan.get_available_circuits()
assert circuits == []
assert [
call.kwargs["params"]["Parameter"] for call in request_mock.await_args_list
] == expected_params


@pytest.mark.asyncio
Expand Down
Loading