Compare commits

...

82 Commits

Author SHA1 Message Date
Franck Nijhof 7d7738303a 2026.5.1 (#170146) 2026-05-08 22:07:51 +02:00
Franck Nijhof dd0cdc4fc4 Bump version to 2026.5.1 2026-05-08 18:54:08 +00:00
Mick Vleeshouwer 18ea40c46d Fix tilt support for UpDownVenetianBlind (rts:VenetianBlindRTSComponent) in Overkiz (#170047) 2026-05-08 18:53:57 +00:00
Mick Vleeshouwer a23131efc8 Fix is_closed state for DynamicGate covers in Overkiz (#170130) 2026-05-08 18:53:10 +00:00
bkobus-bbx 4940a0abae Bump blebox_uniapi to v2.5.3 (#170115) 2026-05-08 18:53:08 +00:00
Willem-Jan van Rootselaar 5f98d5ae52 Bump python-bsblan to 5.2.1 (#170100) 2026-05-08 18:53:06 +00:00
TheJulianJES ba18cded30 Bump ZHA to 1.3.1 (#170095) 2026-05-08 18:53:04 +00:00
TheJulianJES fb7504e9df Fix Z-Wave discovery crash with unknown node firmware version (#170090) 2026-05-08 18:53:02 +00:00
Mick Vleeshouwer 106f815a1e Fix sensors getting wrong unit from MeasuredValueType attribute in Overkiz (#170088) 2026-05-08 18:53:00 +00:00
Mick Vleeshouwer 167757762b Set is_closed state to None when a cover state returns "unknown" in Overkiz (#170081) 2026-05-08 18:52:58 +00:00
Robert Resch 3a902e1a16 Bump deebot-client to 18.3.0 (#170066) 2026-05-08 18:52:56 +00:00
Mick Vleeshouwer 85c11672d8 Bump pyOverkiz to 1.20.3 (#170060) 2026-05-08 18:52:54 +00:00
Mick Vleeshouwer 89649df20d Fix cover controls for UpDownBioclimaticPergola in Overkiz (#170058) 2026-05-08 18:52:52 +00:00
Mick Vleeshouwer 7b749b95ce Fix tilt controls for TiltOnlyVenetianBlind in Overkiz (#170055)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-08 18:52:50 +00:00
Mick Vleeshouwer cc140be85c Fix is_closed state for DynamicGarageDoor in Overkiz (#170052) 2026-05-08 18:52:47 +00:00
Robert Svensson e1ad765414 Fix websocket certificate verification Bump axis to v70 (#170038) 2026-05-08 18:48:55 +00:00
Michael 44b1fea745 Proper handling of malformed data during FRITZ!Box Tools setup (#170030) 2026-05-08 18:48:54 +00:00
Ronald van der Meer 5dd04363b2 Bump python-duco-client to 0.4.1 (#169991) 2026-05-08 18:48:51 +00:00
Ronald van der Meer 03aa979309 Bump python-duco-client to 0.4.0 (#169776) 2026-05-08 18:48:49 +00:00
Daniel Hjelseth Høyer 6fabbb354b Bump pyTibber to 0.37.5 (#169981)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-05-08 18:45:50 +00:00
Erik Montnemery f644448d0f Add support for options to todo triggers (#169947) 2026-05-08 18:45:48 +00:00
G Johansson 4e61581cd8 Bump holidays to 0.96 (#169939) 2026-05-08 18:45:47 +00:00
puddly 6f87d02b72 Bump serialx to 1.7.1 (#169928) 2026-05-08 18:45:45 +00:00
Joakim Plate 348f6149b4 Update gardena ble to 2.8.1 (#169914) 2026-05-08 18:45:43 +00:00
Stefan Agner a4227ef1bc Fix hassio auth IndexError on Supervisor Unix socket requests (#169911) 2026-05-08 18:45:41 +00:00
Jeef aac49a567f Fix IntelliFire setup recovery (#169739) 2026-05-08 18:45:39 +00:00
Rob Treacy 76b878b136 Fix WiZ Light config flow timeout by properly closing UDP connections (#168456) 2026-05-08 18:45:37 +00:00
th3spis 2d05931683 Added wfsens as a occupancy source in wiz (#166799)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-08 18:45:35 +00:00
Franck Nijhof b10582b0a9 2026.5.0 (#169484) 2026-05-06 17:22:09 +02:00
Franck Nijhof b193d951d7 Bump version to 2026.5.0 2026-05-06 15:01:09 +00:00
Franck Nijhof 4cd0d9dcec Bump version to 2026.5.0b4 2026-05-06 13:27:18 +00:00
Daniel Hjelseth Høyer 32f65b2e11 Bump pyTibber to 0.37.4 (#169907) 2026-05-06 13:27:09 +00:00
Erik Montnemery 8c79d1e44b Remove _get_tracked_value method from EntityConditionBase (#169906) 2026-05-06 13:27:07 +00:00
Erik Montnemery 8d53f7a520 Exclude incompatible humidifier entities from humidifier automations (#169905) 2026-05-06 13:27:05 +00:00
Erik Montnemery cc83ee88fb Exclude incompatible water_heater entities from water_heater automations (#169904) 2026-05-06 13:27:03 +00:00
Erik Montnemery 0c5b02eff3 Exclude incompatible climate entities from climate automations (#169903) 2026-05-06 13:27:02 +00:00
Erik Montnemery 9da9f8fd50 Unload scripts and conditions created by template entities (#169366) 2026-05-06 13:27:00 +00:00
Franck Nijhof d70ffcd3e9 Bump version to 2026.5.0b3 2026-05-06 11:16:10 +00:00
Erik Montnemery 3e26d0dfe3 Exclude incompatible entities from temperature automations (#169901) 2026-05-06 11:15:56 +00:00
Erik Montnemery eab9747b32 Exclude incompatible entities from humidity automations (#169898) 2026-05-06 11:15:54 +00:00
Erik Montnemery 9e955d8294 Add media_player volume condition (#169897) 2026-05-06 11:15:52 +00:00
Bram Kragten f08cd01ff8 Update frontend to 20260429.3 (#169893) 2026-05-06 11:10:49 +00:00
Erik Montnemery eabaf3b0fe Add media_player muted conditions (#169892) 2026-05-06 11:10:47 +00:00
Tom Matheussen 65ca790d15 Bump satel_integra to 1.3.1 (#169889) 2026-05-06 11:10:45 +00:00
Joost Lekkerkerker d177944f7a Fix Zinvolt select options (#169886) 2026-05-06 11:10:43 +00:00
Erik Montnemery 7f186f4430 Add media_player volume triggers (#169885) 2026-05-06 11:10:41 +00:00
Erik Montnemery 4f4f4642a7 Add method _should_include to EntityConditionBase (#169884)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-06 11:10:39 +00:00
Erik Montnemery 12e443cd31 Improve entity trigger tests (#169881) 2026-05-06 11:10:37 +00:00
Erik Montnemery 22a7daabe7 Add method _should_include to EntityTriggerBase (#169837) 2026-05-06 11:10:35 +00:00
Erik Montnemery c139e99abd Improve condition test helper docstrings (#169871) 2026-05-06 11:09:06 +00:00
Erik Montnemery 2bfdb96a3f Improve trigger test helper docstrings (#169869) 2026-05-06 11:09:04 +00:00
puddly 4b24ca924b Bump serialx to 1.7.0 (#169867) 2026-05-06 11:09:02 +00:00
Michael Hansen 1d3d714e4f Bump intents to 2026.5.5 (#169855) 2026-05-06 11:09:00 +00:00
Erik Montnemery ffae6eda8a Validate yaml matches implementation in automation options_supported tests (#169798) 2026-05-06 11:05:41 +00:00
Erik Montnemery 4dd996b728 Add trigger media_player.unmuted (#169797) 2026-05-06 11:05:40 +00:00
Erik Montnemery afad1e8dac Improve mobile_app device tracker tests (#169724) 2026-05-06 11:05:38 +00:00
Manu 8e41933251 Record notification from legacy notify action in Mobile App (#169749) 2026-05-06 11:00:21 +00:00
Erik Montnemery c581eaad53 Add trigger timer.time_remaining (#169763) 2026-05-06 10:58:59 +00:00
Ludovic BOUÉ 3050e79d06 Expose SET_SPEED for all fans via PercentSetting in Matter (#169696)
Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com>
2026-05-06 08:55:15 +00:00
Andres Ruiz 0e8ecd1065 Catch additional errors as potentially retryable errors during energy data updates (#169646) 2026-05-06 08:55:13 +00:00
Paulus Schoutsen 94732139f4 Bump version to 2026.5.0b2 2026-05-05 10:29:37 -04:00
Denis Shulyaka c5e08b2409 Return the requested format for OpenAI TTS (#169839)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-05 10:29:30 -04:00
Joost Lekkerkerker c12e1b5f4a Add Zunzunbee Zigbee brand (#169838) 2026-05-05 10:29:29 -04:00
Joost Lekkerkerker 6cfedb55e6 Add Sensereo matter brand (#169836) 2026-05-05 10:29:27 -04:00
Åke Strandberg af4cb9530b Add missing code for miele washing machine (#169795) 2026-05-05 10:29:26 -04:00
Matthias Alphart 58e97e7d5f Update xknxproject to 3.9.0 (#169775) 2026-05-05 10:29:25 -04:00
Daniel Hjelseth Høyer 2945b51617 Bump pyTibber to 0.37.3 (#169762) 2026-05-05 10:29:24 -04:00
Keilin Bickar 9d0e2df627 bump sense-energy to 0.14.1 (#169761) 2026-05-05 10:29:23 -04:00
Steve Syrell 643ae080db Bump Insteon-panel to 0.6.2 (#169757) 2026-05-05 10:29:22 -04:00
G Johansson a7eaa51179 Fix config flow validation in Nord Pool (#169751) 2026-05-05 10:29:21 -04:00
Petro31 e15852ff38 Fix uptime template sensor (#169743) 2026-05-05 10:29:20 -04:00
Diogo Gomes f6dec34136 Bump pytrydan to 1.0.0 (#169742) 2026-05-05 10:29:19 -04:00
Raj Laud 53905fbc49 Bump victron-ble-ha-parser to 0.7.0 (#169736)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 10:29:17 -04:00
Thomas D 8218ff0fe8 Add missing initialization charging power status option to Volvo (#169727) 2026-05-05 10:29:16 -04:00
kernelpanic85 663f7e3e6b Add Celsius and Fahrenheit to Smartthings UNITS mapping (#169686) 2026-05-05 10:29:15 -04:00
Nathan Spencer 4dfa2b8b88 Limit power status binary sensor to non-LR5 devices (#169659) 2026-05-05 10:29:14 -04:00
Nathan Spencer f828b165b1 Bump pylitterbot to 2025.4.0 (#169652) 2026-05-05 10:29:13 -04:00
shbatm c56c506648 Add precipitation device class to WeatherFlow Cloud accumulation sensors (#169638)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 10:29:12 -04:00
Artur Pragacz 8e5bf2a35f Fix async_unload teardown race in scripts (#169562) 2026-05-05 10:29:10 -04:00
Erik Montnemery 4d575e69a4 Improve template reload (#169480) 2026-05-05 10:29:09 -04:00
Christian Lackas 4f78bbccc0 Use all_devices in ViCare diagnostics for completeness (#169429) 2026-05-05 10:29:08 -04:00
Erik Montnemery 2d66ebe54a Add trigger media_player.muted (#156736) 2026-05-05 10:29:07 -04:00
149 changed files with 5462 additions and 908 deletions
Generated
+2 -2
View File
@@ -851,8 +851,8 @@ CLAUDE.md @home-assistant/core
/tests/components/input_select/ @home-assistant/core
/homeassistant/components/input_text/ @home-assistant/core
/tests/components/input_text/ @home-assistant/core
/homeassistant/components/insteon/ @teharris1
/tests/components/insteon/ @teharris1
/homeassistant/components/insteon/ @teharris1 @ssyrell
/tests/components/insteon/ @teharris1 @ssyrell
/homeassistant/components/integration/ @dgomes
/tests/components/integration/ @dgomes
/homeassistant/components/intelliclima/ @dvdinth
+5
View File
@@ -0,0 +1,5 @@
{
"domain": "sensereo",
"name": "Sensereo",
"iot_standards": ["matter"]
}
+5
View File
@@ -0,0 +1,5 @@
{
"domain": "zunzunbee",
"name": "Zunzunbee",
"iot_standards": ["zigbee"]
}
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["serialx==1.4.1"]
"requirements": ["serialx==1.7.1"]
}
@@ -901,12 +901,13 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
async def async_will_remove_from_hass(self) -> None:
"""Remove listeners when removing automation from Home Assistant."""
await super().async_will_remove_from_hass()
await self._async_disable()
if self.registry_entry and self.registry_entry.entity_id != self.entity_id:
# Entity ID change, do not unload the script or conditions as they will
# be reused.
await self._async_disable()
return
self.action_script.async_unload()
await self._async_disable(stop_actions=False)
await self.action_script.async_unload()
if self._condition is not None:
self._condition.async_unload()
+1 -1
View File
@@ -29,7 +29,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["axis"],
"requirements": ["axis==69"],
"requirements": ["axis==70"],
"ssdp": [
{
"manufacturer": "AXIS"
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["blebox_uniapi"],
"requirements": ["blebox-uniapi==2.5.2"],
"requirements": ["blebox-uniapi==2.5.3"],
"zeroconf": ["_bbxsrv._tcp.local."]
}
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["bsblan"],
"quality_scale": "silver",
"requirements": ["python-bsblan==5.2.0"],
"requirements": ["python-bsblan==5.2.1"],
"zeroconf": [
{
"name": "bsb-lan*",
+23 -5
View File
@@ -13,8 +13,8 @@ from homeassistant.helpers.condition import (
Condition,
ConditionConfig,
EntityConditionBase,
EntityNumericalConditionBase,
EntityNumericalConditionWithUnitBase,
make_entity_numerical_condition,
make_entity_state_condition,
)
from homeassistant.util.unit_conversion import TemperatureConverter
@@ -59,12 +59,33 @@ class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase):
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target temperature."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_TEMPERATURE) is not None
)
def _get_entity_unit(self, entity_state: State) -> str | None:
"""Get the temperature unit of a climate entity from its state."""
# Climate entities convert temperatures to the system unit via show_temp
return self._hass.config.units.temperature_unit
class ClimateTargetHumidityCondition(EntityNumericalConditionBase):
"""Condition for climate target humidity."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_valid_unit = "%"
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
)
CONDITIONS: dict[str, type[Condition]] = {
"is_hvac_mode": ClimateHVACModeCondition,
"is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF),
@@ -88,10 +109,7 @@ CONDITIONS: dict[str, type[Condition]] = {
"is_heating": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
),
"target_humidity": make_entity_numerical_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_humidity": ClimateTargetHumidityCondition,
"target_temperature": ClimateTargetTemperatureCondition,
}
+38 -10
View File
@@ -8,14 +8,15 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateChangedTriggerWithUnitBase,
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
EntityNumericalStateTriggerBase,
EntityNumericalStateTriggerWithUnitBase,
EntityTargetStateTriggerBase,
Trigger,
TriggerConfig,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
)
@@ -55,6 +56,13 @@ class _ClimateTargetTemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitB
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target temperature."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_TEMPERATURE) is not None
)
def _get_entity_unit(self, state: State) -> str | None:
"""Get the temperature unit of a climate entity from its state."""
# Climate entities convert temperatures to the system unit via show_temp
@@ -75,6 +83,32 @@ class ClimateTargetTemperatureCrossedThresholdTrigger(
"""Trigger for climate target temperature value crossing a threshold."""
class _ClimateTargetHumidityTriggerMixin(EntityNumericalStateTriggerBase):
"""Mixin for climate target humidity triggers."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_valid_unit = "%"
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
)
class ClimateTargetHumidityChangedTrigger(
_ClimateTargetHumidityTriggerMixin, EntityNumericalStateChangedTriggerBase
):
"""Trigger for climate target humidity value changes."""
class ClimateTargetHumidityCrossedThresholdTrigger(
_ClimateTargetHumidityTriggerMixin, EntityNumericalStateCrossedThresholdTriggerBase
):
"""Trigger for climate target humidity value crossing a threshold."""
TRIGGERS: dict[str, type[Trigger]] = {
"hvac_mode_changed": HVACModeChangedTrigger,
"started_cooling": make_entity_target_state_trigger(
@@ -83,14 +117,8 @@ TRIGGERS: dict[str, type[Trigger]] = {
"started_drying": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
),
"target_humidity_changed": make_entity_numerical_state_changed_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_humidity_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_humidity_changed": ClimateTargetHumidityChangedTrigger,
"target_humidity_crossed_threshold": ClimateTargetHumidityCrossedThresholdTrigger,
"target_temperature_changed": ClimateTargetTemperatureChangedTrigger,
"target_temperature_crossed_threshold": ClimateTargetTemperatureCrossedThresholdTrigger,
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.3.24"]
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.5.5"]
}
+1 -1
View File
@@ -13,7 +13,7 @@
"iot_class": "local_polling",
"loggers": ["duco"],
"quality_scale": "platinum",
"requirements": ["python-duco-client==0.3.10"],
"requirements": ["python-duco-client==0.4.1"],
"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][]].*",
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.11", "deebot-client==18.2.0"]
"requirements": ["py-sucks==0.9.11", "deebot-client==18.3.0"]
}
@@ -6,5 +6,5 @@
"iot_class": "local_push",
"loggers": ["sense_energy"],
"quality_scale": "internal",
"requirements": ["sense-energy==0.14.0"]
"requirements": ["sense-energy==0.14.1"]
}
@@ -10,6 +10,7 @@ from functools import partial
import logging
import re
from typing import Any, TypedDict, cast
from xml.etree.ElementTree import ParseError
from fritzconnection import FritzConnection
from fritzconnection.core.exceptions import FritzActionError
@@ -26,7 +27,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.dispatcher import async_dispatcher_send
@@ -228,7 +229,13 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
self.fritz_guest_wifi = FritzGuestWLAN(fc=self.connection)
self.fritz_status = FritzStatus(fc=self.connection)
self.fritz_call = FritzCall(fc=self.connection)
info = self.fritz_status.get_device_info()
try:
info = self.fritz_status.get_device_info()
except ParseError as ex:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="error_parse_device_info",
) from ex
_LOGGER.debug(
"gathered device info of %s %s",
@@ -185,6 +185,9 @@
"config_entry_not_found": {
"message": "Failed to perform action \"{service}\". Config entry for target not found"
},
"error_parse_device_info": {
"message": "Error parsing device info. Please check the system event log of your FRITZ!Box for malformed data and clear the event list."
},
"error_refresh_hosts_info": {
"message": "Error refreshing hosts info"
},
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260429.2"]
"requirements": ["home-assistant-frontend==20260429.3"]
}
@@ -15,5 +15,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"],
"requirements": ["gardena-bluetooth==2.4.0"]
"requirements": ["gardena-bluetooth==2.8.1"]
}
+13 -8
View File
@@ -12,6 +12,7 @@ import voluptuous as vol
from homeassistant.auth.models import User
from homeassistant.auth.providers import homeassistant as auth_ha
from homeassistant.components.http import KEY_HASS, KEY_HASS_USER, HomeAssistantView
from homeassistant.components.http.const import is_supervisor_unix_socket_request
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
@@ -41,14 +42,18 @@ class HassIOBaseAuth(HomeAssistantView):
def _check_access(self, request: web.Request) -> None:
"""Check if this call is from Supervisor."""
# Check caller IP
hassio_ip = os.environ["SUPERVISOR"].split(":")[0]
assert request.transport
if ip_address(request.transport.get_extra_info("peername")[0]) != ip_address(
hassio_ip
):
_LOGGER.error("Invalid auth request from %s", request.remote)
raise HTTPUnauthorized
# Requests over the Supervisor Unix socket are authenticated by the
# http auth middleware as the Supervisor user, so the caller-IP check
# below does not apply (and would crash, since `peername` is empty for
# Unix sockets). The user-ID check still runs to ensure only the
# Supervisor user can reach this endpoint.
if not is_supervisor_unix_socket_request(request):
hassio_ip = os.environ["SUPERVISOR"].split(":")[0]
assert request.transport
peername = request.transport.get_extra_info("peername")
if not peername or ip_address(peername[0]) != ip_address(hassio_ip):
_LOGGER.error("Invalid auth request from %s", request.remote)
raise HTTPUnauthorized
# Check caller token
if request[KEY_HASS_USER].id != self.user.id:
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.95", "babel==2.15.0"]
"requirements": ["holidays==0.96", "babel==2.15.0"]
}
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.const import ATTR_MODE, CONF_OPTIONS, PERCENTAGE, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, State
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
@@ -13,8 +13,8 @@ from homeassistant.helpers.condition import (
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL,
Condition,
ConditionConfig,
EntityNumericalConditionBase,
EntityStateConditionBase,
make_entity_numerical_condition,
make_entity_state_condition,
)
from homeassistant.helpers.entity import get_supported_features
@@ -46,6 +46,20 @@ def _supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> boo
return False
class IsTargetHumidityCondition(EntityNumericalConditionBase):
"""Condition for humidifier target humidity."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_valid_unit = PERCENTAGE
def _should_include(self, state: State) -> bool:
"""Skip humidifier entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
)
class IsModeCondition(EntityStateConditionBase):
"""Condition for humidifier mode."""
@@ -79,10 +93,7 @@ CONDITIONS: dict[str, type[Condition]] = {
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING
),
"is_mode": IsModeCondition,
"is_target_humidity": make_entity_numerical_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit=PERCENTAGE,
),
"is_target_humidity": IsTargetHumidityCondition,
}
+26 -3
View File
@@ -16,9 +16,9 @@ from homeassistant.components.weather import (
DOMAIN as WEATHER_DOMAIN,
)
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import Condition, make_entity_numerical_condition
from homeassistant.helpers.condition import Condition, EntityNumericalConditionBase
HUMIDITY_DOMAIN_SPECS = {
CLIMATE_DOMAIN: DomainSpec(
@@ -33,8 +33,31 @@ HUMIDITY_DOMAIN_SPECS = {
),
}
class HumidityCondition(EntityNumericalConditionBase):
"""Condition for humidity value across multiple domains."""
_domain_specs = HUMIDITY_DOMAIN_SPECS
_valid_unit = PERCENTAGE
def _should_include(self, state: State) -> bool:
"""Skip attribute-source entities that lack the humidity attribute.
Mirrors the humidity trigger: for climate / humidifier / weather
(attribute-based), the entity is filtered when the source attribute
is absent; sensor entities (state-value-based) fall through to the
base impl.
"""
if not super()._should_include(state):
return False
domain_spec = self._domain_specs[state.domain]
if domain_spec.value_source is None:
return True
return state.attributes.get(domain_spec.value_source) is not None
CONDITIONS: dict[str, type[Condition]] = {
"is_value": make_entity_numerical_condition(HUMIDITY_DOMAIN_SPECS, PERCENTAGE),
"is_value": HumidityCondition,
}
+43 -9
View File
@@ -15,12 +15,13 @@ from homeassistant.components.weather import (
ATTR_WEATHER_HUMIDITY,
DOMAIN as WEATHER_DOMAIN,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateTriggerBase,
Trigger,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
)
HUMIDITY_DOMAIN_SPECS: dict[str, DomainSpec] = {
@@ -38,13 +39,46 @@ HUMIDITY_DOMAIN_SPECS: dict[str, DomainSpec] = {
),
}
class _HumidityTriggerMixin(EntityNumericalStateTriggerBase):
"""Mixin for humidity triggers providing entity filtering."""
_domain_specs = HUMIDITY_DOMAIN_SPECS
_valid_unit = "%"
def _should_include(self, state: State) -> bool:
"""Skip attribute-source entities that lack the humidity attribute.
For domains whose tracked value comes from an attribute
(climate / humidifier / weather), require the attribute to be
present; otherwise the all/count check would treat an entity that
cannot report a humidity as a non-match and block behavior=last.
Sensor entities source their value from `state.state`, so they
fall through to the base impl.
"""
if not super()._should_include(state):
return False
domain_spec = self._domain_specs[state.domain]
if domain_spec.value_source is None:
return True
return state.attributes.get(domain_spec.value_source) is not None
class HumidityChangedTrigger(
_HumidityTriggerMixin, EntityNumericalStateChangedTriggerBase
):
"""Trigger for humidity value changes across multiple domains."""
class HumidityCrossedThresholdTrigger(
_HumidityTriggerMixin, EntityNumericalStateCrossedThresholdTriggerBase
):
"""Trigger for humidity value crossing a threshold across multiple domains."""
TRIGGERS: dict[str, type[Trigger]] = {
"changed": make_entity_numerical_state_changed_trigger(
HUMIDITY_DOMAIN_SPECS, valid_unit="%"
),
"crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
HUMIDITY_DOMAIN_SPECS, valid_unit="%"
),
"changed": HumidityChangedTrigger,
"crossed_threshold": HumidityCrossedThresholdTrigger,
}
@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.4.0"]
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.8.1"]
}
@@ -2,7 +2,7 @@
"domain": "insteon",
"name": "Insteon",
"after_dependencies": ["panel_custom"],
"codeowners": ["@teharris1"],
"codeowners": ["@teharris1", "@ssyrell"],
"config_flow": true,
"dependencies": ["http", "usb", "websocket_api"],
"dhcp": [
@@ -19,7 +19,7 @@
"loggers": ["pyinsteon", "pypubsub"],
"requirements": [
"pyinsteon==1.6.4",
"insteon-frontend-home-assistant==0.6.1"
"insteon-frontend-home-assistant==0.6.2"
],
"single_config_entry": true,
"usb": [
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import aiohttp
from intellifire4py import UnifiedFireplace
from intellifire4py.cloud_interface import IntelliFireCloudInterface
from intellifire4py.const import IntelliFireApiMode
@@ -155,6 +156,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: IntellifireConfigEntry)
raise ConfigEntryNotReady(
"Initialization of fireplace timed out after 10 minutes"
) from err
except (aiohttp.ClientConnectionError, ConnectionError) as err:
raise ConfigEntryNotReady(
"Error communicating with fireplace during initialization"
) from err
# Construct coordinator
data_update_coordinator = IntellifireDataUpdateCoordinator(hass, entry, fireplace)
@@ -15,6 +15,7 @@ import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_REAUTH,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
@@ -289,10 +290,8 @@ class IntelliFireOptionsFlowHandler(OptionsFlow):
errors: dict[str, str] = {}
if user_input is not None:
# Validate connectivity for requested modes if runtime data is available
coordinator = self.config_entry.runtime_data
if coordinator is not None:
fireplace = coordinator.fireplace
if self.config_entry.state is ConfigEntryState.LOADED:
fireplace = self.config_entry.runtime_data.fireplace
# Refresh connectivity status before validating
await fireplace.async_validate_connectivity()
@@ -79,10 +79,9 @@ async def async_reload(hass: HomeAssistant, service_call: ServiceCall) -> None:
existing_intents = hass.data[DOMAIN]
for intent_type, conf in existing_intents.items():
if isinstance(conf.get(CONF_ACTION), script.Script):
await conf[CONF_ACTION].async_stop()
conf[CONF_ACTION].async_unload()
intent.async_remove(hass, intent_type)
if isinstance(conf.get(CONF_ACTION), script.Script):
await conf[CONF_ACTION].async_unload()
if not new_config or DOMAIN not in new_config:
hass.data[DOMAIN] = {}
+1 -1
View File
@@ -12,7 +12,7 @@
"quality_scale": "platinum",
"requirements": [
"xknx==3.15.0",
"xknxproject==3.8.2",
"xknxproject==3.9.0",
"knx-frontend==2026.4.30.60856"
],
"single_config_entry": true
@@ -6,7 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import Generic
from pylitterbot import LitterRobot, LitterRobot4, Robot
from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot4, Robot
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -32,8 +32,11 @@ class RobotBinarySensorEntityDescription(
is_on_fn: Callable[[_WhiskerEntityT], bool]
BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, ...]] = {
LitterRobot: ( # type: ignore[type-abstract] # only used for isinstance check
BINARY_SENSOR_MAP: dict[
type[Robot] | tuple[type[Robot], ...],
tuple[RobotBinarySensorEntityDescription, ...],
] = {
LitterRobot: (
RobotBinarySensorEntityDescription[LitterRobot](
key="sleeping",
translation_key="sleeping",
@@ -58,14 +61,14 @@ BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, .
is_on_fn=lambda robot: not robot.is_hopper_removed,
),
),
Robot: ( # type: ignore[type-abstract] # only used for isinstance check
RobotBinarySensorEntityDescription[Robot](
(FeederRobot, LitterRobot3, LitterRobot4): (
RobotBinarySensorEntityDescription[FeederRobot | LitterRobot3 | LitterRobot4](
key="power_status",
translation_key="power_status",
device_class=BinarySensorDeviceClass.PLUG,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
is_on_fn=lambda robot: robot.power_status == "AC",
is_on_fn=lambda robot: robot.power_type == "AC",
),
),
}
@@ -16,5 +16,5 @@
"iot_class": "cloud_push",
"loggers": ["pylitterbot"],
"quality_scale": "platinum",
"requirements": ["pylitterbot==2025.3.2"]
"requirements": ["pylitterbot==2025.4.0"]
}
+8 -2
View File
@@ -253,8 +253,10 @@ class MatterFan(MatterEntity, FanEntity):
return
self._feature_map = feature_map
self._attr_supported_features = FanEntityFeature(0)
# Reset to default so a featuremap change from MultiSpeed -> non-MultiSpeed
# does not leave a stale speed_count / percentage_step.
self._attr_speed_count = 100
if feature_map & FanControlFeature.kMultiSpeed:
self._attr_supported_features |= FanEntityFeature.SET_SPEED
self._attr_speed_count = int(
self.get_matter_attribute_value(clusters.FanControl.Attributes.SpeedMax)
)
@@ -304,8 +306,12 @@ class MatterFan(MatterEntity, FanEntity):
if feature_map & FanControlFeature.kAirflowDirection:
self._attr_supported_features |= FanEntityFeature.DIRECTION
# PercentSetting is always a mandatory attribute of the FanControl cluster,
# so percentage-based speed control is always available.
self._attr_supported_features |= (
FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
@@ -1,11 +1,108 @@
"""Provides conditions for media players."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from datetime import datetime
from typing import Any
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
Condition,
EntityConditionBase,
EntityNumericalConditionBase,
make_entity_state_condition,
)
from . import ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED
from .const import DOMAIN, MediaPlayerState
class _MediaPlayerMutedConditionBase(EntityConditionBase):
"""Base class for media player is_muted/is_unmuted conditions."""
_domain_specs = {DOMAIN: DomainSpec()}
_target_muted: bool
def _state_valid_since(self, state: State) -> datetime:
"""Anchor `for:` durations to `last_updated` for the muted attribute.
Needed because the domain spec does not reflect that the condition
reads from the muted and volume attributes.
"""
return state.last_updated
def _has_volume_attributes(self, state: State) -> bool:
"""Check if the state has volume muted or volume level attributes."""
return (
state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is not None
or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None
)
def _should_include(self, state: State) -> bool:
"""Skip entities without volume attributes from the all/count check."""
return super()._should_include(state) and self._has_volume_attributes(state)
def _is_muted(self, state: State) -> bool:
"""Check if the media player is muted."""
return (
state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True
or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0
)
def is_valid_state(self, entity_state: State) -> bool:
"""Check if the entity state matches the targeted muted state."""
if not self._has_volume_attributes(entity_state):
return False
return self._is_muted(entity_state) is self._target_muted
class MediaPlayerIsMutedCondition(_MediaPlayerMutedConditionBase):
"""Condition that passes when the media player is muted."""
_target_muted = True
class MediaPlayerIsUnmutedCondition(_MediaPlayerMutedConditionBase):
"""Condition that passes when the media player is not muted."""
_target_muted = False
class MediaPlayerIsVolumeCondition(EntityNumericalConditionBase):
"""Condition for media player volume level with 0.0-1.0 to percentage conversion."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_MEDIA_VOLUME_LEVEL)}
_valid_unit = "%"
def _get_tracked_value(self, entity_state: State) -> Any:
"""Get the volume value converted from 0.0-1.0 to percentage (0-100)."""
raw = super()._get_tracked_value(entity_state)
if raw is None:
return None
try:
return float(raw) * 100.0
except TypeError, ValueError:
return None
def _should_include(self, state: State) -> bool:
"""Skip media players that do not expose a volume_level attribute."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None
)
CONDITIONS: dict[str, type[Condition]] = {
"is_muted": MediaPlayerIsMutedCondition,
"is_not_playing": make_entity_state_condition(
DOMAIN,
{
MediaPlayerState.BUFFERING,
MediaPlayerState.IDLE,
MediaPlayerState.OFF,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
},
),
"is_off": make_entity_state_condition(DOMAIN, MediaPlayerState.OFF),
"is_on": make_entity_state_condition(
DOMAIN,
@@ -17,18 +114,10 @@ CONDITIONS: dict[str, type[Condition]] = {
MediaPlayerState.PLAYING,
},
),
"is_not_playing": make_entity_state_condition(
DOMAIN,
{
MediaPlayerState.BUFFERING,
MediaPlayerState.IDLE,
MediaPlayerState.OFF,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
},
),
"is_paused": make_entity_state_condition(DOMAIN, MediaPlayerState.PAUSED),
"is_playing": make_entity_state_condition(DOMAIN, MediaPlayerState.PLAYING),
"is_unmuted": MediaPlayerIsUnmutedCondition,
"is_volume": MediaPlayerIsVolumeCondition,
}
@@ -1,22 +1,51 @@
.condition_common: &condition_common
target:
target: &condition_media_player_target
entity:
domain: media_player
fields:
behavior:
behavior: &condition_behavior
required: true
default: any
selector:
automation_behavior:
mode: condition
for:
for: &condition_for
required: true
default: 00:00:00
selector:
duration:
.volume_threshold_entity: &volume_threshold_entity
- domain: input_number
unit_of_measurement: "%"
- domain: number
unit_of_measurement: "%"
- domain: sensor
unit_of_measurement: "%"
.volume_threshold_number: &volume_threshold_number
min: 0
max: 100
mode: box
unit_of_measurement: "%"
is_muted: *condition_common
is_off: *condition_common
is_on: *condition_common
is_not_playing: *condition_common
is_paused: *condition_common
is_playing: *condition_common
is_unmuted: *condition_common
is_volume:
target: *condition_media_player_target
fields:
behavior: *condition_behavior
for: *condition_for
threshold:
required: true
selector:
numeric_threshold:
entity: *volume_threshold_entity
mode: is
number: *volume_threshold_number
@@ -1,5 +1,8 @@
{
"conditions": {
"is_muted": {
"condition": "mdi:volume-mute"
},
"is_not_playing": {
"condition": "mdi:stop"
},
@@ -14,6 +17,12 @@
},
"is_playing": {
"condition": "mdi:play"
},
"is_unmuted": {
"condition": "mdi:volume-high"
},
"is_volume": {
"condition": "mdi:volume-medium"
}
},
"entity_component": {
@@ -123,6 +132,9 @@
}
},
"triggers": {
"muted": {
"trigger": "mdi:volume-mute"
},
"paused_playing": {
"trigger": "mdi:pause"
},
@@ -137,6 +149,15 @@
},
"turned_on": {
"trigger": "mdi:power"
},
"unmuted": {
"trigger": "mdi:volume-high"
},
"volume_changed": {
"trigger": "mdi:volume-medium"
},
"volume_crossed_threshold": {
"trigger": "mdi:volume-medium"
}
}
}
@@ -2,10 +2,24 @@
"common": {
"condition_behavior_name": "Condition passes if",
"condition_for_name": "For at least",
"condition_threshold_name": "Threshold",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
"trigger_for_name": "For at least",
"trigger_threshold_name": "Threshold"
},
"conditions": {
"is_muted": {
"description": "Tests if one or more media players are muted.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::condition_for_name%]"
}
},
"name": "Media player is muted"
},
"is_not_playing": {
"description": "Tests if one or more media players are not playing.",
"fields": {
@@ -65,6 +79,33 @@
}
},
"name": "Media player is playing"
},
"is_unmuted": {
"description": "Tests if one or more media players are not muted.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::condition_for_name%]"
}
},
"name": "Media player is not muted"
},
"is_volume": {
"description": "Tests the volume of one or more media players.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::condition_for_name%]"
},
"threshold": {
"name": "[%key:component::media_player::common::condition_threshold_name%]"
}
},
"name": "Volume"
}
},
"device_automation": {
@@ -437,6 +478,18 @@
},
"title": "Media player",
"triggers": {
"muted": {
"description": "Triggers after one or more media players are muted.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::trigger_for_name%]"
}
},
"name": "Media player muted"
},
"paused_playing": {
"description": "Triggers after one or more media players pause playing.",
"fields": {
@@ -496,6 +549,42 @@
}
},
"name": "Media player turned on"
},
"unmuted": {
"description": "Triggers after one or more media players are unmuted.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::trigger_for_name%]"
}
},
"name": "Media player unmuted"
},
"volume_changed": {
"description": "Triggers after the volume of one or more media players changes.",
"fields": {
"threshold": {
"name": "[%key:component::media_player::common::trigger_threshold_name%]"
}
},
"name": "Media player volume changed"
},
"volume_crossed_threshold": {
"description": "Triggers after the volume of one or more media players crosses a threshold.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::media_player::common::trigger_for_name%]"
},
"threshold": {
"name": "[%key:component::media_player::common::trigger_threshold_name%]"
}
},
"name": "Media player volume crossed threshold"
}
}
}
@@ -1,12 +1,125 @@
"""Provides triggers for media players."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_transition_trigger
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateTriggerBase,
EntityTriggerBase,
Trigger,
make_entity_transition_trigger,
)
from . import MediaPlayerState
from . import ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, MediaPlayerState
from .const import DOMAIN
VOLUME_DOMAIN_SPECS = {
DOMAIN: DomainSpec(value_source=ATTR_MEDIA_VOLUME_LEVEL),
}
class _MediaPlayerMutedStateTriggerBase(EntityTriggerBase):
"""Base class for media player muted/unmuted triggers."""
_domain_specs = {DOMAIN: DomainSpec()}
_target_muted: bool
def _has_volume_attributes(self, state: State) -> bool:
"""Check if the state has volume muted or volume level attributes."""
return (
state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is not None
or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None
)
def _should_include(self, state: State) -> bool:
"""Check if an entity should participate in all/count checks.
Entities without volume attributes cannot be muted, so they are
excluded from the check - otherwise an "all" check would never
pass when there are media players without volume support.
"""
return super()._should_include(state) and self._has_volume_attributes(state)
def is_muted(self, state: State) -> bool:
"""Check if the media player is muted."""
return (
state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True
or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0
)
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
if not self._has_volume_attributes(to_state):
return False
return self.is_muted(from_state) != self.is_muted(to_state)
def is_valid_state(self, state: State) -> bool:
"""Check if the new state matches the expected state."""
if not self._has_volume_attributes(state):
return False
return self.is_muted(state) is self._target_muted
class MediaPlayerMutedTrigger(_MediaPlayerMutedStateTriggerBase):
"""Class for media player muted triggers."""
_target_muted = True
class MediaPlayerUnmutedTrigger(_MediaPlayerMutedStateTriggerBase):
"""Class for media player unmuted triggers."""
_target_muted = False
class VolumeTriggerMixin(EntityNumericalStateTriggerBase):
"""Mixin for volume triggers."""
_domain_specs = VOLUME_DOMAIN_SPECS
_valid_unit = "%"
def _get_tracked_value(self, state: State) -> float | None:
"""Get tracked volume as a percentage."""
value = super()._get_tracked_value(state)
if value is None:
return None
# Convert 0.0-1.0 range to percentage (0-100)
return value * 100.0
def _should_include(self, state: State) -> bool:
"""Check if an entity should participate in all/count checks.
Entities without a volume level cannot have their volume tracked,
so they are excluded - otherwise an "all" check would never pass
when there are media players without volume support.
"""
return (
super()._should_include(state)
and state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None
)
class VolumeChangedTrigger(EntityNumericalStateChangedTriggerBase, VolumeTriggerMixin):
"""Trigger for media player volume changes."""
class VolumeCrossedThresholdTrigger(
EntityNumericalStateCrossedThresholdTriggerBase, VolumeTriggerMixin
):
"""Trigger for media player volume crossing a threshold."""
TRIGGERS: dict[str, type[Trigger]] = {
"muted": MediaPlayerMutedTrigger,
"unmuted": MediaPlayerUnmutedTrigger,
"volume_changed": VolumeChangedTrigger,
"volume_crossed_threshold": VolumeCrossedThresholdTrigger,
"paused_playing": make_entity_transition_trigger(
DOMAIN,
from_states={
@@ -1,22 +1,62 @@
.trigger_common: &trigger_common
target:
target: &trigger_media_player_target
entity:
domain: media_player
fields:
behavior:
behavior: &trigger_behavior
required: true
default: any
selector:
automation_behavior:
mode: trigger
for:
for: &trigger_for
required: true
default: 00:00:00
selector:
duration:
.volume_threshold_entity: &volume_threshold_entity
- domain: input_number
unit_of_measurement: "%"
- domain: number
unit_of_measurement: "%"
- domain: sensor
unit_of_measurement: "%"
.volume_threshold_number: &volume_threshold_number
min: 0
max: 100
mode: box
unit_of_measurement: "%"
muted: *trigger_common
unmuted: *trigger_common
paused_playing: *trigger_common
started_playing: *trigger_common
stopped_playing: *trigger_common
turned_off: *trigger_common
turned_on: *trigger_common
volume_changed:
target: *trigger_media_player_target
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *volume_threshold_entity
mode: changed
number: *volume_threshold_number
volume_crossed_threshold:
target: *trigger_media_player_target
fields:
behavior: *trigger_behavior
for: *trigger_for
threshold:
required: true
selector:
numeric_threshold:
entity: *volume_threshold_entity
mode: crossed
number: *volume_threshold_number
+1
View File
@@ -479,6 +479,7 @@ class WashingMachineProgramId(MieleEnum, missing_to_none=True):
down_filled_items = 129
cottons_eco = 133
quick_power_wash = 146, 10031
quick_intense = 177
eco_40_60 = 190, 10007
bed_linen = 10047
easy_care = 10016
@@ -82,6 +82,7 @@ ATTR_SENSOR_UOM = "unit_of_measurement"
SIGNAL_SENSOR_UPDATE = f"{DOMAIN}_sensor_update"
SIGNAL_LOCATION_UPDATE = DOMAIN + "_location_update_{}"
SIGNAL_RECORD_NOTIFICATION = f"{DOMAIN}_record_notification"
ATTR_CAMERA_ENTITY_ID = "camera_entity_id"
+23 -1
View File
@@ -23,9 +23,13 @@ from homeassistant.components.notify import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
@@ -48,6 +52,7 @@ from .const import (
DATA_NOTIFY,
DATA_PUSH_CHANNEL,
DOMAIN,
SIGNAL_RECORD_NOTIFICATION,
)
from .helpers import device_info
from .push_notification import PushChannel
@@ -113,6 +118,21 @@ class MobileAppNotifyEntity(NotifyEntity):
translation_placeholders={"device_name": self._config_entry.title},
)
@callback
def _async_handle_notification(self, webhook_id: str) -> None:
"""Handle notifications triggered externally."""
if webhook_id == self._config_entry.data[ATTR_WEBHOOK_ID]:
self._async_record_notification()
async def async_added_to_hass(self) -> None:
"""Register callback."""
self.async_on_remove(
async_dispatcher_connect(
self.hass, SIGNAL_RECORD_NOTIFICATION, self._async_handle_notification
)
)
def push_registrations(hass: HomeAssistant) -> dict[str, str]:
"""Return a dictionary of push enabled registrations."""
@@ -197,6 +217,7 @@ class MobileAppNotificationService(BaseNotificationService):
data,
partial(self._async_send_remote_message_target, entry),
)
async_dispatcher_send(self.hass, SIGNAL_RECORD_NOTIFICATION, target)
continue
# Test if local push only.
@@ -205,6 +226,7 @@ class MobileAppNotificationService(BaseNotificationService):
continue
await self._async_send_remote_message_target(entry, data)
async_dispatcher_send(self.hass, SIGNAL_RECORD_NOTIFICATION, target)
if failed_targets:
raise HomeAssistantError(
@@ -56,6 +56,8 @@ DATA_SCHEMA = vol.Schema(
async def test_api(hass: HomeAssistant, user_input: dict[str, Any]) -> dict[str, str]:
"""Test fetch data from Nord Pool."""
if not user_input.get(CONF_AREAS):
return {CONF_AREAS: "no_areas"}
client = NordPoolClient(async_get_clientsession(hass))
try:
await client.async_get_delivery_period(
@@ -5,6 +5,7 @@
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_areas": "No area(s) selected",
"no_data": "API connected but the response was empty"
},
"step": {
@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Literal
from openai import OpenAIError
from propcache.api import cached_property
@@ -166,14 +166,15 @@ class OpenAITTSEntity(TextToSpeechEntity, OpenAIBaseLLMEntity):
client = self.entry.runtime_data
response_format = options[ATTR_PREFERRED_FORMAT]
if response_format not in self._supported_formats:
# common aliases
if response_format == "ogg":
response_format = "opus"
elif response_format == "raw":
response_format = "pcm"
else:
response_format = self.default_options[ATTR_PREFERRED_FORMAT]
if response_format in ("ogg", "oga"):
codec: Literal["mp3", "opus", "aac", "flac", "wav", "pcm"] = "opus"
elif response_format == "raw":
response_format = codec = "pcm"
elif response_format not in self._supported_formats:
response_format = self.default_options[ATTR_PREFERRED_FORMAT]
codec = response_format
else:
codec = response_format
try:
async with client.audio.speech.with_streaming_response.create(
@@ -182,7 +183,7 @@ class OpenAITTSEntity(TextToSpeechEntity, OpenAIBaseLLMEntity):
input=message,
instructions=str(options.get(CONF_PROMPT)),
speed=options.get(CONF_TTS_SPEED, RECOMMENDED_TTS_SPEED),
response_format=response_format,
response_format=codec,
) as response:
response_data = bytearray()
async for chunk in response.iter_bytes():
+66 -3
View File
@@ -101,14 +101,21 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
close_tilt_command=OverkizCommand.LOWER_CLOSE,
stop_tilt_command=OverkizCommand.STOP,
),
# Needs override to remove open/close commands
# Needs override to add support for very specific tilt commands
# uiClass is VenetianBlind
OverkizCoverDescription(
key=UIWidget.TILT_ONLY_VENETIAN_BLIND,
device_class=CoverDeviceClass.BLIND,
is_closed_state=OverkizState.CORE_OPEN_CLOSED,
# Position commands fully open/close the tilt
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
stop_command=OverkizCommand.STOP,
# Tilt commands move the tilt with a few degrees
open_tilt_command=OverkizCommand.TILT_POSITIVE,
open_tilt_command_args=(1, 0),
close_tilt_command=OverkizCommand.TILT_NEGATIVE,
close_tilt_command_args=(1, 0),
stop_tilt_command=OverkizCommand.STOP,
),
# Needs override to support very specific tilt commands (rts:ExteriorVenetianBlindRTSComponent)
@@ -125,6 +132,57 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
close_tilt_command_args=(15, 1), # position (1-127), speed (1-15)
stop_tilt_command=OverkizCommand.STOP,
),
# Needs override to support very specific tilt commands (rts:VenetianBlindRTSComponent)
# uiClass is VenetianBlind
OverkizCoverDescription(
key=UIWidget.UP_DOWN_VENETIAN_BLIND,
device_class=CoverDeviceClass.BLIND,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
stop_command=OverkizCommand.STOP,
open_tilt_command=OverkizCommand.TILT_POSITIVE,
open_tilt_command_args=(15, 1), # position (1-127), speed (1-15)
close_tilt_command=OverkizCommand.TILT_NEGATIVE,
close_tilt_command_args=(15, 1), # position (1-127), speed (1-15)
stop_tilt_command=OverkizCommand.STOP,
),
# Needs override since PositionableGarageDoor reports
# core:OpenClosedUnknownState instead of core:OpenClosedState
# uiClass is GarageDoor
OverkizCoverDescription(
key=UIWidget.POSITIONABLE_GARAGE_DOOR,
device_class=CoverDeviceClass.GARAGE,
current_position_state=OverkizState.CORE_CLOSURE,
set_position_command=OverkizCommand.SET_CLOSURE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
stop_command=OverkizCommand.STOP,
is_closed_state=OverkizState.CORE_OPEN_CLOSED_UNKNOWN,
),
# Needs override since PositionableGarageDoorWithPartialPosition reports
# core:OpenClosedPartialState instead of core:OpenClosedState
# uiClass is GarageDoor
OverkizCoverDescription(
key=UIWidget.POSITIONABLE_GARAGE_DOOR_WITH_PARTIAL_POSITION,
device_class=CoverDeviceClass.GARAGE,
current_position_state=OverkizState.CORE_CLOSURE,
set_position_command=OverkizCommand.SET_CLOSURE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
stop_command=OverkizCommand.STOP,
is_closed_state=OverkizState.CORE_OPEN_CLOSED_PARTIAL,
),
# Needs override since DiscreteGateWithPedestrianPosition reports
# core:OpenClosedPedestrianState instead of core:OpenClosedState
# uiClass is Gate
OverkizCoverDescription(
key=UIWidget.DISCRETE_GATE_WITH_PEDESTRIAN_POSITION,
device_class=CoverDeviceClass.GATE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN,
stop_command=OverkizCommand.STOP,
),
# Needs override to support this Generic device (rts:GenericRTSComponent)
# uiClass is Generic (not mapped to cover as this is a Generic device class)
OverkizCoverDescription(
@@ -201,7 +259,7 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
set_position_command=OverkizCommand.SET_CLOSURE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
is_closed_state=OverkizState.CORE_OPEN_CLOSED_UNKNOWN,
is_closed_state=OverkizState.CORE_OPEN_CLOSED,
stop_command=OverkizCommand.STOP,
),
OverkizCoverDescription(
@@ -209,12 +267,15 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
device_class=CoverDeviceClass.GATE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN,
is_closed_state=OverkizState.CORE_OPEN_CLOSED,
stop_command=OverkizCommand.STOP,
),
OverkizCoverDescription(
key=UIClass.PERGOLA,
device_class=CoverDeviceClass.AWNING,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
stop_command=OverkizCommand.STOP,
is_closed_state=OverkizState.CORE_SLATS_OPEN_CLOSED,
current_tilt_position_state=OverkizState.CORE_SLATE_ORIENTATION,
set_tilt_position_command=OverkizCommand.SET_ORIENTATION,
@@ -392,6 +453,8 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
"""Return if the cover is closed."""
if is_closed_state := self.entity_description.is_closed_state:
if state := self.device.states.get(is_closed_state):
if state.value == OverkizCommandParam.UNKNOWN:
return None
return state.value == OverkizCommandParam.CLOSED
if (position := self.current_cover_position) is not None:
@@ -22,6 +22,8 @@ COMMANDS_WITHOUT_DELAY = [
OverkizCommand.ON,
OverkizCommand.ON_WITH_TIMER,
OverkizCommand.TEST,
OverkizCommand.TILT_POSITIVE,
OverkizCommand.TILT_NEGATIVE,
]
@@ -13,7 +13,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
"requirements": ["pyoverkiz==1.20.0"],
"requirements": ["pyoverkiz==1.20.3"],
"zeroconf": [
{
"name": "gateway*",
+17 -1
View File
@@ -10,6 +10,7 @@ from pyoverkiz.enums import OverkizAttribute, OverkizState, UIWidget
from pyoverkiz.types import StateType as OverkizStateType
from homeassistant.components.sensor import (
DEVICE_CLASS_UNITS,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
@@ -606,10 +607,25 @@ class OverkizStateSensor(OverkizDescriptiveEntity, SensorEntity):
if (unit := attrs[OverkizAttribute.CORE_MEASURED_VALUE_TYPE]) and (
unit_value := unit.value_as_str
):
return OVERKIZ_UNIT_TO_HA.get(unit_value, default_unit)
ha_unit = OVERKIZ_UNIT_TO_HA.get(unit_value, default_unit)
if self._is_unit_valid_for_device_class(ha_unit):
return ha_unit
return default_unit
def _is_unit_valid_for_device_class(self, unit: str) -> bool:
"""Check if a unit is valid for this sensor's device class.
The device-level core:MeasuredValueType attribute describes the primary
sensor (e.g. luminance/temperature), but must not override the unit of
unrelated sensors on the same device (e.g. RSSI).
"""
if not (device_class := self.entity_description.device_class):
return True
if (valid_units := DEVICE_CLASS_UNITS.get(device_class)) is None:
return True
return unit in valid_units
class OverkizHomeKitSetupCodeSensor(OverkizEntity, SensorEntity):
"""Representation of an Overkiz HomeKit Setup Code."""
@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["satel_integra"],
"quality_scale": "bronze",
"requirements": ["satel-integra==1.3.0"]
"requirements": ["satel-integra==1.3.1"]
}
+6 -6
View File
@@ -768,14 +768,14 @@ class ScriptEntity(BaseScriptEntity, RestoreEntity):
async def async_will_remove_from_hass(self) -> None:
"""Stop script and remove service when it will be removed from HA."""
await self.script.async_stop()
if not self.registry_entry or self.registry_entry.entity_id == self.entity_id:
# Entity ID not changed, unload the script as it will not be reused.
self.script.async_unload()
# remove service
self.hass.services.async_remove(DOMAIN, self._attr_unique_id)
if self.registry_entry and self.registry_entry.entity_id != self.entity_id:
# Entity ID change, do not unload the script as it will be reused.
await self.script.async_stop()
return
await self.script.async_unload()
@websocket_api.websocket_command({"type": "script/config", "entity_id": str})
def websocket_config(
+1 -1
View File
@@ -21,5 +21,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["sense_energy"],
"requirements": ["sense-energy==0.14.0"]
"requirements": ["sense-energy==0.14.1"]
}
@@ -4,5 +4,5 @@
"codeowners": ["@fabaff"],
"documentation": "https://www.home-assistant.io/integrations/serial",
"iot_class": "local_polling",
"requirements": ["serialx==1.4.1"]
"requirements": ["serialx==1.7.1"]
}
@@ -1286,6 +1286,8 @@ CAPABILITY_TO_SENSORS: dict[
UNITS = {
"C": UnitOfTemperature.CELSIUS,
"F": UnitOfTemperature.FAHRENHEIT,
"Celsius": UnitOfTemperature.CELSIUS,
"Fahrenheit": UnitOfTemperature.FAHRENHEIT,
"ccf": UnitOfVolume.CENTUM_CUBIC_FEET,
"lux": LIGHT_LUX,
"mG": None,
@@ -48,6 +48,21 @@ class TemperatureCondition(EntityNumericalConditionWithUnitBase):
_domain_specs = TEMPERATURE_DOMAIN_SPECS
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip attribute-source entities that lack the temperature attribute.
Mirrors the temperature trigger: for climate / water_heater /
weather (attribute-based), the entity is filtered when the source
attribute is absent; sensor entities (state-value-based) fall
through to the base impl.
"""
if not super()._should_include(state):
return False
domain_spec = self._domain_specs[state.domain]
if domain_spec.value_source is None:
return True
return state.attributes.get(domain_spec.value_source) is not None
def _get_entity_unit(self, entity_state: State) -> str | None:
"""Get the temperature unit of an entity from its state."""
if entity_state.domain == SENSOR_DOMAIN:
@@ -48,6 +48,23 @@ class _TemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitBase):
_domain_specs = TEMPERATURE_DOMAIN_SPECS
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip attribute-source entities that lack the temperature attribute.
For domains whose tracked value comes from an attribute
(climate / water_heater / weather), require the attribute to be
present; otherwise the all/count check would treat an entity that
cannot report a temperature as a non-match and block behavior=last.
Sensor entities source their value from `state.state`, so they
fall through to the base impl.
"""
if not super()._should_include(state):
return False
domain_spec = self._domain_specs[state.domain]
if domain_spec.value_source is None:
return True
return state.attributes.get(domain_spec.value_source) is not None
def _get_entity_unit(self, state: State) -> str | None:
"""Get the temperature unit of an entity from its state."""
if state.domain == SENSOR_DOMAIN:
@@ -206,7 +206,7 @@ async def _process_config(hass: HomeAssistant, hass_config: ConfigType) -> None:
# Remove old ones
if coordinators:
for coordinator in coordinators:
coordinator.async_remove()
await coordinator.async_shutdown()
async def init_coordinator(
hass: HomeAssistant, conf_section: dict[str, Any]
@@ -1,8 +1,8 @@
"""Data update coordinator for trigger based template entities."""
from collections.abc import Callable, Mapping
from collections.abc import Callable
import logging
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, cast
from homeassistant.components.blueprint import CONF_USE_BLUEPRINT
from homeassistant.const import (
@@ -37,7 +37,7 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator):
hass, _LOGGER, config_entry=None, name="Trigger Update Coordinator"
)
self.config = config
self._cond_func: Callable[[Mapping[str, Any] | None], bool] | None = None
self._cond_func: condition.ConditionsChecker | None = None
self._unsub_start: Callable[[], None] | None = None
self._unsub_trigger: Callable[[], None] | None = None
self._script: Script | None = None
@@ -59,13 +59,19 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator):
"""Return unique ID for the entity."""
return self.config.get("unique_id")
@callback
def async_remove(self) -> None:
"""Signal that the entities need to remove themselves."""
async def async_shutdown(self) -> None:
"""Shut down the coordinator and clean up resources."""
await super().async_shutdown()
if self._unsub_start:
self._unsub_start()
self._unsub_start = None
if self._unsub_trigger:
self._unsub_trigger()
self._unsub_trigger = None
if self._script is not None:
await self._script.async_unload()
if self._cond_func is not None:
self._cond_func.async_unload()
async def async_setup(self, hass_config: ConfigType) -> None:
"""Set up the trigger and create entities."""
@@ -154,7 +160,7 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator):
def _check_condition(self, run_variables: TemplateVarsType) -> bool:
if not self._cond_func:
return True
condition_result = self._cond_func(run_variables)
condition_result = self._cond_func.async_check(variables=run_variables)
if condition_result is False:
_LOGGER.debug(
"Conditions not met, aborting template trigger update. Condition summary: %s",
@@ -168,6 +168,17 @@ class AbstractTemplateEntity(Entity):
domain,
)
async def async_will_remove_from_hass(self) -> None:
"""Clean up scripts when removing from Home Assistant."""
if not self.registry_entry or self.registry_entry.entity_id == self.entity_id:
# Entity ID not changed, unload scripts as they will not be reused.
for action_script in self._action_scripts.values():
await action_script.async_unload()
else:
# Entity ID changed, just stop scripts
for action_script in self._action_scripts.values():
await action_script.async_stop()
async def async_run_script(
self,
script: Script,
+2 -1
View File
@@ -193,7 +193,7 @@ def validate_datetime(
"""Converts the template result into a datetime or date."""
def convert(result: Any) -> datetime | date | None:
if resolve_as == SensorDeviceClass.TIMESTAMP:
if resolve_as in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME):
if isinstance(result, datetime):
return result
@@ -265,6 +265,7 @@ class AbstractTemplateSensor(AbstractTemplateEntity, RestoreSensor):
if result is None or self.device_class not in (
SensorDeviceClass.DATE,
SensorDeviceClass.TIMESTAMP,
SensorDeviceClass.UPTIME,
):
return result
@@ -8,5 +8,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["tibber"],
"requirements": ["pyTibber==0.37.2"]
"requirements": ["pyTibber==0.37.5"]
}
@@ -45,6 +45,9 @@
},
"started": {
"trigger": "mdi:timer-play"
},
"time_remaining": {
"trigger": "mdi:timer-alert-outline"
}
}
}
@@ -183,6 +183,15 @@
}
},
"name": "Timer started"
},
"time_remaining": {
"description": "Triggers when one or more timers reach a specific remaining time.",
"fields": {
"remaining": {
"name": "Time remaining"
}
},
"name": "Timer time remaining"
}
}
}
+155 -4
View File
@@ -2,11 +2,161 @@
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from datetime import datetime, timedelta
from typing import cast, override
import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID, CONF_OPTIONS
from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, State, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec, filter_by_domain_specs
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.helpers.target import (
TargetStateChangedData,
async_track_target_selector_state_change_event,
)
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
Trigger,
TriggerActionRunner,
TriggerConfig,
make_entity_target_state_trigger,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from . import ATTR_FINISHES_AT, ATTR_LAST_TRANSITION, DOMAIN, STATUS_ACTIVE
CONF_REMAINING = "remaining"
TIME_REMAINING_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_REMAINING): cv.positive_time_period_dict,
},
}
)
class TimeRemainingTrigger(Trigger):
"""Trigger when a timer has a specific amount of time remaining."""
_domain_specs: dict[str, DomainSpec] = {DOMAIN: DomainSpec()}
_schema = TIME_REMAINING_TRIGGER_SCHEMA
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, cls._schema(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the time remaining trigger."""
super().__init__(hass, config)
assert config.target is not None
self._target = config.target
options = config.options or {}
self._remaining: timedelta = options[CONF_REMAINING]
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities to timer domain."""
return filter_by_domain_specs(self._hass, self._domain_specs, entities)
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
scheduled: dict[str, CALLBACK_TYPE] = {}
@callback
def schedule_for_state(
entity_id: str,
to_state: State | None,
context: Context | None,
) -> None:
"""Schedule a fire for an active timer state, if applicable."""
if to_state is None:
return
if to_state.state != STATUS_ACTIVE:
return
finishes_at_str = to_state.attributes.get(ATTR_FINISHES_AT)
if finishes_at_str is None:
return
finishes_at = dt_util.parse_datetime(finishes_at_str)
if finishes_at is None:
return
fire_at = finishes_at - self._remaining
if fire_at <= dt_util.utcnow():
return
@callback
def fire_trigger(now: datetime) -> None:
"""Fire the trigger."""
scheduled.pop(entity_id, None)
run_action(
{
ATTR_ENTITY_ID: entity_id,
"to_state": to_state,
"remaining": self._remaining,
},
f"time remaining of {entity_id}",
context,
)
scheduled[entity_id] = async_track_point_in_utc_time(
self._hass, fire_trigger, fire_at
)
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and schedule trigger."""
event = target_state_change_data.state_change_event
entity_id: str = event.data["entity_id"]
to_state = event.data["new_state"]
# Cancel any previously scheduled callback for this entity
if entity_id in scheduled:
scheduled.pop(entity_id)()
schedule_for_state(entity_id, to_state, event.context)
@callback
def on_entities_update(added: set[str], removed: set[str]) -> None:
"""Handle changes to the tracked entity set."""
for entity_id in removed:
if entity_id in scheduled:
scheduled.pop(entity_id)()
for entity_id in added:
state = self._hass.states.get(entity_id)
schedule_for_state(entity_id, state, state.context if state else None)
unsub = async_track_target_selector_state_change_event(
self._hass,
self._target,
state_change_listener,
self.entity_filter,
on_entities_update,
)
@callback
def async_remove() -> None:
"""Remove state listeners."""
unsub()
for cancel in scheduled.values():
cancel()
scheduled.clear()
return async_remove
from . import ATTR_LAST_TRANSITION, DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"cancelled": make_entity_target_state_trigger(
@@ -24,6 +174,7 @@ TRIGGERS: dict[str, type[Trigger]] = {
"started": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_LAST_TRANSITION)}, "started"
),
"time_remaining": TimeRemainingTrigger,
}
@@ -20,3 +20,13 @@ finished: *trigger_common
paused: *trigger_common
restarted: *trigger_common
started: *trigger_common
time_remaining:
target:
entity:
domain: timer
fields:
remaining:
required: true
selector:
duration:
+2 -1
View File
@@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, cast, override
import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID, CONF_TARGET
from homeassistant.const import ATTR_ENTITY_ID, CONF_OPTIONS, CONF_TARGET
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
@@ -25,6 +25,7 @@ from .const import DATA_COMPONENT, DOMAIN, TodoItemStatus
ITEM_TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
vol.Required(CONF_OPTIONS, default={}): {},
}
)
+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.4.1"]
"requirements": ["aiousbwatcher==1.1.2", "serialx==1.7.1"]
}
+1 -1
View File
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/v2c",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["pytrydan==0.8.0"]
"requirements": ["pytrydan==1.0.0"]
}
@@ -23,7 +23,7 @@ async def async_get_config_entry_diagnostics(
"""Dump devices."""
return [
json.loads(device.dump_secure())
for device in entry.runtime_data.client.devices
for device in entry.runtime_data.client.all_devices
]
return {
@@ -15,5 +15,5 @@
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["victron-ble-ha-parser==0.6.3"]
"requirements": ["victron-ble-ha-parser==0.7.0"]
}
+1
View File
@@ -101,6 +101,7 @@ def _direction_value(field: VolvoCarsApiBaseModel) -> str | None:
_CHARGING_POWER_STATUS_OPTIONS = [
"fault",
"initialization",
"power_available_but_not_activated",
"providing_power",
"no_power_available",
@@ -281,6 +281,7 @@
"name": "Charging power status",
"state": {
"fault": "[%key:common::state::fault%]",
"initialization": "Initialization",
"no_power_available": "No power",
"power_available_but_not_activated": "Power available",
"providing_power": "Providing power"
@@ -129,11 +129,11 @@ class WolSwitch(SwitchEntity):
"""Clean up script when removing from Home Assistant."""
if self._off_script is None:
return
await self._off_script.async_stop()
if self.registry_entry and self.registry_entry.entity_id != self.entity_id:
# Entity ID change, do not unload the script as it will be reused.
await self._off_script.async_stop()
return
self._off_script.async_unload()
await self._off_script.async_unload()
def turn_off(self, **kwargs: Any) -> None:
"""Turn the device off if an off action is present."""
@@ -76,6 +76,13 @@ class WaterHeaterTargetTemperatureCondition(EntityNumericalConditionWithUnitBase
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip water heater entities that do not expose a target temperature."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_TEMPERATURE) is not None
)
def _get_entity_unit(self, entity_state: State) -> str | None:
"""Get the temperature unit of a water heater entity from its state."""
# Water heater entities convert temperatures to the system unit via show_temp
@@ -60,6 +60,13 @@ class _WaterHeaterTargetTemperatureTriggerMixin(
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip water heater entities that do not expose a target temperature."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_TEMPERATURE) is not None
)
def _get_entity_unit(self, state: State) -> str | None:
"""Get the temperature unit of a water heater entity from its state."""
# Water heater entities convert temperatures to the system unit via show_temp
@@ -13,6 +13,7 @@ from typing import TYPE_CHECKING
from waterfurnace.waterfurnace import (
WaterFurnace,
WFCredentialError,
WFError,
WFException,
WFGateway,
WFNoDataError,
@@ -174,7 +175,7 @@ class WaterFurnaceEnergyCoordinator(DataUpdateCoordinator[None]):
frequency="1H",
timezone_str=self.hass.config.time_zone,
)
except WFCredentialError:
except WFCredentialError, WFError:
try:
self.client.login()
except WFCredentialError as err:
@@ -192,6 +193,10 @@ class WaterFurnaceEnergyCoordinator(DataUpdateCoordinator[None]):
raise UpdateFailed(
"Authentication failed during energy data fetch"
) from err
except WFError as err:
raise UpdateFailed(
"Error fetching energy data after re-authentication"
) from err
return [
(reading.timestamp, reading.total_power)
for reading in data
@@ -23,6 +23,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import (
EntityCategory,
UnitOfLength,
UnitOfPrecipitationDepth,
UnitOfPressure,
UnitOfSpeed,
UnitOfTemperature,
@@ -237,42 +238,47 @@ WF_SENSORS: tuple[WeatherFlowCloudSensorEntityDescription, ...] = (
WeatherFlowCloudSensorEntityDescription(
key="precip_accum_last_1hr",
translation_key="precip_accum_last_1hr",
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.precip_accum_last_1hr,
native_unit_of_measurement=UnitOfLength.MILLIMETERS,
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
),
WeatherFlowCloudSensorEntityDescription(
key="precip_accum_local_day",
translation_key="precip_accum_local_day",
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.precip_accum_local_day,
native_unit_of_measurement=UnitOfLength.MILLIMETERS,
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
),
WeatherFlowCloudSensorEntityDescription(
key="precip_accum_local_day_final",
translation_key="precip_accum_local_day_final",
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.precip_accum_local_day_final,
native_unit_of_measurement=UnitOfLength.MILLIMETERS,
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
),
WeatherFlowCloudSensorEntityDescription(
key="precip_accum_local_yesterday",
translation_key="precip_accum_local_yesterday",
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.precip_accum_local_yesterday,
native_unit_of_measurement=UnitOfLength.MILLIMETERS,
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
),
WeatherFlowCloudSensorEntityDescription(
key="precip_accum_local_yesterday_final",
translation_key="precip_accum_local_yesterday_final",
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.precip_accum_local_yesterday_final,
native_unit_of_measurement=UnitOfLength.MILLIMETERS,
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
),
WeatherFlowCloudSensorEntityDescription(
key="precip_analysis_type_yesterday",
@@ -1077,7 +1077,7 @@ async def handle_execute_script(
)
return
finally:
script_obj.async_unload()
await script_obj.async_unload()
connection.send_result(
msg["id"],
{
+2 -2
View File
@@ -6,7 +6,6 @@ import logging
from typing import Any
from pywizlight import PilotParser, wizlight
from pywizlight.bulb import PIR_SOURCE
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant, callback
@@ -20,6 +19,7 @@ from .const import (
DISCOVER_SCAN_TIMEOUT,
DISCOVERY_INTERVAL,
DOMAIN,
OCCUPANCY_SOURCES,
SIGNAL_WIZ_PIR,
WIZ_CONNECT_EXCEPTIONS,
)
@@ -101,7 +101,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WizConfigEntry) -> bool:
"""Receive a push update."""
_LOGGER.debug("%s: Got push update: %s", bulb.mac, state.pilotResult)
coordinator.async_set_updated_data(coordinator.data)
if state.get_source() == PIR_SOURCE:
if state.get_source() in OCCUPANCY_SOURCES:
async_dispatcher_send(hass, SIGNAL_WIZ_PIR.format(bulb.mac))
await bulb.start_push(_async_push_update)
@@ -4,8 +4,6 @@ from __future__ import annotations
from collections.abc import Callable
from pywizlight.bulb import PIR_SOURCE
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
@@ -16,7 +14,7 @@ from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, SIGNAL_WIZ_PIR
from .const import DOMAIN, OCCUPANCY_SOURCES, SIGNAL_WIZ_PIR
from .coordinator import WizConfigEntry, WizData
from .entity import WizEntity
@@ -75,5 +73,5 @@ class WizOccupancyEntity(WizEntity, BinarySensorEntity):
@callback
def _async_update_attrs(self) -> None:
"""Handle updating _attr values."""
if self._device.state.get_source() == PIR_SOURCE:
if self._device.state.get_source() in OCCUPANCY_SOURCES:
self._attr_is_on = self._device.status
@@ -81,6 +81,8 @@ class WizConfigFlow(ConfigFlow, domain=DOMAIN):
exc_info=True,
)
raise AbortFlow("cannot_connect") from ex
finally:
await bulb.async_close()
self._name = name_from_bulb_type_and_mac(bulbtype, device.mac_address)
async def async_step_discovery_confirm(
@@ -118,6 +120,8 @@ class WizConfigFlow(ConfigFlow, domain=DOMAIN):
bulbtype = await bulb.get_bulbtype()
except WIZ_CONNECT_EXCEPTIONS:
return self.async_abort(reason="cannot_connect")
finally:
await bulb.async_close()
return self.async_create_entry(
title=name_from_bulb_type_and_mac(bulbtype, device.mac_address),
@@ -182,6 +186,8 @@ class WizConfigFlow(ConfigFlow, domain=DOMAIN):
title=name,
data=user_input,
)
finally:
await bulb.async_close()
return self.async_show_form(
step_id="user",
+2
View File
@@ -2,6 +2,7 @@
from datetime import timedelta
from pywizlight.bulb import PIR_SOURCE
from pywizlight.exceptions import (
WizLightConnectionError,
WizLightNotKnownBulb,
@@ -24,3 +25,4 @@ WIZ_EXCEPTIONS = (
WIZ_CONNECT_EXCEPTIONS = (WizLightNotKnownBulb, *WIZ_EXCEPTIONS)
SIGNAL_WIZ_PIR = "wiz_pir_{}"
OCCUPANCY_SOURCES = frozenset({PIR_SOURCE, "wfsens"})
@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["holidays"],
"quality_scale": "internal",
"requirements": ["holidays==0.95"]
"requirements": ["holidays==0.96"]
}
+1 -1
View File
@@ -23,7 +23,7 @@
"universal_silabs_flasher",
"serialx"
],
"requirements": ["zha==1.3.0"],
"requirements": ["zha==1.3.1"],
"usb": [
{
"description": "*2652*",
+2 -3
View File
@@ -13,9 +13,8 @@ MODE_MAP = {
SmartMode.DYNAMIC: "dynamic",
SmartMode.SELF_USE: "self_use",
SmartMode.PERFORMANCE: "fast_discharge",
SmartMode.CHARGED: "charged",
SmartMode.DEFAULT: "idle",
SmartMode.FEED: "fast_charge",
SmartMode.CHARGED: "fast_charge",
SmartMode.FEED: "connected_solar_panels",
}
HA_TO_MODE = {v: k for k, v in MODE_MAP.items()}
@@ -66,11 +66,10 @@
"battery_mode": {
"name": "Mode",
"state": {
"charged": "Charged",
"connected_solar_panels": "Connected solar panels",
"dynamic": "Dynamic",
"fast_charge": "Fast charge",
"fast_discharge": "Fast discharge",
"idle": "[%key:common::state::idle%]",
"self_use": "Self-use"
}
}
+11 -11
View File
@@ -1284,19 +1284,19 @@ def async_discover_single_value(
continue
# check firmware_version_range
if schema.firmware_version_range is not None and (
(
if schema.firmware_version_range is not None:
# skip schema if device firmware version is unknown
if value.node.firmware_version is None:
continue
node_firmware = AwesomeVersion(value.node.firmware_version)
if (
schema.firmware_version_range.min is not None
and schema.firmware_version_range.min_ver
> AwesomeVersion(value.node.firmware_version)
)
or (
and schema.firmware_version_range.min_ver > node_firmware
) or (
schema.firmware_version_range.max is not None
and schema.firmware_version_range.max_ver
< AwesomeVersion(value.node.firmware_version)
)
):
continue
and schema.firmware_version_range.max_ver < node_firmware
):
continue
# check device_class_generic
# If the value has an endpoint but it is missing on the node
+1 -1
View File
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2026
MINOR_VERSION: Final = 5
PATCH_VERSION: Final = "0b1"
PATCH_VERSION: Final = "1"
__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)
+12
View File
@@ -6134,6 +6134,12 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
"sensereo": {
"name": "Sensereo",
"iot_standards": [
"matter"
]
},
"sensibo": {
"name": "Sensibo",
"integration_type": "hub",
@@ -8230,6 +8236,12 @@
"zwave"
]
},
"zunzunbee": {
"name": "Zunzunbee",
"iot_standards": [
"zigbee"
]
},
"zwave_js": {
"name": "Z-Wave",
"integration_type": "hub",
+35 -24
View File
@@ -439,6 +439,9 @@ class EntityConditionBase(Condition):
"""Base class for entity conditions."""
_domain_specs: Mapping[str, DomainSpec]
_excluded_states: Final[frozenset[str]] = frozenset(
{STATE_UNAVAILABLE, STATE_UNKNOWN}
)
_schema: vol.Schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL
# When True, indirect target expansion (via device/area/floor) skips
# entities with an entity_category.
@@ -486,34 +489,32 @@ class EntityConditionBase(Condition):
"""
return True
def _state_valid_since(self, _state: State) -> datetime:
"""Return the datetime that anchors `for:` durations for `state`.
Override in subclasses whose `is_valid_state` reads
attributes directly without going through `value_source`.
"""
if self._domain_specs[_state.domain].value_source is None:
return _state.last_changed
return _state.last_updated
def _update_valid_since(self, entity_id: str, _state: State | None) -> None:
"""Update _valid_since tracking for an entity based on its current state.
If the entity is in a valid state and not already tracked, records when
the condition became true. If the entity is not in a valid state, removes
it from tracking.
For state-based conditions (value_source is None), last_changed
accurately reflects when the state changed to the current value.
For attribute-based conditions, last_changed only tracks main state
changes, so we use last_updated which is bumped on any update
(state or attributes). This is conservative the tracked attribute
may have held its value longer but it's the best we can do
to avoid false positives.
If the entity is in a valid state and not already tracked, records
when the condition became true (via `_state_valid_since`). If the
entity is not in a valid state, removes it from tracking.
"""
if (
_state is not None
and _state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and self._should_include(_state)
and self.is_valid_state(_state)
):
# Only record the time if not already tracked, to avoid
# resetting the duration on unrelated state/attribute updates.
if entity_id not in self._valid_since:
domain_spec = self._domain_specs[_state.domain]
if domain_spec.value_source is None:
self._valid_since[entity_id] = _state.last_changed
else:
self._valid_since[entity_id] = _state.last_updated
self._valid_since[entity_id] = self._state_valid_since(_state)
else:
self._valid_since.pop(entity_id, None)
@@ -561,12 +562,15 @@ class EntityConditionBase(Condition):
cb()
self._on_unload.clear()
def _get_tracked_value(self, entity_state: State) -> Any:
"""Get the tracked value from a state based on the DomainSpec."""
domain_spec = self._domain_specs[entity_state.domain]
if domain_spec.value_source is None:
return entity_state.state
return entity_state.attributes.get(domain_spec.value_source)
def _should_include(self, _state: State) -> bool:
"""Check if an entity should participate in any/all checks.
The default implementation excludes only entities whose state.state
is in `_excluded_states` (unavailable / unknown). Subclasses can
override to also exclude entities that lack the optional capability
the condition relies on.
"""
return _state.state not in self._excluded_states
@abc.abstractmethod
def is_valid_state(self, entity_state: State) -> bool:
@@ -624,7 +628,7 @@ class EntityConditionBase(Condition):
_state
for entity_id in filtered_entity_ids
if (_state := self._hass.states.get(entity_id))
and _state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and self._should_include(_state)
]
return self._matcher(entity_states)
@@ -643,6 +647,13 @@ class EntityStateConditionBase(EntityConditionBase):
spec.value_source is not None for spec in self._domain_specs.values()
)
def _get_tracked_value(self, entity_state: State) -> Any:
"""Get the tracked value from a state based on the DomainSpec."""
domain_spec = self._domain_specs[entity_state.domain]
if domain_spec.value_source is None:
return entity_state.state
return entity_state.attributes.get(domain_spec.value_source)
def is_valid_state(self, entity_state: State) -> bool:
"""Check if the state matches the expected state(s)."""
return self._get_tracked_value(entity_state) in self._states
+28 -15
View File
@@ -1521,7 +1521,7 @@ class Script:
if self._unloaded:
return
try:
self.async_unload()
self._async_unload()
except Exception:
_LOGGER.exception("Error while unloading script")
@@ -1789,11 +1789,16 @@ class Script:
started_action: Callable[..., Any] | None = None,
) -> ScriptRunResult | None:
"""Run script."""
# Prevent running an unloaded script
if self._unloaded:
raise RuntimeError(
f"Cannot run script '{self.name}' after it has been unloaded"
)
if DATA_NEW_SCRIPT_RUNS_NOT_ALLOWED in self._hass.data:
self._log("Home Assistant is shutting down, starting script blocked")
return None
# The fences above rely on there being no await between these checks
# and the _runs.append below, so that setting either flag is
# sufficient to block new runs from being added.
if context is None:
self._log(
@@ -1801,11 +1806,6 @@ class Script:
)
context = Context()
# Prevent spawning new script runs when Home Assistant is shutting down
if DATA_NEW_SCRIPT_RUNS_NOT_ALLOWED in self._hass.data:
self._log("Home Assistant is shutting down, starting script blocked")
return None
# Prevent spawning new script runs if not allowed by script mode
if self.is_running:
if self.script_mode == SCRIPT_MODE_SINGLE:
@@ -1915,7 +1915,20 @@ class Script:
return
await asyncio.shield(create_eager_task(self._async_stop(aws, update_state)))
def async_unload(self) -> None:
async def async_unload(self) -> None:
"""Unload the script, stopping any in-flight runs first.
Blocks new runs immediately, stops any in-flight runs, then cleans
up all resources.
"""
if self._unloaded:
return
# Set the flag before stopping so async_run rejects new runs.
self._unloaded = True
await self.async_stop()
self._async_unload()
def _async_unload(self) -> None:
"""Unload the script, cleaning up all resources.
Unloads cached conditions, and recursively unloads sub-scripts.
@@ -1937,31 +1950,31 @@ class Script:
self._condition_cache.clear()
for sub_script in self._repeat_script.values():
sub_script.async_unload()
sub_script._async_unload() # noqa: SLF001
self._repeat_script.clear()
# Conditions in _choose_data and _if_data are the same objects as in
# _condition_cache, so they're already unloaded above. Only unload scripts.
for choose_data in self._choose_data.values():
for _conditions, sub_script in choose_data["choices"]:
sub_script.async_unload()
sub_script._async_unload() # noqa: SLF001
if choose_data["default"] is not None:
choose_data["default"].async_unload()
choose_data["default"]._async_unload() # noqa: SLF001
self._choose_data.clear()
for if_data in self._if_data.values():
if_data["if_then"].async_unload()
if_data["if_then"]._async_unload() # noqa: SLF001
if if_data["if_else"] is not None:
if_data["if_else"].async_unload()
if_data["if_else"]._async_unload() # noqa: SLF001
self._if_data.clear()
for scripts in self._parallel_scripts.values():
for sub_script in scripts:
sub_script.async_unload()
sub_script._async_unload() # noqa: SLF001
self._parallel_scripts.clear()
for sub_script in self._sequence_scripts.values():
sub_script.async_unload()
sub_script._async_unload() # noqa: SLF001
self._sequence_scripts.clear()
async def _async_get_condition(self, config: ConfigType) -> ConditionChecker:
+46 -26
View File
@@ -399,23 +399,36 @@ class EntityTriggerBase(Trigger):
def is_valid_state(self, state: State) -> bool:
"""Check if the new state matches the expected state(s)."""
def check_all_match(self, entity_ids: set[str]) -> bool:
"""Check if all entity states match."""
return all(
self.is_valid_state(state)
for entity_id in entity_ids
if (state := self._hass.states.get(entity_id)) is not None
and state.state not in self._excluded_states
)
def _should_include(self, state: State) -> bool:
"""Check if an entity should participate in all/count checks.
def count_matches(self, entity_ids: set[str]) -> int:
"""Count the number of entity states that match."""
return sum(
self.is_valid_state(state)
for entity_id in entity_ids
if (state := self._hass.states.get(entity_id)) is not None
and state.state not in self._excluded_states
)
The default implementation excludes only entities whose state.state
is in `_excluded_states` (unavailable / unknown). Subclasses can
override to also exclude entities that lack the optional capability
the trigger relies on (e.g. a missing volume_level attribute).
"""
return state.state not in self._excluded_states
def count_matches(self, entity_ids: set[str]) -> tuple[int, int]:
"""Return (matches, included) for the entity set.
`matches` is the number of entities that pass `_should_include` AND
`is_valid_state`. `included` is the number that pass
`_should_include` (i.e. are visible to the all/count check at all).
Callers can use the pair to distinguish vacuous truth
(`included == 0`) from a genuine all-match
(`matches == included > 0`).
"""
matches = 0
included = 0
for entity_id in entity_ids:
state = self._hass.states.get(entity_id)
if state is None or not self._should_include(state):
continue
included += 1
if self.is_valid_state(state):
matches += 1
return matches, included
@override
async def async_attach_runner(
@@ -447,14 +460,20 @@ class EntityTriggerBase(Trigger):
For behavior first/last, checks the combined state.
"""
if behavior == BEHAVIOR_LAST:
return self.check_all_match(
matches, included = self.count_matches(
target_state_change_data.targeted_entity_ids
)
# Require at least one included entity to avoid keeping
# the timer alive when every targeted entity has been
# filtered out since it started — a vacuous all-match
# (`included == 0`) would otherwise let the action fire
# after `for:` even though no entity still matches.
return included > 0 and matches == included
if behavior == BEHAVIOR_FIRST:
return (
self.count_matches(target_state_change_data.targeted_entity_ids)
>= 1
matches, _included = self.count_matches(
target_state_change_data.targeted_entity_ids
)
return matches >= 1
# Behavior any: check the individual entity's state
if not to_state:
return False
@@ -472,18 +491,19 @@ class EntityTriggerBase(Trigger):
return
if behavior == BEHAVIOR_LAST:
if not self.check_all_match(
matches, included = self.count_matches(
target_state_change_data.targeted_entity_ids
):
)
if matches != included:
return
elif behavior == BEHAVIOR_FIRST:
# Note: It's enough to test for exactly 1 match here because if there
# were previously 2 matches the transition would not be valid and we
# would have returned already.
if (
self.count_matches(target_state_change_data.targeted_entity_ids)
!= 1
):
matches, _ = self.count_matches(
target_state_change_data.targeted_entity_ids
)
if matches != 1:
return
@callback
+3 -3
View File
@@ -39,8 +39,8 @@ habluetooth==6.1.0
hass-nabucasa==2.2.0
hassil==3.5.0
home-assistant-bluetooth==2.0.0
home-assistant-frontend==20260429.2
home-assistant-intents==2026.3.24
home-assistant-frontend==20260429.3
home-assistant-intents==2026.5.5
httpx==0.28.1
ifaddr==0.2.0
Jinja2==3.1.6
@@ -63,7 +63,7 @@ PyTurboJPEG==1.8.3
PyYAML==6.0.3
requests==2.33.1
securetar==2026.4.1
serialx==1.4.1
serialx==1.7.1
SQLAlchemy==2.0.49
standard-aifc==3.13.0
standard-telnetlib==3.13.0
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2026.5.0b1"
version = "2026.5.1"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."
+1 -1
View File
@@ -27,7 +27,7 @@ ha-ffmpeg==3.2.2
hass-nabucasa==2.2.0
hassil==3.5.0
home-assistant-bluetooth==2.0.0
home-assistant-intents==2026.3.24
home-assistant-intents==2026.5.5
httpx==0.28.1
ifaddr==0.2.0
infrared-protocols==2.0.0
+20 -20
View File
@@ -600,7 +600,7 @@ avea==1.6.1
# avion==0.10
# homeassistant.components.axis
axis==69
axis==70
# homeassistant.components.fujitsu_fglair
ayla-iot-unofficial==1.4.7
@@ -654,7 +654,7 @@ bleak-retry-connector==4.6.0
bleak==2.1.1
# homeassistant.components.blebox
blebox-uniapi==2.5.2
blebox-uniapi==2.5.3
# homeassistant.components.blink
blinkpy==0.25.2
@@ -794,7 +794,7 @@ debugpy==1.8.17
decora-wifi==1.4
# homeassistant.components.ecovacs
deebot-client==18.2.0
deebot-client==18.3.0
# homeassistant.components.ihc
# homeassistant.components.ohmconnect
@@ -1048,7 +1048,7 @@ gTTS==2.5.3
# homeassistant.components.gardena_bluetooth
# homeassistant.components.husqvarna_automower_ble
gardena-bluetooth==2.4.0
gardena-bluetooth==2.8.1
# homeassistant.components.google_assistant_sdk
gassist-text==0.0.14
@@ -1242,13 +1242,13 @@ hole==0.9.0
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.95
holidays==0.96
# homeassistant.components.frontend
home-assistant-frontend==20260429.2
home-assistant-frontend==20260429.3
# homeassistant.components.conversation
home-assistant-intents==2026.3.24
home-assistant-intents==2026.5.5
# homeassistant.components.homekit
homekit-audio-proxy==1.2.1
@@ -1344,7 +1344,7 @@ infrared-protocols==2.0.0
inkbird-ble==1.1.1
# homeassistant.components.insteon
insteon-frontend-home-assistant==0.6.1
insteon-frontend-home-assistant==0.6.2
# homeassistant.components.intellifire
intellifire4py==4.4.0
@@ -1941,7 +1941,7 @@ pyRFXtrx==0.31.1
pySDCP==1
# homeassistant.components.tibber
pyTibber==0.37.2
pyTibber==0.37.5
# homeassistant.components.dlink
pyW215==0.8.0
@@ -2273,7 +2273,7 @@ pyliebherrhomeapi==0.4.1
pylitejet==0.6.3
# homeassistant.components.litterrobot
pylitterbot==2025.3.2
pylitterbot==2025.4.0
# homeassistant.components.lutron_caseta
pylutron-caseta==0.28.0
@@ -2386,7 +2386,7 @@ pyotgw==2.2.3
pyotp==2.9.0
# homeassistant.components.overkiz
pyoverkiz==1.20.0
pyoverkiz==1.20.3
# homeassistant.components.palazzetti
pypalazzetti==0.1.20
@@ -2566,7 +2566,7 @@ python-awair==0.2.5
python-blockchain-api==0.0.2
# homeassistant.components.bsblan
python-bsblan==5.2.0
python-bsblan==5.2.1
# homeassistant.components.citybikes
python-citybikes==0.3.3
@@ -2581,7 +2581,7 @@ python-digitalocean==1.13.2
python-dropbox-api==0.1.3
# homeassistant.components.duco
python-duco-client==0.3.10
python-duco-client==0.4.1
# homeassistant.components.ecobee
python-ecobee-api==0.3.2
@@ -2736,7 +2736,7 @@ pytradfri[async]==9.0.1
pytrafikverket==1.1.1
# homeassistant.components.v2c
pytrydan==0.8.0
pytrydan==1.0.0
# homeassistant.components.uptimerobot
pyuptimerobot==25.0.0
@@ -2900,7 +2900,7 @@ samsungtvws[async,encrypted]==2.7.2
sanix==1.0.6
# homeassistant.components.satel_integra
satel-integra==1.3.0
satel-integra==1.3.1
# homeassistant.components.screenlogic
screenlogicpy==0.10.2
@@ -2916,7 +2916,7 @@ sendgrid==6.8.2
# homeassistant.components.emulated_kasa
# homeassistant.components.sense
sense-energy==0.14.0
sense-energy==0.14.1
# homeassistant.components.sensirion_ble
sensirion-ble==0.1.1
@@ -2945,7 +2945,7 @@ sentry-sdk==2.48.0
# homeassistant.components.acer_projector
# homeassistant.components.serial
# homeassistant.components.usb
serialx==1.4.1
serialx==1.7.1
# homeassistant.components.sfr_box
sfrbox-api==0.1.1
@@ -3249,7 +3249,7 @@ venstarcolortouch==0.21
viaggiatreno_ha==0.2.4
# homeassistant.components.victron_ble
victron-ble-ha-parser==0.6.3
victron-ble-ha-parser==0.7.0
# homeassistant.components.victron_gx
victron-mqtt==2026.4.17
@@ -3346,7 +3346,7 @@ xiaomi-ble==1.10.1
xknx==3.15.0
# homeassistant.components.knx
xknxproject==3.8.2
xknxproject==3.9.0
# homeassistant.components.fritz
# homeassistant.components.rest
@@ -3404,7 +3404,7 @@ zeroconf==0.148.0
zeversolar==0.3.2
# homeassistant.components.zha
zha==1.3.0
zha==1.3.1
# homeassistant.components.zhong_hong
zhong-hong-hvac==1.0.13
+20 -20
View File
@@ -552,7 +552,7 @@ autoskope_client==1.4.1
av==16.0.1
# homeassistant.components.axis
axis==69
axis==70
# homeassistant.components.fujitsu_fglair
ayla-iot-unofficial==1.4.7
@@ -591,7 +591,7 @@ bleak-retry-connector==4.6.0
bleak==2.1.1
# homeassistant.components.blebox
blebox-uniapi==2.5.2
blebox-uniapi==2.5.3
# homeassistant.components.blink
blinkpy==0.25.2
@@ -706,7 +706,7 @@ debugpy==1.8.17
decora-wifi==1.4
# homeassistant.components.ecovacs
deebot-client==18.2.0
deebot-client==18.3.0
# homeassistant.components.ihc
# homeassistant.components.ohmconnect
@@ -930,7 +930,7 @@ gTTS==2.5.3
# homeassistant.components.gardena_bluetooth
# homeassistant.components.husqvarna_automower_ble
gardena-bluetooth==2.4.0
gardena-bluetooth==2.8.1
# homeassistant.components.google_assistant_sdk
gassist-text==0.0.14
@@ -1106,13 +1106,13 @@ hole==0.9.0
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.95
holidays==0.96
# homeassistant.components.frontend
home-assistant-frontend==20260429.2
home-assistant-frontend==20260429.3
# homeassistant.components.conversation
home-assistant-intents==2026.3.24
home-assistant-intents==2026.5.5
# homeassistant.components.homekit
homekit-audio-proxy==1.2.1
@@ -1196,7 +1196,7 @@ infrared-protocols==2.0.0
inkbird-ble==1.1.1
# homeassistant.components.insteon
insteon-frontend-home-assistant==0.6.1
insteon-frontend-home-assistant==0.6.2
# homeassistant.components.intellifire
intellifire4py==4.4.0
@@ -1684,7 +1684,7 @@ pyHomee==1.3.8
pyRFXtrx==0.31.1
# homeassistant.components.tibber
pyTibber==0.37.2
pyTibber==0.37.5
# homeassistant.components.dlink
pyW215==0.8.0
@@ -1950,7 +1950,7 @@ pyliebherrhomeapi==0.4.1
pylitejet==0.6.3
# homeassistant.components.litterrobot
pylitterbot==2025.3.2
pylitterbot==2025.4.0
# homeassistant.components.lutron_caseta
pylutron-caseta==0.28.0
@@ -2045,7 +2045,7 @@ pyotgw==2.2.3
pyotp==2.9.0
# homeassistant.components.overkiz
pyoverkiz==1.20.0
pyoverkiz==1.20.3
# homeassistant.components.palazzetti
pypalazzetti==0.1.20
@@ -2198,7 +2198,7 @@ python-MotionMount==2.3.0
python-awair==0.2.5
# homeassistant.components.bsblan
python-bsblan==5.2.0
python-bsblan==5.2.1
# homeassistant.components.citybikes
python-citybikes==0.3.3
@@ -2207,7 +2207,7 @@ python-citybikes==0.3.3
python-dropbox-api==0.1.3
# homeassistant.components.duco
python-duco-client==0.3.10
python-duco-client==0.4.1
# homeassistant.components.ecobee
python-ecobee-api==0.3.2
@@ -2335,7 +2335,7 @@ pytradfri[async]==9.0.1
pytrafikverket==1.1.1
# homeassistant.components.v2c
pytrydan==0.8.0
pytrydan==1.0.0
# homeassistant.components.uptimerobot
pyuptimerobot==25.0.0
@@ -2472,7 +2472,7 @@ samsungtvws[async,encrypted]==2.7.2
sanix==1.0.6
# homeassistant.components.satel_integra
satel-integra==1.3.0
satel-integra==1.3.1
# homeassistant.components.screenlogic
screenlogicpy==0.10.2
@@ -2482,7 +2482,7 @@ securetar==2026.4.1
# homeassistant.components.emulated_kasa
# homeassistant.components.sense
sense-energy==0.14.0
sense-energy==0.14.1
# homeassistant.components.sensirion_ble
sensirion-ble==0.1.1
@@ -2511,7 +2511,7 @@ sentry-sdk==2.48.0
# homeassistant.components.acer_projector
# homeassistant.components.serial
# homeassistant.components.usb
serialx==1.4.1
serialx==1.7.1
# homeassistant.components.sfr_box
sfrbox-api==0.1.1
@@ -2761,7 +2761,7 @@ velbus-aio==2026.4.1
venstarcolortouch==0.21
# homeassistant.components.victron_ble
victron-ble-ha-parser==0.6.3
victron-ble-ha-parser==0.7.0
# homeassistant.components.victron_gx
victron-mqtt==2026.4.17
@@ -2846,7 +2846,7 @@ xiaomi-ble==1.10.1
xknx==3.15.0
# homeassistant.components.knx
xknxproject==3.8.2
xknxproject==3.9.0
# homeassistant.components.fritz
# homeassistant.components.rest
@@ -2895,7 +2895,7 @@ zeroconf==0.148.0
zeversolar==0.3.2
# homeassistant.components.zha
zha==1.3.0
zha==1.3.1
# homeassistant.components.zinvolt
zinvolt==0.4.3
@@ -332,12 +332,14 @@ async def test_climate_attribute_condition_behavior_all(
"climate.target_humidity",
HVACMode.AUTO,
ATTR_HUMIDITY,
attribute_required=True,
),
*parametrize_numerical_attribute_condition_above_below_any(
"climate.target_temperature",
HVACMode.AUTO,
ATTR_TEMPERATURE,
threshold_unit=UnitOfTemperature.CELSIUS,
attribute_required=True,
),
],
)
@@ -376,12 +378,14 @@ async def test_climate_numerical_condition_behavior_any(
"climate.target_humidity",
HVACMode.AUTO,
ATTR_HUMIDITY,
attribute_required=True,
),
*parametrize_numerical_attribute_condition_above_below_all(
"climate.target_temperature",
HVACMode.AUTO,
ATTR_TEMPERATURE,
threshold_unit=UnitOfTemperature.CELSIUS,
attribute_required=True,
),
],
)
+20 -4
View File
@@ -218,22 +218,30 @@ async def test_climate_state_trigger_behavior_any(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_changed_trigger_states(
"climate.target_humidity_changed", HVACMode.AUTO, ATTR_HUMIDITY
"climate.target_humidity_changed",
HVACMode.AUTO,
ATTR_HUMIDITY,
attribute_required=True,
),
*parametrize_numerical_attribute_changed_trigger_states(
"climate.target_temperature_changed",
HVACMode.AUTO,
ATTR_TEMPERATURE,
threshold_unit=UnitOfTemperature.CELSIUS,
attribute_required=True,
),
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.target_humidity_crossed_threshold", HVACMode.AUTO, ATTR_HUMIDITY
"climate.target_humidity_crossed_threshold",
HVACMode.AUTO,
ATTR_HUMIDITY,
attribute_required=True,
),
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.target_temperature_crossed_threshold",
HVACMode.AUTO,
ATTR_TEMPERATURE,
threshold_unit=UnitOfTemperature.CELSIUS,
attribute_required=True,
),
*parametrize_trigger_states(
trigger="climate.started_cooling",
@@ -342,13 +350,17 @@ async def test_climate_state_trigger_behavior_first(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.target_humidity_crossed_threshold", HVACMode.AUTO, ATTR_HUMIDITY
"climate.target_humidity_crossed_threshold",
HVACMode.AUTO,
ATTR_HUMIDITY,
attribute_required=True,
),
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.target_temperature_crossed_threshold",
HVACMode.AUTO,
ATTR_TEMPERATURE,
threshold_unit=UnitOfTemperature.CELSIUS,
attribute_required=True,
),
*parametrize_trigger_states(
trigger="climate.started_cooling",
@@ -457,13 +469,17 @@ async def test_climate_state_trigger_behavior_last(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.target_humidity_crossed_threshold", HVACMode.AUTO, ATTR_HUMIDITY
"climate.target_humidity_crossed_threshold",
HVACMode.AUTO,
ATTR_HUMIDITY,
attribute_required=True,
),
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.target_temperature_crossed_threshold",
HVACMode.AUTO,
ATTR_TEMPERATURE,
threshold_unit=UnitOfTemperature.CELSIUS,
attribute_required=True,
),
*parametrize_trigger_states(
trigger="climate.started_cooling",
+712 -127
View File
File diff suppressed because it is too large Load Diff
@@ -4,10 +4,12 @@
'board_info': dict({
'box_name': 'SILENT_CONNECT',
'box_sub_type_name': 'Eu',
'public_api_version': None,
'serial_board_box': '**REDACTED**',
'serial_board_comm': '**REDACTED**',
'serial_duco_box': '**REDACTED**',
'serial_duco_comm': '**REDACTED**',
'software_version': None,
}),
'duco_diagnostics': list([
dict({

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