Handle empty BSB-LAN heating circuits (#170249)

This commit is contained in:
Willem-Jan van Rootselaar
2026-05-15 19:53:46 +00:00
committed by Franck Nijhof
parent c33f174041
commit 23e19ea2e4
6 changed files with 182 additions and 13 deletions
+38 -4
View File
@@ -38,7 +38,14 @@ from homeassistant.helpers.device_registry import (
)
from homeassistant.helpers.typing import ConfigType
from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
from .const import (
CONF_HEATING_CIRCUITS,
CONF_PASSKEY,
DEFAULT_HEATING_CIRCUITS,
DEFAULT_PORT,
DOMAIN,
LOGGER,
)
from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator
from .services import async_setup_services
@@ -118,7 +125,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
# Read available heating circuits from config entry data
# (populated by config flow or migration)
circuits: list[int] = entry.data[CONF_HEATING_CIRCUITS]
circuits: list[int] = entry.data[CONF_HEATING_CIRCUITS] or list(
DEFAULT_HEATING_CIRCUITS
)
# Fetch required device metadata in parallel for faster startup
device, info = await asyncio.gather(
@@ -229,7 +238,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
# heating circuits from the device; fall back to [1] (pre-multi-circuit
# default) if the device is unreachable or the endpoint is unsupported.
if entry.version == 1 and entry.minor_version < 2:
circuits: list[int] = [1]
circuits: list[int] = list(DEFAULT_HEATING_CIRCUITS)
config = BSBLANConfig(
host=entry.data[CONF_HOST],
passkey=entry.data[CONF_PASSKEY],
@@ -245,11 +254,18 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
except (BSBLANError, TimeoutError) as err:
LOGGER.warning(
"Circuit discovery during migration failed for %s (%s); "
"defaulting to single circuit [1]. Use Reconfigure to "
"defaulting to a single circuit. Use Reconfigure to "
"rediscover additional circuits later",
entry.data[CONF_HOST],
err,
)
if not circuits:
LOGGER.warning(
"Circuit discovery during migration returned no heating circuits "
"for %s; defaulting to a single circuit",
entry.data[CONF_HOST],
)
circuits = list(DEFAULT_HEATING_CIRCUITS)
hass.config_entries.async_update_entry(
entry,
@@ -263,4 +279,22 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
circuits,
)
# 1.2 -> 1.3: Repair entries that stored an empty circuit list during
# discovery. Every BSB-LAN setup has at least one heating circuit.
if entry.version == 1 and entry.minor_version < 3:
if not entry.data[CONF_HEATING_CIRCUITS]:
LOGGER.warning(
"Stored heating circuits for %s are empty; defaulting to a "
"single circuit",
entry.data[CONF_HOST],
)
data = {
**entry.data,
CONF_HEATING_CIRCUITS: list(DEFAULT_HEATING_CIRCUITS),
}
else:
data = {**entry.data}
hass.config_entries.async_update_entry(entry, data=data, minor_version=3)
return True
+18 -4
View File
@@ -15,21 +15,28 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
from .const import (
CONF_HEATING_CIRCUITS,
CONF_PASSKEY,
DEFAULT_HEATING_CIRCUITS,
DEFAULT_PORT,
DOMAIN,
LOGGER,
)
class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a BSBLAN config flow."""
VERSION = 1
MINOR_VERSION = 2
MINOR_VERSION = 3
def __init__(self) -> None:
"""Initialize BSBLan flow."""
self.host: str = ""
self.port: int = DEFAULT_PORT
self.mac: str | None = None
self.circuits: list[int] = [1]
self.circuits: list[int] = list(DEFAULT_HEATING_CIRCUITS)
self.passkey: str | None = None
self.username: str | None = None
self.password: str | None = None
@@ -386,6 +393,13 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
try:
await bsblan.initialize()
self.circuits = await bsblan.get_available_circuits()
if not self.circuits:
LOGGER.debug(
"Circuit discovery returned no heating circuits for %s, "
"defaulting to single circuit",
self.host,
)
self.circuits = list(DEFAULT_HEATING_CIRCUITS)
except (
BSBLANError,
TimeoutError,
@@ -394,4 +408,4 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
"Circuit discovery not available for %s, defaulting to single circuit",
self.host,
)
self.circuits = [1]
self.circuits = list(DEFAULT_HEATING_CIRCUITS)
+1
View File
@@ -24,4 +24,5 @@ ATTR_OUTSIDE_TEMPERATURE: Final = "outside_temperature"
CONF_PASSKEY: Final = "passkey"
CONF_HEATING_CIRCUITS: Final = "heating_circuits"
DEFAULT_HEATING_CIRCUITS: Final = (1,)
DEFAULT_PORT: Final = 80
+2 -2
View File
@@ -41,7 +41,7 @@ def mock_config_entry() -> MockConfigEntry:
},
unique_id="00:80:41:19:69:90",
version=1,
minor_version=2,
minor_version=3,
)
@@ -61,7 +61,7 @@ def mock_config_entry_dual_circuit() -> MockConfigEntry:
},
unique_id="00:80:41:19:69:90",
version=1,
minor_version=2,
minor_version=3,
)
@@ -213,6 +213,44 @@ async def test_circuit_discovery_failure_falls_back_to_default(
)
async def test_circuit_discovery_empty_result_falls_back_to_default(
hass: HomeAssistant,
mock_bsblan: MagicMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test that empty circuit discovery falls back to single circuit."""
mock_bsblan.get_available_circuits.return_value = []
result = await _init_user_flow(hass)
_assert_form_result(result, "user")
result = await _configure_flow(
hass,
result["flow_id"],
{
CONF_HOST: "127.0.0.1",
CONF_PORT: 80,
CONF_PASSKEY: "1234",
CONF_USERNAME: "admin",
CONF_PASSWORD: "admin1234",
},
)
_assert_create_entry_result(
result,
"BSB-LAN",
{
CONF_HOST: "127.0.0.1",
CONF_PORT: 80,
CONF_PASSKEY: "1234",
CONF_USERNAME: "admin",
CONF_PASSWORD: "admin1234",
CONF_HEATING_CIRCUITS: [1],
},
format_mac("00:80:41:19:69:90"),
)
async def test_connection_error(
hass: HomeAssistant,
mock_bsblan: MagicMock,
@@ -1150,6 +1188,38 @@ async def test_reconfigure_flow_success(
assert mock_config_entry.data[CONF_HEATING_CIRCUITS] == [1]
async def test_reconfigure_flow_empty_circuit_discovery_falls_back(
hass: HomeAssistant,
mock_bsblan: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reconfigure stores single circuit when discovery returns no circuits."""
mock_config_entry.add_to_hass(hass)
mock_bsblan.get_available_circuits.return_value = []
result = await mock_config_entry.start_reconfigure_flow(hass)
_assert_form_result(result, "reconfigure")
result = await _configure_flow(
hass,
result["flow_id"],
{
CONF_HOST: "192.168.1.50",
CONF_PORT: 8080,
CONF_PASSKEY: "new_passkey",
CONF_USERNAME: "new_admin",
CONF_PASSWORD: "new_password",
},
)
_assert_abort_result(result, "reconfigure_successful")
assert mock_config_entry.data[CONF_HOST] == "192.168.1.50"
assert mock_config_entry.data[CONF_PORT] == 8080
assert mock_config_entry.data[CONF_HEATING_CIRCUITS] == [1]
@pytest.mark.parametrize(
("side_effect", "error"),
[
+53 -3
View File
@@ -373,10 +373,35 @@ async def test_migrate_entry_discovers_circuits(
assert entry.state is ConfigEntryState.LOADED
assert entry.version == 1
assert entry.minor_version == 2
assert entry.minor_version == 3
assert entry.data[CONF_HEATING_CIRCUITS] == [1, 2]
async def test_migrate_entry_empty_discovery_falls_back(
hass: HomeAssistant,
mock_bsblan: MagicMock,
) -> None:
"""Test migration falls back to [1] when discovery returns no circuits."""
mock_bsblan.get_available_circuits.return_value = []
entry = MockConfigEntry(
title="BSBLAN Setup",
domain=DOMAIN,
data=_legacy_entry_data(),
unique_id="00:80:41:19:69:90",
version=1,
minor_version=1,
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.LOADED
assert entry.version == 1
assert entry.minor_version == 3
assert entry.data[CONF_HEATING_CIRCUITS] == [1]
async def test_migrate_entry_discovery_failure_falls_back(
hass: HomeAssistant,
mock_bsblan: MagicMock,
@@ -398,7 +423,7 @@ async def test_migrate_entry_discovery_failure_falls_back(
assert entry.state is ConfigEntryState.LOADED
assert entry.version == 1
assert entry.minor_version == 2
assert entry.minor_version == 3
assert entry.data[CONF_HEATING_CIRCUITS] == [1]
@@ -422,10 +447,35 @@ async def test_migrate_entry_discovery_timeout_falls_back(
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.LOADED
assert entry.minor_version == 2
assert entry.minor_version == 3
assert entry.data[CONF_HEATING_CIRCUITS] == [1]
async def test_migrate_entry_stored_empty_circuits_falls_back(
hass: HomeAssistant,
mock_bsblan: MagicMock,
) -> None:
"""Test migration repairs stored empty heating circuits."""
entry = MockConfigEntry(
title="BSBLAN Setup",
domain=DOMAIN,
data={**_legacy_entry_data(), CONF_HEATING_CIRCUITS: []},
unique_id="00:80:41:19:69:90",
version=1,
minor_version=2,
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.LOADED
assert entry.version == 1
assert entry.minor_version == 3
assert entry.data[CONF_HEATING_CIRCUITS] == [1]
assert entry.runtime_data.available_circuits == [1]
assert mock_bsblan.get_available_circuits.call_count == 0
async def test_migrate_entry_future_version_aborts(
hass: HomeAssistant,
mock_bsblan: MagicMock,