1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-08 17:49:37 +01:00

Add multiple heating system circuit support to BSBlan (#165992)

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Willem-Jan van Rootselaar
2026-04-24 15:12:09 +02:00
committed by GitHub
parent cf1faf3a20
commit 10d78d280a
19 changed files with 695 additions and 211 deletions
+131 -15
View File
@@ -13,6 +13,7 @@ from bsblan import (
Info,
StaticState,
)
from yarl import URL
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -28,11 +29,16 @@ from homeassistant.exceptions import (
ConfigEntryError,
ConfigEntryNotReady,
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.typing import ConfigType
from .const import CONF_PASSKEY, DOMAIN, LOGGER
from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator
from .services import async_setup_services
@@ -52,7 +58,35 @@ class BSBLanData:
client: BSBLAN
device: Device
info: Info
static: StaticState | None
static: dict[int, StaticState | None]
available_circuits: list[int]
def get_bsblan_device_info(
device: Device, info: Info, host: str, port: int
) -> DeviceInfo:
"""Build DeviceInfo for the main BSB-LAN controller device."""
return DeviceInfo(
identifiers={(DOMAIN, device.MAC)},
connections={(CONNECTION_NETWORK_MAC, format_mac(device.MAC))},
name=device.name,
manufacturer="BSBLAN Inc.",
model=(
info.device_identification.value
if info.device_identification and info.device_identification.value
else None
),
model_id=(
f"{info.controller_family.value}_{info.controller_variant.value}"
if info.controller_family
and info.controller_variant
and info.controller_family.value
and info.controller_variant.value
else None
),
sw_version=device.version,
configuration_url=str(URL.build(scheme="http", host=host, port=port)),
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@@ -75,13 +109,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
# create BSBLAN client
session = async_get_clientsession(hass)
bsblan = BSBLAN(config, session)
bsblan = BSBLAN(config=config, session=session)
try:
# Initialize the client first - this sets up internal caches and validates
# the connection by fetching firmware version
await bsblan.initialize()
# Read available heating circuits from config entry data
# (populated by config flow or migration)
circuits: list[int] = entry.data[CONF_HEATING_CIRCUITS]
# Fetch required device metadata in parallel for faster startup
device, info = await asyncio.gather(
bsblan.device(),
@@ -110,18 +148,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
translation_key="setup_general_error",
) from err
try:
static = await bsblan.static_values()
except (BSBLANError, TimeoutError) as err:
LOGGER.debug(
"Static values not available for %s: %s",
entry.data[CONF_HOST],
err,
)
static = None
# Fetch static values per configured circuit.
# BSB-LAN is a serial bus — it processes one parameter at a time,
# so concurrent requests offer no speed benefit over sequential.
# Static values are optional — some devices may not support them.
static_per_circuit: dict[int, StaticState | None] = {}
for circuit in circuits:
try:
static_per_circuit[circuit] = await bsblan.static_values(circuit=circuit)
except (BSBLANError, TimeoutError) as err:
LOGGER.debug(
"Static values not available for %s circuit %d: %s",
entry.data[CONF_HOST],
circuit,
err,
)
static_per_circuit[circuit] = None
# Create coordinators with the already-initialized client
fast_coordinator = BSBLanFastCoordinator(hass, entry, bsblan)
fast_coordinator = BSBLanFastCoordinator(hass, entry, bsblan, circuits)
slow_coordinator = BSBLanSlowCoordinator(hass, entry, bsblan)
# Perform first refresh of fast coordinator (required for entities)
@@ -137,7 +182,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
slow_coordinator=slow_coordinator,
device=device,
info=info,
static=static,
static=static_per_circuit,
available_circuits=circuits,
)
# Register main device before forwarding platforms, so sub-devices
# (heating circuits, water heater) can reference it via via_device
device_registry = dr.async_get(hass)
port = entry.data.get(CONF_PORT, DEFAULT_PORT)
main_device_info = get_bsblan_device_info(device, info, entry.data[CONF_HOST], port)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers=main_device_info["identifiers"],
connections=main_device_info["connections"],
name=main_device_info["name"],
manufacturer=main_device_info["manufacturer"],
model=main_device_info.get("model"),
model_id=main_device_info.get("model_id"),
sw_version=main_device_info.get("sw_version"),
configuration_url=main_device_info.get("configuration_url"),
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -148,3 +211,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
async def async_unload_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bool:
"""Unload BSBLAN config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bool:
"""Migrate old config entries to the latest schema."""
LOGGER.debug(
"Migrating BSB-LAN entry from version %s.%s",
entry.version,
entry.minor_version,
)
if entry.version > 1:
# Downgraded from a future version; cannot migrate.
return False
# 1.1 -> 1.2: Add CONF_HEATING_CIRCUITS. Attempt to discover available
# 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]
config = BSBLANConfig(
host=entry.data[CONF_HOST],
passkey=entry.data[CONF_PASSKEY],
port=entry.data[CONF_PORT],
username=entry.data.get(CONF_USERNAME),
password=entry.data.get(CONF_PASSWORD),
)
session = async_get_clientsession(hass)
bsblan = BSBLAN(config=config, session=session)
try:
await bsblan.initialize()
circuits = await bsblan.get_available_circuits()
except (BSBLANError, TimeoutError) as err:
LOGGER.warning(
"Circuit discovery during migration failed for %s (%s); "
"defaulting to single circuit [1]. Use Reconfigure to "
"rediscover additional circuits later",
entry.data[CONF_HOST],
err,
)
hass.config_entries.async_update_entry(
entry,
data={**entry.data, CONF_HEATING_CIRCUITS: circuits},
minor_version=2,
)
LOGGER.debug(
"Migrated BSB-LAN entry to version %s.%s with circuits %s",
entry.version,
entry.minor_version,
circuits,
)
return True
+28 -15
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any, Final
from bsblan import BSBLANError, get_hvac_action_category
from bsblan import BSBLANError, State, get_hvac_action_category
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
@@ -24,7 +24,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BSBLanConfigEntry, BSBLanData
from .const import ATTR_TARGET_TEMPERATURE, DOMAIN
from .entity import BSBLanEntity
from .entity import BSBLanCircuitEntity
PARALLEL_UPDATES = 1
@@ -63,10 +63,12 @@ async def async_setup_entry(
) -> None:
"""Set up BSBLAN device based on a config entry."""
data = entry.runtime_data
async_add_entities([BSBLANClimate(data)])
async_add_entities(
BSBLANClimate(data, circuit) for circuit in data.available_circuits
)
class BSBLANClimate(BSBLanEntity, ClimateEntity):
class BSBLANClimate(BSBLanCircuitEntity, ClimateEntity):
"""Defines a BSBLAN climate device."""
_attr_name = None
@@ -84,37 +86,50 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
def __init__(
self,
data: BSBLanData,
circuit: int,
) -> None:
"""Initialize BSBLAN climate device."""
super().__init__(data.fast_coordinator, data)
self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate"
super().__init__(data.fast_coordinator, data, circuit)
self._circuit = circuit
mac = format_mac(data.device.MAC)
# Set temperature range if available, otherwise use Home Assistant defaults
if (static := data.static) is not None:
# Backward compatible unique ID: circuit 1 keeps old format
if circuit == 1:
self._attr_unique_id = f"{mac}-climate"
else:
self._attr_unique_id = f"{mac}-climate-{circuit}"
# Set temperature range from per-circuit static data
if (static := data.static.get(circuit)) is not None:
if (min_temp := static.min_temp) is not None and min_temp.value is not None:
self._attr_min_temp = min_temp.value
if (max_temp := static.max_temp) is not None and max_temp.value is not None:
self._attr_max_temp = max_temp.value
self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit
@property
def _circuit_state(self) -> State:
"""Return the state for this circuit."""
return self.coordinator.data.states[self._circuit]
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if (current_temp := self.coordinator.data.state.current_temperature) is None:
if (current_temp := self._circuit_state.current_temperature) is None:
return None
return current_temp.value
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
if (target_temp := self.coordinator.data.state.target_temperature) is None:
if (target_temp := self._circuit_state.target_temperature) is None:
return None
return target_temp.value
@property
def _hvac_mode_value(self) -> int | None:
"""Return the raw hvac_mode value from the coordinator."""
if (hvac_mode := self.coordinator.data.state.hvac_mode) is None:
if (hvac_mode := self._circuit_state.hvac_mode) is None:
return None
return hvac_mode.value
@@ -128,9 +143,7 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
@property
def hvac_action(self) -> HVACAction | None:
"""Return the current running hvac action."""
if (
action := self.coordinator.data.state.hvac_action
) is None or action.value is None:
if (action := self._circuit_state.hvac_action) is None or action.value is None:
return None
category = get_hvac_action_category(action.value)
return HVACAction(category.name.lower())
@@ -170,7 +183,7 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
data[ATTR_HVAC_MODE] = 1
try:
await self.coordinator.client.thermostat(**data)
await self.coordinator.client.thermostat(**data, circuit=self._circuit)
except BSBLANError as err:
raise HomeAssistantError(
"An error occurred while updating the BSBLAN device",
+38 -5
View File
@@ -15,19 +15,21 @@ 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_PASSKEY, DEFAULT_PORT, DOMAIN
from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a BSBLAN config flow."""
VERSION = 1
MINOR_VERSION = 2
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.passkey: str | None = None
self.username: str | None = None
self.password: str | None = None
@@ -77,7 +79,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
# Try to get device info without authentication to minimize discovery popup
config = BSBLANConfig(host=self.host, port=self.port)
session = async_get_clientsession(self.hass)
bsblan = BSBLAN(config, session)
bsblan = BSBLAN(config=config, session=session)
try:
device = await bsblan.device()
except BSBLANError:
@@ -123,6 +125,8 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
)
if not self._auth_required:
# Discover available heating circuits
await self._discover_circuits()
return self._async_create_entry()
self.passkey = user_input.get(CONF_PASSKEY)
@@ -137,6 +141,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
"""Validate device connection and create entry."""
try:
await self._get_bsblan_info()
await self._discover_circuits()
except BSBLANAuthError:
if is_discovery:
return self.async_show_form(
@@ -230,9 +235,12 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
# it gets the unique ID from the device info when it validates credentials
self._abort_if_unique_id_mismatch()
# Rediscover circuits in case hardware changed
await self._discover_circuits()
return self.async_update_reload_and_abort(
existing_entry,
data_updates=user_input,
data_updates={**user_input, CONF_HEATING_CIRCUITS: self.circuits},
reason="reconfigure_successful",
)
@@ -316,13 +324,14 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
def _async_create_entry(self) -> ConfigFlowResult:
"""Create the config entry."""
return self.async_create_entry(
title=format_mac(self.mac),
title="BSB-LAN",
data={
CONF_HOST: self.host,
CONF_PORT: self.port,
CONF_PASSKEY: self.passkey,
CONF_USERNAME: self.username,
CONF_PASSWORD: self.password,
CONF_HEATING_CIRCUITS: self.circuits,
},
)
@@ -340,7 +349,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
password=self.password,
)
session = async_get_clientsession(self.hass)
bsblan = BSBLAN(config, session)
bsblan = BSBLAN(config=config, session=session)
device = await bsblan.device()
retrieved_mac = device.MAC
@@ -362,3 +371,27 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_PORT: self.port,
}
)
async def _discover_circuits(self) -> None:
"""Discover available heating circuits."""
config = BSBLANConfig(
host=self.host,
passkey=self.passkey,
port=self.port,
username=self.username,
password=self.password,
)
session = async_get_clientsession(self.hass)
bsblan = BSBLAN(config=config, session=session)
try:
await bsblan.initialize()
self.circuits = await bsblan.get_available_circuits()
except (
BSBLANError,
TimeoutError,
):
LOGGER.debug(
"Circuit discovery not available for %s, defaulting to single circuit",
self.host,
)
self.circuits = [1]
+1
View File
@@ -22,5 +22,6 @@ ATTR_INSIDE_TEMPERATURE: Final = "inside_temperature"
ATTR_OUTSIDE_TEMPERATURE: Final = "outside_temperature"
CONF_PASSKEY: Final = "passkey"
CONF_HEATING_CIRCUITS: Final = "heating_circuits"
DEFAULT_PORT: Final = 80
+12 -6
View File
@@ -49,7 +49,7 @@ DHW_CONFIG_INCLUDE = ["reduced_setpoint", "nominal_setpoint_max"]
class BSBLanFastData:
"""BSBLan fast-polling data."""
state: State
states: dict[int, State]
sensor: Sensor
dhw: HotWaterState | None = None
@@ -94,6 +94,7 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
hass: HomeAssistant,
config_entry: BSBLanConfigEntry,
client: BSBLAN,
circuits: list[int],
) -> None:
"""Initialize the BSB-LAN fast coordinator."""
super().__init__(
@@ -103,14 +104,19 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
name=f"{DOMAIN}_fast_{config_entry.data[CONF_HOST]}",
update_interval=SCAN_INTERVAL_FAST,
)
self.circuits: list[int] = circuits
async def _async_update_data(self) -> BSBLanFastData:
"""Fetch fast-changing data from the BSB-LAN device."""
states: dict[int, State] = {}
try:
# Client is already initialized in async_setup_entry
# Use include filtering to only fetch parameters we actually use
# This reduces response time significantly (~0.2s per parameter)
state = await self.client.state(include=STATE_INCLUDE)
# Use include filtering to only fetch parameters we actually use.
# BSB-LAN is a serial bus — it processes one parameter at a time,
# so concurrent requests offer no speed benefit over sequential.
for circuit in self.circuits:
states[circuit] = await self.client.state(
include=STATE_INCLUDE, circuit=circuit
)
sensor = await self.client.sensor(include=SENSOR_INCLUDE)
except BSBLANAuthError as err:
@@ -140,7 +146,7 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
)
return BSBLanFastData(
state=state,
states=states,
sensor=sensor,
dhw=dhw,
)
@@ -20,13 +20,20 @@ async def async_get_config_entry_diagnostics(
"info": data.info.model_dump(),
"device": data.device.model_dump(),
"fast_coordinator_data": {
"state": data.fast_coordinator.data.state.model_dump(),
"states": {
str(circuit): state.model_dump()
for circuit, state in data.fast_coordinator.data.states.items()
},
"sensor": data.fast_coordinator.data.sensor.model_dump(),
"dhw": data.fast_coordinator.data.dhw.model_dump()
if data.fast_coordinator.data.dhw
else None,
},
"static": data.static.model_dump() if data.static is not None else None,
"static": {
str(circuit): static.model_dump() if static is not None else None
for circuit, static in data.static.items()
},
"available_circuits": data.available_circuits,
}
# Add DHW config and schedule from slow coordinator if available
+55 -30
View File
@@ -2,17 +2,11 @@
from __future__ import annotations
from yarl import URL
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import BSBLanData
from . import BSBLanData, get_bsblan_device_info
from .const import DEFAULT_PORT, DOMAIN
from .coordinator import BSBLanCoordinator, BSBLanFastCoordinator, BSBLanSlowCoordinator
@@ -27,28 +21,8 @@ class BSBLanEntityBase[_T: BSBLanCoordinator](CoordinatorEntity[_T]):
super().__init__(coordinator)
host = coordinator.config_entry.data[CONF_HOST]
port = coordinator.config_entry.data.get(CONF_PORT, DEFAULT_PORT)
mac = data.device.MAC
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mac)},
connections={(CONNECTION_NETWORK_MAC, format_mac(mac))},
name=data.device.name,
manufacturer="BSBLAN Inc.",
model=(
data.info.device_identification.value
if data.info.device_identification
and data.info.device_identification.value
else None
),
model_id=(
f"{data.info.controller_family.value}_{data.info.controller_variant.value}"
if data.info.controller_family
and data.info.controller_variant
and data.info.controller_family.value
and data.info.controller_variant.value
else None
),
sw_version=data.device.version,
configuration_url=str(URL.build(scheme="http", host=host, port=port)),
self._attr_device_info = get_bsblan_device_info(
data.device, data.info, host, port
)
@@ -60,6 +34,32 @@ class BSBLanEntity(BSBLanEntityBase[BSBLanFastCoordinator]):
super().__init__(coordinator, data)
class BSBLanCircuitEntity(BSBLanEntity):
"""BSBLan entity belonging to a heating circuit sub-device."""
def __init__(
self,
coordinator: BSBLanFastCoordinator,
data: BSBLanData,
circuit: int,
) -> None:
"""Initialize BSBLan circuit entity with sub-device info."""
super().__init__(coordinator, data)
mac = data.device.MAC
host = coordinator.config_entry.data[CONF_HOST]
port = coordinator.config_entry.data.get(CONF_PORT, DEFAULT_PORT)
main_info = get_bsblan_device_info(data.device, data.info, host, port)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{mac}-circuit-{circuit}")},
translation_key="heating_circuit",
translation_placeholders={"circuit": str(circuit)},
via_device=(DOMAIN, mac),
manufacturer=main_info["manufacturer"],
model=main_info.get("model"),
model_id=main_info.get("model_id"),
)
class BSBLanDualCoordinatorEntity(BSBLanEntity):
"""Entity that listens to both fast and slow coordinators."""
@@ -80,3 +80,28 @@ class BSBLanDualCoordinatorEntity(BSBLanEntity):
self.async_on_remove(
self.slow_coordinator.async_add_listener(self._handle_coordinator_update)
)
class BSBLanWaterHeaterDeviceEntity(BSBLanDualCoordinatorEntity):
"""BSBLan entity belonging to the water heater sub-device."""
def __init__(
self,
fast_coordinator: BSBLanFastCoordinator,
slow_coordinator: BSBLanSlowCoordinator,
data: BSBLanData,
) -> None:
"""Initialize BSBLan water heater sub-device entity."""
super().__init__(fast_coordinator, slow_coordinator, data)
mac = data.device.MAC
host = fast_coordinator.config_entry.data[CONF_HOST]
port = fast_coordinator.config_entry.data.get(CONF_PORT, DEFAULT_PORT)
main_info = get_bsblan_device_info(data.device, data.info, host, port)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{mac}-water-heater")},
translation_key="water_heater",
via_device=(DOMAIN, mac),
manufacturer=main_info["manufacturer"],
model=main_info.get("model"),
model_id=main_info.get("model_id"),
)
@@ -48,13 +48,10 @@ rules:
dynamic-devices:
status: exempt
comment: |
This integration has a fixed single device.
Devices and sub-devices are determined at config entry setup and do not change at runtime.
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: |
This integration provides a limited number of entities, all of which are useful to users.
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: todo
@@ -66,7 +63,7 @@ rules:
stale-devices:
status: exempt
comment: |
This integration has a fixed single device.
Devices and sub-devices are determined at config entry setup and do not change at runtime.
# Platinum
async-dependency: done
@@ -79,6 +79,14 @@
}
}
},
"device": {
"heating_circuit": {
"name": "Heating circuit {circuit}"
},
"water_heater": {
"name": "Water heater"
}
},
"entity": {
"button": {
"sync_time": {
@@ -21,7 +21,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BSBLanConfigEntry, BSBLanData
from .const import DOMAIN
from .entity import BSBLanDualCoordinatorEntity
from .entity import BSBLanWaterHeaterDeviceEntity
PARALLEL_UPDATES = 1
@@ -61,7 +61,7 @@ async def async_setup_entry(
async_add_entities([BSBLANWaterHeater(data)])
class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
class BSBLANWaterHeater(BSBLanWaterHeaterDeviceEntity, WaterHeaterEntity):
"""Defines a BSBLAN water heater entity."""
_attr_name = None
+30 -1
View File
@@ -15,7 +15,11 @@ from bsblan import (
)
import pytest
from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN
from homeassistant.components.bsblan.const import (
CONF_HEATING_CIRCUITS,
CONF_PASSKEY,
DOMAIN,
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from tests.common import MockConfigEntry, load_fixture
@@ -33,8 +37,31 @@ def mock_config_entry() -> MockConfigEntry:
CONF_PASSKEY: "1234",
CONF_USERNAME: "admin",
CONF_PASSWORD: "admin1234",
CONF_HEATING_CIRCUITS: [1],
},
unique_id="00:80:41:19:69:90",
version=1,
minor_version=2,
)
@pytest.fixture
def mock_config_entry_dual_circuit() -> MockConfigEntry:
"""Return a mocked config entry with dual heating circuits."""
return MockConfigEntry(
title="BSBLAN Setup",
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PORT: 80,
CONF_PASSKEY: "1234",
CONF_USERNAME: "admin",
CONF_PASSWORD: "admin1234",
CONF_HEATING_CIRCUITS: [1, 2],
},
unique_id="00:80:41:19:69:90",
version=1,
minor_version=2,
)
@@ -82,5 +109,7 @@ def mock_bsblan() -> Generator[MagicMock]:
)
# mock get_temperature_unit property
bsblan.get_temperature_unit = "°C"
# Default: single circuit (for config flow tests)
bsblan.get_available_circuits.return_value = [1]
yield bsblan
@@ -1,5 +1,5 @@
# serializer version: 1
# name: test_celsius_fahrenheit[climate.bsb_lan-entry]
# name: test_celsius_fahrenheit[climate.heating_circuit_1-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
@@ -25,7 +25,7 @@
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.bsb_lan',
'entity_id': 'climate.heating_circuit_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -48,11 +48,11 @@
'unit_of_measurement': None,
})
# ---
# name: test_celsius_fahrenheit[climate.bsb_lan-state]
# name: test_celsius_fahrenheit[climate.heating_circuit_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 18.6,
'friendly_name': 'BSB-LAN',
'friendly_name': 'Heating circuit 1',
'hvac_action': <HVACAction.IDLE: 'idle'>,
'hvac_modes': list([
<HVACMode.AUTO: 'auto'>,
@@ -70,14 +70,14 @@
'temperature': 18.5,
}),
'context': <ANY>,
'entity_id': 'climate.bsb_lan',
'entity_id': 'climate.heating_circuit_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_climate_entity_properties[climate.bsb_lan-entry]
# name: test_climate_entity_properties[climate.heating_circuit_1-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
@@ -103,7 +103,7 @@
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.bsb_lan',
'entity_id': 'climate.heating_circuit_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -126,11 +126,11 @@
'unit_of_measurement': None,
})
# ---
# name: test_climate_entity_properties[climate.bsb_lan-state]
# name: test_climate_entity_properties[climate.heating_circuit_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 18.6,
'friendly_name': 'BSB-LAN',
'friendly_name': 'Heating circuit 1',
'hvac_action': <HVACAction.IDLE: 'idle'>,
'hvac_modes': list([
<HVACMode.AUTO: 'auto'>,
@@ -148,7 +148,7 @@
'temperature': 18.5,
}),
'context': <ANY>,
'entity_id': 'climate.bsb_lan',
'entity_id': 'climate.heating_circuit_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
@@ -1,6 +1,9 @@
# serializer version: 1
# name: test_diagnostics
dict({
'available_circuits': list([
1,
]),
'device': dict({
'MAC': '00:80:41:19:69:90',
'name': 'BSB-LAN',
@@ -116,72 +119,74 @@
'value': 7968,
}),
}),
'state': dict({
'current_temperature': dict({
'data_type': 0,
'data_type_family': '',
'data_type_name': '',
'desc': '',
'error': 0,
'name': 'Room temp 1 actual value',
'precision': None,
'readonly': 1,
'readwrite': 0,
'unit': '&deg;C',
'value': 18.6,
}),
'hvac_action': dict({
'data_type': 1,
'data_type_family': '',
'data_type_name': '',
'desc': 'Raumtempbegrenzung',
'error': 0,
'name': 'Status heating circuit 1',
'precision': None,
'readonly': 1,
'readwrite': 0,
'unit': '',
'value': 122,
}),
'hvac_mode': dict({
'data_type': 1,
'data_type_family': '',
'data_type_name': '',
'desc': 'Komfort',
'error': 0,
'name': 'Operating mode',
'precision': None,
'readonly': 0,
'readwrite': 0,
'unit': '',
'value': 3,
}),
'hvac_mode_changeover': None,
'room1_temp_setpoint_boost': dict({
'data_type': 1,
'data_type_family': '',
'data_type_name': '',
'desc': 'Boost',
'error': 0,
'name': 'Room 1 Temp Setpoint Boost',
'precision': None,
'readonly': 1,
'readwrite': 0,
'unit': '°C',
'value': 22.5,
}),
'target_temperature': dict({
'data_type': 0,
'data_type_family': '',
'data_type_name': '',
'desc': '',
'error': 0,
'name': 'Room temperature Comfort setpoint',
'precision': None,
'readonly': 0,
'readwrite': 0,
'unit': '&deg;C',
'value': 18.5,
'states': dict({
'1': dict({
'current_temperature': dict({
'data_type': 0,
'data_type_family': '',
'data_type_name': '',
'desc': '',
'error': 0,
'name': 'Room temp 1 actual value',
'precision': None,
'readonly': 1,
'readwrite': 0,
'unit': '&deg;C',
'value': 18.6,
}),
'hvac_action': dict({
'data_type': 1,
'data_type_family': '',
'data_type_name': '',
'desc': 'Raumtempbegrenzung',
'error': 0,
'name': 'Status heating circuit 1',
'precision': None,
'readonly': 1,
'readwrite': 0,
'unit': '',
'value': 122,
}),
'hvac_mode': dict({
'data_type': 1,
'data_type_family': '',
'data_type_name': '',
'desc': 'Komfort',
'error': 0,
'name': 'Operating mode',
'precision': None,
'readonly': 0,
'readwrite': 0,
'unit': '',
'value': 3,
}),
'hvac_mode_changeover': None,
'room1_temp_setpoint_boost': dict({
'data_type': 1,
'data_type_family': '',
'data_type_name': '',
'desc': 'Boost',
'error': 0,
'name': 'Room 1 Temp Setpoint Boost',
'precision': None,
'readonly': 1,
'readwrite': 0,
'unit': '°C',
'value': 22.5,
}),
'target_temperature': dict({
'data_type': 0,
'data_type_family': '',
'data_type_name': '',
'desc': '',
'error': 0,
'name': 'Room temperature Comfort setpoint',
'precision': None,
'readonly': 0,
'readwrite': 0,
'unit': '&deg;C',
'value': 18.5,
}),
}),
}),
}),
@@ -449,31 +454,33 @@
}),
}),
'static': dict({
'max_temp': dict({
'data_type': 0,
'data_type_family': '',
'data_type_name': '',
'desc': '',
'error': 0,
'name': 'Summer/winter changeover temp heat circuit 1',
'precision': None,
'readonly': 0,
'readwrite': 0,
'unit': '&deg;C',
'value': 20.0,
}),
'min_temp': dict({
'data_type': 0,
'data_type_family': '',
'data_type_name': '',
'desc': '',
'error': 0,
'name': 'Room temp frost protection setpoint',
'precision': None,
'readonly': 0,
'readwrite': 0,
'unit': '&deg;C',
'value': 8.0,
'1': dict({
'max_temp': dict({
'data_type': 0,
'data_type_family': '',
'data_type_name': '',
'desc': '',
'error': 0,
'name': 'Summer/winter changeover temp heat circuit 1',
'precision': None,
'readonly': 0,
'readwrite': 0,
'unit': '&deg;C',
'value': 20.0,
}),
'min_temp': dict({
'data_type': 0,
'data_type_family': '',
'data_type_name': '',
'desc': '',
'error': 0,
'name': 'Room temp frost protection setpoint',
'precision': None,
'readonly': 0,
'readwrite': 0,
'unit': '&deg;C',
'value': 8.0,
}),
}),
}),
})
@@ -1,5 +1,5 @@
# serializer version: 1
# name: test_water_heater_states[dhw_state.json][water_heater.bsb_lan-entry]
# name: test_water_heater_states[dhw_state.json][water_heater.water_heater-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
@@ -21,7 +21,7 @@
'disabled_by': None,
'domain': 'water_heater',
'entity_category': None,
'entity_id': 'water_heater.bsb_lan',
'entity_id': 'water_heater.water_heater',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -44,11 +44,11 @@
'unit_of_measurement': None,
})
# ---
# name: test_water_heater_states[dhw_state.json][water_heater.bsb_lan-state]
# name: test_water_heater_states[dhw_state.json][water_heater.water_heater-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 48.5,
'friendly_name': 'BSB-LAN',
'friendly_name': 'Water heater',
'max_temp': 65.0,
'min_temp': 35.0,
'operation_list': list([
@@ -63,7 +63,7 @@
'temperature': 50.0,
}),
'context': <ANY>,
'entity_id': 'water_heater.bsb_lan',
'entity_id': 'water_heater.water_heater',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
+30 -7
View File
@@ -29,7 +29,7 @@ from . import setup_with_selected_platforms
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
ENTITY_ID = "climate.bsb_lan"
ENTITY_ID = "climate.heating_circuit_1"
async def test_celsius_fahrenheit(
@@ -270,7 +270,7 @@ async def test_async_set_hvac_mode(
# Assert that the thermostat method was called with integer value
expected_int = HA_TO_BSBLAN_HVAC_MODE_TEST[mode]
mock_bsblan.thermostat.assert_called_once_with(hvac_mode=expected_int)
mock_bsblan.thermostat.assert_called_once_with(hvac_mode=expected_int, circuit=1)
mock_bsblan.thermostat.reset_mock()
@@ -332,7 +332,9 @@ async def test_async_set_temperature(
blocking=True,
)
# Assert that the thermostat method was called with the correct temperature
mock_bsblan.thermostat.assert_called_once_with(target_temperature=target_temp)
mock_bsblan.thermostat.assert_called_once_with(
target_temperature=target_temp, circuit=1
)
async def test_async_set_data(
@@ -350,7 +352,7 @@ async def test_async_set_data(
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 19},
blocking=True,
)
mock_bsblan.thermostat.assert_called_once_with(target_temperature=19)
mock_bsblan.thermostat.assert_called_once_with(target_temperature=19, circuit=1)
mock_bsblan.thermostat.reset_mock()
# Test setting HVAC mode - should convert to integer (3=heat)
@@ -360,7 +362,7 @@ async def test_async_set_data(
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT},
blocking=True,
)
mock_bsblan.thermostat.assert_called_once_with(hvac_mode=3) # 3 = heat
mock_bsblan.thermostat.assert_called_once_with(hvac_mode=3, circuit=1) # 3 = heat
mock_bsblan.thermostat.reset_mock()
# Patch HVAC mode to AUTO (integer 1)
@@ -375,7 +377,9 @@ async def test_async_set_data(
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_ECO},
blocking=True,
)
mock_bsblan.thermostat.assert_called_once_with(hvac_mode=2) # 2 = eco/reduced
mock_bsblan.thermostat.assert_called_once_with(
hvac_mode=2, circuit=1
) # 2 = eco/reduced
mock_bsblan.thermostat.reset_mock()
# Test setting preset mode to NONE - should use integer 1 (auto)
@@ -385,7 +389,7 @@ async def test_async_set_data(
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_NONE},
blocking=True,
)
mock_bsblan.thermostat.assert_called_once_with(hvac_mode=1) # 1 = auto
mock_bsblan.thermostat.assert_called_once_with(hvac_mode=1, circuit=1) # 1 = auto
mock_bsblan.thermostat.reset_mock()
# Test error handling
@@ -398,3 +402,22 @@ async def test_async_set_data(
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 20},
blocking=True,
)
async def test_dual_circuit_climate_entities(
hass: HomeAssistant,
mock_bsblan: AsyncMock,
mock_config_entry_dual_circuit: MockConfigEntry,
) -> None:
"""Test that dual-circuit config creates two climate entities with correct IDs."""
await setup_with_selected_platforms(
hass, mock_config_entry_dual_circuit, [Platform.CLIMATE]
)
# Circuit 1 entity should exist
state1 = hass.states.get("climate.heating_circuit_1")
assert state1 is not None
# Circuit 2 entity should exist
state2 = hass.states.get("climate.heating_circuit_2")
assert state2 is not None
+63 -8
View File
@@ -7,7 +7,11 @@ from bsblan import BSBLANAuthError, BSBLANConnectionError, BSBLANError
import pytest
import voluptuous as vol
from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN
from homeassistant.components.bsblan.const import (
CONF_HEATING_CIRCUITS,
CONF_PASSKEY,
DOMAIN,
)
from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.core import HomeAssistant
@@ -144,13 +148,14 @@ async def test_full_user_flow_implementation(
_assert_create_entry_result(
result,
format_mac("00:80:41:19:69:90"),
"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"),
)
@@ -165,6 +170,49 @@ async def test_show_user_form(hass: HomeAssistant) -> None:
_assert_form_result(result, "user")
@pytest.mark.parametrize(
"side_effect",
[BSBLANError, TimeoutError],
)
async def test_circuit_discovery_failure_falls_back_to_default(
hass: HomeAssistant,
mock_bsblan: MagicMock,
mock_setup_entry: AsyncMock,
side_effect: type[Exception],
) -> None:
"""Test that circuit discovery failure falls back to single circuit."""
mock_bsblan.initialize.side_effect = side_effect
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,
@@ -319,13 +367,14 @@ async def test_zeroconf_discovery(
_assert_create_entry_result(
result,
format_mac("00:80:41:19:69:90"),
"BSB-LAN",
{
CONF_HOST: "10.0.2.60",
CONF_PORT: 80,
CONF_PASSKEY: "1234",
CONF_USERNAME: "admin",
CONF_PASSWORD: "admin1234",
CONF_HEATING_CIRCUITS: [1],
},
format_mac("00:80:41:19:69:90"),
)
@@ -386,13 +435,14 @@ async def test_zeroconf_discovery_no_mac_requires_auth(
_assert_create_entry_result(
result,
"00:80:41:19:69:90", # MAC from fixture file
"BSB-LAN",
{
CONF_HOST: "10.0.2.60",
CONF_PORT: 80,
CONF_PASSKEY: None,
CONF_USERNAME: "admin",
CONF_PASSWORD: "secret",
CONF_HEATING_CIRCUITS: [1],
},
"00:80:41:19:69:90",
)
@@ -418,13 +468,14 @@ async def test_zeroconf_discovery_no_mac_no_auth_required(
_assert_create_entry_result(
result,
"00:80:41:19:69:90", # MAC from fixture file
"BSB-LAN",
{
CONF_HOST: "10.0.2.60",
CONF_PORT: 80,
CONF_PASSKEY: None,
CONF_USERNAME: None,
CONF_PASSWORD: None,
CONF_HEATING_CIRCUITS: [1],
},
"00:80:41:19:69:90",
)
@@ -562,13 +613,14 @@ async def test_zeroconf_discovery_connection_error_recovery(
_assert_create_entry_result(
result,
format_mac("00:80:41:19:69:90"),
"BSB-LAN",
{
CONF_HOST: "10.0.2.60",
CONF_PORT: 80,
CONF_PASSKEY: "1234",
CONF_USERNAME: "admin",
CONF_PASSWORD: "admin1234",
CONF_HEATING_CIRCUITS: [1],
},
format_mac("00:80:41:19:69:90"),
)
@@ -617,13 +669,14 @@ async def test_connection_error_recovery(
_assert_create_entry_result(
result,
format_mac("00:80:41:19:69:90"),
"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"),
)
@@ -1094,6 +1147,7 @@ async def test_reconfigure_flow_success(
assert mock_config_entry.data[CONF_PASSKEY] == "new_passkey"
assert mock_config_entry.data[CONF_USERNAME] == "new_admin"
assert mock_config_entry.data[CONF_PASSWORD] == "new_password"
assert mock_config_entry.data[CONF_HEATING_CIRCUITS] == [1]
@pytest.mark.parametrize(
@@ -1107,7 +1161,7 @@ async def test_reconfigure_flow_error_recovery(
hass: HomeAssistant,
mock_bsblan: MagicMock,
mock_config_entry: MockConfigEntry,
side_effect: Exception,
side_effect: type[Exception],
error: str,
) -> None:
"""Test reconfigure flow can recover from errors."""
@@ -1155,6 +1209,7 @@ async def test_reconfigure_flow_error_recovery(
assert mock_config_entry.data[CONF_PASSKEY] == "new_passkey"
assert mock_config_entry.data[CONF_USERNAME] == "new_admin"
assert mock_config_entry.data[CONF_PASSWORD] == "new_password"
assert mock_config_entry.data[CONF_HEATING_CIRCUITS] == [1]
async def test_reconfigure_flow_unique_id_mismatch(
+1 -1
View File
@@ -50,4 +50,4 @@ async def test_diagnostics_without_static_values(
assert "info" in diagnostics_data
assert "device" in diagnostics_data
assert "fast_coordinator_data" in diagnostics_data
assert diagnostics_data["static"] is None
assert diagnostics_data["static"] == {"1": None}
+169 -5
View File
@@ -1,16 +1,21 @@
"""Tests for the BSBLan integration."""
from datetime import timedelta
from unittest.mock import MagicMock
from bsblan import BSBLANAuthError, BSBLANConnectionError, BSBLANError
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN
from homeassistant.components.bsblan.const import (
CONF_HEATING_CIRCUITS,
CONF_PASSKEY,
DOMAIN,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import device_registry as dr, entity_registry as er
from tests.common import MockConfigEntry, async_fire_time_changed
@@ -134,7 +139,7 @@ async def test_config_entry_setup_errors(
assert mock_config_entry.state is expected_state
if assert_static_fallback:
assert mock_config_entry.runtime_data.static is None
assert mock_config_entry.runtime_data.static == {1: None}
async def test_coordinator_dhw_config_update_error(
@@ -208,6 +213,7 @@ async def test_coordinator_fast_no_dhw_support(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_bsblan: MagicMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test fast coordinator when device does not support DHW."""
mock_bsblan.hot_water_state.side_effect = BSBLANError(
@@ -224,8 +230,46 @@ async def test_coordinator_fast_no_dhw_support(
# DHW data should be None in the fast coordinator
assert mock_config_entry.runtime_data.fast_coordinator.data.dhw is None
# Water heater entity should not be created
assert hass.states.get("water_heater.bsb_lan") is None
# No water heater entities should be registered for this config entry
water_heater_entities = [
entry
for entry in er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
if entry.domain == "water_heater"
]
assert not water_heater_entities
async def test_coordinator_fast_dhw_fails_on_refresh_preserves_state(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_bsblan: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test fast coordinator preserves last DHW state when DHW fails on refresh."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
# DHW should be available initially
coordinator = mock_config_entry.runtime_data.fast_coordinator
initial_dhw = coordinator.data.dhw
assert initial_dhw is not None
# Now make DHW fail on the next refresh
mock_bsblan.hot_water_state.side_effect = BSBLANError(
"None of the requested parameters are valid for this section"
)
freezer.tick(timedelta(seconds=15))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Last known DHW state should be preserved
assert coordinator.data.dhw is initial_dhw
async def test_coordinator_slow_no_dhw_support(
@@ -295,3 +339,123 @@ async def test_configuration_url_non_default_port(
)
assert device is not None
assert device.configuration_url == "http://192.168.1.100:8080"
def _legacy_entry_data() -> dict:
"""Return config entry data as stored before CONF_HEATING_CIRCUITS existed."""
return {
CONF_HOST: "127.0.0.1",
CONF_PORT: 80,
CONF_PASSKEY: "1234",
CONF_USERNAME: "admin",
CONF_PASSWORD: "admin1234",
}
async def test_migrate_entry_discovers_circuits(
hass: HomeAssistant,
mock_bsblan: MagicMock,
) -> None:
"""Test migration from 1.1 to 1.2 discovers available circuits."""
mock_bsblan.get_available_circuits.return_value = [1, 2]
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 == 2
assert entry.data[CONF_HEATING_CIRCUITS] == [1, 2]
async def test_migrate_entry_discovery_failure_falls_back(
hass: HomeAssistant,
mock_bsblan: MagicMock,
) -> None:
"""Test migration falls back to [1] when circuit discovery fails."""
mock_bsblan.get_available_circuits.side_effect = BSBLANError("boom")
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 == 2
assert entry.data[CONF_HEATING_CIRCUITS] == [1]
async def test_migrate_entry_discovery_timeout_falls_back(
hass: HomeAssistant,
mock_bsblan: MagicMock,
) -> None:
"""Test migration falls back to [1] when circuit discovery times out."""
mock_bsblan.get_available_circuits.side_effect = TimeoutError
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.minor_version == 2
assert entry.data[CONF_HEATING_CIRCUITS] == [1]
async def test_migrate_entry_future_version_aborts(
hass: HomeAssistant,
mock_bsblan: MagicMock,
) -> None:
"""Test migration refuses to downgrade from a future major version."""
entry = MockConfigEntry(
title="BSBLAN Setup",
domain=DOMAIN,
data={**_legacy_entry_data(), CONF_HEATING_CIRCUITS: [1]},
unique_id="00:80:41:19:69:90",
version=2,
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.MIGRATION_ERROR
async def test_migrate_entry_already_current(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_bsblan: MagicMock,
) -> None:
"""Test that an up-to-date entry is loaded without re-running discovery."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
assert mock_bsblan.get_available_circuits.call_count == 0
assert mock_config_entry.data[CONF_HEATING_CIRCUITS] == [1]
+2 -2
View File
@@ -28,7 +28,7 @@ from . import setup_with_selected_platforms
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
ENTITY_ID = "water_heater.bsb_lan"
ENTITY_ID = "water_heater.water_heater"
@pytest.fixture
@@ -181,7 +181,7 @@ async def test_set_invalid_operation_mode(
with pytest.raises(
HomeAssistantError,
match=r"Operation mode invalid_mode is not valid for water_heater\.bsb_lan\. Valid operation modes are: off, performance, eco",
match=r"Operation mode invalid_mode is not valid for water_heater\.water_heater\. Valid operation modes are: off, performance, eco",
):
await hass.services.async_call(
domain=WATER_HEATER_DOMAIN,