From 4094251654383d77ebbd7569ab5d7ed2c6d00230 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Sun, 10 May 2026 09:33:00 +0000 Subject: [PATCH] Handle empty BSB-LAN heating circuits --- homeassistant/components/bsblan/__init__.py | 42 +++++++++-- .../components/bsblan/config_flow.py | 22 ++++-- homeassistant/components/bsblan/const.py | 1 + tests/components/bsblan/conftest.py | 4 +- tests/components/bsblan/test_config_flow.py | 70 +++++++++++++++++++ tests/components/bsblan/test_init.py | 56 ++++++++++++++- 6 files changed, 182 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py index 1e5b641a357f6..8cfd065c916ee 100644 --- a/homeassistant/components/bsblan/__init__.py +++ b/homeassistant/components/bsblan/__init__.py @@ -38,7 +38,14 @@ ) 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 diff --git a/homeassistant/components/bsblan/config_flow.py b/homeassistant/components/bsblan/config_flow.py index c8713be1a388f..c5a9159a225ab 100644 --- a/homeassistant/components/bsblan/config_flow.py +++ b/homeassistant/components/bsblan/config_flow.py @@ -13,21 +13,28 @@ 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 @@ -384,6 +391,13 @@ async def _discover_circuits(self) -> None: 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, @@ -392,4 +406,4 @@ async def _discover_circuits(self) -> None: "Circuit discovery not available for %s, defaulting to single circuit", self.host, ) - self.circuits = [1] + self.circuits = list(DEFAULT_HEATING_CIRCUITS) diff --git a/homeassistant/components/bsblan/const.py b/homeassistant/components/bsblan/const.py index 57299aa4658e2..7a36f038e72f3 100644 --- a/homeassistant/components/bsblan/const.py +++ b/homeassistant/components/bsblan/const.py @@ -22,4 +22,5 @@ CONF_PASSKEY: Final = "passkey" CONF_HEATING_CIRCUITS: Final = "heating_circuits" +DEFAULT_HEATING_CIRCUITS: Final = (1,) DEFAULT_PORT: Final = 80 diff --git a/tests/components/bsblan/conftest.py b/tests/components/bsblan/conftest.py index a3a00b255b7af..245fb9e8c9e94 100644 --- a/tests/components/bsblan/conftest.py +++ b/tests/components/bsblan/conftest.py @@ -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, ) diff --git a/tests/components/bsblan/test_config_flow.py b/tests/components/bsblan/test_config_flow.py index feda9e9b40877..c2d639855838b 100644 --- a/tests/components/bsblan/test_config_flow.py +++ b/tests/components/bsblan/test_config_flow.py @@ -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"), [ diff --git a/tests/components/bsblan/test_init.py b/tests/components/bsblan/test_init.py index 5496cf09fd00a..aaaa7bd5afa5d 100644 --- a/tests/components/bsblan/test_init.py +++ b/tests/components/bsblan/test_init.py @@ -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,8 +447,33 @@ 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(