mirror of
https://github.com/home-assistant/core.git
synced 2026-05-16 11:51:46 +00:00
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e47149764 | |||
| 116b63ca3a | |||
| 3096bcf8a9 | |||
| a4027029d0 | |||
| fffc9d0695 | |||
| 3ca5cf5add | |||
| 087cb77042 | |||
| 8bd1c07ec9 | |||
| 9ecb59590b | |||
| e14eb9fbc5 | |||
| 149c796227 | |||
| 3383e5b1e9 | |||
| 05862c6dc8 | |||
| b35ac41470 | |||
| 20cec56512 | |||
| 74580262b6 | |||
| f75cdae602 | |||
| 8c95f4f7ae | |||
| c3ec51c471 | |||
| 0f80a4bc18 | |||
| 0761d618f1 | |||
| 03e3c46faf | |||
| d1962b0df2 | |||
| 7a38a2303a | |||
| 6f5c2a8614 | |||
| ff36498698 | |||
| 23e19ea2e4 | |||
| c33f174041 | |||
| bbe64d74e3 | |||
| ed3a71f2ee | |||
| 46c49daba4 | |||
| a2f2ded188 | |||
| 7be061796d | |||
| 27c7d8de0c | |||
| 07542523b5 | |||
| 18597bb653 | |||
| c4be57a294 | |||
| 7ceaebb086 | |||
| 7c5ef09734 | |||
| b4d8ba66fe | |||
| 308221ce67 | |||
| 1344213335 | |||
| 7e405e9014 | |||
| b0c45132ed | |||
| dd0cdc4fc4 | |||
| 18ea40c46d | |||
| a23131efc8 | |||
| 4940a0abae | |||
| 5f98d5ae52 | |||
| ba18cded30 | |||
| fb7504e9df | |||
| 106f815a1e | |||
| 167757762b | |||
| 3a902e1a16 | |||
| 85c11672d8 | |||
| 89649df20d | |||
| 7b749b95ce | |||
| cc140be85c | |||
| e1ad765414 | |||
| 44b1fea745 | |||
| 5dd04363b2 | |||
| 03aa979309 | |||
| 6fabbb354b | |||
| f644448d0f | |||
| 4e61581cd8 | |||
| 6f87d02b72 | |||
| 348f6149b4 | |||
| a4227ef1bc | |||
| aac49a567f | |||
| 76b878b136 | |||
| 2d05931683 |
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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*",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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][]].*",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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*",
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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,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"]
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"universal_silabs_flasher",
|
||||
"serialx"
|
||||
],
|
||||
"requirements": ["zha==1.3.0"],
|
||||
"requirements": ["zha==1.3.1"],
|
||||
"usb": [
|
||||
{
|
||||
"description": "*2652*",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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."
|
||||
|
||||
Generated
+19
-19
@@ -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
|
||||
|
||||
Generated
+18
-18
@@ -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
|
||||
|
||||
@@ -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"}},
|
||||
# ???
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user