Compare commits

..

85 Commits

Author SHA1 Message Date
Franck Nijhof c9ed57bc56 2026.5.3 (#171185) 2026-05-19 11:49:12 +02:00
bkobus-bbx 0e0901993d Fix blebox light temperature scaling (#170573) 2026-05-19 08:47:23 +00:00
Franck Nijhof 54aba11091 Bump version to 2026.5.3 2026-05-19 08:39:50 +00:00
Mick Vleeshouwer dc9116a7a7 Fix tilt and position support for VenetianBlind covers in Overkiz (#170974) 2026-05-19 08:39:38 +00:00
Mick Vleeshouwer 1e90882918 Fix is_closed state and position for DynamicPergola covers in Overkiz (#170983) 2026-05-19 08:37:54 +00:00
puddly e8295e14b1 Fix ZHA config entries using a URI without a port (#171164) 2026-05-19 08:35:43 +00:00
Mick Vleeshouwer 7ebaaf129a Fix controls for UpDownGarageDoor4T and additional 4T covers in Overkiz (#171144) 2026-05-19 08:35:00 +00:00
Michael ee734dede6 Bump aioimmich to 0.14.1 (#171138) 2026-05-19 08:33:58 +00:00
Franck Nijhof ebc582c813 Return media_content_id as string in forked_daapd (#171059) 2026-05-19 08:33:56 +00:00
James Nimmo 311e5a9bd2 Bump pyIntesishome to 1.8.8 (#171041) 2026-05-19 08:33:54 +00:00
Franck Nijhof cd6c3c878b Fix WeatherFlow websocket crash when data payload is None (#171037) 2026-05-19 08:33:52 +00:00
Franck Nijhof 51589ec2ff Add stop command to Overkiz pergola horizontal awning covers (#171034) 2026-05-19 08:33:50 +00:00
Franck Nijhof 8e1a04dc82 Fix Verisure alarm crash when cloud rejects arm/disarm command (#171024) 2026-05-19 08:33:48 +00:00
Mick Vleeshouwer 6b15f9a2ec Add additional overrides to cover entity in Overkiz (#171019) 2026-05-19 08:33:46 +00:00
Franck Nijhof 8d66752556 Fix shorthand template conditions in choose blocks crashing all automations (#171018) 2026-05-19 08:33:44 +00:00
Franck Nijhof 266767e37d Handle Daikin connection errors gracefully in coordinator (#171017) 2026-05-19 08:33:42 +00:00
Franck Nijhof d39775ac34 Fix manual alarm panel crash on restore with invalid state (#171016) 2026-05-19 08:33:40 +00:00
Franck Nijhof a314f7bf64 Fix Control4 climate crash when humidity is 'Undefined' (#171015) 2026-05-19 08:33:38 +00:00
Franck Nijhof 37478d33eb Fix SleepIQ timer units: seconds should be minutes for core climate and foot warmer (#171013) 2026-05-19 08:33:36 +00:00
Franck Nijhof 5a76f3bd19 Fix Growatt mix device IndexError when chart data is empty (#171012) 2026-05-19 08:33:34 +00:00
Franck Nijhof 17e105083e Fix threshold preview crash when hysteresis is not provided (#171009) 2026-05-19 08:33:32 +00:00
Franck Nijhof db8589b2bc Fix time trigger crash when using entity_id dict format without offset (#171006)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2026-05-19 08:33:30 +00:00
Franck Nijhof 771b016f33 Fix Netatmo valve KeyError when hvac_action state is unavailable in Overkiz (#171004) 2026-05-19 08:33:28 +00:00
Franck Nijhof 0bc0745e8c Use asyncio.get_running_loop() in emulated_hue UPnP responder (#171000)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-19 08:31:43 +00:00
Franck Nijhof ea084797d3 Load template extensions by class to prevent import deadlock (#170995)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-19 08:31:41 +00:00
Franck Nijhof 2456753caf Prevent Google Assistant entity sync from blocking startup (#170991)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-19 08:31:39 +00:00
Mick Vleeshouwer 070de13c14 Fix controls for OpenCloseGate4T (rts:GateOpenerRTS4TComponent) in Overkiz (#170987) 2026-05-19 08:30:30 +00:00
Mick Vleeshouwer 5e45f37ee6 Fix is_closed state for DiscretePositionableGarageDoor in Overkiz (#170981) 2026-05-19 08:25:10 +00:00
Franck Nijhof 4a96880f51 Reduce GoodWe connect retries to avoid blocking startup (#170964)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-19 08:20:26 +00:00
Franck Nijhof 228ac01124 Use correct state_class for utility meters with device classes that don't support total_increasing (#170962)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-19 08:20:24 +00:00
Franck Nijhof d366027e6b Fix utility meter next_reset shifting forward on entity rename (#170957)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-19 08:20:22 +00:00
puddly 2f35ad2a8a Disable USB discovery for teleinfo (#170933) 2026-05-19 08:20:20 +00:00
Mick Vleeshouwer 95cc9aed64 Fix is_closed state for SlidingDiscreteGateWithPedestrianPosition covers in Overkiz (#170913) 2026-05-19 08:19:03 +00:00
Franck Nijhof 37d6449a49 Populate uid and recurrence_id in CalDAV calendar events (#170910) 2026-05-19 08:14:10 +00:00
J. Nick Koston 249b5435d9 Bump aiodns to 4.0.3 (#170865)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2026-05-19 08:07:21 +00:00
bkobus-bbx 3293ebcea5 Fix ValueError when turning on blebox light with brightness set to 0 (#170769)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-19 08:07:18 +00:00
Daniil Karpenko 47d8adc77c Add tilt controls for UpDownSheerScreen in Overkiz (#170563)
Co-authored-by: Mick Vleeshouwer <mick@imick.nl>
2026-05-19 08:01:00 +00:00
Keith Roehrenbeck 356e6a691b Fix Apple TV keyboard focus binary_sensor missing on cold start (#170360) 2026-05-19 08:00:58 +00:00
Florent Thoumie b26c2f3854 Improve iaqualink 429 handling (#170231) 2026-05-19 08:00:56 +00:00
Luka Matijević 0830988687 Bump qbittorrent-api to 2026.5.1 (#170181) 2026-05-19 08:00:54 +00:00
Franck Nijhof 456202325a 2026.5.2 (#170840) 2026-05-15 22:55:45 +02:00
Franck Nijhof 1e47149764 Fix hassfest warning 2026-05-15 20:26:51 +00:00
Franck Nijhof 116b63ca3a Bump version to 2026.5.2 2026-05-15 20:13:00 +00:00
Ronald van der Meer 3096bcf8a9 Bump python-duco-connectivity to 0.4.0 (#170661) 2026-05-15 20:12:26 +00:00
Ronald van der Meer a4027029d0 Migrate Duco to python-duco-connectivity and remove temperature sensors (#170237) 2026-05-15 20:11:35 +00:00
Bram Kragten fffc9d0695 Update frontend to 20260429.4 (#170567) 2026-05-15 20:06:23 +00:00
G Johansson 3ca5cf5add Add missing optional category strings in workday (#170505)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-15 20:06:21 +00:00
Jan Bouwhuis 087cb77042 Fix MQTT settings in device subentry device settings are not recalled when reconfiguring the device (#170484) 2026-05-15 20:06:19 +00:00
Michael Keck 8bd1c07ec9 Increase WebDAV client timeout from 10 to 30 seconds (#170476) 2026-05-15 20:06:17 +00:00
J. Nick Koston 9ecb59590b Bump aioharmony to 1.0.3 (#170459) 2026-05-15 20:02:46 +00:00
Rob Bierbooms e14eb9fbc5 Fix influxdb reconfigure for v1 configuration (#170448) 2026-05-15 20:01:59 +00:00
TheJulianJES 149c796227 Fix fractional setpoints in Matter climate not rounded (#170442) 2026-05-15 20:01:11 +00:00
J. Nick Koston 3383e5b1e9 Bump aioesphomeapi to 44.24.1 (#170428) 2026-05-15 20:00:24 +00:00
Åke Strandberg 05862c6dc8 Bump pymiele version to 0.6.2 (#170419) 2026-05-15 19:59:37 +00:00
Petar Petrov b35ac41470 Apply unit_of_measurement to energy combined power sensor (#170404) 2026-05-15 19:58:50 +00:00
James Nimmo 20cec56512 Bump pyintesishome to 1.8.7 (#170382) 2026-05-15 19:58:03 +00:00
puddly 74580262b6 Bump serialx to 1.7.3 (#170368) 2026-05-15 19:57:16 +00:00
Pascal Brunot f75cdae602 Bump serialx to 1.7.2 (#170272) 2026-05-15 19:56:59 +00:00
Jan Bouwhuis 8c95f4f7ae Fix duplicate doorbell events when entity becomes unavailable (#170354)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-15 19:54:02 +00:00
Robert Svensson c3ec51c471 Bump axis to v71 (#170347) 2026-05-15 19:54:00 +00:00
Raman Gupta 0f80a4bc18 Cancel previous Debouncer timer handle in _schedule_timer (#170339)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-15 19:53:58 +00:00
Maciej Bieniek 0761d618f1 Fix Shelly media player availability (#170319) 2026-05-15 19:53:57 +00:00
Stefan Agner 03e3c46faf Fix hassio.backup_partial AttributeError when folders are specified (#170312)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:53:55 +00:00
Craig Dean d1962b0df2 Bump renault-api to 0.5.8 (#170309) 2026-05-15 19:53:53 +00:00
Florent Thoumie 7a38a2303a iaqualink: set system specific polling interval (#170279) 2026-05-15 19:53:51 +00:00
Maciej Bieniek 6f5c2a8614 Bump imgw-pib to 2.1.2 (#170274) 2026-05-15 19:53:49 +00:00
Sören Beye ff36498698 fix: Do not forget segments from state when a new config arrives (#170265)
Co-authored-by: Jan Bouwhuis <jbouwh@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-15 19:53:47 +00:00
Willem-Jan van Rootselaar 23e19ea2e4 Handle empty BSB-LAN heating circuits (#170249) 2026-05-15 19:53:46 +00:00
Ronald van der Meer c33f174041 Bump python-duco-client to 0.5.0 (#170065) 2026-05-15 19:52:32 +00:00
Ronald van der Meer bbe64d74e3 Bump python-duco-client to 0.4.2 (#170027) 2026-05-15 19:52:30 +00:00
Ronald van der Meer ed3a71f2ee Add API version to Duco diagnostics for support triage (#169802) 2026-05-15 19:51:21 +00:00
Ronald van der Meer 46c49daba4 Add system health platform for Duco integration (#169517) 2026-05-15 19:48:52 +00:00
Ronald van der Meer a2f2ded188 Add target flow level and mode end time sensors to Duco integration (#169298) 2026-05-15 19:47:15 +00:00
Simone Chemelli 7be061796d Fix entities refresh for UptimeRobot (#170217) 2026-05-15 19:32:16 +00:00
Jan Bouwhuis 27c7d8de0c Fix MQTT device discovery not using shared QoS and encoding options (#170195)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-15 19:32:14 +00:00
Simone Chemelli 07542523b5 Reinit API on stale session for Vodafone Station (#170190) 2026-05-15 19:32:12 +00:00
puddly 18597bb653 Set serial port description from description, not product (#170160)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2026-05-15 19:32:10 +00:00
Christian Lackas c4be57a294 homematicip_cloud: fix HmIP-FLC lock state polarity (#170159) 2026-05-15 19:32:08 +00:00
Christian Lackas 7ceaebb086 Fix homematicip_cloud config entry setup crash after migration to 2026.5.0 (#170156) 2026-05-15 19:32:06 +00:00
Mick Vleeshouwer 7c5ef09734 Fix local API incorrectly marking devices as unavailable in Overkiz (#170118)
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2026-05-15 19:32:05 +00:00
Thijs W. b4d8ba66fe Update afsapi to 1.0.1 (#170073) 2026-05-15 19:32:02 +00:00
puddly 308221ce67 Migrate ZBT-1 and ZBT-2 to use serial number for unique_id (#169879)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-05-15 19:30:56 +00:00
Simone Chemelli 1344213335 Fix non unique_id for Comelit (#169756)
Co-authored-by: Copilot <copilot@github.com>
2026-05-15 19:26:54 +00:00
r2xj 7e405e9014 Only use SmartThings switch for light if it should (#166424)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-05-15 19:26:52 +00:00
LG-ThinQ-Integration b0c45132ed Fix ValueError for non-numeric value in LG ThinQ (#166300)
Co-authored-by: YunseonPark-LGE <yunseon.park@lge.com>
2026-05-15 19:26:49 +00:00
179 changed files with 6989 additions and 730 deletions
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["serialx==1.7.1"]
"requirements": ["serialx==1.7.3"]
}
+36 -3
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from asyncio import timeout
from collections.abc import Mapping
from datetime import datetime, timedelta
from http import HTTPStatus
import json
import logging
@@ -13,7 +14,12 @@ from uuid import uuid4
import aiohttp
from homeassistant.components import event
from homeassistant.const import EVENT_STATE_CHANGED, STATE_ON
from homeassistant.const import (
EVENT_STATE_CHANGED,
STATE_ON,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import (
CALLBACK_TYPE,
Event,
@@ -53,6 +59,25 @@ DEFAULT_TIMEOUT = 10
TO_REDACT = {"correlationToken", "token"}
def valid_doorbell_timestamp(entity_id: str, event_state: str) -> bool:
"""Check if doorbell event timestamp is valid."""
if event_state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
try:
timestamp = datetime.fromisoformat(event_state)
except ValueError:
_LOGGER.debug(
"Unable to parse ISO timestamp from state for %s. Got %s",
entity_id,
event_state,
)
return False
else:
if (dt_util.utcnow() - timestamp) < timedelta(seconds=30):
return True
return False
class AlexaDirective:
"""An incoming Alexa directive."""
@@ -317,9 +342,17 @@ async def async_enable_proactive_mode(
if should_doorbell:
old_state = data["old_state"]
if new_state.domain == event.DOMAIN or (
if (
new_state.domain == event.DOMAIN
and valid_doorbell_timestamp(new_state.entity_id, new_state.state)
and (old_state is None or old_state.state != STATE_UNAVAILABLE)
and (old_state is None or old_state.state != new_state.state)
) or (
new_state.state == STATE_ON
and (old_state is None or old_state.state != STATE_ON)
and (
old_state is None
or old_state.state not in (STATE_ON, STATE_UNAVAILABLE)
)
):
await async_send_doorbell_event_message(
hass, smart_home_config, alexa_changed_entity
@@ -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):
+1 -1
View File
@@ -29,7 +29,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["axis"],
"requirements": ["axis==70"],
"requirements": ["axis==71"],
"ssdp": [
{
"manufacturer": "AXIS"
+4
View File
@@ -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
+46 -9
View File
@@ -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 -4
View File
@@ -38,7 +38,14 @@ from homeassistant.helpers.device_registry import (
)
from homeassistant.helpers.typing import ConfigType
from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
from .const import (
CONF_HEATING_CIRCUITS,
CONF_PASSKEY,
DEFAULT_HEATING_CIRCUITS,
DEFAULT_PORT,
DOMAIN,
LOGGER,
)
from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator
from .services import async_setup_services
@@ -118,7 +125,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
# Read available heating circuits from config entry data
# (populated by config flow or migration)
circuits: list[int] = entry.data[CONF_HEATING_CIRCUITS]
circuits: list[int] = entry.data[CONF_HEATING_CIRCUITS] or list(
DEFAULT_HEATING_CIRCUITS
)
# Fetch required device metadata in parallel for faster startup
device, info = await asyncio.gather(
@@ -229,7 +238,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
# heating circuits from the device; fall back to [1] (pre-multi-circuit
# default) if the device is unreachable or the endpoint is unsupported.
if entry.version == 1 and entry.minor_version < 2:
circuits: list[int] = [1]
circuits: list[int] = list(DEFAULT_HEATING_CIRCUITS)
config = BSBLANConfig(
host=entry.data[CONF_HOST],
passkey=entry.data[CONF_PASSKEY],
@@ -245,11 +254,18 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
except (BSBLANError, TimeoutError) as err:
LOGGER.warning(
"Circuit discovery during migration failed for %s (%s); "
"defaulting to single circuit [1]. Use Reconfigure to "
"defaulting to a single circuit. Use Reconfigure to "
"rediscover additional circuits later",
entry.data[CONF_HOST],
err,
)
if not circuits:
LOGGER.warning(
"Circuit discovery during migration returned no heating circuits "
"for %s; defaulting to a single circuit",
entry.data[CONF_HOST],
)
circuits = list(DEFAULT_HEATING_CIRCUITS)
hass.config_entries.async_update_entry(
entry,
@@ -263,4 +279,22 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
circuits,
)
# 1.2 -> 1.3: Repair entries that stored an empty circuit list during
# discovery. Every BSB-LAN setup has at least one heating circuit.
if entry.version == 1 and entry.minor_version < 3:
if not entry.data[CONF_HEATING_CIRCUITS]:
LOGGER.warning(
"Stored heating circuits for %s are empty; defaulting to a "
"single circuit",
entry.data[CONF_HOST],
)
data = {
**entry.data,
CONF_HEATING_CIRCUITS: list(DEFAULT_HEATING_CIRCUITS),
}
else:
data = {**entry.data}
hass.config_entries.async_update_entry(entry, data=data, minor_version=3)
return True
+18 -4
View File
@@ -15,21 +15,28 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
from .const import (
CONF_HEATING_CIRCUITS,
CONF_PASSKEY,
DEFAULT_HEATING_CIRCUITS,
DEFAULT_PORT,
DOMAIN,
LOGGER,
)
class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a BSBLAN config flow."""
VERSION = 1
MINOR_VERSION = 2
MINOR_VERSION = 3
def __init__(self) -> None:
"""Initialize BSBLan flow."""
self.host: str = ""
self.port: int = DEFAULT_PORT
self.mac: str | None = None
self.circuits: list[int] = [1]
self.circuits: list[int] = list(DEFAULT_HEATING_CIRCUITS)
self.passkey: str | None = None
self.username: str | None = None
self.password: str | None = None
@@ -386,6 +393,13 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
try:
await bsblan.initialize()
self.circuits = await bsblan.get_available_circuits()
if not self.circuits:
LOGGER.debug(
"Circuit discovery returned no heating circuits for %s, "
"defaulting to single circuit",
self.host,
)
self.circuits = list(DEFAULT_HEATING_CIRCUITS)
except (
BSBLANError,
TimeoutError,
@@ -394,4 +408,4 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
"Circuit discovery not available for %s, defaulting to single circuit",
self.host,
)
self.circuits = [1]
self.circuits = list(DEFAULT_HEATING_CIRCUITS)
+1
View File
@@ -24,4 +24,5 @@ ATTR_OUTSIDE_TEMPERATURE: Final = "outside_temperature"
CONF_PASSKEY: Final = "passkey"
CONF_HEATING_CIRCUITS: Final = "heating_circuits"
DEFAULT_HEATING_CIRCUITS: Final = (1,)
DEFAULT_PORT: Final = 80
@@ -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
+53 -2
View File
@@ -3,9 +3,10 @@
from aiocomelit.const import BRIDGE
from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .const import CONF_VEDO_PIN, DEFAULT_PORT
from .const import _LOGGER, CONF_VEDO_PIN, DEFAULT_PORT, DOMAIN
from .coordinator import (
ComelitBaseCoordinator,
ComelitConfigEntry,
@@ -81,6 +82,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: ComelitConfigEntry) -> b
return True
async def async_migrate_entry(
hass: HomeAssistant, config_entry: ComelitConfigEntry
) -> bool:
"""Migrate old entry."""
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1 and config_entry.minor_version == 1:
device_registry = dr.async_get(hass)
@callback
def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None:
if (
entry.domain != Platform.SENSOR
or entry.device_id is None
or not (device_entry := device_registry.async_get(entry.device_id))
or not any(
platform == DOMAIN
and identifier.startswith(f"{config_entry.entry_id}-zone-")
for platform, identifier in device_entry.identifiers
)
):
return None
_LOGGER.debug(
"Migrating from version %s.%s",
config_entry.version,
config_entry.minor_version,
)
zone_index = entry.unique_id.removeprefix(f"{config_entry.entry_id}-")
return {
"new_unique_id": f"{config_entry.entry_id}-human_status-{zone_index}"
}
await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id)
hass.config_entries.async_update_entry(config_entry, version=1, minor_version=2)
_LOGGER.info(
"Migration to version %s.%s successful",
config_entry.version,
config_entry.minor_version,
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ComelitConfigEntry) -> bool:
"""Unload a config entry."""
@@ -94,6 +94,7 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Comelit."""
VERSION = 1
MINOR_VERSION = 2
async def async_step_user(
self, user_input: dict[str, Any] | None = None
+1 -1
View File
@@ -153,7 +153,7 @@ class ComelitVedoSensorEntity(
super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id
# because no serial number or mac is available
self._attr_unique_id = f"{config_entry_entry_id}-{zone.index}"
self._attr_unique_id = f"{config_entry_entry_id}-{description.key}-{zone.index}"
self._attr_device_info = coordinator.platform_device_info(zone, "zone")
self.entity_description = description
+4 -1
View File
@@ -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:
+10 -2
View File
@@ -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."
},
+1 -1
View File
@@ -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"]
}
+14 -4
View File
@@ -1,24 +1,34 @@
"""The Duco integration."""
from __future__ import annotations
import re
from duco import DucoClient, build_ssl_context
from duco_connectivity import DucoClient
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import PLATFORMS
from .coordinator import DucoConfigEntry, DucoCoordinator
_REMOVED_SENSOR_RE = re.compile(r"_\d+_(box_)?temperature$")
async def async_setup_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool:
"""Set up Duco from a config entry."""
ssl_context = await hass.async_add_executor_job(build_ssl_context)
# Remove entity registry entries for the temperature and box_temperature
# sensors that were removed when migrating to python-duco-connectivity.
entity_registry = er.async_get(hass)
for entity_entry in er.async_entries_for_config_entry(
entity_registry, entry.entry_id
):
if _REMOVED_SENSOR_RE.search(entity_entry.unique_id):
entity_registry.async_remove(entity_entry.entity_id)
client = DucoClient(
session=async_get_clientsession(hass),
host=entry.data[CONF_HOST],
ssl_context=ssl_context,
)
coordinator = DucoCoordinator(hass, entry, client)
+2 -4
View File
@@ -5,8 +5,8 @@ from __future__ import annotations
import logging
from typing import Any
from duco import DucoClient, build_ssl_context
from duco.exceptions import DucoConnectionError, DucoError
from duco_connectivity import DucoClient
from duco_connectivity.exceptions import DucoConnectionError, DucoError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@@ -160,11 +160,9 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
Returns a tuple of (box_name, mac_address).
"""
ssl_context = await self.hass.async_add_executor_job(build_ssl_context)
client = DucoClient(
session=async_get_clientsession(self.hass),
host=host,
ssl_context=ssl_context,
)
board_info = await client.async_get_board_info()
lan_info = await client.async_get_lan_info()
+3 -3
View File
@@ -5,9 +5,9 @@ from __future__ import annotations
from dataclasses import dataclass
import logging
from duco import DucoClient
from duco.exceptions import DucoConnectionError, DucoError
from duco.models import BoardInfo, Node
from duco_connectivity import DucoClient
from duco_connectivity.exceptions import DucoConnectionError, DucoError
from duco_connectivity.models import BoardInfo, Node
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
+16 -2
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from dataclasses import asdict
from typing import Any
from duco.exceptions import DucoConnectionError
from duco_connectivity.exceptions import DucoConnectionError
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST
@@ -15,6 +15,9 @@ from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN
from .coordinator import DucoConfigEntry
# MAC addresses and serial numbers are redacted because a Duco installer or
# manufacturer could cross-reference them against an installation registry to
# identify the physical location of the device.
TO_REDACT = {
CONF_HOST,
"mac",
@@ -33,22 +36,33 @@ async def async_get_config_entry_diagnostics(
coordinator = entry.runtime_data
board = asdict(coordinator.board_info)
# `time` is a Unix epoch timestamp of the last board info fetch; not useful for support triage.
board.pop("time")
if board["public_api_version"] is None:
board.pop("public_api_version")
if board["software_version"] is None:
board.pop("software_version")
try:
api_info_obj = await coordinator.client.async_get_api_info()
lan_info = await coordinator.client.async_get_lan_info()
duco_diags = await coordinator.client.async_get_diagnostics()
write_remaining = await coordinator.client.async_get_write_req_remaining()
write_remaining = await coordinator.client.async_get_write_requests_remaining()
except DucoConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="connection_error",
) from err
api_info: dict[str, Any] = {"public_api_version": api_info_obj.public_api_version}
if api_info_obj.reported_api_version is not None:
api_info["reported_api_version"] = api_info_obj.reported_api_version
return async_redact_data(
{
"entry_data": entry.data,
"board_info": board,
"api_info": api_info,
"lan_info": asdict(lan_info),
"nodes": {
str(node_id): asdict(node)
+1 -3
View File
@@ -1,8 +1,6 @@
"""Base entity for the Duco integration."""
from __future__ import annotations
from duco.models import Node
from duco_connectivity.models import Node
from homeassistant.const import ATTR_VIA_DEVICE
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
+2 -2
View File
@@ -4,8 +4,8 @@ from __future__ import annotations
import logging
from duco.exceptions import DucoError, DucoRateLimitError
from duco.models import Node, NodeType, VentilationState
from duco_connectivity.exceptions import DucoError, DucoRateLimitError
from duco_connectivity.models import Node, NodeType, VentilationState
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.core import HomeAssistant
+6
View File
@@ -7,6 +7,12 @@
"iaq_rh": {
"default": "mdi:water-percent"
},
"target_flow_level": {
"default": "mdi:gauge"
},
"time_state_end": {
"default": "mdi:timer-outline"
},
"ventilation_state": {
"default": "mdi:tune-variant"
}
+2 -2
View File
@@ -11,9 +11,9 @@
"documentation": "https://www.home-assistant.io/integrations/duco",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["duco"],
"loggers": ["duco_connectivity"],
"quality_scale": "platinum",
"requirements": ["python-duco-client==0.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][]].*",
+31 -19
View File
@@ -4,9 +4,10 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
import logging
from duco.models import Node, NodeType, VentilationState
from duco_connectivity.models import Node, NodeType, VentilationState
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -19,11 +20,11 @@ from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import DucoConfigEntry, DucoCoordinator
@@ -38,7 +39,7 @@ PARALLEL_UPDATES = 0
class DucoSensorEntityDescription(SensorEntityDescription):
"""Duco sensor entity description."""
value_fn: Callable[[Node], int | float | str | None]
value_fn: Callable[[Node], datetime | int | float | str | None]
node_types: tuple[NodeType, ...]
@@ -54,29 +55,40 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
key="ventilation_state",
translation_key="ventilation_state",
device_class=SensorDeviceClass.ENUM,
options=[s.lower() for s in VentilationState],
options=[
state.lower()
for state in VentilationState
if state != VentilationState.UNKNOWN
],
value_fn=lambda node: (
node.ventilation.state.lower() if node.ventilation else None
node.ventilation.state.lower()
if node.ventilation and node.ventilation.state != VentilationState.UNKNOWN
else None
),
node_types=(NodeType.BOX,),
),
DucoSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
key="target_flow_level",
translation_key="target_flow_level",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda node: node.sensor.temp if node.sensor else None,
node_types=(NodeType.UCCO2, NodeType.BSRH, NodeType.UCRH),
native_unit_of_measurement=PERCENTAGE,
suggested_display_precision=0,
value_fn=lambda node: (
node.ventilation.flow_lvl_tgt if node.ventilation else None
),
node_types=(NodeType.BOX,),
),
DucoSensorEntityDescription(
key="box_temperature",
translation_key="box_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda node: node.sensor.temp if node.sensor else None,
key="time_state_end",
translation_key="time_state_end",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda node: (
dt_util.utc_from_timestamp(node.ventilation.time_state_end).replace(
second=0, microsecond=0
)
if node.ventilation and node.ventilation.time_state_end != 0
else None
),
node_types=(NodeType.BOX,),
),
DucoSensorEntityDescription(
@@ -216,7 +228,7 @@ class DucoSensorEntity(DucoEntity, SensorEntity):
)
@property
def native_value(self) -> int | float | str | None:
def native_value(self) -> datetime | int | float | str | None:
"""Return the sensor value."""
return self.entity_description.value_fn(self._node)
+11 -3
View File
@@ -47,15 +47,18 @@
}
},
"sensor": {
"box_temperature": {
"name": "Box temperature"
},
"iaq_co2": {
"name": "CO2 air quality index"
},
"iaq_rh": {
"name": "Humidity air quality index"
},
"target_flow_level": {
"name": "Target flow level"
},
"time_state_end": {
"name": "Mode end time"
},
"ventilation_state": {
"name": "Ventilation state",
"state": {
@@ -96,5 +99,10 @@
"rate_limit_exceeded": {
"message": "The Duco device has reached its daily write limit. Try again tomorrow."
}
},
"system_health": {
"info": {
"write_requests_remaining": "Remaining write requests today"
}
}
}
@@ -0,0 +1,47 @@
"""Provide info to system health."""
from typing import Any
from duco_connectivity.exceptions import DucoConnectionError
from homeassistant.components import system_health
from homeassistant.core import HomeAssistant, callback
from .const import DOMAIN
from .coordinator import DucoConfigEntry
@callback
def async_register(
hass: HomeAssistant, register: system_health.SystemHealthRegistration
) -> None:
"""Register system health callbacks."""
register.async_register_info(system_health_info)
async def _async_get_write_requests_remaining(
config_entry: DucoConfigEntry,
) -> int | dict[str, str]:
"""Get the remaining write-request quota for system health."""
try:
return (
await config_entry.runtime_data.client.async_get_write_requests_remaining()
)
except DucoConnectionError:
return {"type": "failed", "error": "unreachable"}
async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
"""Get info for the info page."""
config_entries: list[DucoConfigEntry] = hass.config_entries.async_loaded_entries(
DOMAIN
)
if not config_entries:
return {}
return {
"write_requests_remaining": _async_get_write_requests_remaining(
config_entries[0]
)
}
@@ -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),
+6 -5
View File
@@ -666,6 +666,12 @@ class EnergyPowerSensor(SensorEntity):
self._is_inverted = "stat_rate_inverted" in config
self._is_combined = "stat_rate_from" in config and "stat_rate_to" in config
# Combined mode always emits Watts because _update_state converts
# heterogeneous source units to W internally. Inverted mode copies
# the source unit in _update_state to track source changes.
if self._is_combined:
self._attr_native_unit_of_measurement = UnitOfPower.WATT
# Determine source sensors
if self._is_inverted:
self._source_sensors = [config["stat_rate_inverted"]]
@@ -766,11 +772,6 @@ class EnergyPowerSensor(SensorEntity):
# Check first sensor
if source_entry := entity_reg.async_get(self._source_sensors[0]):
device_id = source_entry.device_id
# Combined mode always emits Watts because we convert
# heterogeneous source units internally. For inverted mode the
# unit is copied from the source state in _update_state.
if self._is_combined:
self._attr_native_unit_of_measurement = UnitOfPower.WATT
# Get source name from registry
source_name = source_entry.name or source_entry.original_name
# Assign power sensor to same device as source sensor(s)
@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==44.21.0",
"aioesphomeapi==44.24.1",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.7.3"
],
@@ -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)
+1 -1
View File
@@ -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",
+36 -7
View File
@@ -7,6 +7,7 @@ from typing import Any
from aiohasupervisor import SupervisorClient, SupervisorError
from aiohasupervisor.models import (
Folder,
FullBackupOptions,
FullRestoreOptions,
PartialBackupOptions,
@@ -70,6 +71,31 @@ SERVICE_MOUNT_RELOAD = "mount_reload"
VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$"))
# Legacy alias used by the Supervisor API for the homeassistant flag, kept
# for backwards compatibility with existing automations.
LEGACY_FOLDER_HOMEASSISTANT = "homeassistant"
def _normalize_partial_options_data(data: dict[str, Any]) -> dict[str, Any]:
"""Map legacy aliases used by both partial backup and partial restore handlers."""
if ATTR_APPS in data:
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
if ATTR_FOLDERS in data:
folders: set[Any] = set(data[ATTR_FOLDERS])
if LEGACY_FOLDER_HOMEASSISTANT in folders:
folders.discard(LEGACY_FOLDER_HOMEASSISTANT)
if data.get(ATTR_HOMEASSISTANT) is False:
raise ServiceValidationError(
f"{ATTR_HOMEASSISTANT}=False conflicts with the legacy "
f"{LEGACY_FOLDER_HOMEASSISTANT!r} entry in {ATTR_FOLDERS}"
)
data[ATTR_HOMEASSISTANT] = True
if folders:
data[ATTR_FOLDERS] = folders
else:
data.pop(ATTR_FOLDERS)
return data
def valid_addon(value: Any) -> str:
"""Validate value is a valid addon slug."""
@@ -113,7 +139,10 @@ SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
cv.ensure_list,
[vol.Any(LEGACY_FOLDER_HOMEASSISTANT, vol.Coerce(Folder))],
vol.Unique(),
vol.Coerce(set),
),
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
@@ -136,7 +165,10 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
cv.ensure_list,
[vol.Any(LEGACY_FOLDER_HOMEASSISTANT, vol.Coerce(Folder))],
vol.Unique(),
vol.Coerce(set),
),
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
@@ -343,9 +375,7 @@ def async_register_backup_restore_services(
service: ServiceCall,
) -> ServiceResponse:
"""Handler for create partial backup service. Returns the new backup's ID."""
data = service.data.copy()
if ATTR_APPS in data:
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
data = _normalize_partial_options_data(service.data.copy())
options = PartialBackupOptions(**data)
try:
@@ -392,8 +422,7 @@ def async_register_backup_restore_services(
"""Handler for partial restore service."""
data = service.data.copy()
backup_slug = data.pop(ATTR_SLUG)
if ATTR_APPS in data:
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
data = _normalize_partial_options_data(data)
options = PartialRestoreOptions(**data)
try:
@@ -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
+6 -1
View File
@@ -3,4 +3,9 @@
from datetime import timedelta
DOMAIN = "iaqualink"
UPDATE_INTERVAL = timedelta(seconds=15)
UPDATE_INTERVAL_BY_SYSTEM_TYPE: dict[str, timedelta] = {
"iaqua": timedelta(seconds=15),
"exo": timedelta(seconds=60),
}
UPDATE_INTERVAL_DEFAULT = timedelta(seconds=30)
@@ -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"]
}
+64 -5
View File
@@ -520,10 +520,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
),
DeviceType.KIMCHI_REFRIGERATOR: (
REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER],
SensorEntityDescription(
key=ThinQProperty.TARGET_TEMPERATURE,
translation_key=ThinQProperty.TARGET_TEMPERATURE,
),
),
DeviceType.MICROWAVE_OVEN: (RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],),
DeviceType.OVEN: (
@@ -594,6 +590,17 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
}
ENUM_TEMPERATURE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = {
DeviceType.KIMCHI_REFRIGERATOR: (
SensorEntityDescription(
key=ThinQProperty.TARGET_TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
translation_key=ThinQProperty.TARGET_TEMPERATURE,
),
),
}
@dataclass(frozen=True, kw_only=True)
class ThinQEnergySensorEntityDescription(SensorEntityDescription):
"""Describes ThinQ energy sensor entity."""
@@ -641,7 +648,9 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up an entry for sensor platform."""
entities: list[ThinQSensorEntity | ThinQEnergySensorEntity] = []
entities: list[
ThinQSensorEntity | ThinQEnergySensorEntity | ThinQEnumTempSensorEntity
] = []
for coordinator in entry.runtime_data.coordinators.values():
if (
descriptions := DEVICE_TYPE_SENSOR_MAP.get(
@@ -663,6 +672,21 @@ async def async_setup_entry(
),
)
)
if (
descriptions := ENUM_TEMPERATURE_SENSOR_MAP.get(
coordinator.api.device.device_type
)
) is not None:
for description in descriptions:
entities.extend(
ThinQEnumTempSensorEntity(coordinator, description, property_id)
for property_id in coordinator.api.get_active_idx(
description.key,
ActiveMode.READ_ONLY,
)
)
for energy_description in ENERGY_USAGE_SENSORS:
entities.extend(
ThinQEnergySensorEntity(
@@ -862,3 +886,38 @@ class ThinQEnergySensorEntity(ThinQEntity, SensorEntity):
self.async_update,
next_update,
)
class ThinQEnumTempSensorEntity(ThinQEntity, SensorEntity):
"""Represent a thinq sensor platform."""
def __init__(
self,
coordinator: DeviceDataUpdateCoordinator,
entity_description: SensorEntityDescription,
property_id: str,
) -> None:
"""Initialize a sensor entity."""
super().__init__(coordinator, entity_description, property_id)
if self.data.options:
# some kimchi refrigerator's target temperature have data in the form of string with enum options.
# Set options to display the correct value in the UI.
self._attr_options = self.data.options
self._attr_device_class = SensorDeviceClass.ENUM
self._attr_native_unit_of_measurement = None
def _update_status(self) -> None:
"""Update status itself."""
super()._update_status()
self._attr_native_value = self.data.value
_LOGGER.debug(
"[%s:%s] update status: %s -> %s, options:%s, unit:%s",
self.coordinator.device_name,
self.property_id,
self.data.value,
self.native_value,
self.options,
self.native_unit_of_measurement,
)
@@ -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
+3 -3
View File
@@ -250,7 +250,7 @@ class MatterClimate(MatterEntity, ClimateEntity):
clusters.Thermostat.Attributes.OccupiedHeatingSetpoint
)
await self.write_attribute(
value=int(target_temperature * TEMPERATURE_SCALING_FACTOR),
value=round(target_temperature * TEMPERATURE_SCALING_FACTOR),
matter_attribute=matter_attribute,
)
return
@@ -259,7 +259,7 @@ class MatterClimate(MatterEntity, ClimateEntity):
# multi setpoint control - low setpoint (heat)
if self.target_temperature_low != target_temperature_low:
await self.write_attribute(
value=int(target_temperature_low * TEMPERATURE_SCALING_FACTOR),
value=round(target_temperature_low * TEMPERATURE_SCALING_FACTOR),
matter_attribute=clusters.Thermostat.Attributes.OccupiedHeatingSetpoint,
)
@@ -267,7 +267,7 @@ class MatterClimate(MatterEntity, ClimateEntity):
# multi setpoint control - high setpoint (cool)
if self.target_temperature_high != target_temperature_high:
await self.write_attribute(
value=int(target_temperature_high * TEMPERATURE_SCALING_FACTOR),
value=round(target_temperature_high * TEMPERATURE_SCALING_FACTOR),
matter_attribute=clusters.Thermostat.Attributes.OccupiedCoolingSetpoint,
)
+1 -1
View File
@@ -9,7 +9,7 @@
"iot_class": "cloud_push",
"loggers": ["pymiele"],
"quality_scale": "platinum",
"requirements": ["pymiele==0.6.1"],
"requirements": ["pymiele==0.6.2"],
"single_config_entry": true,
"zeroconf": ["_mieleathome._tcp.local."]
}
+3 -2
View File
@@ -3836,7 +3836,7 @@ def data_schema_from_fields(
if not data_schema_element:
# Do not show empty sections
continue
# Collapse if values are changed or required fields need to be set
# Collapse if no values are changed and no required fields need to be set
collapsed = (
not any(
(default := data_schema_fields[str(option)].default) is vol.UNDEFINED
@@ -4540,7 +4540,8 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
self, data_schema: vol.Schema
) -> dict[str, Any]:
"""Get suggestions from device data based on the data schema."""
device_data = self._subentry_data["device"]
device_data = deepcopy(self._subentry_data["device"])
device_data.update(device_data.get("mqtt_settings", {}))
return {
field_key: self.get_suggested_values_from_device_data(value.schema)
if isinstance(value, section)
@@ -344,9 +344,11 @@ def _merge_common_device_options(
CONF_AVAILABILITY_TEMPLATE,
CONF_AVAILABILITY_TOPIC,
CONF_COMMAND_TOPIC,
CONF_ENCODING,
CONF_PAYLOAD_AVAILABLE,
CONF_PAYLOAD_NOT_AVAILABLE,
CONF_STATE_TOPIC,
CONF_QOS
Common options in the body of the device based config are inherited into
the component. Unless the option is explicitly specified at component level,
in that case the option at component level will override the common option.
+2
View File
@@ -67,9 +67,11 @@ SHARED_OPTIONS = [
CONF_AVAILABILITY_TEMPLATE,
CONF_AVAILABILITY_TOPIC,
CONF_COMMAND_TOPIC,
CONF_ENCODING,
CONF_PAYLOAD_AVAILABLE,
CONF_PAYLOAD_NOT_AVAILABLE,
CONF_STATE_TOPIC,
CONF_QOS,
]
+4
View File
@@ -251,6 +251,10 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
supported_feature_strings: list[str] = config[CONF_SUPPORTED_FEATURES]
self._attr_supported_features = _strings_to_services(
supported_feature_strings, STRING_TO_SERVICE
) | (
self.supported_features & VacuumEntityFeature.CLEAN_AREA
if CONF_CLEAN_SEGMENTS_COMMAND_TOPIC in config
else 0
)
self._clean_segments_command_topic = config.get(
CONF_CLEAN_SEGMENTS_COMMAND_TOPIC
@@ -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:
+159 -6
View File
@@ -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,
+15 -2
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import cast
from pyoverkiz.enums import OverkizAttribute, OverkizState
from pyoverkiz.enums import APIType, OverkizAttribute, OverkizCommandParam, OverkizState
from pyoverkiz.models import Device
from homeassistant.helpers.device_registry import DeviceInfo
@@ -46,7 +46,20 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self.device.available and super().available
if self.device.available:
return super().available
# Workaround: local API may incorrectly report available=False (Somfy-TaHoma-Developer-Mode#217)
if self.coordinator.client.api_type != APIType.LOCAL:
return False
if status_state := self.device.states.get(OverkizState.CORE_STATUS):
return (
status_state.value == OverkizCommandParam.AVAILABLE
and super().available
)
return False
@property
def is_sub_device(self) -> bool:
@@ -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."""
+3 -1
View File
@@ -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:
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": ["aiousbwatcher==1.1.2", "serialx==1.7.1"]
"requirements": ["aiousbwatcher==1.1.2", "serialx==1.7.3"]
}
+2 -2
View File
@@ -25,7 +25,7 @@ def usb_device_from_port(port: SerialPortInfo) -> USBDevice:
pid=f"{hex(port.pid)[2:]:0>4}".upper(),
serial_number=port.serial_number,
manufacturer=port.manufacturer,
description=port.product,
description=port.description,
bcd_device=port.bcd_device,
interface_description=port.interface_description,
interface_num=port.interface_num,
@@ -38,7 +38,7 @@ def serial_device_from_port(port: SerialPortInfo) -> SerialDevice:
device=port.device,
serial_number=port.serial_number,
manufacturer=port.manufacturer,
description=port.product,
description=port.description,
interface_description=port.interface_description,
interface_num=port.interface_num,
)
@@ -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]
+1 -1
View File
@@ -28,7 +28,7 @@ def async_create_client(
options=ClientOptions(
verify_ssl=verify_ssl,
session=async_get_clientsession(hass),
timeout=ClientTimeout(total=10),
timeout=ClientTimeout(total=30),
),
)
+13 -1
View File
@@ -183,21 +183,33 @@
"selector": {
"category": {
"options": {
"albanian": "Albanian",
"armed_forces": "Armed forces",
"armenian": "Armenian",
"bank": "Bank",
"bosnian": "Bosnian",
"catholic": "Catholic",
"chinese": "Chinese",
"christian": "Christian",
"de_facto": "De facto",
"government": "Government",
"half_day": "Half day",
"hebrew": "Hebrew",
"hindu": "Hindu",
"islamic": "Islamic",
"optional": "Optional",
"orthodox": "Orthodox",
"protestant": "Protestant",
"public": "Public",
"roma": "Roma",
"sabian": "Sabian",
"school": "School",
"serbian": "Serbian",
"turkish": "Turkish",
"unofficial": "Unofficial",
"workday": "Workday"
"vlach": "Vlach",
"workday": "Workday",
"yazidi": "Yazidi"
}
},
"days": {
+35 -2
View File
@@ -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
+8 -2
View File
@@ -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
+2
View File
@@ -64,6 +64,8 @@ DEVICE_PAIRING_STATUS = "pairing_status"
DOMAIN = "zha"
LEGACY_ZEROCONF_PORT = 6638
GROUP_ID = "group_id"
-25
View File
@@ -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, {}))
+1 -1
View File
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2026
MINOR_VERSION: Final = 5
PATCH_VERSION: Final = "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)
-10
View File
@@ -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",
+6 -1
View File
@@ -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"):
+6 -5
View File
@@ -195,8 +195,9 @@ class Debouncer[_R_co]:
@callback
def _schedule_timer(self) -> None:
"""Schedule a timer."""
if not self._shutdown_requested:
self._timer_task = self.hass.loop.call_later(
self.cooldown, self._on_debounce
)
"""Schedule a timer, cancelling any previously-scheduled handle."""
if self._shutdown_requested:
return
if self._timer_task is not None:
self._timer_task.cancel()
self._timer_task = self.hass.loop.call_later(self.cooldown, self._on_debounce)

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