Skip to content

Commit 0ef2216

Browse files
authored
Handle empty BSB-LAN heating circuits (home-assistant#170249)
1 parent 59e04c2 commit 0ef2216

6 files changed

Lines changed: 182 additions & 13 deletions

File tree

homeassistant/components/bsblan/__init__.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,14 @@
3838
)
3939
from homeassistant.helpers.typing import ConfigType
4040

41-
from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
41+
from .const import (
42+
CONF_HEATING_CIRCUITS,
43+
CONF_PASSKEY,
44+
DEFAULT_HEATING_CIRCUITS,
45+
DEFAULT_PORT,
46+
DOMAIN,
47+
LOGGER,
48+
)
4249
from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator
4350
from .services import async_setup_services
4451

@@ -118,7 +125,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
118125

119126
# Read available heating circuits from config entry data
120127
# (populated by config flow or migration)
121-
circuits: list[int] = entry.data[CONF_HEATING_CIRCUITS]
128+
circuits: list[int] = entry.data[CONF_HEATING_CIRCUITS] or list(
129+
DEFAULT_HEATING_CIRCUITS
130+
)
122131

123132
# Fetch required device metadata in parallel for faster startup
124133
device, info = await asyncio.gather(
@@ -229,7 +238,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
229238
# heating circuits from the device; fall back to [1] (pre-multi-circuit
230239
# default) if the device is unreachable or the endpoint is unsupported.
231240
if entry.version == 1 and entry.minor_version < 2:
232-
circuits: list[int] = [1]
241+
circuits: list[int] = list(DEFAULT_HEATING_CIRCUITS)
233242
config = BSBLANConfig(
234243
host=entry.data[CONF_HOST],
235244
passkey=entry.data[CONF_PASSKEY],
@@ -245,11 +254,18 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
245254
except (BSBLANError, TimeoutError) as err:
246255
LOGGER.warning(
247256
"Circuit discovery during migration failed for %s (%s); "
248-
"defaulting to single circuit [1]. Use Reconfigure to "
257+
"defaulting to a single circuit. Use Reconfigure to "
249258
"rediscover additional circuits later",
250259
entry.data[CONF_HOST],
251260
err,
252261
)
262+
if not circuits:
263+
LOGGER.warning(
264+
"Circuit discovery during migration returned no heating circuits "
265+
"for %s; defaulting to a single circuit",
266+
entry.data[CONF_HOST],
267+
)
268+
circuits = list(DEFAULT_HEATING_CIRCUITS)
253269

254270
hass.config_entries.async_update_entry(
255271
entry,
@@ -263,4 +279,22 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
263279
circuits,
264280
)
265281

282+
# 1.2 -> 1.3: Repair entries that stored an empty circuit list during
283+
# discovery. Every BSB-LAN setup has at least one heating circuit.
284+
if entry.version == 1 and entry.minor_version < 3:
285+
if not entry.data[CONF_HEATING_CIRCUITS]:
286+
LOGGER.warning(
287+
"Stored heating circuits for %s are empty; defaulting to a "
288+
"single circuit",
289+
entry.data[CONF_HOST],
290+
)
291+
data = {
292+
**entry.data,
293+
CONF_HEATING_CIRCUITS: list(DEFAULT_HEATING_CIRCUITS),
294+
}
295+
else:
296+
data = {**entry.data}
297+
298+
hass.config_entries.async_update_entry(entry, data=data, minor_version=3)
299+
266300
return True

homeassistant/components/bsblan/config_flow.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,28 @@
1313
from homeassistant.helpers.device_registry import format_mac
1414
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
1515

16-
from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
16+
from .const import (
17+
CONF_HEATING_CIRCUITS,
18+
CONF_PASSKEY,
19+
DEFAULT_HEATING_CIRCUITS,
20+
DEFAULT_PORT,
21+
DOMAIN,
22+
LOGGER,
23+
)
1724

1825

1926
class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
2027
"""Handle a BSBLAN config flow."""
2128

2229
VERSION = 1
23-
MINOR_VERSION = 2
30+
MINOR_VERSION = 3
2431

2532
def __init__(self) -> None:
2633
"""Initialize BSBLan flow."""
2734
self.host: str = ""
2835
self.port: int = DEFAULT_PORT
2936
self.mac: str | None = None
30-
self.circuits: list[int] = [1]
37+
self.circuits: list[int] = list(DEFAULT_HEATING_CIRCUITS)
3138
self.passkey: str | None = None
3239
self.username: str | None = None
3340
self.password: str | None = None
@@ -384,6 +391,13 @@ async def _discover_circuits(self) -> None:
384391
try:
385392
await bsblan.initialize()
386393
self.circuits = await bsblan.get_available_circuits()
394+
if not self.circuits:
395+
LOGGER.debug(
396+
"Circuit discovery returned no heating circuits for %s, "
397+
"defaulting to single circuit",
398+
self.host,
399+
)
400+
self.circuits = list(DEFAULT_HEATING_CIRCUITS)
387401
except (
388402
BSBLANError,
389403
TimeoutError,
@@ -392,4 +406,4 @@ async def _discover_circuits(self) -> None:
392406
"Circuit discovery not available for %s, defaulting to single circuit",
393407
self.host,
394408
)
395-
self.circuits = [1]
409+
self.circuits = list(DEFAULT_HEATING_CIRCUITS)

homeassistant/components/bsblan/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,5 @@
2222
CONF_PASSKEY: Final = "passkey"
2323
CONF_HEATING_CIRCUITS: Final = "heating_circuits"
2424

25+
DEFAULT_HEATING_CIRCUITS: Final = (1,)
2526
DEFAULT_PORT: Final = 80

tests/components/bsblan/conftest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def mock_config_entry() -> MockConfigEntry:
4141
},
4242
unique_id="00:80:41:19:69:90",
4343
version=1,
44-
minor_version=2,
44+
minor_version=3,
4545
)
4646

