Compare commits

..

71 Commits

Author SHA1 Message Date
Franck Nijhof 1e47149764 Fix hassfest warning 2026-05-15 20:26:51 +00:00
Franck Nijhof 116b63ca3a Bump version to 2026.5.2 2026-05-15 20:13:00 +00:00
Ronald van der Meer 3096bcf8a9 Bump python-duco-connectivity to 0.4.0 (#170661) 2026-05-15 20:12:26 +00:00
Ronald van der Meer a4027029d0 Migrate Duco to python-duco-connectivity and remove temperature sensors (#170237) 2026-05-15 20:11:35 +00:00
Bram Kragten fffc9d0695 Update frontend to 20260429.4 (#170567) 2026-05-15 20:06:23 +00:00
G Johansson 3ca5cf5add Add missing optional category strings in workday (#170505)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-15 20:06:21 +00:00
Jan Bouwhuis 087cb77042 Fix MQTT settings in device subentry device settings are not recalled when reconfiguring the device (#170484) 2026-05-15 20:06:19 +00:00
Michael Keck 8bd1c07ec9 Increase WebDAV client timeout from 10 to 30 seconds (#170476) 2026-05-15 20:06:17 +00:00
J. Nick Koston 9ecb59590b Bump aioharmony to 1.0.3 (#170459) 2026-05-15 20:02:46 +00:00
Rob Bierbooms e14eb9fbc5 Fix influxdb reconfigure for v1 configuration (#170448) 2026-05-15 20:01:59 +00:00
TheJulianJES 149c796227 Fix fractional setpoints in Matter climate not rounded (#170442) 2026-05-15 20:01:11 +00:00
J. Nick Koston 3383e5b1e9 Bump aioesphomeapi to 44.24.1 (#170428) 2026-05-15 20:00:24 +00:00
Åke Strandberg 05862c6dc8 Bump pymiele version to 0.6.2 (#170419) 2026-05-15 19:59:37 +00:00
Petar Petrov b35ac41470 Apply unit_of_measurement to energy combined power sensor (#170404) 2026-05-15 19:58:50 +00:00
James Nimmo 20cec56512 Bump pyintesishome to 1.8.7 (#170382) 2026-05-15 19:58:03 +00:00
puddly 74580262b6 Bump serialx to 1.7.3 (#170368) 2026-05-15 19:57:16 +00:00
Pascal Brunot f75cdae602 Bump serialx to 1.7.2 (#170272) 2026-05-15 19:56:59 +00:00
Jan Bouwhuis 8c95f4f7ae Fix duplicate doorbell events when entity becomes unavailable (#170354)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-15 19:54:02 +00:00
Robert Svensson c3ec51c471 Bump axis to v71 (#170347) 2026-05-15 19:54:00 +00:00
Raman Gupta 0f80a4bc18 Cancel previous Debouncer timer handle in _schedule_timer (#170339)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-15 19:53:58 +00:00
Maciej Bieniek 0761d618f1 Fix Shelly media player availability (#170319) 2026-05-15 19:53:57 +00:00
Stefan Agner 03e3c46faf Fix hassio.backup_partial AttributeError when folders are specified (#170312)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:53:55 +00:00
Craig Dean d1962b0df2 Bump renault-api to 0.5.8 (#170309) 2026-05-15 19:53:53 +00:00
Florent Thoumie 7a38a2303a iaqualink: set system specific polling interval (#170279) 2026-05-15 19:53:51 +00:00
Maciej Bieniek 6f5c2a8614 Bump imgw-pib to 2.1.2 (#170274) 2026-05-15 19:53:49 +00:00
Sören Beye ff36498698 fix: Do not forget segments from state when a new config arrives (#170265)
Co-authored-by: Jan Bouwhuis <jbouwh@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-15 19:53:47 +00:00
Willem-Jan van Rootselaar 23e19ea2e4 Handle empty BSB-LAN heating circuits (#170249) 2026-05-15 19:53:46 +00:00
Ronald van der Meer c33f174041 Bump python-duco-client to 0.5.0 (#170065) 2026-05-15 19:52:32 +00:00
Ronald van der Meer bbe64d74e3 Bump python-duco-client to 0.4.2 (#170027) 2026-05-15 19:52:30 +00:00
Ronald van der Meer ed3a71f2ee Add API version to Duco diagnostics for support triage (#169802) 2026-05-15 19:51:21 +00:00
Ronald van der Meer 46c49daba4 Add system health platform for Duco integration (#169517) 2026-05-15 19:48:52 +00:00
Ronald van der Meer a2f2ded188 Add target flow level and mode end time sensors to Duco integration (#169298) 2026-05-15 19:47:15 +00:00
Simone Chemelli 7be061796d Fix entities refresh for UptimeRobot (#170217) 2026-05-15 19:32:16 +00:00
Jan Bouwhuis 27c7d8de0c Fix MQTT device discovery not using shared QoS and encoding options (#170195)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-15 19:32:14 +00:00
Simone Chemelli 07542523b5 Reinit API on stale session for Vodafone Station (#170190) 2026-05-15 19:32:12 +00:00
puddly 18597bb653 Set serial port description from description, not product (#170160)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2026-05-15 19:32:10 +00:00
Christian Lackas c4be57a294 homematicip_cloud: fix HmIP-FLC lock state polarity (#170159) 2026-05-15 19:32:08 +00:00
Christian Lackas 7ceaebb086 Fix homematicip_cloud config entry setup crash after migration to 2026.5.0 (#170156) 2026-05-15 19:32:06 +00:00
Mick Vleeshouwer 7c5ef09734 Fix local API incorrectly marking devices as unavailable in Overkiz (#170118)
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2026-05-15 19:32:05 +00:00
Thijs W. b4d8ba66fe Update afsapi to 1.0.1 (#170073) 2026-05-15 19:32:02 +00:00
puddly 308221ce67 Migrate ZBT-1 and ZBT-2 to use serial number for unique_id (#169879)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-05-15 19:30:56 +00:00
Simone Chemelli 1344213335 Fix non unique_id for Comelit (#169756)
Co-authored-by: Copilot <copilot@github.com>
2026-05-15 19:26:54 +00:00
r2xj 7e405e9014 Only use SmartThings switch for light if it should (#166424)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-05-15 19:26:52 +00:00
LG-ThinQ-Integration b0c45132ed Fix ValueError for non-numeric value in LG ThinQ (#166300)
Co-authored-by: YunseonPark-LGE <yunseon.park@lge.com>
2026-05-15 19:26:49 +00:00
Franck Nijhof dd0cdc4fc4 Bump version to 2026.5.1 2026-05-08 18:54:08 +00:00
Mick Vleeshouwer 18ea40c46d Fix tilt support for UpDownVenetianBlind (rts:VenetianBlindRTSComponent) in Overkiz (#170047) 2026-05-08 18:53:57 +00:00
Mick Vleeshouwer a23131efc8 Fix is_closed state for DynamicGate covers in Overkiz (#170130) 2026-05-08 18:53:10 +00:00
bkobus-bbx 4940a0abae Bump blebox_uniapi to v2.5.3 (#170115) 2026-05-08 18:53:08 +00:00
Willem-Jan van Rootselaar 5f98d5ae52 Bump python-bsblan to 5.2.1 (#170100) 2026-05-08 18:53:06 +00:00
TheJulianJES ba18cded30 Bump ZHA to 1.3.1 (#170095) 2026-05-08 18:53:04 +00:00
TheJulianJES fb7504e9df Fix Z-Wave discovery crash with unknown node firmware version (#170090) 2026-05-08 18:53:02 +00:00
Mick Vleeshouwer 106f815a1e Fix sensors getting wrong unit from MeasuredValueType attribute in Overkiz (#170088) 2026-05-08 18:53:00 +00:00
Mick Vleeshouwer 167757762b Set is_closed state to None when a cover state returns "unknown" in Overkiz (#170081) 2026-05-08 18:52:58 +00:00
Robert Resch 3a902e1a16 Bump deebot-client to 18.3.0 (#170066) 2026-05-08 18:52:56 +00:00
Mick Vleeshouwer 85c11672d8 Bump pyOverkiz to 1.20.3 (#170060) 2026-05-08 18:52:54 +00:00
Mick Vleeshouwer 89649df20d Fix cover controls for UpDownBioclimaticPergola in Overkiz (#170058) 2026-05-08 18:52:52 +00:00
Mick Vleeshouwer 7b749b95ce Fix tilt controls for TiltOnlyVenetianBlind in Overkiz (#170055)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-08 18:52:50 +00:00
Mick Vleeshouwer cc140be85c Fix is_closed state for DynamicGarageDoor in Overkiz (#170052) 2026-05-08 18:52:47 +00:00
Robert Svensson e1ad765414 Fix websocket certificate verification Bump axis to v70 (#170038) 2026-05-08 18:48:55 +00:00
Michael 44b1fea745 Proper handling of malformed data during FRITZ!Box Tools setup (#170030) 2026-05-08 18:48:54 +00:00
Ronald van der Meer 5dd04363b2 Bump python-duco-client to 0.4.1 (#169991) 2026-05-08 18:48:51 +00:00
Ronald van der Meer 03aa979309 Bump python-duco-client to 0.4.0 (#169776) 2026-05-08 18:48:49 +00:00
Daniel Hjelseth Høyer 6fabbb354b Bump pyTibber to 0.37.5 (#169981)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-05-08 18:45:50 +00:00
Erik Montnemery f644448d0f Add support for options to todo triggers (#169947) 2026-05-08 18:45:48 +00:00
G Johansson 4e61581cd8 Bump holidays to 0.96 (#169939) 2026-05-08 18:45:47 +00:00
puddly 6f87d02b72 Bump serialx to 1.7.1 (#169928) 2026-05-08 18:45:45 +00:00
Joakim Plate 348f6149b4 Update gardena ble to 2.8.1 (#169914) 2026-05-08 18:45:43 +00:00
Stefan Agner a4227ef1bc Fix hassio auth IndexError on Supervisor Unix socket requests (#169911) 2026-05-08 18:45:41 +00:00
Jeef aac49a567f Fix IntelliFire setup recovery (#169739) 2026-05-08 18:45:39 +00:00
Rob Treacy 76b878b136 Fix WiZ Light config flow timeout by properly closing UDP connections (#168456) 2026-05-08 18:45:37 +00:00
th3spis 2d05931683 Added wfsens as a occupancy source in wiz (#166799)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-08 18:45:35 +00:00
157 changed files with 4724 additions and 622 deletions
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["serialx==1.7.0"]
"requirements": ["serialx==1.7.3"]
}
+36 -3
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from asyncio import timeout
from collections.abc import Mapping
from datetime import datetime, timedelta
from http import HTTPStatus
import json
import logging
@@ -13,7 +14,12 @@ from uuid import uuid4
import aiohttp
from homeassistant.components import event
from homeassistant.const import EVENT_STATE_CHANGED, STATE_ON
from homeassistant.const import (
EVENT_STATE_CHANGED,
STATE_ON,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import (
CALLBACK_TYPE,
Event,
@@ -53,6 +59,25 @@ DEFAULT_TIMEOUT = 10
TO_REDACT = {"correlationToken", "token"}
def valid_doorbell_timestamp(entity_id: str, event_state: str) -> bool:
"""Check if doorbell event timestamp is valid."""
if event_state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
try:
timestamp = datetime.fromisoformat(event_state)
except ValueError:
_LOGGER.debug(
"Unable to parse ISO timestamp from state for %s. Got %s",
entity_id,
event_state,
)
return False
else:
if (dt_util.utcnow() - timestamp) < timedelta(seconds=30):
return True
return False
class AlexaDirective:
"""An incoming Alexa directive."""
@@ -317,9 +342,17 @@ async def async_enable_proactive_mode(
if should_doorbell:
old_state = data["old_state"]
if new_state.domain == event.DOMAIN or (
if (
new_state.domain == event.DOMAIN
and valid_doorbell_timestamp(new_state.entity_id, new_state.state)
and (old_state is None or old_state.state != STATE_UNAVAILABLE)
and (old_state is None or old_state.state != new_state.state)
) or (
new_state.state == STATE_ON
and (old_state is None or old_state.state != STATE_ON)
and (
old_state is None
or old_state.state not in (STATE_ON, STATE_UNAVAILABLE)
)
):
await async_send_doorbell_event_message(
hass, smart_home_config, alexa_changed_entity
+1 -1
View File
@@ -29,7 +29,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["axis"],
"requirements": ["axis==69"],
"requirements": ["axis==71"],
"ssdp": [
{
"manufacturer": "AXIS"
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["blebox_uniapi"],
"requirements": ["blebox-uniapi==2.5.2"],
"requirements": ["blebox-uniapi==2.5.3"],
"zeroconf": ["_bbxsrv._tcp.local."]
}
+38 -4
View File
@@ -38,7 +38,14 @@ from homeassistant.helpers.device_registry import (
)
from homeassistant.helpers.typing import ConfigType
from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
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
@@ -118,7 +125,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
# Read available heating circuits from config entry data
# (populated by config flow or migration)
circuits: list[int] = entry.data[CONF_HEATING_CIRCUITS]
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(
@@ -229,7 +238,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
# 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]
circuits: list[int] = list(DEFAULT_HEATING_CIRCUITS)
config = BSBLANConfig(
host=entry.data[CONF_HOST],
passkey=entry.data[CONF_PASSKEY],
@@ -245,11 +254,18 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
except (BSBLANError, TimeoutError) as err:
LOGGER.warning(
"Circuit discovery during migration failed for %s (%s); "
"defaulting to single circuit [1]. Use Reconfigure to "
"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,
@@ -263,4 +279,22 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
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
+18 -4
View File
@@ -15,21 +15,28 @@ 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_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
from .const import (
CONF_HEATING_CIRCUITS,
CONF_PASSKEY,
DEFAULT_HEATING_CIRCUITS,
DEFAULT_PORT,
DOMAIN,
LOGGER,
)
class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a BSBLAN config flow."""
VERSION = 1
MINOR_VERSION = 2
MINOR_VERSION = 3
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.circuits: list[int] = list(DEFAULT_HEATING_CIRCUITS)
self.passkey: str | None = None
self.username: str | None = None
self.password: str | None = None
@@ -386,6 +393,13 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
try:
await bsblan.initialize()
self.circuits = await bsblan.get_available_circuits()
if not self.circuits:
LOGGER.debug(
"Circuit discovery returned no heating circuits for %s, "
"defaulting to single circuit",
self.host,
)
self.circuits = list(DEFAULT_HEATING_CIRCUITS)
except (
BSBLANError,
TimeoutError,
@@ -394,4 +408,4 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
"Circuit discovery not available for %s, defaulting to single circuit",
self.host,
)
self.circuits = [1]
self.circuits = list(DEFAULT_HEATING_CIRCUITS)
+1
View File
@@ -24,4 +24,5 @@ ATTR_OUTSIDE_TEMPERATURE: Final = "outside_temperature"
CONF_PASSKEY: Final = "passkey"
CONF_HEATING_CIRCUITS: Final = "heating_circuits"
DEFAULT_HEATING_CIRCUITS: Final = (1,)
DEFAULT_PORT: Final = 80
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["bsblan"],
"quality_scale": "silver",
"requirements": ["python-bsblan==5.2.0"],
"requirements": ["python-bsblan==5.2.1"],
"zeroconf": [
{
"name": "bsb-lan*",
+53 -2
View File
@@ -3,9 +3,10 @@
from aiocomelit.const import BRIDGE
from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .const import CONF_VEDO_PIN, DEFAULT_PORT
from .const import _LOGGER, CONF_VEDO_PIN, DEFAULT_PORT, DOMAIN
from .coordinator import (
ComelitBaseCoordinator,
ComelitConfigEntry,
@@ -81,6 +82,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: ComelitConfigEntry) -> b
return True
async def async_migrate_entry(
hass: HomeAssistant, config_entry: ComelitConfigEntry
) -> bool:
"""Migrate old entry."""
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1 and config_entry.minor_version == 1:
device_registry = dr.async_get(hass)
@callback
def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None:
if (
entry.domain != Platform.SENSOR
or entry.device_id is None
or not (device_entry := device_registry.async_get(entry.device_id))
or not any(
platform == DOMAIN
and identifier.startswith(f"{config_entry.entry_id}-zone-")
for platform, identifier in device_entry.identifiers
)
):
return None
_LOGGER.debug(
"Migrating from version %s.%s",
config_entry.version,
config_entry.minor_version,
)
zone_index = entry.unique_id.removeprefix(f"{config_entry.entry_id}-")
return {
"new_unique_id": f"{config_entry.entry_id}-human_status-{zone_index}"
}
await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id)
hass.config_entries.async_update_entry(config_entry, version=1, minor_version=2)
_LOGGER.info(
"Migration to version %s.%s successful",
config_entry.version,
config_entry.minor_version,
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ComelitConfigEntry) -> bool:
"""Unload a config entry."""
@@ -94,6 +94,7 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Comelit."""
VERSION = 1
MINOR_VERSION = 2
async def async_step_user(
self, user_input: dict[str, Any] | None = None
+1 -1
View File
@@ -153,7 +153,7 @@ class ComelitVedoSensorEntity(
super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id
# because no serial number or mac is available
self._attr_unique_id = f"{config_entry_entry_id}-{zone.index}"
self._attr_unique_id = f"{config_entry_entry_id}-{description.key}-{zone.index}"
self._attr_device_info = coordinator.platform_device_info(zone, "zone")
self.entity_description = description
+14 -4
View File
@@ -1,24 +1,34 @@
"""The Duco integration."""
from __future__ import annotations
import re
from duco import DucoClient, build_ssl_context
from duco_connectivity import DucoClient
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import PLATFORMS
from .coordinator import DucoConfigEntry, DucoCoordinator
_REMOVED_SENSOR_RE = re.compile(r"_\d+_(box_)?temperature$")
async def async_setup_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool:
"""Set up Duco from a config entry."""
ssl_context = await hass.async_add_executor_job(build_ssl_context)
# Remove entity registry entries for the temperature and box_temperature
# sensors that were removed when migrating to python-duco-connectivity.
entity_registry = er.async_get(hass)
for entity_entry in er.async_entries_for_config_entry(
entity_registry, entry.entry_id
):
if _REMOVED_SENSOR_RE.search(entity_entry.unique_id):
entity_registry.async_remove(entity_entry.entity_id)
client = DucoClient(
session=async_get_clientsession(hass),
host=entry.data[CONF_HOST],
ssl_context=ssl_context,
)
coordinator = DucoCoordinator(hass, entry, client)
+2 -4
View File
@@ -5,8 +5,8 @@ from __future__ import annotations
import logging
from typing import Any
from duco import DucoClient, build_ssl_context
from duco.exceptions import DucoConnectionError, DucoError
from duco_connectivity import DucoClient
from duco_connectivity.exceptions import DucoConnectionError, DucoError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@@ -160,11 +160,9 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
Returns a tuple of (box_name, mac_address).
"""
ssl_context = await self.hass.async_add_executor_job(build_ssl_context)
client = DucoClient(
session=async_get_clientsession(self.hass),
host=host,
ssl_context=ssl_context,
)
board_info = await client.async_get_board_info()
lan_info = await client.async_get_lan_info()
+3 -3
View File
@@ -5,9 +5,9 @@ from __future__ import annotations
from dataclasses import dataclass
import logging
from duco import DucoClient
from duco.exceptions import DucoConnectionError, DucoError
from duco.models import BoardInfo, Node
from duco_connectivity import DucoClient
from duco_connectivity.exceptions import DucoConnectionError, DucoError
from duco_connectivity.models import BoardInfo, Node
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
+16 -2
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from dataclasses import asdict
from typing import Any
from duco.exceptions import DucoConnectionError
from duco_connectivity.exceptions import DucoConnectionError
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST
@@ -15,6 +15,9 @@ from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN
from .coordinator import DucoConfigEntry
# MAC addresses and serial numbers are redacted because a Duco installer or
# manufacturer could cross-reference them against an installation registry to
# identify the physical location of the device.
TO_REDACT = {
CONF_HOST,
"mac",
@@ -33,22 +36,33 @@ async def async_get_config_entry_diagnostics(
coordinator = entry.runtime_data
board = asdict(coordinator.board_info)
# `time` is a Unix epoch timestamp of the last board info fetch; not useful for support triage.
board.pop("time")
if board["public_api_version"] is None:
board.pop("public_api_version")
if board["software_version"] is None:
board.pop("software_version")
try:
api_info_obj = await coordinator.client.async_get_api_info()
lan_info = await coordinator.client.async_get_lan_info()
duco_diags = await coordinator.client.async_get_diagnostics()
write_remaining = await coordinator.client.async_get_write_req_remaining()
write_remaining = await coordinator.client.async_get_write_requests_remaining()
except DucoConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="connection_error",
) from err
api_info: dict[str, Any] = {"public_api_version": api_info_obj.public_api_version}
if api_info_obj.reported_api_version is not None:
api_info["reported_api_version"] = api_info_obj.reported_api_version
return async_redact_data(
{
"entry_data": entry.data,
"board_info": board,
"api_info": api_info,
"lan_info": asdict(lan_info),
"nodes": {
str(node_id): asdict(node)
+1 -3
View File
@@ -1,8 +1,6 @@
"""Base entity for the Duco integration."""
from __future__ import annotations
from duco.models import Node
from duco_connectivity.models import Node
from homeassistant.const import ATTR_VIA_DEVICE
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
+2 -2
View File
@@ -4,8 +4,8 @@ from __future__ import annotations
import logging
from duco.exceptions import DucoError, DucoRateLimitError
from duco.models import Node, NodeType, VentilationState
from duco_connectivity.exceptions import DucoError, DucoRateLimitError
from duco_connectivity.models import Node, NodeType, VentilationState
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.core import HomeAssistant
+6
View File
@@ -7,6 +7,12 @@
"iaq_rh": {
"default": "mdi:water-percent"
},
"target_flow_level": {
"default": "mdi:gauge"
},
"time_state_end": {
"default": "mdi:timer-outline"
},
"ventilation_state": {
"default": "mdi:tune-variant"
}
+2 -2
View File
@@ -11,9 +11,9 @@
"documentation": "https://www.home-assistant.io/integrations/duco",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["duco"],
"loggers": ["duco_connectivity"],
"quality_scale": "platinum",
"requirements": ["python-duco-client==0.3.10"],
"requirements": ["python-duco-connectivity==0.4.0"],
"zeroconf": [
{
"name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*",
+31 -19
View File
@@ -4,9 +4,10 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
import logging
from duco.models import Node, NodeType, VentilationState
from duco_connectivity.models import Node, NodeType, VentilationState
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -19,11 +20,11 @@ from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import DucoConfigEntry, DucoCoordinator
@@ -38,7 +39,7 @@ PARALLEL_UPDATES = 0
class DucoSensorEntityDescription(SensorEntityDescription):
"""Duco sensor entity description."""
value_fn: Callable[[Node], int | float | str | None]
value_fn: Callable[[Node], datetime | int | float | str | None]
node_types: tuple[NodeType, ...]
@@ -54,29 +55,40 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
key="ventilation_state",
translation_key="ventilation_state",
device_class=SensorDeviceClass.ENUM,
options=[s.lower() for s in VentilationState],
options=[
state.lower()
for state in VentilationState
if state != VentilationState.UNKNOWN
],
value_fn=lambda node: (
node.ventilation.state.lower() if node.ventilation else None
node.ventilation.state.lower()
if node.ventilation and node.ventilation.state != VentilationState.UNKNOWN
else None
),
node_types=(NodeType.BOX,),
),
DucoSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
key="target_flow_level",
translation_key="target_flow_level",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda node: node.sensor.temp if node.sensor else None,
node_types=(NodeType.UCCO2, NodeType.BSRH, NodeType.UCRH),
native_unit_of_measurement=PERCENTAGE,
suggested_display_precision=0,
value_fn=lambda node: (
node.ventilation.flow_lvl_tgt if node.ventilation else None
),
node_types=(NodeType.BOX,),
),
DucoSensorEntityDescription(
key="box_temperature",
translation_key="box_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda node: node.sensor.temp if node.sensor else None,
key="time_state_end",
translation_key="time_state_end",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda node: (
dt_util.utc_from_timestamp(node.ventilation.time_state_end).replace(
second=0, microsecond=0
)
if node.ventilation and node.ventilation.time_state_end != 0
else None
),
node_types=(NodeType.BOX,),
),
DucoSensorEntityDescription(
@@ -216,7 +228,7 @@ class DucoSensorEntity(DucoEntity, SensorEntity):
)
@property
def native_value(self) -> int | float | str | None:
def native_value(self) -> datetime | int | float | str | None:
"""Return the sensor value."""
return self.entity_description.value_fn(self._node)
+11 -3
View File
@@ -47,15 +47,18 @@
}
},
"sensor": {
"box_temperature": {
"name": "Box temperature"
},
"iaq_co2": {
"name": "CO2 air quality index"
},
"iaq_rh": {
"name": "Humidity air quality index"
},
"target_flow_level": {
"name": "Target flow level"
},
"time_state_end": {
"name": "Mode end time"
},
"ventilation_state": {
"name": "Ventilation state",
"state": {
@@ -96,5 +99,10 @@
"rate_limit_exceeded": {
"message": "The Duco device has reached its daily write limit. Try again tomorrow."
}
},
"system_health": {
"info": {
"write_requests_remaining": "Remaining write requests today"
}
}
}
@@ -0,0 +1,47 @@
"""Provide info to system health."""
from typing import Any
from duco_connectivity.exceptions import DucoConnectionError
from homeassistant.components import system_health
from homeassistant.core import HomeAssistant, callback
from .const import DOMAIN
from .coordinator import DucoConfigEntry
@callback
def async_register(
hass: HomeAssistant, register: system_health.SystemHealthRegistration
) -> None:
"""Register system health callbacks."""
register.async_register_info(system_health_info)
async def _async_get_write_requests_remaining(
config_entry: DucoConfigEntry,
) -> int | dict[str, str]:
"""Get the remaining write-request quota for system health."""
try:
return (
await config_entry.runtime_data.client.async_get_write_requests_remaining()
)
except DucoConnectionError:
return {"type": "failed", "error": "unreachable"}
async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
"""Get info for the info page."""
config_entries: list[DucoConfigEntry] = hass.config_entries.async_loaded_entries(
DOMAIN
)
if not config_entries:
return {}
return {
"write_requests_remaining": _async_get_write_requests_remaining(
config_entries[0]
)
}
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.11", "deebot-client==18.2.0"]
"requirements": ["py-sucks==0.9.11", "deebot-client==18.3.0"]
}
+6 -5
View File
@@ -666,6 +666,12 @@ class EnergyPowerSensor(SensorEntity):
self._is_inverted = "stat_rate_inverted" in config
self._is_combined = "stat_rate_from" in config and "stat_rate_to" in config
# Combined mode always emits Watts because _update_state converts
# heterogeneous source units to W internally. Inverted mode copies
# the source unit in _update_state to track source changes.
if self._is_combined:
self._attr_native_unit_of_measurement = UnitOfPower.WATT
# Determine source sensors
if self._is_inverted:
self._source_sensors = [config["stat_rate_inverted"]]
@@ -766,11 +772,6 @@ class EnergyPowerSensor(SensorEntity):
# Check first sensor
if source_entry := entity_reg.async_get(self._source_sensors[0]):
device_id = source_entry.device_id
# Combined mode always emits Watts because we convert
# heterogeneous source units internally. For inverted mode the
# unit is copied from the source state in _update_state.
if self._is_combined:
self._attr_native_unit_of_measurement = UnitOfPower.WATT
# Get source name from registry
source_name = source_entry.name or source_entry.original_name
# Assign power sensor to same device as source sensor(s)
@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==44.21.0",
"aioesphomeapi==44.24.1",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.7.3"
],
@@ -10,6 +10,7 @@ from functools import partial
import logging
import re
from typing import Any, TypedDict, cast
from xml.etree.ElementTree import ParseError
from fritzconnection import FritzConnection
from fritzconnection.core.exceptions import FritzActionError
@@ -26,7 +27,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.dispatcher import async_dispatcher_send
@@ -228,7 +229,13 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
self.fritz_guest_wifi = FritzGuestWLAN(fc=self.connection)
self.fritz_status = FritzStatus(fc=self.connection)
self.fritz_call = FritzCall(fc=self.connection)
info = self.fritz_status.get_device_info()
try:
info = self.fritz_status.get_device_info()
except ParseError as ex:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="error_parse_device_info",
) from ex
_LOGGER.debug(
"gathered device info of %s %s",
@@ -185,6 +185,9 @@
"config_entry_not_found": {
"message": "Failed to perform action \"{service}\". Config entry for target not found"
},
"error_parse_device_info": {
"message": "Error parsing device info. Please check the system event log of your FRITZ!Box for malformed data and clear the event list."
},
"error_refresh_hosts_info": {
"message": "Error refreshing hosts info"
},
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260429.3"]
"requirements": ["home-assistant-frontend==20260429.4"]
}
@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["afsapi"],
"requirements": ["afsapi==1.0.0"],
"requirements": ["afsapi==1.0.1"],
"ssdp": [
{
"st": "urn:schemas-frontier-silicon-com:undok:fsapi:1"
@@ -198,7 +198,9 @@ class AFSAPIDevice(MediaPlayerEntity):
if not self._attr_source_list:
self.__modes_by_label = {
(mode.label or mode.id): mode.key for mode in await afsapi.get_modes()
(mode.label or mode.id): mode.key
for mode in await afsapi.get_modes()
if mode.selectable
}
self._attr_source_list = list(self.__modes_by_label)
@@ -15,5 +15,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"],
"requirements": ["gardena-bluetooth==2.4.0"]
"requirements": ["gardena-bluetooth==2.8.1"]
}
@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["aioharmony", "slixmpp"],
"requirements": ["aioharmony==0.5.3"],
"requirements": ["aioharmony==1.0.3"],
"ssdp": [
{
"deviceType": "urn:myharmony-com:device:harmony:1",
+13 -8
View File
@@ -12,6 +12,7 @@ import voluptuous as vol
from homeassistant.auth.models import User
from homeassistant.auth.providers import homeassistant as auth_ha
from homeassistant.components.http import KEY_HASS, KEY_HASS_USER, HomeAssistantView
from homeassistant.components.http.const import is_supervisor_unix_socket_request
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
@@ -41,14 +42,18 @@ class HassIOBaseAuth(HomeAssistantView):
def _check_access(self, request: web.Request) -> None:
"""Check if this call is from Supervisor."""
# Check caller IP
hassio_ip = os.environ["SUPERVISOR"].split(":")[0]
assert request.transport
if ip_address(request.transport.get_extra_info("peername")[0]) != ip_address(
hassio_ip
):
_LOGGER.error("Invalid auth request from %s", request.remote)
raise HTTPUnauthorized
# Requests over the Supervisor Unix socket are authenticated by the
# http auth middleware as the Supervisor user, so the caller-IP check
# below does not apply (and would crash, since `peername` is empty for
# Unix sockets). The user-ID check still runs to ensure only the
# Supervisor user can reach this endpoint.
if not is_supervisor_unix_socket_request(request):
hassio_ip = os.environ["SUPERVISOR"].split(":")[0]
assert request.transport
peername = request.transport.get_extra_info("peername")
if not peername or ip_address(peername[0]) != ip_address(hassio_ip):
_LOGGER.error("Invalid auth request from %s", request.remote)
raise HTTPUnauthorized
# Check caller token
if request[KEY_HASS_USER].id != self.user.id:
+36 -7
View File
@@ -7,6 +7,7 @@ from typing import Any
from aiohasupervisor import SupervisorClient, SupervisorError
from aiohasupervisor.models import (
Folder,
FullBackupOptions,
FullRestoreOptions,
PartialBackupOptions,
@@ -70,6 +71,31 @@ SERVICE_MOUNT_RELOAD = "mount_reload"
VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$"))
# Legacy alias used by the Supervisor API for the homeassistant flag, kept
# for backwards compatibility with existing automations.
LEGACY_FOLDER_HOMEASSISTANT = "homeassistant"
def _normalize_partial_options_data(data: dict[str, Any]) -> dict[str, Any]:
"""Map legacy aliases used by both partial backup and partial restore handlers."""
if ATTR_APPS in data:
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
if ATTR_FOLDERS in data:
folders: set[Any] = set(data[ATTR_FOLDERS])
if LEGACY_FOLDER_HOMEASSISTANT in folders:
folders.discard(LEGACY_FOLDER_HOMEASSISTANT)
if data.get(ATTR_HOMEASSISTANT) is False:
raise ServiceValidationError(
f"{ATTR_HOMEASSISTANT}=False conflicts with the legacy "
f"{LEGACY_FOLDER_HOMEASSISTANT!r} entry in {ATTR_FOLDERS}"
)
data[ATTR_HOMEASSISTANT] = True
if folders:
data[ATTR_FOLDERS] = folders
else:
data.pop(ATTR_FOLDERS)
return data
def valid_addon(value: Any) -> str:
"""Validate value is a valid addon slug."""
@@ -113,7 +139,10 @@ SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
cv.ensure_list,
[vol.Any(LEGACY_FOLDER_HOMEASSISTANT, vol.Coerce(Folder))],
vol.Unique(),
vol.Coerce(set),
),
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
@@ -136,7 +165,10 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
cv.ensure_list,
[vol.Any(LEGACY_FOLDER_HOMEASSISTANT, vol.Coerce(Folder))],
vol.Unique(),
vol.Coerce(set),
),
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
@@ -343,9 +375,7 @@ def async_register_backup_restore_services(
service: ServiceCall,
) -> ServiceResponse:
"""Handler for create partial backup service. Returns the new backup's ID."""
data = service.data.copy()
if ATTR_APPS in data:
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
data = _normalize_partial_options_data(service.data.copy())
options = PartialBackupOptions(**data)
try:
@@ -392,8 +422,7 @@ def async_register_backup_restore_services(
"""Handler for partial restore service."""
data = service.data.copy()
backup_slug = data.pop(ATTR_SLUG)
if ATTR_APPS in data:
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
data = _normalize_partial_options_data(data)
options = PartialRestoreOptions(**data)
try:
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.95", "babel==2.15.0"]
"requirements": ["holidays==0.96", "babel==2.15.0"]
}
@@ -10,14 +10,14 @@ from homeassistant.components.homeassistant_hardware.coordinator import (
FirmwareUpdateCoordinator,
)
from homeassistant.components.usb import USBDevice, async_register_port_event_callback
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import SOURCE_IGNORE, ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import DEVICE, DOMAIN, NABU_CASA_FIRMWARE_RELEASES_URL
from .const import DEVICE, DOMAIN, NABU_CASA_FIRMWARE_RELEASES_URL, SERIAL_NUMBER
_LOGGER = logging.getLogger(__name__)
@@ -97,3 +97,75 @@ async def async_unload_entry(
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, ["switch", "update"])
async def async_migrate_entry(
hass: HomeAssistant, config_entry: HomeAssistantConnectZBT2ConfigEntry
) -> bool:
"""Migrate old entry."""
_LOGGER.debug(
"Migrating from version %s.%s",
config_entry.version,
config_entry.minor_version,
)
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
if config_entry.minor_version == 1:
serial_number = config_entry.data[SERIAL_NUMBER]
# Installations ended up with multiple config entries per physical adapter
# in 2026.5.0 and 2026.5.1. We need to delete the older entry.
duplicates = [
entry
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.data.get(SERIAL_NUMBER) == serial_number
]
canonical = max(
duplicates,
key=lambda e: (
e.source != SOURCE_IGNORE,
e.disabled_by is None,
e.minor_version,
e.modified_at,
e.entry_id,
),
)
if canonical.entry_id != config_entry.entry_id:
# The canonical entry's migration will remove this duplicate.
return False
for duplicate in duplicates:
if duplicate.entry_id == config_entry.entry_id:
continue
_LOGGER.debug(
"Removing duplicate config entry %s for serial %s in favor of %s",
duplicate.entry_id,
serial_number,
config_entry.entry_id,
)
await hass.config_entries.async_remove(duplicate.entry_id)
# Replace the synthetic unique ID with the USB serial number
hass.config_entries.async_update_entry(
config_entry,
unique_id=serial_number,
version=1,
minor_version=2,
)
_LOGGER.debug(
"Migration to version %s.%s successful",
config_entry.version,
config_entry.minor_version,
)
return True
# This means the user has downgraded from a future version
return False
@@ -16,10 +16,7 @@ from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
)
from homeassistant.components.usb import (
usb_service_info_from_device,
usb_unique_id_from_service_info,
)
from homeassistant.components.usb import usb_service_info_from_device
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryBaseFlow,
@@ -114,7 +111,7 @@ class HomeAssistantConnectZBT2ConfigFlow(
"""Handle a config flow for Home Assistant Connect ZBT-2."""
VERSION = 1
MINOR_VERSION = 1
MINOR_VERSION = 2
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Initialize the config flow."""
@@ -132,14 +129,12 @@ class HomeAssistantConnectZBT2ConfigFlow(
async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult:
"""Handle usb discovery."""
unique_id = usb_unique_id_from_service_info(discovery_info)
discovery_info.device = await self.hass.async_add_executor_job(
usb.get_serial_by_id, discovery_info.device
)
try:
await self.async_set_unique_id(unique_id)
await self.async_set_unique_id(discovery_info.serial_number)
finally:
self._abort_if_unique_id_configured(updates={DEVICE: discovery_info.device})
@@ -157,9 +152,10 @@ class HomeAssistantConnectZBT2ConfigFlow(
"""Handle import from ZHA/OTBR firmware notification."""
assert fw_discovery_info["usb_device"] is not None
usb_info = usb_service_info_from_device(fw_discovery_info["usb_device"])
unique_id = usb_unique_id_from_service_info(usb_info)
if await self.async_set_unique_id(unique_id, raise_on_progress=False):
if await self.async_set_unique_id(
usb_info.serial_number, raise_on_progress=False
):
self._abort_if_unique_id_configured(updates={DEVICE: usb_info.device})
self._usb_info = usb_info
@@ -15,7 +15,7 @@ from homeassistant.components.usb import (
async_register_port_event_callback,
async_scan_serial_ports,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import SOURCE_IGNORE, ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import config_validation as cv
@@ -125,6 +125,10 @@ async def async_migrate_entry(
"Migrating from version %s.%s", config_entry.version, config_entry.minor_version
)
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
if config_entry.minor_version == 1:
# Add-on startup with type service get started before Core, always (e.g. the
@@ -196,6 +200,50 @@ async def async_migrate_entry(
minor_version=4,
)
if config_entry.minor_version == 4:
serial_number = config_entry.data[SERIAL_NUMBER]
# Installations ended up with multiple config entries per physical adapter
# in 2026.5.0 and 2026.5.1. We need to delete the older entry.
duplicates = [
entry
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.data.get(SERIAL_NUMBER) == serial_number
]
canonical = max(
duplicates,
key=lambda e: (
e.source != SOURCE_IGNORE,
e.disabled_by is None,
e.minor_version,
e.modified_at,
e.entry_id,
),
)
if canonical.entry_id != config_entry.entry_id:
# The canonical entry's migration will remove this duplicate.
return False
for duplicate in duplicates:
if duplicate.entry_id == config_entry.entry_id:
continue
_LOGGER.warning(
"Removing duplicate config entry %s for serial %s in favor of %s",
duplicate.entry_id,
serial_number,
config_entry.entry_id,
)
await hass.config_entries.async_remove(duplicate.entry_id)
# Replace the synthetic unique ID with the USB serial number
hass.config_entries.async_update_entry(
config_entry,
unique_id=serial_number,
version=1,
minor_version=5,
)
_LOGGER.debug(
"Migration to version %s.%s successful",
config_entry.version,
@@ -19,10 +19,7 @@ from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
)
from homeassistant.components.usb import (
usb_service_info_from_device,
usb_unique_id_from_service_info,
)
from homeassistant.components.usb import usb_service_info_from_device
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryBaseFlow,
@@ -130,7 +127,7 @@ class HomeAssistantSkyConnectConfigFlow(
"""Handle a config flow for Home Assistant SkyConnect."""
VERSION = 1
MINOR_VERSION = 4
MINOR_VERSION = 5
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Initialize the config flow."""
@@ -154,9 +151,7 @@ class HomeAssistantSkyConnectConfigFlow(
async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult:
"""Handle usb discovery."""
unique_id = usb_unique_id_from_service_info(discovery_info)
if await self.async_set_unique_id(unique_id):
if await self.async_set_unique_id(discovery_info.serial_number):
self._abort_if_unique_id_configured(updates={DEVICE: discovery_info.device})
discovery_info.device = await self.hass.async_add_executor_job(
@@ -182,9 +177,10 @@ class HomeAssistantSkyConnectConfigFlow(
"""Handle import from ZHA/OTBR firmware notification."""
assert fw_discovery_info["usb_device"] is not None
usb_info = usb_service_info_from_device(fw_discovery_info["usb_device"])
unique_id = usb_unique_id_from_service_info(usb_info)
if await self.async_set_unique_id(unique_id, raise_on_progress=False):
if await self.async_set_unique_id(
usb_info.serial_number, raise_on_progress=False
):
self._abort_if_unique_id_configured(updates={DEVICE: usb_info.device})
self._usb_info = usb_info
@@ -25,7 +25,7 @@ from .const import (
HMIPC_NAME,
)
from .hap import HomematicIPConfigEntry, HomematicipHAP
from .migration import _migrate_unique_id
from .migration import _match_legacy_class_name, _migrate_unique_id
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
@@ -157,6 +157,73 @@ async def async_migrate_entry(
)
entity_registry.async_remove(entry.entity_id)
# Pre-pass: deduplicate legacy entries that would migrate to the same
# new unique_id, and drop legacy entries whose target is already
# occupied by a stable-format entry from a previously-aborted
# migration. Two collision shapes are handled here:
#
# a) Two or more legacy entries share the same new target id (e.g.
# HomematicipNotificationLight + HomematicipNotificationLightV2
# for the same HmIP-BSL after firmware 2.0.0, or Switch +
# SwitchMeasuring on a device whose capability class changed).
#
# b) One legacy entry shares its target with a stable-format entry
# that was successfully migrated on a previous run before the
# run aborted on a sibling collision. async_migrate_entries
# commits each update individually with no rollback, so partial
# migration is the steady state for any user who already hit
# this bug at least once.
#
# When deduplicating pure-legacy groups, prefer the entry whose
# legacy class name is longer — that is the more specific variant
# (V2 over V1, Measuring over plain) and the one HA has been
# actively binding to since the class transition.
legacy_by_target: dict[tuple[str, str], list[er.RegistryEntry]] = {}
stable_targets: set[tuple[str, str]] = set()
for entry in er.async_entries_for_config_entry(
entity_registry, config_entry.entry_id
):
new_id = _migrate_unique_id(entry.unique_id)
if new_id is None:
# Stable-format entry — record so we can detect (b).
stable_targets.add((entry.domain, entry.unique_id))
continue
legacy_by_target.setdefault((entry.domain, new_id), []).append(entry)
for key, group in legacy_by_target.items():
if key in stable_targets:
# (b): stable entry already occupies the target. Drop every
# legacy duplicate; the surviving stable entry stays put.
for dup in group:
_LOGGER.warning(
"Removing legacy registry entry %s (%s) — its"
" migration target %s is already in use by a stable"
" entry from a previously-aborted migration",
dup.entity_id,
dup.unique_id,
key[1],
)
entity_registry.async_remove(dup.entity_id)
continue
if len(group) <= 1:
continue
# (a): multiple legacy entries collide on the same target.
group.sort(
key=lambda e: len(_match_legacy_class_name(e.unique_id) or ""),
reverse=True,
)
keeper, *duplicates = group
for dup in duplicates:
_LOGGER.warning(
"Removing duplicate registry entry %s (%s) — collides"
" with %s on migration to %s",
dup.entity_id,
dup.unique_id,
keeper.entity_id,
key[1],
)
entity_registry.async_remove(dup.entity_id)
@callback
def _update_unique_id(
entity_entry: er.RegistryEntry,
@@ -4,7 +4,12 @@ from __future__ import annotations
from typing import Any
from homematicip.base.enums import LockState, SmokeDetectorAlarmType, WindowState
from homematicip.base.enums import (
BinaryBehaviorType,
LockState,
SmokeDetectorAlarmType,
WindowState,
)
from homematicip.base.functionalChannels import MultiModeInputChannel
from homematicip.device import (
AccelerationSensor,
@@ -354,7 +359,22 @@ class HomematicipFullFlushLockControllerLocked(
@property
def is_on(self) -> bool:
"""Return true if the controlled lock is locked."""
"""Return true if the controlled lock is unlocked.
Per HA's BinarySensorDeviceClass.LOCK contract, ON means
unlocked / open and OFF means locked / closed.
The mapping from the firmware-reported ``lockState`` depends on
the channel's ``binaryBehaviorType``. With the default
``NORMALLY_OPEN`` wiring, the input goes ACTIVE (and lockState
flips to ``LOCKED``) when the contact closes i.e. when a
magnetic door contact registers the door as closed. With
``NORMALLY_CLOSE`` the same physical event puts the input into
the IDLE state (lockState ``UNLOCKED``). To present the same
HA semantics regardless of which way the user wired the
contact, ``lockState`` is interpreted relative to the
configured behavior.
"""
channel = _get_channel_by_role(
self._device,
"MULTI_MODE_LOCK_INPUT_CHANNEL",
@@ -363,7 +383,15 @@ class HomematicipFullFlushLockControllerLocked(
if channel is None:
return False
lock_state = getattr(channel, "lockState", None)
return getattr(lock_state, "name", lock_state) == LockState.LOCKED.name
is_locked_state = (
getattr(lock_state, "name", lock_state) == LockState.LOCKED.name
)
binary_behavior = getattr(channel, "binaryBehaviorType", None)
normally_close = (
getattr(binary_behavior, "name", binary_behavior)
== BinaryBehaviorType.NORMALLY_CLOSE.name
)
return is_locked_state if normally_close else not is_locked_state
class HomematicipFullFlushLockControllerGlassBreak(
@@ -168,6 +168,14 @@ _NOTIFICATION_LIGHT_RE = re.compile(r"^(Top|Bottom)_(.+)$")
_NOTIFICATION_LIGHT_CHANNEL_MAP = {"Top": 2, "Bottom": 3}
def _match_legacy_class_name(old_unique_id: str) -> str | None:
"""Return the legacy class name that prefixes ``old_unique_id``, if any."""
for class_name in _SORTED_CLASS_NAMES:
if old_unique_id.startswith(class_name + "_"):
return class_name
return None
def _migrate_unique_id(old_unique_id: str) -> str | None:
"""Convert an old-format unique_id to the new format.
@@ -180,14 +188,7 @@ def _migrate_unique_id(old_unique_id: str) -> str | None:
{device_id}_{channel}_{feature_id} (device entities)
{device_id}_{feature_id} (group/home entities)
"""
# Find the matching class name (longest first)
matched_class: str | None = None
for class_name in _SORTED_CLASS_NAMES:
prefix = class_name + "_"
if old_unique_id.startswith(prefix):
matched_class = class_name
break
matched_class = _match_legacy_class_name(old_unique_id)
if matched_class is None:
return None
@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.4.0"]
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.8.1"]
}
+6 -1
View File
@@ -3,4 +3,9 @@
from datetime import timedelta
DOMAIN = "iaqualink"
UPDATE_INTERVAL = timedelta(seconds=15)
UPDATE_INTERVAL_BY_SYSTEM_TYPE: dict[str, timedelta] = {
"iaqua": timedelta(seconds=15),
"exo": timedelta(seconds=60),
}
UPDATE_INTERVAL_DEFAULT = timedelta(seconds=30)
@@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, UPDATE_INTERVAL
from .const import DOMAIN, UPDATE_INTERVAL_BY_SYSTEM_TYPE, UPDATE_INTERVAL_DEFAULT
_LOGGER = logging.getLogger(__name__)
@@ -28,12 +28,15 @@ class AqualinkDataUpdateCoordinator(DataUpdateCoordinator[None]):
self, hass: HomeAssistant, config_entry: ConfigEntry, system: Any
) -> None:
"""Initialize the coordinator."""
update_interval = UPDATE_INTERVAL_BY_SYSTEM_TYPE.get(
system.NAME, UPDATE_INTERVAL_DEFAULT
)
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"{DOMAIN}_{system.serial}",
update_interval=UPDATE_INTERVAL,
update_interval=update_interval,
)
self.system = system
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["imgw_pib==2.1.1"]
"requirements": ["imgw_pib==2.1.2"]
}
@@ -290,7 +290,9 @@ class InfluxDBConfigFlow(ConfigFlow, domain=DOMAIN):
scheme="https" if entry.data.get(CONF_SSL) else "http",
host=entry.data.get(CONF_HOST, ""),
port=entry.data.get(CONF_PORT),
path=entry.data.get(CONF_PATH, ""),
path=""
if entry.data.get(CONF_PATH) is None
else entry.data[CONF_PATH],
)
)
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import aiohttp
from intellifire4py import UnifiedFireplace
from intellifire4py.cloud_interface import IntelliFireCloudInterface
from intellifire4py.const import IntelliFireApiMode
@@ -155,6 +156,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: IntellifireConfigEntry)
raise ConfigEntryNotReady(
"Initialization of fireplace timed out after 10 minutes"
) from err
except (aiohttp.ClientConnectionError, ConnectionError) as err:
raise ConfigEntryNotReady(
"Error communicating with fireplace during initialization"
) from err
# Construct coordinator
data_update_coordinator = IntellifireDataUpdateCoordinator(hass, entry, fireplace)
@@ -15,6 +15,7 @@ import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_REAUTH,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
@@ -289,10 +290,8 @@ class IntelliFireOptionsFlowHandler(OptionsFlow):
errors: dict[str, str] = {}
if user_input is not None:
# Validate connectivity for requested modes if runtime data is available
coordinator = self.config_entry.runtime_data
if coordinator is not None:
fireplace = coordinator.fireplace
if self.config_entry.state is ConfigEntryState.LOADED:
fireplace = self.config_entry.runtime_data.fireplace
# Refresh connectivity status before validating
await fireplace.async_validate_connectivity()
@@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["pyintesishome"],
"quality_scale": "legacy",
"requirements": ["pyintesishome==1.8.0"]
"requirements": ["pyintesishome==1.8.7"]
}
+64 -5
View File
@@ -520,10 +520,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
),
DeviceType.KIMCHI_REFRIGERATOR: (
REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER],
SensorEntityDescription(
key=ThinQProperty.TARGET_TEMPERATURE,
translation_key=ThinQProperty.TARGET_TEMPERATURE,
),
),
DeviceType.MICROWAVE_OVEN: (RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],),
DeviceType.OVEN: (
@@ -594,6 +590,17 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
}
ENUM_TEMPERATURE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = {
DeviceType.KIMCHI_REFRIGERATOR: (
SensorEntityDescription(
key=ThinQProperty.TARGET_TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
translation_key=ThinQProperty.TARGET_TEMPERATURE,
),
),
}
@dataclass(frozen=True, kw_only=True)
class ThinQEnergySensorEntityDescription(SensorEntityDescription):
"""Describes ThinQ energy sensor entity."""
@@ -641,7 +648,9 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up an entry for sensor platform."""
entities: list[ThinQSensorEntity | ThinQEnergySensorEntity] = []
entities: list[
ThinQSensorEntity | ThinQEnergySensorEntity | ThinQEnumTempSensorEntity
] = []
for coordinator in entry.runtime_data.coordinators.values():
if (
descriptions := DEVICE_TYPE_SENSOR_MAP.get(
@@ -663,6 +672,21 @@ async def async_setup_entry(
),
)
)
if (
descriptions := ENUM_TEMPERATURE_SENSOR_MAP.get(
coordinator.api.device.device_type
)
) is not None:
for description in descriptions:
entities.extend(
ThinQEnumTempSensorEntity(coordinator, description, property_id)
for property_id in coordinator.api.get_active_idx(
description.key,
ActiveMode.READ_ONLY,
)
)
for energy_description in ENERGY_USAGE_SENSORS:
entities.extend(
ThinQEnergySensorEntity(
@@ -862,3 +886,38 @@ class ThinQEnergySensorEntity(ThinQEntity, SensorEntity):
self.async_update,
next_update,
)
class ThinQEnumTempSensorEntity(ThinQEntity, SensorEntity):
"""Represent a thinq sensor platform."""
def __init__(
self,
coordinator: DeviceDataUpdateCoordinator,
entity_description: SensorEntityDescription,
property_id: str,
) -> None:
"""Initialize a sensor entity."""
super().__init__(coordinator, entity_description, property_id)
if self.data.options:
# some kimchi refrigerator's target temperature have data in the form of string with enum options.
# Set options to display the correct value in the UI.
self._attr_options = self.data.options
self._attr_device_class = SensorDeviceClass.ENUM
self._attr_native_unit_of_measurement = None
def _update_status(self) -> None:
"""Update status itself."""
super()._update_status()
self._attr_native_value = self.data.value
_LOGGER.debug(
"[%s:%s] update status: %s -> %s, options:%s, unit:%s",
self.coordinator.device_name,
self.property_id,
self.data.value,
self.native_value,
self.options,
self.native_unit_of_measurement,
)
+3 -3
View File
@@ -250,7 +250,7 @@ class MatterClimate(MatterEntity, ClimateEntity):
clusters.Thermostat.Attributes.OccupiedHeatingSetpoint
)
await self.write_attribute(
value=int(target_temperature * TEMPERATURE_SCALING_FACTOR),
value=round(target_temperature * TEMPERATURE_SCALING_FACTOR),
matter_attribute=matter_attribute,
)
return
@@ -259,7 +259,7 @@ class MatterClimate(MatterEntity, ClimateEntity):
# multi setpoint control - low setpoint (heat)
if self.target_temperature_low != target_temperature_low:
await self.write_attribute(
value=int(target_temperature_low * TEMPERATURE_SCALING_FACTOR),
value=round(target_temperature_low * TEMPERATURE_SCALING_FACTOR),
matter_attribute=clusters.Thermostat.Attributes.OccupiedHeatingSetpoint,
)
@@ -267,7 +267,7 @@ class MatterClimate(MatterEntity, ClimateEntity):
# multi setpoint control - high setpoint (cool)
if self.target_temperature_high != target_temperature_high:
await self.write_attribute(
value=int(target_temperature_high * TEMPERATURE_SCALING_FACTOR),
value=round(target_temperature_high * TEMPERATURE_SCALING_FACTOR),
matter_attribute=clusters.Thermostat.Attributes.OccupiedCoolingSetpoint,
)
+1 -1
View File
@@ -9,7 +9,7 @@
"iot_class": "cloud_push",
"loggers": ["pymiele"],
"quality_scale": "platinum",
"requirements": ["pymiele==0.6.1"],
"requirements": ["pymiele==0.6.2"],
"single_config_entry": true,
"zeroconf": ["_mieleathome._tcp.local."]
}
+3 -2
View File
@@ -3836,7 +3836,7 @@ def data_schema_from_fields(
if not data_schema_element:
# Do not show empty sections
continue
# Collapse if values are changed or required fields need to be set
# Collapse if no values are changed and no required fields need to be set
collapsed = (
not any(
(default := data_schema_fields[str(option)].default) is vol.UNDEFINED
@@ -4540,7 +4540,8 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
self, data_schema: vol.Schema
) -> dict[str, Any]:
"""Get suggestions from device data based on the data schema."""
device_data = self._subentry_data["device"]
device_data = deepcopy(self._subentry_data["device"])
device_data.update(device_data.get("mqtt_settings", {}))
return {
field_key: self.get_suggested_values_from_device_data(value.schema)
if isinstance(value, section)
@@ -344,9 +344,11 @@ def _merge_common_device_options(
CONF_AVAILABILITY_TEMPLATE,
CONF_AVAILABILITY_TOPIC,
CONF_COMMAND_TOPIC,
CONF_ENCODING,
CONF_PAYLOAD_AVAILABLE,
CONF_PAYLOAD_NOT_AVAILABLE,
CONF_STATE_TOPIC,
CONF_QOS
Common options in the body of the device based config are inherited into
the component. Unless the option is explicitly specified at component level,
in that case the option at component level will override the common option.
+2
View File
@@ -67,9 +67,11 @@ SHARED_OPTIONS = [
CONF_AVAILABILITY_TEMPLATE,
CONF_AVAILABILITY_TOPIC,
CONF_COMMAND_TOPIC,
CONF_ENCODING,
CONF_PAYLOAD_AVAILABLE,
CONF_PAYLOAD_NOT_AVAILABLE,
CONF_STATE_TOPIC,
CONF_QOS,
]
+4
View File
@@ -251,6 +251,10 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
supported_feature_strings: list[str] = config[CONF_SUPPORTED_FEATURES]
self._attr_supported_features = _strings_to_services(
supported_feature_strings, STRING_TO_SERVICE
) | (
self.supported_features & VacuumEntityFeature.CLEAN_AREA
if CONF_CLEAN_SEGMENTS_COMMAND_TOPIC in config
else 0
)
self._clean_segments_command_topic = config.get(
CONF_CLEAN_SEGMENTS_COMMAND_TOPIC
+66 -3
View File
@@ -101,14 +101,21 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
close_tilt_command=OverkizCommand.LOWER_CLOSE,
stop_tilt_command=OverkizCommand.STOP,
),
# Needs override to remove open/close commands
# Needs override to add support for very specific tilt commands
# uiClass is VenetianBlind
OverkizCoverDescription(
key=UIWidget.TILT_ONLY_VENETIAN_BLIND,
device_class=CoverDeviceClass.BLIND,
is_closed_state=OverkizState.CORE_OPEN_CLOSED,
# Position commands fully open/close the tilt
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
stop_command=OverkizCommand.STOP,
# Tilt commands move the tilt with a few degrees
open_tilt_command=OverkizCommand.TILT_POSITIVE,
open_tilt_command_args=(1, 0),
close_tilt_command=OverkizCommand.TILT_NEGATIVE,
close_tilt_command_args=(1, 0),
stop_tilt_command=OverkizCommand.STOP,
),
# Needs override to support very specific tilt commands (rts:ExteriorVenetianBlindRTSComponent)
@@ -125,6 +132,57 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
close_tilt_command_args=(15, 1), # position (1-127), speed (1-15)
stop_tilt_command=OverkizCommand.STOP,
),
# Needs override to support very specific tilt commands (rts:VenetianBlindRTSComponent)
# uiClass is VenetianBlind
OverkizCoverDescription(
key=UIWidget.UP_DOWN_VENETIAN_BLIND,
device_class=CoverDeviceClass.BLIND,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
stop_command=OverkizCommand.STOP,
open_tilt_command=OverkizCommand.TILT_POSITIVE,
open_tilt_command_args=(15, 1), # position (1-127), speed (1-15)
close_tilt_command=OverkizCommand.TILT_NEGATIVE,
close_tilt_command_args=(15, 1), # position (1-127), speed (1-15)
stop_tilt_command=OverkizCommand.STOP,
),
# Needs override since PositionableGarageDoor reports
# core:OpenClosedUnknownState instead of core:OpenClosedState
# uiClass is GarageDoor
OverkizCoverDescription(
key=UIWidget.POSITIONABLE_GARAGE_DOOR,
device_class=CoverDeviceClass.GARAGE,
current_position_state=OverkizState.CORE_CLOSURE,
set_position_command=OverkizCommand.SET_CLOSURE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
stop_command=OverkizCommand.STOP,
is_closed_state=OverkizState.CORE_OPEN_CLOSED_UNKNOWN,
),
# Needs override since PositionableGarageDoorWithPartialPosition reports
# core:OpenClosedPartialState instead of core:OpenClosedState
# uiClass is GarageDoor
OverkizCoverDescription(
key=UIWidget.POSITIONABLE_GARAGE_DOOR_WITH_PARTIAL_POSITION,
device_class=CoverDeviceClass.GARAGE,
current_position_state=OverkizState.CORE_CLOSURE,
set_position_command=OverkizCommand.SET_CLOSURE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
stop_command=OverkizCommand.STOP,
is_closed_state=OverkizState.CORE_OPEN_CLOSED_PARTIAL,
),
# Needs override since DiscreteGateWithPedestrianPosition reports
# core:OpenClosedPedestrianState instead of core:OpenClosedState
# uiClass is Gate
OverkizCoverDescription(
key=UIWidget.DISCRETE_GATE_WITH_PEDESTRIAN_POSITION,
device_class=CoverDeviceClass.GATE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN,
stop_command=OverkizCommand.STOP,
),
# Needs override to support this Generic device (rts:GenericRTSComponent)
# uiClass is Generic (not mapped to cover as this is a Generic device class)
OverkizCoverDescription(
@@ -201,7 +259,7 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
set_position_command=OverkizCommand.SET_CLOSURE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
is_closed_state=OverkizState.CORE_OPEN_CLOSED_UNKNOWN,
is_closed_state=OverkizState.CORE_OPEN_CLOSED,
stop_command=OverkizCommand.STOP,
),
OverkizCoverDescription(
@@ -209,12 +267,15 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
device_class=CoverDeviceClass.GATE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN,
is_closed_state=OverkizState.CORE_OPEN_CLOSED,
stop_command=OverkizCommand.STOP,
),
OverkizCoverDescription(
key=UIClass.PERGOLA,
device_class=CoverDeviceClass.AWNING,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
stop_command=OverkizCommand.STOP,
is_closed_state=OverkizState.CORE_SLATS_OPEN_CLOSED,
current_tilt_position_state=OverkizState.CORE_SLATE_ORIENTATION,
set_tilt_position_command=OverkizCommand.SET_ORIENTATION,
@@ -392,6 +453,8 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
"""Return if the cover is closed."""
if is_closed_state := self.entity_description.is_closed_state:
if state := self.device.states.get(is_closed_state):
if state.value == OverkizCommandParam.UNKNOWN:
return None
return state.value == OverkizCommandParam.CLOSED
if (position := self.current_cover_position) is not None:
+15 -2
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import cast
from pyoverkiz.enums import OverkizAttribute, OverkizState
from pyoverkiz.enums import APIType, OverkizAttribute, OverkizCommandParam, OverkizState
from pyoverkiz.models import Device
from homeassistant.helpers.device_registry import DeviceInfo
@@ -46,7 +46,20 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self.device.available and super().available
if self.device.available:
return super().available
# Workaround: local API may incorrectly report available=False (Somfy-TaHoma-Developer-Mode#217)
if self.coordinator.client.api_type != APIType.LOCAL:
return False
if status_state := self.device.states.get(OverkizState.CORE_STATUS):
return (
status_state.value == OverkizCommandParam.AVAILABLE
and super().available
)
return False
@property
def is_sub_device(self) -> bool:
@@ -22,6 +22,8 @@ COMMANDS_WITHOUT_DELAY = [
OverkizCommand.ON,
OverkizCommand.ON_WITH_TIMER,
OverkizCommand.TEST,
OverkizCommand.TILT_POSITIVE,
OverkizCommand.TILT_NEGATIVE,
]
@@ -13,7 +13,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
"requirements": ["pyoverkiz==1.20.0"],
"requirements": ["pyoverkiz==1.20.3"],
"zeroconf": [
{
"name": "gateway*",
+17 -1
View File
@@ -10,6 +10,7 @@ from pyoverkiz.enums import OverkizAttribute, OverkizState, UIWidget
from pyoverkiz.types import StateType as OverkizStateType
from homeassistant.components.sensor import (
DEVICE_CLASS_UNITS,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
@@ -606,10 +607,25 @@ class OverkizStateSensor(OverkizDescriptiveEntity, SensorEntity):
if (unit := attrs[OverkizAttribute.CORE_MEASURED_VALUE_TYPE]) and (
unit_value := unit.value_as_str
):
return OVERKIZ_UNIT_TO_HA.get(unit_value, default_unit)
ha_unit = OVERKIZ_UNIT_TO_HA.get(unit_value, default_unit)
if self._is_unit_valid_for_device_class(ha_unit):
return ha_unit
return default_unit
def _is_unit_valid_for_device_class(self, unit: str) -> bool:
"""Check if a unit is valid for this sensor's device class.
The device-level core:MeasuredValueType attribute describes the primary
sensor (e.g. luminance/temperature), but must not override the unit of
unrelated sensors on the same device (e.g. RSSI).
"""
if not (device_class := self.entity_description.device_class):
return True
if (valid_units := DEVICE_CLASS_UNITS.get(device_class)) is None:
return True
return unit in valid_units
class OverkizHomeKitSetupCodeSensor(OverkizEntity, SensorEntity):
"""Representation of an Overkiz HomeKit Setup Code."""
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["renault_api"],
"quality_scale": "silver",
"requirements": ["renault-api==0.5.7"]
"requirements": ["renault-api==0.5.8"]
}
@@ -4,5 +4,5 @@
"codeowners": ["@fabaff"],
"documentation": "https://www.home-assistant.io/integrations/serial",
"iot_class": "local_polling",
"requirements": ["serialx==1.7.0"]
"requirements": ["serialx==1.7.3"]
}
@@ -193,6 +193,14 @@ class ShellyRpcMediaPlayer(ShellyRpcAttributeEntity, MediaPlayerEntity):
return self._last_media_position_updated_at
@property
def entity_picture(self) -> str | None:
"""Return image of the media playing."""
if not self.available:
return None
return super().entity_picture
@property
def media_image_url(self) -> str | None:
"""Return the image URL of current playing media."""
@@ -304,6 +304,8 @@ class SmartThingsLamp(SmartThingsEntity, LightEntity):
)
or []
)
# If "off" is in supported levels, the switch doesn't control the lamp
self._use_switch = "off" not in levels
color_modes = set()
if "off" not in levels or len(levels) > 2:
color_modes.add(ColorMode.BRIGHTNESS)
@@ -318,7 +320,7 @@ class SmartThingsLamp(SmartThingsEntity, LightEntity):
if ATTR_BRIGHTNESS in kwargs:
await self.async_set_level(kwargs[ATTR_BRIGHTNESS])
return
if self.supports_capability(Capability.SWITCH):
if self._use_switch and self.supports_capability(Capability.SWITCH):
await self.execute_device_command(Capability.SWITCH, Command.ON)
# if no switch, turn on via brightness level
else:
@@ -326,7 +328,7 @@ class SmartThingsLamp(SmartThingsEntity, LightEntity):
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the lamp off."""
if self.supports_capability(Capability.SWITCH):
if self._use_switch and self.supports_capability(Capability.SWITCH):
await self.execute_device_command(Capability.SWITCH, Command.OFF)
return
await self.execute_device_command(
@@ -356,7 +358,8 @@ class SmartThingsLamp(SmartThingsEntity, LightEntity):
)
# turn on switch separately if needed
if (
self.supports_capability(Capability.SWITCH)
self._use_switch
and self.supports_capability(Capability.SWITCH)
and not self.is_on
and brightness > 0
):
@@ -387,7 +390,7 @@ class SmartThingsLamp(SmartThingsEntity, LightEntity):
@property
def is_on(self) -> bool | None:
"""Return true if lamp is on."""
if self.supports_capability(Capability.SWITCH):
if self._use_switch and self.supports_capability(Capability.SWITCH):
state = self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH)
if state is None:
return None
@@ -8,5 +8,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["tibber"],
"requirements": ["pyTibber==0.37.4"]
"requirements": ["pyTibber==0.37.5"]
}
+2 -1
View File
@@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, cast, override
import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID, CONF_TARGET
from homeassistant.const import ATTR_ENTITY_ID, CONF_OPTIONS, CONF_TARGET
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
@@ -25,6 +25,7 @@ from .const import DATA_COMPONENT, DOMAIN, TodoItemStatus
ITEM_TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
vol.Required(CONF_OPTIONS, default={}): {},
}
)
@@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import STATUS_UP
from .const import STATUSES_ON
from .coordinator import UptimeRobotConfigEntry
from .entity import UptimeRobotEntity
from .utils import new_device_listener
@@ -38,7 +38,6 @@ async def async_setup_entry(
key=str(monitor.id),
device_class=BinarySensorDeviceClass.CONNECTIVITY,
),
monitor=monitor,
)
for monitor in new_monitors
]
@@ -54,4 +53,4 @@ class UptimeRobotBinarySensor(UptimeRobotEntity, BinarySensorEntity):
@property
def is_on(self) -> bool:
"""Return True if the entity is on."""
return bool(self._monitor.status == STATUS_UP)
return bool(self._monitor.status in STATUSES_ON)
@@ -24,3 +24,6 @@ API_ATTR_OK: Final = "ok"
STATUS_UP = "UP"
STATUS_DOWN = "DOWN"
STATUS_STARTED = "STARTED"
STATUSES_ON = [STATUS_UP, STATUS_STARTED]
@@ -23,22 +23,26 @@ class UptimeRobotEntity(CoordinatorEntity[UptimeRobotDataUpdateCoordinator]):
self,
coordinator: UptimeRobotDataUpdateCoordinator,
description: EntityDescription,
monitor: UptimeRobotMonitor,
) -> None:
"""Initialize UptimeRobot entities."""
super().__init__(coordinator)
self.entity_description = description
self._monitor = monitor
self._monitor_id = description.key
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, str(self._monitor.id))},
identifiers={(DOMAIN, self._monitor_id)},
name=self._monitor.friendlyName,
manufacturer="UptimeRobot Team",
entry_type=DeviceEntryType.SERVICE,
model=self._monitor.type,
configuration_url=f"https://uptimerobot.com/dashboard#{self._monitor.id}",
configuration_url=f"https://uptimerobot.com/dashboard#{self._monitor_id}",
)
self._attr_extra_state_attributes = {
ATTR_TARGET: self._monitor.url,
}
self._attr_unique_id = str(self._monitor.id)
self._attr_unique_id = self._monitor_id
self.api = coordinator.api
@property
def _monitor(self) -> UptimeRobotMonitor:
"""Handle monitor updates."""
return self.coordinator.data[int(self._monitor_id)]
@@ -7,6 +7,7 @@
"down": "mdi:television-off",
"pause": "mdi:television-pause",
"seems_down": "mdi:television-off",
"started": "mdi:television-play",
"up": "mdi:television-shimmer"
}
}
@@ -43,11 +43,11 @@ async def async_setup_entry(
"not_checked_yet",
"pause",
"seems_down",
"started",
"up",
],
translation_key="monitor_status",
),
monitor=monitor,
)
for monitor in new_monitors
]
@@ -50,6 +50,7 @@
"not_checked_yet": "Not checked yet",
"pause": "[%key:common::action::pause%]",
"seems_down": "Seems down",
"started": "Started",
"up": "Up"
}
}
@@ -14,7 +14,7 @@ from homeassistant.components.switch import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import STATUS_UP
from .const import STATUSES_ON
from .coordinator import UptimeRobotConfigEntry
from .entity import UptimeRobotEntity
from .utils import new_device_listener, uptimerobot_api_call
@@ -40,7 +40,6 @@ async def async_setup_entry(
key=str(monitor.id),
device_class=SwitchDeviceClass.SWITCH,
),
monitor=monitor,
)
for monitor in new_monitors
]
@@ -58,7 +57,7 @@ class UptimeRobotSwitch(UptimeRobotEntity, SwitchEntity):
@property
def is_on(self) -> bool:
"""Return True if the entity is on."""
return bool(self._monitor.status == STATUS_UP)
return bool(self._monitor.status in STATUSES_ON)
@uptimerobot_api_call
async def async_turn_off(self, **kwargs: Any) -> None:
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": ["aiousbwatcher==1.1.2", "serialx==1.7.0"]
"requirements": ["aiousbwatcher==1.1.2", "serialx==1.7.3"]
}
+2 -2
View File
@@ -25,7 +25,7 @@ def usb_device_from_port(port: SerialPortInfo) -> USBDevice:
pid=f"{hex(port.pid)[2:]:0>4}".upper(),
serial_number=port.serial_number,
manufacturer=port.manufacturer,
description=port.product,
description=port.description,
bcd_device=port.bcd_device,
interface_description=port.interface_description,
interface_num=port.interface_num,
@@ -38,7 +38,7 @@ def serial_device_from_port(port: SerialPortInfo) -> SerialDevice:
device=port.device,
serial_number=port.serial_number,
manufacturer=port.manufacturer,
description=port.product,
description=port.description,
interface_description=port.interface_description,
interface_num=port.interface_num,
)
@@ -8,7 +8,6 @@ from homeassistant.core import HomeAssistant
from .const import _LOGGER, CONF_DEVICE_DETAILS, DEVICE_TYPE, DEVICE_URL
from .coordinator import VodafoneConfigEntry, VodafoneStationRouter
from .utils import async_client_session
PLATFORMS = [
Platform.BUTTON,
@@ -21,13 +20,12 @@ PLATFORMS = [
async def async_setup_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> bool:
"""Set up Vodafone Station platform."""
session = await async_client_session(hass)
coordinator = VodafoneStationRouter(
hass,
entry,
session,
)
await coordinator.initialize_api()
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
@@ -7,7 +7,7 @@ from typing import Any, cast
from aiohttp import ClientSession
from aiovodafone import exceptions
from aiovodafone.api import VodafoneStationDevice
from aiovodafone.api import VodafoneStationCommonApi, VodafoneStationDevice
from aiovodafone.models import init_device_class
from yarl import URL
@@ -33,6 +33,7 @@ from .const import (
SCAN_INTERVAL,
)
from .helpers import cleanup_device_tracker
from .utils import async_client_session
CONSIDER_HOME_SECONDS = DEFAULT_CONSIDER_HOME.total_seconds()
@@ -61,32 +62,23 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]):
"""Queries router running Vodafone Station firmware."""
config_entry: VodafoneConfigEntry
api: VodafoneStationCommonApi
_session: ClientSession
def __init__(
self,
hass: HomeAssistant,
config_entry: VodafoneConfigEntry,
session: ClientSession,
) -> None:
"""Initialize the scanner."""
data = config_entry.data
self.api = init_device_class(
URL(data[CONF_DEVICE_DETAILS][DEVICE_URL]),
data[CONF_DEVICE_DETAILS][DEVICE_TYPE],
data,
session,
)
self._session = session
# Last resort as no MAC or S/N can be retrieved via API
self._id = config_entry.unique_id
super().__init__(
hass=hass,
logger=_LOGGER,
name=f"{DOMAIN}-{data[CONF_HOST]}-coordinator",
name=f"{DOMAIN}-{config_entry.data[CONF_HOST]}-coordinator",
update_interval=timedelta(seconds=SCAN_INTERVAL),
config_entry=config_entry,
)
@@ -157,6 +149,10 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]):
exceptions.GenericLoginError,
JSONDecodeError,
) as err:
if isinstance(err, JSONDecodeError):
# Plain html response (usually occurs after a firmware update), requiring session reinitialization
_LOGGER.info("Stale session detected, reinitializing API session")
await self.initialize_api()
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
@@ -211,3 +207,15 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]):
sw_version=sensors_data["sys_firmware_version"],
serial_number=self.serial_number,
)
async def initialize_api(self) -> None:
"""Init API session."""
data = self.config_entry.data
session = await async_client_session(self.hass)
self.api = init_device_class(
URL(data[CONF_DEVICE_DETAILS][DEVICE_URL]),
data[CONF_DEVICE_DETAILS][DEVICE_TYPE],
data,
session,
)
self._session = session
+1 -1
View File
@@ -28,7 +28,7 @@ def async_create_client(
options=ClientOptions(
verify_ssl=verify_ssl,
session=async_get_clientsession(hass),
timeout=ClientTimeout(total=10),
timeout=ClientTimeout(total=30),
),
)
+2 -2
View File
@@ -6,7 +6,6 @@ import logging
from typing import Any
from pywizlight import PilotParser, wizlight
from pywizlight.bulb import PIR_SOURCE
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant, callback
@@ -20,6 +19,7 @@ from .const import (
DISCOVER_SCAN_TIMEOUT,
DISCOVERY_INTERVAL,
DOMAIN,
OCCUPANCY_SOURCES,
SIGNAL_WIZ_PIR,
WIZ_CONNECT_EXCEPTIONS,
)
@@ -101,7 +101,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WizConfigEntry) -> bool:
"""Receive a push update."""
_LOGGER.debug("%s: Got push update: %s", bulb.mac, state.pilotResult)
coordinator.async_set_updated_data(coordinator.data)
if state.get_source() == PIR_SOURCE:
if state.get_source() in OCCUPANCY_SOURCES:
async_dispatcher_send(hass, SIGNAL_WIZ_PIR.format(bulb.mac))
await bulb.start_push(_async_push_update)
@@ -4,8 +4,6 @@ from __future__ import annotations
from collections.abc import Callable
from pywizlight.bulb import PIR_SOURCE
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
@@ -16,7 +14,7 @@ from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, SIGNAL_WIZ_PIR
from .const import DOMAIN, OCCUPANCY_SOURCES, SIGNAL_WIZ_PIR
from .coordinator import WizConfigEntry, WizData
from .entity import WizEntity
@@ -75,5 +73,5 @@ class WizOccupancyEntity(WizEntity, BinarySensorEntity):
@callback
def _async_update_attrs(self) -> None:
"""Handle updating _attr values."""
if self._device.state.get_source() == PIR_SOURCE:
if self._device.state.get_source() in OCCUPANCY_SOURCES:
self._attr_is_on = self._device.status
@@ -81,6 +81,8 @@ class WizConfigFlow(ConfigFlow, domain=DOMAIN):
exc_info=True,
)
raise AbortFlow("cannot_connect") from ex
finally:
await bulb.async_close()
self._name = name_from_bulb_type_and_mac(bulbtype, device.mac_address)
async def async_step_discovery_confirm(
@@ -118,6 +120,8 @@ class WizConfigFlow(ConfigFlow, domain=DOMAIN):
bulbtype = await bulb.get_bulbtype()
except WIZ_CONNECT_EXCEPTIONS:
return self.async_abort(reason="cannot_connect")
finally:
await bulb.async_close()
return self.async_create_entry(
title=name_from_bulb_type_and_mac(bulbtype, device.mac_address),
@@ -182,6 +186,8 @@ class WizConfigFlow(ConfigFlow, domain=DOMAIN):
title=name,
data=user_input,
)
finally:
await bulb.async_close()
return self.async_show_form(
step_id="user",
+2
View File
@@ -2,6 +2,7 @@
from datetime import timedelta
from pywizlight.bulb import PIR_SOURCE
from pywizlight.exceptions import (
WizLightConnectionError,
WizLightNotKnownBulb,
@@ -24,3 +25,4 @@ WIZ_EXCEPTIONS = (
WIZ_CONNECT_EXCEPTIONS = (WizLightNotKnownBulb, *WIZ_EXCEPTIONS)
SIGNAL_WIZ_PIR = "wiz_pir_{}"
OCCUPANCY_SOURCES = frozenset({PIR_SOURCE, "wfsens"})
@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["holidays"],
"quality_scale": "internal",
"requirements": ["holidays==0.95"]
"requirements": ["holidays==0.96"]
}
+13 -1
View File
@@ -183,21 +183,33 @@
"selector": {
"category": {
"options": {
"albanian": "Albanian",
"armed_forces": "Armed forces",
"armenian": "Armenian",
"bank": "Bank",
"bosnian": "Bosnian",
"catholic": "Catholic",
"chinese": "Chinese",
"christian": "Christian",
"de_facto": "De facto",
"government": "Government",
"half_day": "Half day",
"hebrew": "Hebrew",
"hindu": "Hindu",
"islamic": "Islamic",
"optional": "Optional",
"orthodox": "Orthodox",
"protestant": "Protestant",
"public": "Public",
"roma": "Roma",
"sabian": "Sabian",
"school": "School",
"serbian": "Serbian",
"turkish": "Turkish",
"unofficial": "Unofficial",
"workday": "Workday"
"vlach": "Vlach",
"workday": "Workday",
"yazidi": "Yazidi"
}
},
"days": {
+1 -1
View File
@@ -23,7 +23,7 @@
"universal_silabs_flasher",
"serialx"
],
"requirements": ["zha==1.3.0"],
"requirements": ["zha==1.3.1"],
"usb": [
{
"description": "*2652*",
+11 -11
View File
@@ -1284,19 +1284,19 @@ def async_discover_single_value(
continue
# check firmware_version_range
if schema.firmware_version_range is not None and (
(
if schema.firmware_version_range is not None:
# skip schema if device firmware version is unknown
if value.node.firmware_version is None:
continue
node_firmware = AwesomeVersion(value.node.firmware_version)
if (
schema.firmware_version_range.min is not None
and schema.firmware_version_range.min_ver
> AwesomeVersion(value.node.firmware_version)
)
or (
and schema.firmware_version_range.min_ver > node_firmware
) or (
schema.firmware_version_range.max is not None
and schema.firmware_version_range.max_ver
< AwesomeVersion(value.node.firmware_version)
)
):
continue
and schema.firmware_version_range.max_ver < node_firmware
):
continue
# check device_class_generic
# If the value has an endpoint but it is missing on the node
+1 -1
View File
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2026
MINOR_VERSION: Final = 5
PATCH_VERSION: Final = "0"
PATCH_VERSION: Final = "2"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2)
+6 -5
View File
@@ -195,8 +195,9 @@ class Debouncer[_R_co]:
@callback
def _schedule_timer(self) -> None:
"""Schedule a timer."""
if not self._shutdown_requested:
self._timer_task = self.hass.loop.call_later(
self.cooldown, self._on_debounce
)
"""Schedule a timer, cancelling any previously-scheduled handle."""
if self._shutdown_requested:
return
if self._timer_task is not None:
self._timer_task.cancel()
self._timer_task = self.hass.loop.call_later(self.cooldown, self._on_debounce)
+2 -2
View File
@@ -39,7 +39,7 @@ habluetooth==6.1.0
hass-nabucasa==2.2.0
hassil==3.5.0
home-assistant-bluetooth==2.0.0
home-assistant-frontend==20260429.3
home-assistant-frontend==20260429.4
home-assistant-intents==2026.5.5
httpx==0.28.1
ifaddr==0.2.0
@@ -63,7 +63,7 @@ PyTurboJPEG==1.8.3
PyYAML==6.0.3
requests==2.33.1
securetar==2026.4.1
serialx==1.7.0
serialx==1.7.3
SQLAlchemy==2.0.49
standard-aifc==3.13.0
standard-telnetlib==3.13.0
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2026.5.0"
version = "2026.5.2"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."
+19 -19
View File
@@ -151,7 +151,7 @@ adguardhome==0.8.1
advantage-air==0.4.4
# homeassistant.components.frontier_silicon
afsapi==1.0.0
afsapi==1.0.1
# homeassistant.components.agent_dvr
agent-py==0.0.24
@@ -251,7 +251,7 @@ aioelectricitymaps==1.1.1
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==44.21.0
aioesphomeapi==44.24.1
# homeassistant.components.matrix
# homeassistant.components.slack
@@ -273,7 +273,7 @@ aiogithubapi==26.0.0
aioguardian==2026.01.1
# homeassistant.components.harmony
aioharmony==0.5.3
aioharmony==1.0.3
# homeassistant.components.hassio
aiohasupervisor==0.4.3
@@ -600,7 +600,7 @@ avea==1.6.1
# avion==0.10
# homeassistant.components.axis
axis==69
axis==71
# homeassistant.components.fujitsu_fglair
ayla-iot-unofficial==1.4.7
@@ -654,7 +654,7 @@ bleak-retry-connector==4.6.0
bleak==2.1.1
# homeassistant.components.blebox
blebox-uniapi==2.5.2
blebox-uniapi==2.5.3
# homeassistant.components.blink
blinkpy==0.25.2
@@ -794,7 +794,7 @@ debugpy==1.8.17
decora-wifi==1.4
# homeassistant.components.ecovacs
deebot-client==18.2.0
deebot-client==18.3.0
# homeassistant.components.ihc
# homeassistant.components.ohmconnect
@@ -1048,7 +1048,7 @@ gTTS==2.5.3
# homeassistant.components.gardena_bluetooth
# homeassistant.components.husqvarna_automower_ble
gardena-bluetooth==2.4.0
gardena-bluetooth==2.8.1
# homeassistant.components.google_assistant_sdk
gassist-text==0.0.14
@@ -1242,10 +1242,10 @@ hole==0.9.0
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.95
holidays==0.96
# homeassistant.components.frontend
home-assistant-frontend==20260429.3
home-assistant-frontend==20260429.4
# homeassistant.components.conversation
home-assistant-intents==2026.5.5
@@ -1323,7 +1323,7 @@ ihcsdk==2.8.5
imeon_inverter_api==0.4.0
# homeassistant.components.imgw_pib
imgw_pib==2.1.1
imgw_pib==2.1.2
# homeassistant.components.incomfort
incomfort-client==0.7.0
@@ -1941,7 +1941,7 @@ pyRFXtrx==0.31.1
pySDCP==1
# homeassistant.components.tibber
pyTibber==0.37.4
pyTibber==0.37.5
# homeassistant.components.dlink
pyW215==0.8.0
@@ -2192,7 +2192,7 @@ pyinsteon==1.6.4
pyintelliclima==0.3.1
# homeassistant.components.intesishome
pyintesishome==1.8.0
pyintesishome==1.8.7
# homeassistant.components.ipma
pyipma==3.0.9
@@ -2297,7 +2297,7 @@ pymeteoclimatic==0.1.1
pymicro-vad==1.0.1
# homeassistant.components.miele
pymiele==0.6.1
pymiele==0.6.2
# homeassistant.components.xiaomi_tv
pymitv==1.4.3
@@ -2386,7 +2386,7 @@ pyotgw==2.2.3
pyotp==2.9.0
# homeassistant.components.overkiz
pyoverkiz==1.20.0
pyoverkiz==1.20.3
# homeassistant.components.palazzetti
pypalazzetti==0.1.20
@@ -2566,7 +2566,7 @@ python-awair==0.2.5
python-blockchain-api==0.0.2
# homeassistant.components.bsblan
python-bsblan==5.2.0
python-bsblan==5.2.1
# homeassistant.components.citybikes
python-citybikes==0.3.3
@@ -2581,7 +2581,7 @@ python-digitalocean==1.13.2
python-dropbox-api==0.1.3
# homeassistant.components.duco
python-duco-client==0.3.10
python-duco-connectivity==0.4.0
# homeassistant.components.ecobee
python-ecobee-api==0.3.2
@@ -2835,7 +2835,7 @@ refoss-ha==1.2.5
regenmaschine==2024.03.0
# homeassistant.components.renault
renault-api==0.5.7
renault-api==0.5.8
# homeassistant.components.renson
renson-endura-delta==1.7.2
@@ -2945,7 +2945,7 @@ sentry-sdk==2.48.0
# homeassistant.components.acer_projector
# homeassistant.components.serial
# homeassistant.components.usb
serialx==1.7.0
serialx==1.7.3
# homeassistant.components.sfr_box
sfrbox-api==0.1.1
@@ -3404,7 +3404,7 @@ zeroconf==0.148.0
zeversolar==0.3.2
# homeassistant.components.zha
zha==1.3.0
zha==1.3.1
# homeassistant.components.zhong_hong
zhong-hong-hvac==1.0.13
+18 -18
View File
@@ -142,7 +142,7 @@ adguardhome==0.8.1
advantage-air==0.4.4
# homeassistant.components.frontier_silicon
afsapi==1.0.0
afsapi==1.0.1
# homeassistant.components.agent_dvr
agent-py==0.0.24
@@ -242,7 +242,7 @@ aioelectricitymaps==1.1.1
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==44.21.0
aioesphomeapi==44.24.1
# homeassistant.components.matrix
# homeassistant.components.slack
@@ -261,7 +261,7 @@ aiogithubapi==26.0.0
aioguardian==2026.01.1
# homeassistant.components.harmony
aioharmony==0.5.3
aioharmony==1.0.3
# homeassistant.components.hassio
aiohasupervisor==0.4.3
@@ -552,7 +552,7 @@ autoskope_client==1.4.1
av==16.0.1
# homeassistant.components.axis
axis==69
axis==71
# homeassistant.components.fujitsu_fglair
ayla-iot-unofficial==1.4.7
@@ -591,7 +591,7 @@ bleak-retry-connector==4.6.0
bleak==2.1.1
# homeassistant.components.blebox
blebox-uniapi==2.5.2
blebox-uniapi==2.5.3
# homeassistant.components.blink
blinkpy==0.25.2
@@ -706,7 +706,7 @@ debugpy==1.8.17
decora-wifi==1.4
# homeassistant.components.ecovacs
deebot-client==18.2.0
deebot-client==18.3.0
# homeassistant.components.ihc
# homeassistant.components.ohmconnect
@@ -930,7 +930,7 @@ gTTS==2.5.3
# homeassistant.components.gardena_bluetooth
# homeassistant.components.husqvarna_automower_ble
gardena-bluetooth==2.4.0
gardena-bluetooth==2.8.1
# homeassistant.components.google_assistant_sdk
gassist-text==0.0.14
@@ -1106,10 +1106,10 @@ hole==0.9.0
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.95
holidays==0.96
# homeassistant.components.frontend
home-assistant-frontend==20260429.3
home-assistant-frontend==20260429.4
# homeassistant.components.conversation
home-assistant-intents==2026.5.5
@@ -1175,7 +1175,7 @@ igloohome-api==0.1.1
imeon_inverter_api==0.4.0
# homeassistant.components.imgw_pib
imgw_pib==2.1.1
imgw_pib==2.1.2
# homeassistant.components.incomfort
incomfort-client==0.7.0
@@ -1684,7 +1684,7 @@ pyHomee==1.3.8
pyRFXtrx==0.31.1
# homeassistant.components.tibber
pyTibber==0.37.4
pyTibber==0.37.5
# homeassistant.components.dlink
pyW215==0.8.0
@@ -1971,7 +1971,7 @@ pymeteoclimatic==0.1.1
pymicro-vad==1.0.1
# homeassistant.components.miele
pymiele==0.6.1
pymiele==0.6.2
# homeassistant.components.mochad
pymochad==0.2.0
@@ -2045,7 +2045,7 @@ pyotgw==2.2.3
pyotp==2.9.0
# homeassistant.components.overkiz
pyoverkiz==1.20.0
pyoverkiz==1.20.3
# homeassistant.components.palazzetti
pypalazzetti==0.1.20
@@ -2198,7 +2198,7 @@ python-MotionMount==2.3.0
python-awair==0.2.5
# homeassistant.components.bsblan
python-bsblan==5.2.0
python-bsblan==5.2.1
# homeassistant.components.citybikes
python-citybikes==0.3.3
@@ -2207,7 +2207,7 @@ python-citybikes==0.3.3
python-dropbox-api==0.1.3
# homeassistant.components.duco
python-duco-client==0.3.10
python-duco-connectivity==0.4.0
# homeassistant.components.ecobee
python-ecobee-api==0.3.2
@@ -2419,7 +2419,7 @@ refoss-ha==1.2.5
regenmaschine==2024.03.0
# homeassistant.components.renault
renault-api==0.5.7
renault-api==0.5.8
# homeassistant.components.renson
renson-endura-delta==1.7.2
@@ -2511,7 +2511,7 @@ sentry-sdk==2.48.0
# homeassistant.components.acer_projector
# homeassistant.components.serial
# homeassistant.components.usb
serialx==1.7.0
serialx==1.7.3
# homeassistant.components.sfr_box
sfrbox-api==0.1.1
@@ -2895,7 +2895,7 @@ zeroconf==0.148.0
zeversolar==0.3.2
# homeassistant.components.zha
zha==1.3.0
zha==1.3.1
# homeassistant.components.zinvolt
zinvolt==0.4.3
-2
View File
@@ -281,8 +281,6 @@ FORBIDDEN_PACKAGE_FILES_EXCEPTIONS = {
},
# https://github.com/basnijholt/aiokef
"kef": {"homeassistant": {"aiokef"}},
# https://github.com/danifus/pyzipper
"knx": {"xknxproject": {"pyzipper"}},
# https://github.com/hthiery/python-lacrosse
"lacrosse": {"homeassistant": {"pylacrosse"}},
# ???
+127 -3
View File
@@ -1,15 +1,22 @@
"""Test report state."""
import json
import logging
from unittest.mock import AsyncMock, patch
import aiohttp
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant import core
from homeassistant.components.alexa import errors, state_report
from homeassistant.components.alexa.resources import AlexaGlobalCatalog
from homeassistant.const import PERCENTAGE, UnitOfLength, UnitOfTemperature
from homeassistant.const import (
PERCENTAGE,
STATE_UNAVAILABLE,
UnitOfLength,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from .test_common import TEST_URL, get_default_config
@@ -522,10 +529,10 @@ async def test_send_delete_message(
)
async def test_doorbell_event(
async def test_doorbell_event_binary_sensor(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test doorbell press reports."""
"""Test doorbell press via binary sensor reports."""
aioclient_mock.post(TEST_URL, text="", status=202)
hass.states.async_set(
@@ -550,6 +557,16 @@ async def test_doorbell_event(
},
)
hass.states.async_set(
"binary_sensor.test_doorbell",
STATE_UNAVAILABLE,
{
"friendly_name": "Test Doorbell Sensor",
"device_class": "occupancy",
"linkquality": 99,
},
)
hass.states.async_set(
"binary_sensor.test_doorbell",
"on",
@@ -589,6 +606,113 @@ async def test_doorbell_event(
assert len(aioclient_mock.mock_calls) == 2
async def test_doorbell_event_for_event_entity(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
freezer: FrozenDateTimeFactory,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test doorbell event reports."""
freezer.move_to("2026-05-11T19:50:47.647427+0000")
aioclient_mock.post(TEST_URL, text="", status=202)
hass.states.async_set(
"event.test_doorbell",
"unknown",
{
"friendly_name": "Test Doorbell Sensor",
"device_class": "doorbell",
"event_types": ["ring"],
},
)
await state_report.async_enable_proactive_mode(hass, get_default_config(hass))
# Stale event (does not trigger)
hass.states.async_set(
"event.test_doorbell",
"2026-05-11T19:40:48.1265799+00:00",
{
"friendly_name": "Test Doorbell Sensor",
"device_class": "doorbell",
"event_types": ["ring"],
},
)
# Event within 30 sec
hass.states.async_set(
"event.test_doorbell",
"2026-05-11T19:50:30.647427+00:00",
{
"friendly_name": "Test Doorbell Sensor",
"device_class": "doorbell",
"event_types": ["ring"],
},
)
hass.states.async_set(
"event.test_doorbell",
STATE_UNAVAILABLE,
{
"friendly_name": "Test Doorbell Sensor",
"device_class": "doorbell",
"event_types": ["ring"],
},
)
# Same event after being unavailable
hass.states.async_set(
"event.test_doorbell",
"2026-05-11T19:50:30.647427+00:00",
{
"friendly_name": "Test Doorbell Sensor",
"device_class": "doorbell",
"event_types": ["ring"],
},
)
await hass.async_block_till_done()
assert len(aioclient_mock.mock_calls) == 1
call = aioclient_mock.mock_calls
call_json = call[0][2]
assert call_json["event"]["header"]["namespace"] == "Alexa.DoorbellEventSource"
assert call_json["event"]["header"]["name"] == "DoorbellPress"
assert call_json["event"]["payload"]["cause"]["type"] == "PHYSICAL_INTERACTION"
assert call_json["event"]["endpoint"]["endpointId"] == "event#test_doorbell"
# Same event after being unavailable
with caplog.at_level(logging.DEBUG):
hass.states.async_set(
"event.test_doorbell",
"11 may 2026 19:50:30",
{
"friendly_name": "Test Doorbell Sensor",
"device_class": "doorbell",
"event_types": ["ring"],
},
)
await hass.async_block_till_done()
assert (
"Unable to parse ISO timestamp from state for "
"event.test_doorbell. Got 11 may 2026 19:50:30" in caplog.text
)
# Later event
freezer.tick(35000)
await hass.async_block_till_done()
hass.states.async_set(
"event.test_doorbell",
f"{freezer.time_to_freeze.isoformat()}+00:00",
{
"friendly_name": "Test Doorbell Sensor",
"device_class": "doorbell",
"event_types": ["ring"],
},
)
await hass.async_block_till_done()
assert len(aioclient_mock.mock_calls) == 2
async def test_doorbell_event_from_unknown(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
+1 -1
View File
@@ -149,7 +149,7 @@ async def test_device_unavailable(
mock_rtsp_event(
topic="tns1:AudioSource/tnsaxis:TriggerLevel",
data_type="triggered",
data_value="10",
data_value="0",
source_name="channel",
source_idx="1",
)
+2 -2
View File
@@ -41,7 +41,7 @@ def mock_config_entry() -> MockConfigEntry:
},
unique_id="00:80:41:19:69:90",
version=1,
minor_version=2,
minor_version=3,
)
@@ -61,7 +61,7 @@ def mock_config_entry_dual_circuit() -> MockConfigEntry:
},
unique_id="00:80:41:19:69:90",
version=1,
minor_version=2,
minor_version=3,
)
@@ -213,6 +213,44 @@ async def test_circuit_discovery_failure_falls_back_to_default(
)
async def test_circuit_discovery_empty_result_falls_back_to_default(
hass: HomeAssistant,
mock_bsblan: MagicMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test that empty circuit discovery falls back to single circuit."""
mock_bsblan.get_available_circuits.return_value = []
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,
@@ -1150,6 +1188,38 @@ async def test_reconfigure_flow_success(
assert mock_config_entry.data[CONF_HEATING_CIRCUITS] == [1]
async def test_reconfigure_flow_empty_circuit_discovery_falls_back(
hass: HomeAssistant,
mock_bsblan: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reconfigure stores single circuit when discovery returns no circuits."""
mock_config_entry.add_to_hass(hass)
mock_bsblan.get_available_circuits.return_value = []
result = await mock_config_entry.start_reconfigure_flow(hass)
_assert_form_result(result, "reconfigure")
result = await _configure_flow(
hass,
result["flow_id"],
{
CONF_HOST: "192.168.1.50",
CONF_PORT: 8080,
CONF_PASSKEY: "new_passkey",
CONF_USERNAME: "new_admin",
CONF_PASSWORD: "new_password",
},
)
_assert_abort_result(result, "reconfigure_successful")
assert mock_config_entry.data[CONF_HOST] == "192.168.1.50"
assert mock_config_entry.data[CONF_PORT] == 8080
assert mock_config_entry.data[CONF_HEATING_CIRCUITS] == [1]
@pytest.mark.parametrize(
("side_effect", "error"),
[
+53 -3
View File
@@ -373,10 +373,35 @@ async def test_migrate_entry_discovers_circuits(
assert entry.state is ConfigEntryState.LOADED
assert entry.version == 1
assert entry.minor_version == 2
assert entry.minor_version == 3
assert entry.data[CONF_HEATING_CIRCUITS] == [1, 2]
async def test_migrate_entry_empty_discovery_falls_back(
hass: HomeAssistant,
mock_bsblan: MagicMock,
) -> None:
"""Test migration falls back to [1] when discovery returns no circuits."""
mock_bsblan.get_available_circuits.return_value = []
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 == 3
assert entry.data[CONF_HEATING_CIRCUITS] == [1]
async def test_migrate_entry_discovery_failure_falls_back(
hass: HomeAssistant,
mock_bsblan: MagicMock,
@@ -398,7 +423,7 @@ async def test_migrate_entry_discovery_failure_falls_back(
assert entry.state is ConfigEntryState.LOADED
assert entry.version == 1
assert entry.minor_version == 2
assert entry.minor_version == 3
assert entry.data[CONF_HEATING_CIRCUITS] == [1]
@@ -422,10 +447,35 @@ async def test_migrate_entry_discovery_timeout_falls_back(
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.LOADED
assert entry.minor_version == 2
assert entry.minor_version == 3
assert entry.data[CONF_HEATING_CIRCUITS] == [1]
async def test_migrate_entry_stored_empty_circuits_falls_back(
hass: HomeAssistant,
mock_bsblan: MagicMock,
) -> None:
"""Test migration repairs stored empty heating circuits."""
entry = MockConfigEntry(
title="BSBLAN Setup",
domain=DOMAIN,
data={**_legacy_entry_data(), CONF_HEATING_CIRCUITS: []},
unique_id="00:80:41:19:69:90",
version=1,
minor_version=2,
)
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 == 3
assert entry.data[CONF_HEATING_CIRCUITS] == [1]
assert entry.runtime_data.available_circuits == [1]
assert mock_bsblan.get_available_circuits.call_count == 0
async def test_migrate_entry_future_version_aborts(
hass: HomeAssistant,
mock_bsblan: MagicMock,

Some files were not shown because too many files have changed in this diff Show More