Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
28c3ca3
Refactor pylint plugins to use match statements (#168894)
cdce8p Apr 24, 2026
6b5bbed
Update websocket_api.handle_test_condition to use modern condition AP…
emontnemery Apr 24, 2026
eb42804
Add binary sensor platform to Qube heat pump (#166611)
MattieGit Apr 24, 2026
dd2a90a
Add radio_frequency entity integration (#168447)
balloob Apr 24, 2026
bf36c3d
Bump indevolt-api to 1.4.0 (#169050)
Xirt Apr 24, 2026
db01b8e
Migrate async_conditions_from_config to ConditionChecker (#169033)
emontnemery Apr 24, 2026
ccd82e6
Disable sonos tests broken by Python 3.14.3 asyncio changes (#169046)
justanotherariel Apr 24, 2026
cf1faf3
Refactor AirQ config flow tests (#169053)
Sibgatulin Apr 24, 2026
10d78d2
Add multiple heating system circuit support to BSBlan (#165992)
liudger Apr 24, 2026
a92277b
Add method Script.unload (#169036)
emontnemery Apr 24, 2026
032dce2
Bump aioesphomeapi to 44.21.0 (#169056)
balloob Apr 24, 2026
bb95208
Bump aiotractive to 1.0.3 (#169059)
bieniu Apr 24, 2026
7b3b1e3
Bump indevolt-api to 1.4.2 (#169061)
Xirt Apr 24, 2026
8580a64
Add MQTT date platform (#168998)
jbouwh Apr 24, 2026
8cd2d39
Add data descriptions to config flow in OTP integration (#168989)
tr4nt0r Apr 24, 2026
d849b12
Add distance device class to Ecowitt lightning distance sensors (#168…
mtheli Apr 24, 2026
59766bb
Victron GX: quality scale adjustments (#168988)
tomer-w Apr 24, 2026
45adc3d
Bump rf-protocols to 2.1.0 (#169062)
balloob Apr 24, 2026
7d494f6
Adjust compound conditions (#169054)
emontnemery Apr 24, 2026
dd71d6c
Validate local_only user for signed requests (#169066)
edenhaus Apr 24, 2026
c4fd458
Add Honeywell String Lights integration (#168450)
balloob Apr 24, 2026
c4426b9
Add radio_frequency platform to ESPHome (#168448)
balloob Apr 24, 2026
ec18e0c
Add uptime device class to the sensor platform (#164266)
chemelli74 Apr 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .core_files.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ base_platforms: &base_platforms
- homeassistant/components/image_processing/**
- homeassistant/components/infrared/**
- homeassistant/components/lawn_mower/**
- homeassistant/components/radio_frequency/**
- homeassistant/components/light/**
- homeassistant/components/lock/**
- homeassistant/components/media_player/**
Expand Down
4 changes: 4 additions & 0 deletions CODEOWNERS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion homeassistant/brands/honeywell.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"domain": "honeywell",
"name": "Honeywell",
"integrations": ["lyric", "evohome", "honeywell"]
"integrations": ["lyric", "evohome", "honeywell", "honeywell_string_lights"]
}
146 changes: 131 additions & 15 deletions homeassistant/components/bsblan/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
Info,
StaticState,
)
from yarl import URL

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
Expand All @@ -28,11 +29,16 @@
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

Expand All @@ -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:
Expand All @@ -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(),
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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
45 changes: 29 additions & 16 deletions homeassistant/components/bsblan/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

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,
Expand All @@ -24,7 +24,7 @@

from . import BSBLanConfigEntry, BSBLanData
from .const import ATTR_TARGET_TEMPERATURE, DOMAIN
from .entity import BSBLanEntity
from .entity import BSBLanCircuitEntity

PARALLEL_UPDATES = 1

Expand Down Expand Up @@ -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
Expand All @@ -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"

# Set temperature range if available, otherwise use Home Assistant defaults
if (static := data.static) is not None:
super().__init__(data.fast_coordinator, data, circuit)
self._circuit = circuit
mac = format_mac(data.device.MAC)

# 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

Expand All @@ -128,9 +143,7 @@ def hvac_mode(self) -> HVACMode | None:
@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())
Expand Down Expand Up @@ -170,7 +183,7 @@ async def async_set_data(self, **kwargs: Any) -> None:
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",
Expand Down
Loading
Loading