mirror of
https://github.com/home-assistant/core.git
synced 2026-05-20 07:45:09 +02:00
Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9ed57bc56 | |||
| 0e0901993d | |||
| 54aba11091 | |||
| dc9116a7a7 | |||
| 1e90882918 | |||
| e8295e14b1 | |||
| 7ebaaf129a | |||
| ee734dede6 | |||
| ebc582c813 | |||
| 311e5a9bd2 | |||
| cd6c3c878b | |||
| 51589ec2ff | |||
| 8e1a04dc82 | |||
| 6b15f9a2ec | |||
| 8d66752556 | |||
| 266767e37d | |||
| d39775ac34 | |||
| a314f7bf64 | |||
| 37478d33eb | |||
| 5a76f3bd19 | |||
| 17e105083e | |||
| db8589b2bc | |||
| 771b016f33 | |||
| 0bc0745e8c | |||
| ea084797d3 | |||
| 2456753caf | |||
| 070de13c14 | |||
| 5e45f37ee6 | |||
| 4a96880f51 | |||
| 228ac01124 | |||
| d366027e6b | |||
| 2f35ad2a8a | |||
| 95cc9aed64 | |||
| 37d6449a49 | |||
| 249b5435d9 | |||
| 3293ebcea5 | |||
| 47d8adc77c | |||
| 356e6a691b | |||
| b26c2f3854 | |||
| 0830988687 | |||
| 456202325a | |||
| 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 |
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["serialx==1.7.1"]
|
||||
"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
|
||||
|
||||
@@ -7,7 +7,7 @@ from pyatv.interface import AppleTV, KeyboardListener
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -23,23 +23,33 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Load Apple TV binary sensor based on a config entry."""
|
||||
# apple_tv config entries always have a unique id
|
||||
manager = config_entry.runtime_data
|
||||
cb: CALLBACK_TYPE
|
||||
added = False
|
||||
|
||||
@callback
|
||||
def setup_entities(atv: AppleTV) -> None:
|
||||
nonlocal added
|
||||
if added:
|
||||
return
|
||||
if atv.features.in_state(FeatureState.Available, FeatureName.TextFocusState):
|
||||
assert config_entry.unique_id is not None
|
||||
name: str = config_entry.data[CONF_NAME]
|
||||
async_add_entities(
|
||||
[AppleTVKeyboardFocused(name, config_entry.unique_id, manager)]
|
||||
)
|
||||
cb()
|
||||
added = True
|
||||
|
||||
cb = async_dispatcher_connect(
|
||||
hass, f"{SIGNAL_CONNECTED}_{config_entry.unique_id}", setup_entities
|
||||
config_entry.async_on_unload(
|
||||
async_dispatcher_connect(
|
||||
hass, f"{SIGNAL_CONNECTED}_{config_entry.unique_id}", setup_entities
|
||||
)
|
||||
)
|
||||
config_entry.async_on_unload(cb)
|
||||
|
||||
# The manager may have already connected (and dispatched SIGNAL_CONNECTED)
|
||||
# before this platform was forwarded, in which case the signal above was
|
||||
# missed; handle that case directly.
|
||||
if manager.atv is not None:
|
||||
setup_entities(manager.atv)
|
||||
|
||||
|
||||
class AppleTVKeyboardFocused(AppleTVEntity, BinarySensorEntity, KeyboardListener):
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["axis"],
|
||||
"requirements": ["axis==70"],
|
||||
"requirements": ["axis==71"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "AXIS"
|
||||
|
||||
@@ -13,3 +13,7 @@ UNKNOWN = "unknown"
|
||||
|
||||
DEFAULT_HOST = "192.168.0.2"
|
||||
DEFAULT_PORT = 80
|
||||
|
||||
|
||||
LIGHT_MAX_KELVINS = 6500 # 154 Mireds
|
||||
LIGHT_MIN_KELVINS = 2700 # 370 Mireds
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import blebox_uniapi.light
|
||||
@@ -22,9 +23,9 @@ from homeassistant.components.light import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import color as color_util
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .const import LIGHT_MAX_KELVINS, LIGHT_MIN_KELVINS
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -59,8 +60,8 @@ COLOR_MODE_MAP = {
|
||||
class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
"""Representation of BleBox lights."""
|
||||
|
||||
_attr_min_color_temp_kelvin = 2700 # 370 Mireds
|
||||
_attr_max_color_temp_kelvin = 6500 # 154 Mireds
|
||||
_attr_min_color_temp_kelvin = LIGHT_MIN_KELVINS
|
||||
_attr_max_color_temp_kelvin = LIGHT_MAX_KELVINS
|
||||
|
||||
def __init__(self, feature: blebox_uniapi.light.Light) -> None:
|
||||
"""Initialize a BleBox light."""
|
||||
@@ -78,10 +79,43 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
"""Return the name."""
|
||||
return self._feature.brightness
|
||||
|
||||
def _color_temp_to_native_scale(self, x: int) -> int:
|
||||
"""Convert color temperature from Kelvin to native BleBox scale (0-255).
|
||||
|
||||
BleBox native scale is inverted relative to Kelvin: 0=warm (2700K), 255=cold (6500K).
|
||||
"""
|
||||
scaled = (
|
||||
(self._attr_max_color_temp_kelvin - x)
|
||||
/ (self._attr_max_color_temp_kelvin - self._attr_min_color_temp_kelvin)
|
||||
) * 255
|
||||
# note: within the operating temperature range here the Kelvin
|
||||
# scale has less "integer steps" than the native scale used
|
||||
# by blebox devices. Thus we need to use rounding method that is opposite
|
||||
# to the one used in _color_temp_from_native_scale in order to avoid
|
||||
# temperature value jumping by one step when the temperature value is read
|
||||
# back from the device
|
||||
bounded = max(min(math.floor(scaled), 255), 0)
|
||||
return int(bounded)
|
||||
|
||||
def _color_temp_from_native_scale(self, x: int) -> int:
|
||||
"""Convert color temperature from native BleBox scale (0-255) to Kelvin.
|
||||
|
||||
BleBox native scale is inverted relative to Kelvin: 0=warm (2700K), 255=cold (6500K).
|
||||
"""
|
||||
scaled = self._attr_max_color_temp_kelvin - (x / 255) * (
|
||||
self._attr_max_color_temp_kelvin - self._attr_min_color_temp_kelvin
|
||||
)
|
||||
# note: see _color_temp_to_native_scale for explanation of rounding method
|
||||
bounded = max(
|
||||
min(math.ceil(scaled), self._attr_max_color_temp_kelvin),
|
||||
self._attr_min_color_temp_kelvin,
|
||||
)
|
||||
return int(bounded)
|
||||
|
||||
@property
|
||||
def color_temp_kelvin(self) -> int:
|
||||
"""Return the color temperature value in Kelvin."""
|
||||
return color_util.color_temperature_mired_to_kelvin(self._feature.color_temp)
|
||||
return self._color_temp_from_native_scale(self._feature.color_temp)
|
||||
|
||||
@property
|
||||
def color_mode(self) -> ColorMode:
|
||||
@@ -139,15 +173,16 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
effect = kwargs.get(ATTR_EFFECT)
|
||||
color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
|
||||
rgbww = kwargs.get(ATTR_RGBWW_COLOR)
|
||||
rgb = kwargs.get(ATTR_RGB_COLOR)
|
||||
|
||||
feature = self._feature
|
||||
value = feature.sensible_on_value
|
||||
rgb = kwargs.get(ATTR_RGB_COLOR)
|
||||
|
||||
if rgbw is not None:
|
||||
value = list(rgbw)
|
||||
if color_temp_kelvin is not None:
|
||||
value = feature.return_color_temp_with_brightness(
|
||||
int(color_util.color_temperature_kelvin_to_mired(color_temp_kelvin)),
|
||||
self._color_temp_to_native_scale(color_temp_kelvin),
|
||||
self.brightness,
|
||||
)
|
||||
|
||||
@@ -162,14 +197,16 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
if brightness is not None:
|
||||
if self.color_mode == ColorMode.COLOR_TEMP:
|
||||
value = feature.return_color_temp_with_brightness(
|
||||
color_util.color_temperature_kelvin_to_mired(
|
||||
self.color_temp_kelvin
|
||||
),
|
||||
self._color_temp_to_native_scale(self.color_temp_kelvin),
|
||||
brightness,
|
||||
)
|
||||
else:
|
||||
value = feature.apply_brightness(value, brightness)
|
||||
|
||||
if isinstance(value, (list, tuple)) and not any(value):
|
||||
await self._feature.async_off()
|
||||
return
|
||||
|
||||
try:
|
||||
await self._feature.async_on(value)
|
||||
except ValueError as exc:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -81,6 +81,12 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
|
||||
end=self.to_local(self.get_end_date(vevent)),
|
||||
location=get_attr_value(vevent, "location"),
|
||||
description=get_attr_value(vevent, "description"),
|
||||
uid=get_attr_value(vevent, "uid"),
|
||||
recurrence_id=(
|
||||
str(v)
|
||||
if (v := get_attr_value(vevent, "recurrence_id")) is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -176,6 +182,12 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
|
||||
end=self.to_local(self.get_end_date(vevent)),
|
||||
location=get_attr_value(vevent, "location"),
|
||||
description=get_attr_value(vevent, "description"),
|
||||
uid=get_attr_value(vevent, "uid"),
|
||||
recurrence_id=(
|
||||
str(v)
|
||||
if (v := get_attr_value(vevent, "recurrence_id")) is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -273,7 +273,10 @@ class Control4Climate(Control4Entity, ClimateEntity):
|
||||
if data is None:
|
||||
return None
|
||||
humidity = data.get(CONTROL4_HUMIDITY)
|
||||
return int(humidity) if humidity is not None else None
|
||||
try:
|
||||
return int(humidity) if humidity is not None else None
|
||||
except ValueError, TypeError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
|
||||
@@ -4,10 +4,11 @@ from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from pydaikin.daikin_base import Appliance
|
||||
from pydaikin.exceptions import DaikinException
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, TIMEOUT_SEC
|
||||
|
||||
@@ -33,4 +34,11 @@ class DaikinCoordinator(DataUpdateCoordinator[None]):
|
||||
self.device = device
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
await self.device.update_status()
|
||||
try:
|
||||
await self.device.update_status()
|
||||
except DaikinException as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="error_communicating",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
@@ -59,6 +59,9 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"error_communicating": {
|
||||
"message": "Error communicating with Daikin device: {error}"
|
||||
},
|
||||
"zone_hvac_mode_unsupported": {
|
||||
"message": "Zone temperature can only be changed when the main climate mode is heat or cool."
|
||||
},
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/dnsip",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["aiodns==4.0.0"]
|
||||
"requirements": ["aiodns==4.0.3"]
|
||||
}
|
||||
|
||||
@@ -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.4.1"],
|
||||
"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]
|
||||
)
|
||||
}
|
||||
@@ -171,7 +171,7 @@ async def async_create_upnp_datagram_endpoint(
|
||||
|
||||
ssdp_socket.bind(("" if upnp_bind_multicast else host_ip_addr, BROADCAST_PORT))
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
transport_protocol = await loop.create_datagram_endpoint(
|
||||
lambda: UPNPResponderProtocol(loop, ssdp_socket, advertise_ip, advertise_port),
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
@@ -470,9 +470,11 @@ class ForkedDaapdMaster(MediaPlayerEntity):
|
||||
return self._player["volume"] == 0
|
||||
|
||||
@property
|
||||
def media_content_id(self):
|
||||
def media_content_id(self) -> str | None:
|
||||
"""Content ID of current playing media."""
|
||||
return self._player["item_id"]
|
||||
if (item_id := self._player["item_id"]) == 0:
|
||||
return None
|
||||
return str(item_id)
|
||||
|
||||
@property
|
||||
def media_content_type(self):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bo
|
||||
host=host,
|
||||
port=port,
|
||||
family=model_family,
|
||||
retries=10,
|
||||
retries=3,
|
||||
)
|
||||
except InverterError as err:
|
||||
try:
|
||||
|
||||
@@ -73,8 +73,8 @@ class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Detects the port of the Inverter."""
|
||||
port = GOODWE_UDP_PORT
|
||||
try:
|
||||
inverter = await connect(host=host, port=port, retries=10)
|
||||
inverter = await connect(host=host, port=port, retries=3)
|
||||
except InverterError:
|
||||
port = GOODWE_TCP_PORT
|
||||
inverter = await connect(host=host, port=port, retries=10)
|
||||
inverter = await connect(host=host, port=port, retries=3)
|
||||
return inverter, port
|
||||
|
||||
@@ -114,7 +114,7 @@ class AbstractConfig(ABC):
|
||||
"""Sync entities to Google."""
|
||||
await self.async_sync_entities_all()
|
||||
|
||||
self._on_deinitialize.append(start.async_at_start(self.hass, sync_google))
|
||||
self._on_deinitialize.append(start.async_at_started(self.hass, sync_google))
|
||||
|
||||
@callback
|
||||
def async_deinitialize(self) -> None:
|
||||
|
||||
@@ -209,14 +209,15 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
mix_chart_entries = mix_detail["chartData"]
|
||||
sorted_keys = sorted(mix_chart_entries)
|
||||
|
||||
# Create datetime from the latest entry
|
||||
date_now = dt_util.now().date()
|
||||
last_updated_time = dt_util.parse_time(str(sorted_keys[-1]))
|
||||
mix_detail["lastdataupdate"] = datetime.datetime.combine(
|
||||
date_now,
|
||||
last_updated_time, # type: ignore[arg-type]
|
||||
dt_util.get_default_time_zone(),
|
||||
)
|
||||
if sorted_keys:
|
||||
# Create datetime from the latest entry
|
||||
date_now = dt_util.now().date()
|
||||
last_updated_time = dt_util.parse_time(str(sorted_keys[-1]))
|
||||
mix_detail["lastdataupdate"] = datetime.datetime.combine(
|
||||
date_now,
|
||||
last_updated_time, # type: ignore[arg-type]
|
||||
dt_util.get_default_time_zone(),
|
||||
)
|
||||
|
||||
# Dashboard data for mix system
|
||||
dashboard_data = self.api.dashboard_data(self.plant_id)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -265,9 +265,9 @@ async def async_attach_trigger( # noqa: C901
|
||||
# entity
|
||||
update_entity_trigger(at_time, new_state=hass.states.get(at_time))
|
||||
to_track.append(TrackEntity(at_time, update_entity_trigger_event))
|
||||
elif isinstance(at_time, dict) and CONF_OFFSET in at_time:
|
||||
# entity with offset
|
||||
entity_id: str = at_time.get(CONF_ENTITY_ID, "")
|
||||
elif isinstance(at_time, dict):
|
||||
# entity with optional offset
|
||||
entity_id: str = at_time[CONF_ENTITY_ID]
|
||||
offset: timedelta = at_time.get(CONF_OFFSET, timedelta(0))
|
||||
update_entity_trigger(
|
||||
entity_id, new_state=hass.states.get(entity_id), offset=offset
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any
|
||||
import httpx
|
||||
from iaqualink.exception import (
|
||||
AqualinkServiceException,
|
||||
AqualinkServiceThrottledException,
|
||||
AqualinkServiceUnauthorizedException,
|
||||
)
|
||||
|
||||
@@ -16,7 +17,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 +29,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
|
||||
|
||||
@@ -43,6 +47,12 @@ class AqualinkDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
await self.system.update()
|
||||
except AqualinkServiceUnauthorizedException as err:
|
||||
raise ConfigEntryAuthFailed("Invalid credentials for iAquaLink") from err
|
||||
except AqualinkServiceThrottledException:
|
||||
_LOGGER.warning(
|
||||
"Rate limited by iAquaLink system %s, will retry later",
|
||||
self.system.serial,
|
||||
)
|
||||
return
|
||||
except (AqualinkServiceException, httpx.HTTPError) as err:
|
||||
raise UpdateFailed(
|
||||
f"Unable to update iAquaLink system {self.system.serial}: {err}"
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioimmich"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioimmich==0.14.0"]
|
||||
"requirements": ["aioimmich==0.14.1"]
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pyintesishome"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pyintesishome==1.8.0"]
|
||||
"requirements": ["pyintesishome==1.8.8"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -455,12 +455,15 @@ class ManualAlarm(AlarmControlPanelEntity, RestoreEntity):
|
||||
await super().async_added_to_hass()
|
||||
if state := await self.async_get_last_state():
|
||||
self._state_ts = state.last_updated
|
||||
if next_state := state.attributes.get(ATTR_NEXT_STATE):
|
||||
# If in arming or pending state we record the transition,
|
||||
# not the current state
|
||||
self._state = AlarmControlPanelState(next_state)
|
||||
else:
|
||||
self._state = AlarmControlPanelState(state.state)
|
||||
try:
|
||||
if next_state := state.attributes.get(ATTR_NEXT_STATE):
|
||||
# If in arming or pending state we record the transition,
|
||||
# not the current state
|
||||
self._state = AlarmControlPanelState(next_state)
|
||||
else:
|
||||
self._state = AlarmControlPanelState(state.state)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
if prev_state := state.attributes.get(ATTR_PREVIOUS_STATE):
|
||||
self._previous_state = prev_state
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -74,11 +74,13 @@ class ValveHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
|
||||
)
|
||||
|
||||
@property
|
||||
def hvac_action(self) -> HVACAction:
|
||||
def hvac_action(self) -> HVACAction | None:
|
||||
"""Return the current running hvac operation."""
|
||||
return OVERKIZ_TO_HVAC_ACTION[
|
||||
cast(str, self.executor.select_state(OverkizState.CORE_OPEN_CLOSED_VALVE))
|
||||
]
|
||||
if (
|
||||
state := self.executor.select_state(OverkizState.CORE_OPEN_CLOSED_VALVE)
|
||||
) is None:
|
||||
return None
|
||||
return OVERKIZ_TO_HVAC_ACTION[cast(str, state)]
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float:
|
||||
|
||||
@@ -70,6 +70,7 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
|
||||
set_position_command=OverkizCommand.SET_DEPLOYMENT,
|
||||
open_command=OverkizCommand.DEPLOY,
|
||||
close_command=OverkizCommand.UNDEPLOY,
|
||||
stop_command=OverkizCommand.STOP,
|
||||
invert_position=False,
|
||||
is_closed_state=OverkizState.CORE_OPEN_CLOSED,
|
||||
),
|
||||
@@ -80,6 +81,7 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
|
||||
set_position_command=OverkizCommand.SET_DEPLOYMENT,
|
||||
open_command=OverkizCommand.DEPLOY,
|
||||
close_command=OverkizCommand.UNDEPLOY,
|
||||
stop_command=OverkizCommand.STOP,
|
||||
invert_position=False,
|
||||
is_closed_state=OverkizState.CORE_OPEN_CLOSED,
|
||||
),
|
||||
@@ -146,6 +148,36 @@ 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:SheerBlindRTSComponent)
|
||||
# uiClass is VenetianBlind
|
||||
OverkizCoverDescription(
|
||||
key=UIWidget.UP_DOWN_SHEER_SCREEN,
|
||||
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 BioclimaticPergola uses core:SlatsOpenClosedState
|
||||
# and core:SlateOrientationState (tilt-only, no position)
|
||||
# uiClass is Pergola
|
||||
OverkizCoverDescription(
|
||||
key=UIWidget.BIOCLIMATIC_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,
|
||||
open_tilt_command=OverkizCommand.OPEN_SLATS,
|
||||
close_tilt_command=OverkizCommand.CLOSE_SLATS,
|
||||
stop_tilt_command=OverkizCommand.STOP,
|
||||
),
|
||||
# Needs override since PositionableGarageDoor reports
|
||||
# core:OpenClosedUnknownState instead of core:OpenClosedState
|
||||
# uiClass is GarageDoor
|
||||
@@ -159,6 +191,17 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
|
||||
stop_command=OverkizCommand.STOP,
|
||||
is_closed_state=OverkizState.CORE_OPEN_CLOSED_UNKNOWN,
|
||||
),
|
||||
# Needs override since DiscretePositionableGarageDoor reports
|
||||
# core:OpenClosedUnknownState instead of core:OpenClosedState
|
||||
# uiClass is GarageDoor
|
||||
OverkizCoverDescription(
|
||||
key=UIWidget.DISCRETE_POSITIONABLE_GARAGE_DOOR,
|
||||
device_class=CoverDeviceClass.GARAGE,
|
||||
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
|
||||
@@ -183,6 +226,111 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
|
||||
is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN,
|
||||
stop_command=OverkizCommand.STOP,
|
||||
),
|
||||
# Needs override since OpenCloseGate4T only supports the cycle command
|
||||
# (rts:GateOpenerRTS4TComponent)
|
||||
# uiClass is Gate
|
||||
OverkizCoverDescription(
|
||||
key=UIWidget.OPEN_CLOSE_GATE_4T,
|
||||
device_class=CoverDeviceClass.GATE,
|
||||
open_command=OverkizCommand.CYCLE,
|
||||
close_command=OverkizCommand.CYCLE,
|
||||
),
|
||||
# Needs override since UpDownGarageDoor4T only supports the cycle command
|
||||
# (rts:GarageDoor4TRTSComponent)
|
||||
# uiClass is GarageDoor
|
||||
OverkizCoverDescription(
|
||||
key=UIWidget.UP_DOWN_GARAGE_DOOR_4T,
|
||||
device_class=CoverDeviceClass.GARAGE,
|
||||
open_command=OverkizCommand.CYCLE,
|
||||
close_command=OverkizCommand.CYCLE,
|
||||
),
|
||||
# Needs override since OpenCloseSlidingGarageDoor4T only supports the cycle command
|
||||
# uiClass is GarageDoor
|
||||
OverkizCoverDescription(
|
||||
key=UIWidget.OPEN_CLOSE_SLIDING_GARAGE_DOOR_4T,
|
||||
device_class=CoverDeviceClass.GARAGE,
|
||||
open_command=OverkizCommand.CYCLE,
|
||||
close_command=OverkizCommand.CYCLE,
|
||||
),
|
||||
# Needs override since OpenCloseSlidingGate4T only supports the cycle command
|
||||
# uiClass is Gate
|
||||
OverkizCoverDescription(
|
||||
key=UIWidget.OPEN_CLOSE_SLIDING_GATE_4T,
|
||||
device_class=CoverDeviceClass.GATE,
|
||||
open_command=OverkizCommand.CYCLE,
|
||||
close_command=OverkizCommand.CYCLE,
|
||||
),
|
||||
# Needs override since CyclicGarageDoor only supports the cycle command
|
||||
# (io:CyclicGarageOpenerIOComponent)
|
||||
# uiClass is GarageDoor
|
||||
OverkizCoverDescription(
|
||||
key=UIWidget.CYCLIC_GARAGE_DOOR,
|
||||
device_class=CoverDeviceClass.GARAGE,
|
||||
open_command=OverkizCommand.CYCLE,
|
||||
close_command=OverkizCommand.CYCLE,
|
||||
),
|
||||
# Needs override since CyclicSlidingGateOpener only supports the cycle command
|
||||
# uiClass is Gate
|
||||
OverkizCoverDescription(
|
||||
key=UIWidget.CYCLIC_SLIDING_GATE_OPENER,
|
||||
device_class=CoverDeviceClass.GATE,
|
||||
open_command=OverkizCommand.CYCLE,
|
||||
close_command=OverkizCommand.CYCLE,
|
||||
),
|
||||
# Needs override since CyclicSwingingGateOpener only supports the cycle command
|
||||
# uiClass is Gate
|
||||
OverkizCoverDescription(
|
||||
key=UIWidget.CYCLIC_SWINGING_GATE_OPENER,
|
||||
device_class=CoverDeviceClass.GATE,
|
||||
open_command=OverkizCommand.CYCLE,
|
||||
close_command=OverkizCommand.CYCLE,
|
||||
),
|
||||
# Needs override since SlidingDiscreteGateWithPedestrianPosition reports
|
||||
# core:OpenClosedPedestrianState instead of core:OpenClosedState
|
||||
# uiClass is Gate
|
||||
OverkizCoverDescription(
|
||||
key=UIWidget.SLIDING_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 since OpenCloseGateWithPedestrianPosition reports
|
||||
# core:OpenClosedPedestrianState instead of core:OpenClosedState
|
||||
# uiClass is Gate
|
||||
OverkizCoverDescription(
|
||||
key=UIWidget.OPEN_CLOSE_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 since OpenCloseSlidingGateWithPedestrianPosition reports
|
||||
# core:OpenClosedPedestrianState instead of core:OpenClosedState
|
||||
# uiClass is Gate
|
||||
OverkizCoverDescription(
|
||||
key=UIWidget.OPEN_CLOSE_SLIDING_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 since PositionableGateWithPedestrianPosition reports
|
||||
# core:OpenClosedPedestrianState instead of core:OpenClosedState
|
||||
# uiClass is Gate
|
||||
OverkizCoverDescription(
|
||||
key=UIWidget.POSITIONABLE_GATE_WITH_PEDESTRIAN_POSITION,
|
||||
device_class=CoverDeviceClass.GATE,
|
||||
open_command=OverkizCommand.OPEN,
|
||||
close_command=OverkizCommand.CLOSE,
|
||||
is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN,
|
||||
current_position_state=OverkizState.CORE_CLOSURE,
|
||||
set_position_command=OverkizCommand.SET_CLOSURE,
|
||||
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(
|
||||
@@ -273,15 +421,12 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
|
||||
OverkizCoverDescription(
|
||||
key=UIClass.PERGOLA,
|
||||
device_class=CoverDeviceClass.AWNING,
|
||||
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_SLATS_OPEN_CLOSED,
|
||||
current_tilt_position_state=OverkizState.CORE_SLATE_ORIENTATION,
|
||||
set_tilt_position_command=OverkizCommand.SET_ORIENTATION,
|
||||
open_tilt_command=OverkizCommand.OPEN_SLATS,
|
||||
close_tilt_command=OverkizCommand.CLOSE_SLATS,
|
||||
stop_tilt_command=OverkizCommand.STOP,
|
||||
is_closed_state=OverkizState.CORE_OPEN_CLOSED,
|
||||
),
|
||||
OverkizCoverDescription(
|
||||
key=UIClass.ROLLER_SHUTTER,
|
||||
@@ -292,6 +437,9 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
|
||||
close_command=OverkizCommand.CLOSE,
|
||||
is_closed_state=OverkizState.CORE_OPEN_CLOSED,
|
||||
stop_command=OverkizCommand.STOP,
|
||||
current_tilt_position_state=OverkizState.CORE_SLATE_ORIENTATION,
|
||||
set_tilt_position_command=OverkizCommand.SET_ORIENTATION,
|
||||
stop_tilt_command=OverkizCommand.STOP,
|
||||
),
|
||||
OverkizCoverDescription(
|
||||
key=UIClass.SCREEN,
|
||||
@@ -326,9 +474,14 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
|
||||
OverkizCoverDescription(
|
||||
key=UIClass.VENETIAN_BLIND,
|
||||
device_class=CoverDeviceClass.BLIND,
|
||||
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,
|
||||
current_tilt_position_state=OverkizState.CORE_SLATE_ORIENTATION,
|
||||
set_tilt_position_command=OverkizCommand.SET_ORIENTATION,
|
||||
open_tilt_command=OverkizCommand.TILT_UP,
|
||||
close_tilt_command=OverkizCommand.TILT_DOWN,
|
||||
stop_tilt_command=OverkizCommand.STOP,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["qbittorrent"],
|
||||
"requirements": ["qbittorrent-api==2024.9.67"]
|
||||
"requirements": ["qbittorrent-api==2026.5.1"]
|
||||
}
|
||||
|
||||
@@ -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.1"]
|
||||
"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."""
|
||||
|
||||
@@ -158,6 +158,8 @@ NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = {
|
||||
set_value_fn=_async_set_foot_warmer_time,
|
||||
get_name_fn=_get_foot_warming_name,
|
||||
get_unique_id_fn=_get_foot_warming_unique_id,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
),
|
||||
CORE_CLIMATE_TIMER: SleepIQNumberEntityDescription(
|
||||
key=CORE_CLIMATE_TIMER,
|
||||
@@ -170,7 +172,7 @@ NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = {
|
||||
set_value_fn=_async_set_core_climate_time,
|
||||
get_name_fn=_get_core_climate_name,
|
||||
get_unique_id_fn=_get_core_climate_unique_id,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -92,16 +92,6 @@ class TeleinfoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
usb.get_serial_by_id, discovery_info.device
|
||||
)
|
||||
|
||||
# Validate by reading a real Teleinfo frame — silent abort on failure
|
||||
errors, decoded_data = await self._validate_serial_port(dev_path)
|
||||
if errors or decoded_data is None:
|
||||
return self.async_abort(reason="not_teleinfo_device")
|
||||
|
||||
# Use ADCO (meter serial number) as unique_id — same as manual entry
|
||||
adco = decoded_data["ADCO"]
|
||||
await self.async_set_unique_id(adco)
|
||||
self._abort_if_unique_id_configured(updates={CONF_SERIAL_PORT: dev_path})
|
||||
|
||||
self._discovered_device = dev_path
|
||||
self.context["title_placeholders"] = {
|
||||
"name": human_readable_device_name(
|
||||
@@ -122,6 +112,20 @@ class TeleinfoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if TYPE_CHECKING:
|
||||
assert self._discovered_device is not None
|
||||
if user_input is not None:
|
||||
# Validate by reading a real Teleinfo frame — silent abort on failure
|
||||
errors, decoded_data = await self._validate_serial_port(
|
||||
self._discovered_device
|
||||
)
|
||||
if errors or decoded_data is None:
|
||||
return self.async_abort(reason="not_teleinfo_device")
|
||||
|
||||
# Use ADCO (meter serial number) as unique_id — same as manual entry
|
||||
adco = decoded_data["ADCO"]
|
||||
await self.async_set_unique_id(adco)
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_SERIAL_PORT: self._discovered_device}
|
||||
)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=f"Teleinfo ({self._discovered_device})",
|
||||
data={CONF_SERIAL_PORT: self._discovered_device},
|
||||
|
||||
@@ -8,15 +8,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pyteleinfo==0.4.0"],
|
||||
"usb": [
|
||||
{
|
||||
"pid": "6015",
|
||||
"vid": "0403"
|
||||
},
|
||||
{
|
||||
"pid": "EA60",
|
||||
"vid": "10C4"
|
||||
}
|
||||
]
|
||||
"requirements": ["pyteleinfo==0.4.0"]
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ def ws_start_preview(
|
||||
name=name,
|
||||
lower=msg["user_input"].get(CONF_LOWER),
|
||||
upper=msg["user_input"].get(CONF_UPPER),
|
||||
hysteresis=msg["user_input"].get(CONF_HYSTERESIS),
|
||||
hysteresis=msg["user_input"].get(CONF_HYSTERESIS, DEFAULT_HYSTERESIS),
|
||||
device_class=None,
|
||||
unique_id=None,
|
||||
)
|
||||
|
||||
@@ -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.1"]
|
||||
"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,
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
ATTR_LAST_RESET,
|
||||
DEVICE_CLASS_STATE_CLASSES,
|
||||
DEVICE_CLASS_UNITS,
|
||||
RestoreSensor,
|
||||
SensorDeviceClass,
|
||||
@@ -408,11 +409,12 @@ class UtilityMeterSensor(RestoreSensor):
|
||||
self._current_tz = None
|
||||
self._config_scheduler()
|
||||
|
||||
def _config_scheduler(self):
|
||||
def _config_scheduler(self, start_time: datetime | None = None) -> None:
|
||||
self.scheduler = (
|
||||
CronSim(
|
||||
self._cron_pattern,
|
||||
dt_util.now(
|
||||
start_time
|
||||
or dt_util.now(
|
||||
dt_util.get_default_time_zone()
|
||||
), # we need timezone for DST purposes (see issue #102984)
|
||||
)
|
||||
@@ -610,8 +612,6 @@ class UtilityMeterSensor(RestoreSensor):
|
||||
# and we need to reconfigure the scheduler
|
||||
self._current_tz = self.hass.config.time_zone
|
||||
|
||||
await self._program_reset()
|
||||
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass, SIGNAL_RESET_METER, self.async_reset_meter
|
||||
@@ -630,6 +630,13 @@ class UtilityMeterSensor(RestoreSensor):
|
||||
if last_sensor_data.status == COLLECTING:
|
||||
# Null lambda to allow cancelling the collection on tariff change
|
||||
self._collecting = lambda: None
|
||||
# Reconfigure the scheduler from the restored last_reset so that
|
||||
# next_reset is not shifted forward on entity restore/rename.
|
||||
self._config_scheduler(
|
||||
dt_util.as_local(self._last_reset) if self._last_reset else None
|
||||
)
|
||||
|
||||
await self._program_reset()
|
||||
|
||||
@callback
|
||||
def async_source_tracking(event):
|
||||
@@ -697,12 +704,18 @@ class UtilityMeterSensor(RestoreSensor):
|
||||
|
||||
@property
|
||||
def state_class(self) -> SensorStateClass:
|
||||
"""Return the device class of the sensor."""
|
||||
return (
|
||||
SensorStateClass.TOTAL
|
||||
if self._sensor_net_consumption
|
||||
else SensorStateClass.TOTAL_INCREASING
|
||||
)
|
||||
"""Return the state class of the sensor."""
|
||||
if self._sensor_net_consumption:
|
||||
return SensorStateClass.TOTAL
|
||||
if (
|
||||
self._input_device_class is not None
|
||||
and SensorStateClass.TOTAL_INCREASING
|
||||
not in DEVICE_CLASS_STATE_CLASSES.get(
|
||||
self._input_device_class, {SensorStateClass.TOTAL_INCREASING}
|
||||
)
|
||||
):
|
||||
return SensorStateClass.TOTAL
|
||||
return SensorStateClass.TOTAL_INCREASING
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.components.alarm_control_panel import (
|
||||
CodeFormat,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
@@ -65,6 +66,12 @@ class VerisureAlarm(
|
||||
self.coordinator.verisure.request, command_data
|
||||
)
|
||||
LOGGER.debug("Verisure set arm state %s", state)
|
||||
if arm_state is None or "data" not in arm_state:
|
||||
await self.coordinator.async_refresh()
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="arm_state_failed",
|
||||
)
|
||||
result = None
|
||||
attempts = 0
|
||||
while result is None:
|
||||
@@ -79,6 +86,8 @@ class VerisureAlarm(
|
||||
list(arm_state["data"].values())[0], state
|
||||
),
|
||||
)
|
||||
if transaction is None:
|
||||
continue
|
||||
result = (
|
||||
transaction.get("data", {})
|
||||
.get("installation", {})
|
||||
|
||||
@@ -51,6 +51,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"arm_state_failed": {
|
||||
"message": "Failed to change alarm state. Verify your code is correct and that your account is not temporarily locked."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -185,8 +185,10 @@ class WeatherFlowWindCoordinator(BaseWebsocketCoordinator[EventDataRapidWind]):
|
||||
"""Create rapid wind listen message."""
|
||||
return RapidWindListenStartMessage(device_id=str(device_id))
|
||||
|
||||
async def _handle_websocket_message(self, data: RapidWindWS) -> None:
|
||||
async def _handle_websocket_message(self, data: RapidWindWS | None) -> None:
|
||||
"""Handle rapid wind websocket data."""
|
||||
if data is None:
|
||||
return
|
||||
device_id = data.device_id
|
||||
station_id = self.device_to_station_map[device_id]
|
||||
|
||||
@@ -204,8 +206,12 @@ class WeatherFlowObservationCoordinator(BaseWebsocketCoordinator[WebsocketObserv
|
||||
"""Create observation listen message."""
|
||||
return ListenStartMessage(device_id=str(device_id))
|
||||
|
||||
async def _handle_websocket_message(self, data: ObservationTempestWS) -> None:
|
||||
async def _handle_websocket_message(
|
||||
self, data: ObservationTempestWS | None
|
||||
) -> None:
|
||||
"""Handle observation websocket data."""
|
||||
if data is None:
|
||||
return
|
||||
device_id = data.device_id
|
||||
station_id = self.device_to_station_map[device_id]
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import voluptuous as vol
|
||||
from yarl import URL
|
||||
from zha.application.const import BAUD_RATES, RadioType
|
||||
from zha.application.gateway import Gateway
|
||||
from zha.application.helpers import ZHAData
|
||||
@@ -32,6 +33,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import homeassistant_hardware, repairs, websocket_api
|
||||
from .config_flow import ZhaConfigFlowHandler
|
||||
from .const import (
|
||||
CONF_BAUDRATE,
|
||||
CONF_CUSTOM_QUIRKS_PATH,
|
||||
@@ -43,6 +45,7 @@ from .const import (
|
||||
CONF_ZIGPY,
|
||||
DATA_ZHA,
|
||||
DOMAIN,
|
||||
LEGACY_ZEROCONF_PORT,
|
||||
)
|
||||
from .helpers import (
|
||||
SIGNAL_ADD_ENTITIES,
|
||||
@@ -301,7 +304,18 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Migrate old entry."""
|
||||
_LOGGER.debug("Migrating from version %s", config_entry.version)
|
||||
_LOGGER.debug(
|
||||
"Migrating from version %s.%s",
|
||||
config_entry.version,
|
||||
config_entry.minor_version,
|
||||
)
|
||||
|
||||
if (config_entry.version, config_entry.minor_version) > (
|
||||
ZhaConfigFlowHandler.VERSION,
|
||||
ZhaConfigFlowHandler.MINOR_VERSION,
|
||||
):
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if config_entry.version == 1:
|
||||
data = {
|
||||
@@ -361,5 +375,24 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
version=5,
|
||||
)
|
||||
|
||||
_LOGGER.info("Migration to version %s successful", config_entry.version)
|
||||
if config_entry.version == 5 and config_entry.minor_version < 2:
|
||||
data = {**config_entry.data, CONF_DEVICE: {**config_entry.data[CONF_DEVICE]}}
|
||||
device_path = data[CONF_DEVICE][CONF_DEVICE_PATH]
|
||||
|
||||
if device_path.startswith(("socket://", "tcp://")):
|
||||
url = URL(device_path)
|
||||
if url.explicit_port is None:
|
||||
data[CONF_DEVICE][CONF_DEVICE_PATH] = str(
|
||||
url.with_port(LEGACY_ZEROCONF_PORT)
|
||||
)
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, data=data, version=5, minor_version=2
|
||||
)
|
||||
|
||||
_LOGGER.info(
|
||||
"Migration to version %s.%s successful",
|
||||
config_entry.version,
|
||||
config_entry.minor_version,
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -47,7 +47,13 @@ from homeassistant.helpers.service_info.usb import UsbServiceInfo
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import CONF_BAUDRATE, CONF_FLOW_CONTROL, CONF_RADIO_TYPE, DOMAIN
|
||||
from .const import (
|
||||
CONF_BAUDRATE,
|
||||
CONF_FLOW_CONTROL,
|
||||
CONF_RADIO_TYPE,
|
||||
DOMAIN,
|
||||
LEGACY_ZEROCONF_PORT,
|
||||
)
|
||||
from .helpers import get_config_entry_unique_id, get_zha_gateway
|
||||
from .radio_manager import (
|
||||
DEVICE_SCHEMA,
|
||||
@@ -89,7 +95,6 @@ UPLOADED_BACKUP_FILE = "uploaded_backup_file"
|
||||
|
||||
REPAIR_MY_URL = "https://my.home-assistant.io/redirect/repairs/"
|
||||
|
||||
LEGACY_ZEROCONF_PORT = 6638
|
||||
LEGACY_ZEROCONF_ESPHOME_API_PORT = 6053
|
||||
|
||||
ZEROCONF_SERVICE_TYPE = "_zigbee-coordinator._tcp.local."
|
||||
@@ -760,6 +765,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow."""
|
||||
|
||||
VERSION = 5
|
||||
MINOR_VERSION = 2
|
||||
|
||||
async def _set_unique_id_and_update_ignored_flow(
|
||||
self, unique_id: str, device_path: str
|
||||
|
||||
@@ -64,6 +64,8 @@ DEVICE_PAIRING_STATUS = "pairing_status"
|
||||
|
||||
DOMAIN = "zha"
|
||||
|
||||
LEGACY_ZEROCONF_PORT = 6638
|
||||
|
||||
GROUP_ID = "group_id"
|
||||
|
||||
|
||||
|
||||
@@ -1265,19 +1265,6 @@ def async_add_entities(
|
||||
entities.clear()
|
||||
|
||||
|
||||
def _clean_serial_port_path(path: str) -> str:
|
||||
"""Clean the serial port path, applying corrections where necessary."""
|
||||
|
||||
if path.startswith("socket://"):
|
||||
path = path.strip()
|
||||
|
||||
# Removes extraneous brackets from IP addresses (they don't parse in CPython 3.11.4)
|
||||
if re.match(r"^socket://\[\d+\.\d+\.\d+\.\d+\]:\d+$", path):
|
||||
path = path.replace("[", "").replace("]", "")
|
||||
|
||||
return path
|
||||
|
||||
|
||||
CONF_ZHA_OPTIONS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_DEFAULT_LIGHT_TRANSITION, default=0): vol.All(
|
||||
@@ -1316,18 +1303,6 @@ def create_zha_config(hass: HomeAssistant, ha_zha_data: HAZHAData) -> ZHAData:
|
||||
assert ha_zha_data.config_entry is not None
|
||||
assert ha_zha_data.yaml_config is not None
|
||||
|
||||
# Remove brackets around IP addresses, this no longer works in CPython 3.11.4
|
||||
# This will be removed in 2023.11.0
|
||||
path = ha_zha_data.config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH]
|
||||
cleaned_path = _clean_serial_port_path(path)
|
||||
|
||||
if path != cleaned_path:
|
||||
_LOGGER.debug("Cleaned serial port path %r -> %r", path, cleaned_path)
|
||||
ha_zha_data.config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] = cleaned_path
|
||||
hass.config_entries.async_update_entry(
|
||||
ha_zha_data.config_entry, data=ha_zha_data.config_entry.data
|
||||
)
|
||||
|
||||
# deep copy the yaml config to avoid modifying the original and to safely
|
||||
# pass it to the ZHA library
|
||||
app_config = copy.deepcopy(ha_zha_data.yaml_config.get(CONF_ZIGPY, {}))
|
||||
|
||||
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2026
|
||||
MINOR_VERSION: Final = 5
|
||||
PATCH_VERSION: Final = "1"
|
||||
PATCH_VERSION: Final = "3"
|
||||
__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)
|
||||
|
||||
Generated
-10
@@ -58,16 +58,6 @@ USB = [
|
||||
"pid": "0003",
|
||||
"vid": "04B4",
|
||||
},
|
||||
{
|
||||
"domain": "teleinfo",
|
||||
"pid": "6015",
|
||||
"vid": "0403",
|
||||
},
|
||||
{
|
||||
"domain": "teleinfo",
|
||||
"pid": "EA60",
|
||||
"vid": "10C4",
|
||||
},
|
||||
{
|
||||
"domain": "velbus",
|
||||
"pid": "0B1B",
|
||||
|
||||
@@ -1722,9 +1722,14 @@ def state_validate_config(hass: HomeAssistant, config: ConfigType) -> ConfigType
|
||||
|
||||
|
||||
async def async_validate_condition_config(
|
||||
hass: HomeAssistant, config: ConfigType
|
||||
hass: HomeAssistant, config: ConfigType | str
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
if isinstance(config, str):
|
||||
config = {
|
||||
CONF_CONDITION: "template",
|
||||
CONF_VALUE_TEMPLATE: cv.dynamic_template(config),
|
||||
}
|
||||
condition_key: str = config[CONF_CONDITION]
|
||||
|
||||
if condition_key in ("and", "not", "or"):
|
||||
|
||||
@@ -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)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user