mirror of
https://github.com/home-assistant/core.git
synced 2026-08-31 18:24:50 -05:00
Handle empty BSB-LAN heating circuits (#170249)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user