From 10d78d280ab671d9193c67bde1d262f0d0ee66ae Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Fri, 24 Apr 2026 15:12:09 +0200 Subject: [PATCH] Add multiple heating system circuit support to BSBlan (#165992) Co-authored-by: Copilot --- homeassistant/components/bsblan/__init__.py | 146 ++++++++++++-- homeassistant/components/bsblan/climate.py | 43 ++-- .../components/bsblan/config_flow.py | 43 +++- homeassistant/components/bsblan/const.py | 1 + .../components/bsblan/coordinator.py | 18 +- .../components/bsblan/diagnostics.py | 11 +- homeassistant/components/bsblan/entity.py | 85 +++++--- .../components/bsblan/quality_scale.yaml | 9 +- homeassistant/components/bsblan/strings.json | 8 + .../components/bsblan/water_heater.py | 4 +- tests/components/bsblan/conftest.py | 31 ++- .../bsblan/snapshots/test_climate.ambr | 20 +- .../bsblan/snapshots/test_diagnostics.ambr | 189 +++++++++--------- .../bsblan/snapshots/test_water_heater.ambr | 10 +- tests/components/bsblan/test_climate.py | 37 +++- tests/components/bsblan/test_config_flow.py | 71 ++++++- tests/components/bsblan/test_diagnostics.py | 2 +- tests/components/bsblan/test_init.py | 174 +++++++++++++++- tests/components/bsblan/test_water_heater.py | 4 +- 19 files changed, 695 insertions(+), 211 deletions(-) diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py index 0520cb8039e..1e5b641a357 100644 --- a/homeassistant/components/bsblan/__init__.py +++ b/homeassistant/components/bsblan/__init__.py @@ -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 diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index 8ae03e0a7a2..fc8dbd4ff55 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -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", diff --git a/homeassistant/components/bsblan/config_flow.py b/homeassistant/components/bsblan/config_flow.py index 01024a07e42..9ff633b0480 100644 --- a/homeassistant/components/bsblan/config_flow.py +++ b/homeassistant/components/bsblan/config_flow.py @@ -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] diff --git a/homeassistant/components/bsblan/const.py b/homeassistant/components/bsblan/const.py index 8dfdc180089..669db12dc9c 100644 --- a/homeassistant/components/bsblan/const.py +++ b/homeassistant/components/bsblan/const.py @@ -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 diff --git a/homeassistant/components/bsblan/coordinator.py b/homeassistant/components/bsblan/coordinator.py index a2805aa5ff1..6ba18827fa9 100644 --- a/homeassistant/components/bsblan/coordinator.py +++ b/homeassistant/components/bsblan/coordinator.py @@ -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, ) diff --git a/homeassistant/components/bsblan/diagnostics.py b/homeassistant/components/bsblan/diagnostics.py index 324e2fc1497..66e5e97172c 100644 --- a/homeassistant/components/bsblan/diagnostics.py +++ b/homeassistant/components/bsblan/diagnostics.py @@ -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 diff --git a/homeassistant/components/bsblan/entity.py b/homeassistant/components/bsblan/entity.py index 536551fe6d0..d2d1c271d35 100644 --- a/homeassistant/components/bsblan/entity.py +++ b/homeassistant/components/bsblan/entity.py @@ -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"), + ) diff --git a/homeassistant/components/bsblan/quality_scale.yaml b/homeassistant/components/bsblan/quality_scale.yaml index be9efefd137..f309f63765c 100644 --- a/homeassistant/components/bsblan/quality_scale.yaml +++ b/homeassistant/components/bsblan/quality_scale.yaml @@ -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 diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json index bd663eb8ba7..d257119f2a5 100644 --- a/homeassistant/components/bsblan/strings.json +++ b/homeassistant/components/bsblan/strings.json @@ -79,6 +79,14 @@ } } }, + "device": { + "heating_circuit": { + "name": "Heating circuit {circuit}" + }, + "water_heater": { + "name": "Water heater" + } + }, "entity": { "button": { "sync_time": { diff --git a/homeassistant/components/bsblan/water_heater.py b/homeassistant/components/bsblan/water_heater.py index c91a9518f7b..4f11a52b03d 100644 --- a/homeassistant/components/bsblan/water_heater.py +++ b/homeassistant/components/bsblan/water_heater.py @@ -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 diff --git a/tests/components/bsblan/conftest.py b/tests/components/bsblan/conftest.py index 2ffaf857cc6..a3a00b255b7 100644 --- a/tests/components/bsblan/conftest.py +++ b/tests/components/bsblan/conftest.py @@ -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 diff --git a/tests/components/bsblan/snapshots/test_climate.ambr b/tests/components/bsblan/snapshots/test_climate.ambr index 5f71b17202c..9c13a0620f9 100644 --- a/tests/components/bsblan/snapshots/test_climate.ambr +++ b/tests/components/bsblan/snapshots/test_climate.ambr @@ -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': , 'hvac_modes': list([ , @@ -70,14 +70,14 @@ 'temperature': 18.5, }), 'context': , - 'entity_id': 'climate.bsb_lan', + 'entity_id': 'climate.heating_circuit_1', 'last_changed': , 'last_reported': , 'last_updated': , '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': , 'hvac_modes': list([ , @@ -148,7 +148,7 @@ 'temperature': 18.5, }), 'context': , - 'entity_id': 'climate.bsb_lan', + 'entity_id': 'climate.heating_circuit_1', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/bsblan/snapshots/test_diagnostics.ambr b/tests/components/bsblan/snapshots/test_diagnostics.ambr index 28baa7ea6db..2b88f0775d5 100644 --- a/tests/components/bsblan/snapshots/test_diagnostics.ambr +++ b/tests/components/bsblan/snapshots/test_diagnostics.ambr @@ -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': '°C', - 'value': 18.6, - }), - 'hvac_action': dict({ - 'data_type': 1, - 'data_type_family': '', - 'data_type_name': '', - 'desc': 'Raumtemp’begrenzung', - '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': '°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': '°C', + 'value': 18.6, + }), + 'hvac_action': dict({ + 'data_type': 1, + 'data_type_family': '', + 'data_type_name': '', + 'desc': 'Raumtemp’begrenzung', + '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': '°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': '°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': '°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': '°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': '°C', + 'value': 8.0, + }), }), }), }) diff --git a/tests/components/bsblan/snapshots/test_water_heater.ambr b/tests/components/bsblan/snapshots/test_water_heater.ambr index 4bbcb6297f7..19ae489aab5 100644 --- a/tests/components/bsblan/snapshots/test_water_heater.ambr +++ b/tests/components/bsblan/snapshots/test_water_heater.ambr @@ -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': , - 'entity_id': 'water_heater.bsb_lan', + 'entity_id': 'water_heater.water_heater', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/bsblan/test_climate.py b/tests/components/bsblan/test_climate.py index 632b78ad237..aacb5e36361 100644 --- a/tests/components/bsblan/test_climate.py +++ b/tests/components/bsblan/test_climate.py @@ -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 diff --git a/tests/components/bsblan/test_config_flow.py b/tests/components/bsblan/test_config_flow.py index 8df61413b9a..feda9e9b408 100644 --- a/tests/components/bsblan/test_config_flow.py +++ b/tests/components/bsblan/test_config_flow.py @@ -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( diff --git a/tests/components/bsblan/test_diagnostics.py b/tests/components/bsblan/test_diagnostics.py index 7182cf32685..71463051e25 100644 --- a/tests/components/bsblan/test_diagnostics.py +++ b/tests/components/bsblan/test_diagnostics.py @@ -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} diff --git a/tests/components/bsblan/test_init.py b/tests/components/bsblan/test_init.py index bc847031a02..5496cf09fd0 100644 --- a/tests/components/bsblan/test_init.py +++ b/tests/components/bsblan/test_init.py @@ -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] diff --git a/tests/components/bsblan/test_water_heater.py b/tests/components/bsblan/test_water_heater.py index 1427552416d..dac87221dbc 100644 --- a/tests/components/bsblan/test_water_heater.py +++ b/tests/components/bsblan/test_water_heater.py @@ -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,