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:
committed by
GitHub
parent
cf1faf3a20
commit
10d78d280a
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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': '°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,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user