mirror of
https://github.com/home-assistant/core.git
synced 2026-07-02 20:26:16 +01:00
301 lines
10 KiB
Python
301 lines
10 KiB
Python
"""The BSB-LAN integration."""
|
|
|
|
import asyncio
|
|
import dataclasses
|
|
|
|
from bsblan import (
|
|
BSBLAN,
|
|
BSBLANAuthError,
|
|
BSBLANConfig,
|
|
BSBLANConnectionError,
|
|
BSBLANError,
|
|
Device,
|
|
Info,
|
|
StaticState,
|
|
)
|
|
from yarl import URL
|
|
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.const import (
|
|
CONF_HOST,
|
|
CONF_PASSWORD,
|
|
CONF_PORT,
|
|
CONF_USERNAME,
|
|
Platform,
|
|
)
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.exceptions import (
|
|
ConfigEntryAuthFailed,
|
|
ConfigEntryError,
|
|
ConfigEntryNotReady,
|
|
)
|
|
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_HEATING_CIRCUITS,
|
|
CONF_PASSKEY,
|
|
DEFAULT_HEATING_CIRCUITS,
|
|
DEFAULT_PORT,
|
|
DOMAIN,
|
|
LOGGER,
|
|
)
|
|
from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator
|
|
from .services import async_setup_services
|
|
|
|
PLATFORMS = [Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER]
|
|
|
|
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
|
|
|
type BSBLanConfigEntry = ConfigEntry[BSBLanData]
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class BSBLanData:
|
|
"""BSBLan data stored in the Home Assistant data object."""
|
|
|
|
fast_coordinator: BSBLanFastCoordinator
|
|
slow_coordinator: BSBLanSlowCoordinator
|
|
client: BSBLAN
|
|
device: Device
|
|
info: Info
|
|
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:
|
|
"""Set up the BSB-LAN integration."""
|
|
async_setup_services(hass)
|
|
return True
|
|
|
|
|
|
async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bool:
|
|
"""Set up BSB-LAN from a config entry."""
|
|
|
|
# create config using BSBLANConfig
|
|
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),
|
|
)
|
|
|
|
# create BSBLAN client
|
|
session = async_get_clientsession(hass)
|
|
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] or list(
|
|
DEFAULT_HEATING_CIRCUITS
|
|
)
|
|
|
|
# Fetch required device metadata in parallel for faster startup
|
|
device, info = await asyncio.gather(
|
|
bsblan.device(),
|
|
bsblan.info(),
|
|
)
|
|
except BSBLANConnectionError as err:
|
|
raise ConfigEntryNotReady(
|
|
translation_domain=DOMAIN,
|
|
translation_key="setup_connection_error",
|
|
translation_placeholders={"host": entry.data[CONF_HOST]},
|
|
) from err
|
|
except BSBLANAuthError as err:
|
|
raise ConfigEntryAuthFailed(
|
|
translation_domain=DOMAIN,
|
|
translation_key="setup_auth_error",
|
|
) from err
|
|
except TimeoutError as err:
|
|
raise ConfigEntryNotReady(
|
|
translation_domain=DOMAIN,
|
|
translation_key="setup_connection_error",
|
|
translation_placeholders={"host": entry.data[CONF_HOST]},
|
|
) from err
|
|
except BSBLANError as err:
|
|
raise ConfigEntryError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="setup_general_error",
|
|
) from err
|
|
|
|
# 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, circuits)
|
|
slow_coordinator = BSBLanSlowCoordinator(hass, entry, bsblan)
|
|
|
|
# Perform first refresh of fast coordinator (required for entities)
|
|
await fast_coordinator.async_config_entry_first_refresh()
|
|
|
|
# Refresh slow coordinator - don't fail if DHW is not available
|
|
# This allows the integration to work even if the device doesn't support DHW
|
|
await slow_coordinator.async_refresh()
|
|
|
|
entry.runtime_data = BSBLanData(
|
|
client=bsblan,
|
|
fast_coordinator=fast_coordinator,
|
|
slow_coordinator=slow_coordinator,
|
|
device=device,
|
|
info=info,
|
|
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)
|
|
|
|
return True
|
|
|
|
|
|
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] = list(DEFAULT_HEATING_CIRCUITS)
|
|
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 a single circuit. Use Reconfigure to "
|
|
"rediscover additional circuits later",
|
|
entry.data[CONF_HOST],
|
|
err,
|
|
)
|
|
if not circuits:
|
|
LOGGER.warning(
|
|
"Circuit discovery during migration returned no heating circuits "
|
|
"for %s; defaulting to a single circuit",
|
|
entry.data[CONF_HOST],
|
|
)
|
|
circuits = list(DEFAULT_HEATING_CIRCUITS)
|
|
|
|
hass.config_entries.async_update_entry(
|
|
entry,
|
|
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,
|
|
)
|
|
|
|
# 1.2 -> 1.3: Repair entries that stored an empty circuit list during
|
|
# discovery. Every BSB-LAN setup has at least one heating circuit.
|
|
if entry.version == 1 and entry.minor_version < 3:
|
|
if not entry.data[CONF_HEATING_CIRCUITS]:
|
|
LOGGER.warning(
|
|
"Stored heating circuits for %s are empty; defaulting to a "
|
|
"single circuit",
|
|
entry.data[CONF_HOST],
|
|
)
|
|
data = {
|
|
**entry.data,
|
|
CONF_HEATING_CIRCUITS: list(DEFAULT_HEATING_CIRCUITS),
|
|
}
|
|
else:
|
|
data = {**entry.data}
|
|
|
|
hass.config_entries.async_update_entry(entry, data=data, minor_version=3)
|
|
|
|
return True
|