4747

@@ -61,7 +61,7 @@ def mock_config_entry_dual_circuit() -> MockConfigEntry:
6161
},
6262
unique_id="00:80:41:19:69:90",
6363
version=1,
64-
minor_version=2,
64+
minor_version=3,
6565
)
6666

6767

tests/components/bsblan/test_config_flow.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,44 @@ async def test_circuit_discovery_failure_falls_back_to_default(
213213
)
214214

215215

216+
async def test_circuit_discovery_empty_result_falls_back_to_default(
217+
hass: HomeAssistant,
218+
mock_bsblan: MagicMock,
219+
mock_setup_entry: AsyncMock,
220+
) -> None:
221+
"""Test that empty circuit discovery falls back to single circuit."""
222+
mock_bsblan.get_available_circuits.return_value = []
223+
224+
result = await _init_user_flow(hass)
225+
_assert_form_result(result, "user")
226+
227+
result = await _configure_flow(
228+
hass,
229+
result["flow_id"],
230+
{
231+
CONF_HOST: "127.0.0.1",
232+
CONF_PORT: 80,
233+
CONF_PASSKEY: "1234",
234+
CONF_USERNAME: "admin",
235+
CONF_PASSWORD: "admin1234",
236+
},
237+
)
238+
239+
_assert_create_entry_result(
240+
result,
241+
"BSB-LAN",
242+
{
243+
CONF_HOST: "127.0.0.1",
244+
CONF_PORT: 80,
245+
CONF_PASSKEY: "1234",
246+
CONF_USERNAME: "admin",
247+
CONF_PASSWORD: "admin1234",
248+
CONF_HEATING_CIRCUITS: [1],
249+
},
250+
format_mac("00:80:41:19:69:90"),
251+
)
252+
253+
216254
async def test_connection_error(
217255
hass: HomeAssistant,
218256
mock_bsblan: MagicMock,
@@ -1150,6 +1188,38 @@ async def test_reconfigure_flow_success(
11501188
assert mock_config_entry.data[CONF_HEATING_CIRCUITS] == [1]
11511189

11521190

1191+
async def test_reconfigure_flow_empty_circuit_discovery_falls_back(
1192+
hass: HomeAssistant,
1193+
mock_bsblan: MagicMock,
1194+
mock_config_entry: MockConfigEntry,
1195+
) -> None:
1196+
"""Test reconfigure stores single circuit when discovery returns no circuits."""
1197+
mock_config_entry.add_to_hass(hass)
1198+
mock_bsblan.get_available_circuits.return_value = []
1199+
1200+
result = await mock_config_entry.start_reconfigure_flow(hass)
1201+
1202+
_assert_form_result(result, "reconfigure")
1203+
1204+
result = await _configure_flow(
1205+
hass,
1206+
result["flow_id"],
1207+
{
1208+
CONF_HOST: "192.168.1.50",
1209+
CONF_PORT: 8080,
1210+
CONF_PASSKEY: "new_passkey",
1211+
CONF_USERNAME: "new_admin",
1212+
CONF_PASSWORD: "new_password",
1213+
},
1214+
)
1215+
1216+
_assert_abort_result(result, "reconfigure_successful")
1217+
1218+
assert mock_config_entry.data[CONF_HOST] == "192.168.1.50"
1219+
assert mock_config_entry.data[CONF_PORT] == 8080
1220+
assert mock_config_entry.data[CONF_HEATING_CIRCUITS] == [1]
1221+
1222+
11531223
@pytest.mark.parametrize(
11541224
("side_effect", "error"),
11551225
[

tests/components/bsblan/test_init.py

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -373,10 +373,35 @@ async def test_migrate_entry_discovers_circuits(
373373

374374
assert entry.state is ConfigEntryState.LOADED
375375
assert entry.version == 1
376-
assert entry.minor_version == 2
376+
assert entry.minor_version == 3
377377
assert entry.data[CONF_HEATING_CIRCUITS] == [1, 2]
378378

379379

380+
async def test_migrate_entry_empty_discovery_falls_back(
381+
hass: HomeAssistant,
382+
mock_bsblan: MagicMock,
383+
) -> None:
384+
"""Test migration falls back to [1] when discovery returns no circuits."""
385+
mock_bsblan.get_available_circuits.return_value = []
386+
387+
entry = MockConfigEntry(
388+
title="BSBLAN Setup",
389+
domain=DOMAIN,
390+
data=_legacy_entry_data(),
391+
unique_id="00:80:41:19:69:90",
392+
version=1,
393+
minor_version=1,
394+
)
395+
entry.add_to_hass(hass)
396+
await hass.config_entries.async_setup(entry.entry_id)
397+
await hass.async_block_till_done()
398+
399+
assert entry.state is ConfigEntryState.LOADED
400+
assert entry.version == 1
401+
assert entry.minor_version == 3
402+
assert entry.data[CONF_HEATING_CIRCUITS] == [1]
403+
404+
380405
async def test_migrate_entry_discovery_failure_falls_back(
381406
hass: HomeAssistant,
382407
mock_bsblan: MagicMock,
@@ -398,7 +423,7 @@ async def test_migrate_entry_discovery_failure_falls_back(
398423

399424
assert entry.state is ConfigEntryState.LOADED
400425
assert entry.version == 1
401-
assert entry.minor_version == 2
426+
assert entry.minor_version == 3
402427
assert entry.data[CONF_HEATING_CIRCUITS] == [1]
403428

404429

@@ -422,8 +447,33 @@ async def test_migrate_entry_discovery_timeout_falls_back(
422447
await hass.async_block_till_done()
423448

424449
assert entry.state is ConfigEntryState.LOADED
425-
assert entry.minor_version == 2
450+
assert entry.minor_version == 3
451+
assert entry.data[CONF_HEATING_CIRCUITS] == [1]
452+
453+
454+
async def test_migrate_entry_stored_empty_circuits_falls_back(
455+
hass: HomeAssistant,
456+
mock_bsblan: MagicMock,
457+
) -> None:
458+
"""Test migration repairs stored empty heating circuits."""
459+
entry = MockConfigEntry(
460+
title="BSBLAN Setup",
461+
domain=DOMAIN,
462+
data={**_legacy_entry_data(), CONF_HEATING_CIRCUITS: []},
463+
unique_id="00:80:41:19:69:90",
464+
version=1,
465+
minor_version=2,
466+
)
467+
entry.add_to_hass(hass)
468+
await hass.config_entries.async_setup(entry.entry_id)
469+
await hass.async_block_till_done()
470+
471+
assert entry.state is ConfigEntryState.LOADED
472+
assert entry.version == 1
473+
assert entry.minor_version == 3
426474
assert entry.data[CONF_HEATING_CIRCUITS] == [1]
475+
assert entry.runtime_data.available_circuits == [1]
476+
assert mock_bsblan.get_available_circuits.call_count == 0
427477

428478

429479
async def test_migrate_entry_future_version_aborts(

0 commit comments

Comments
 (0)