mirror of
https://github.com/home-assistant/core.git
synced 2026-05-11 14:09:44 +00:00
Compare commits
82 Commits
2026.5.0b1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d7738303a | |||
| dd0cdc4fc4 | |||
| 18ea40c46d | |||
| a23131efc8 | |||
| 4940a0abae | |||
| 5f98d5ae52 | |||
| ba18cded30 | |||
| fb7504e9df | |||
| 106f815a1e | |||
| 167757762b | |||
| 3a902e1a16 | |||
| 85c11672d8 | |||
| 89649df20d | |||
| 7b749b95ce | |||
| cc140be85c | |||
| e1ad765414 | |||
| 44b1fea745 | |||
| 5dd04363b2 | |||
| 03aa979309 | |||
| 6fabbb354b | |||
| f644448d0f | |||
| 4e61581cd8 | |||
| 6f87d02b72 | |||
| 348f6149b4 | |||
| a4227ef1bc | |||
| aac49a567f | |||
| 76b878b136 | |||
| 2d05931683 | |||
| b10582b0a9 | |||
| b193d951d7 | |||
| 4cd0d9dcec | |||
| 32f65b2e11 | |||
| 8c79d1e44b | |||
| 8d53f7a520 | |||
| cc83ee88fb | |||
| 0c5b02eff3 | |||
| 9da9f8fd50 | |||
| d70ffcd3e9 | |||
| 3e26d0dfe3 | |||
| eab9747b32 | |||
| 9e955d8294 | |||
| f08cd01ff8 | |||
| eabaf3b0fe | |||
| 65ca790d15 | |||
| d177944f7a | |||
| 7f186f4430 | |||
| 4f4f4642a7 | |||
| 12e443cd31 | |||
| 22a7daabe7 | |||
| c139e99abd | |||
| 2bfdb96a3f | |||
| 4b24ca924b | |||
| 1d3d714e4f | |||
| ffae6eda8a | |||
| 4dd996b728 | |||
| afad1e8dac | |||
| 8e41933251 | |||
| c581eaad53 | |||
| 3050e79d06 | |||
| 0e8ecd1065 | |||
| 94732139f4 | |||
| c5e08b2409 | |||
| c12e1b5f4a | |||
| 6cfedb55e6 | |||
| af4cb9530b | |||
| 58e97e7d5f | |||
| 2945b51617 | |||
| 9d0e2df627 | |||
| 643ae080db | |||
| a7eaa51179 | |||
| e15852ff38 | |||
| f6dec34136 | |||
| 53905fbc49 | |||
| 8218ff0fe8 | |||
| 663f7e3e6b | |||
| 4dfa2b8b88 | |||
| f828b165b1 | |||
| c56c506648 | |||
| 8e5bf2a35f | |||
| 4d575e69a4 | |||
| 4f78bbccc0 | |||
| 2d66ebe54a |
Generated
+2
-2
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "sensereo",
|
||||
"name": "Sensereo",
|
||||
"iot_standards": ["matter"]
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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*",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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():
|
||||
|
||||
@@ -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*",
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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={}): {},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
{
|
||||
|
||||
@@ -6,7 +6,6 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from pywizlight import PilotParser, wizlight
|
||||
from pywizlight.bulb import PIR_SOURCE
|
||||
|
||||
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
@@ -20,6 +19,7 @@ from .const import (
|
||||
DISCOVER_SCAN_TIMEOUT,
|
||||
DISCOVERY_INTERVAL,
|
||||
DOMAIN,
|
||||
OCCUPANCY_SOURCES,
|
||||
SIGNAL_WIZ_PIR,
|
||||
WIZ_CONNECT_EXCEPTIONS,
|
||||
)
|
||||
@@ -101,7 +101,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WizConfigEntry) -> bool:
|
||||
"""Receive a push update."""
|
||||
_LOGGER.debug("%s: Got push update: %s", bulb.mac, state.pilotResult)
|
||||
coordinator.async_set_updated_data(coordinator.data)
|
||||
if state.get_source() == PIR_SOURCE:
|
||||
if state.get_source() in OCCUPANCY_SOURCES:
|
||||
async_dispatcher_send(hass, SIGNAL_WIZ_PIR.format(bulb.mac))
|
||||
|
||||
await bulb.start_push(_async_push_update)
|
||||
|
||||
@@ -4,8 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from pywizlight.bulb import PIR_SOURCE
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
@@ -16,7 +14,7 @@ from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, SIGNAL_WIZ_PIR
|
||||
from .const import DOMAIN, OCCUPANCY_SOURCES, SIGNAL_WIZ_PIR
|
||||
from .coordinator import WizConfigEntry, WizData
|
||||
from .entity import WizEntity
|
||||
|
||||
@@ -75,5 +73,5 @@ class WizOccupancyEntity(WizEntity, BinarySensorEntity):
|
||||
@callback
|
||||
def _async_update_attrs(self) -> None:
|
||||
"""Handle updating _attr values."""
|
||||
if self._device.state.get_source() == PIR_SOURCE:
|
||||
if self._device.state.get_source() in OCCUPANCY_SOURCES:
|
||||
self._attr_is_on = self._device.status
|
||||
|
||||
@@ -81,6 +81,8 @@ class WizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
exc_info=True,
|
||||
)
|
||||
raise AbortFlow("cannot_connect") from ex
|
||||
finally:
|
||||
await bulb.async_close()
|
||||
self._name = name_from_bulb_type_and_mac(bulbtype, device.mac_address)
|
||||
|
||||
async def async_step_discovery_confirm(
|
||||
@@ -118,6 +120,8 @@ class WizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
bulbtype = await bulb.get_bulbtype()
|
||||
except WIZ_CONNECT_EXCEPTIONS:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
finally:
|
||||
await bulb.async_close()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=name_from_bulb_type_and_mac(bulbtype, device.mac_address),
|
||||
@@ -182,6 +186,8 @@ class WizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
title=name,
|
||||
data=user_input,
|
||||
)
|
||||
finally:
|
||||
await bulb.async_close()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from pywizlight.bulb import PIR_SOURCE
|
||||
from pywizlight.exceptions import (
|
||||
WizLightConnectionError,
|
||||
WizLightNotKnownBulb,
|
||||
@@ -24,3 +25,4 @@ WIZ_EXCEPTIONS = (
|
||||
WIZ_CONNECT_EXCEPTIONS = (WizLightNotKnownBulb, *WIZ_EXCEPTIONS)
|
||||
|
||||
SIGNAL_WIZ_PIR = "wiz_pir_{}"
|
||||
OCCUPANCY_SOURCES = frozenset({PIR_SOURCE, "wfsens"})
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["holidays"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["holidays==0.95"]
|
||||
"requirements": ["holidays==0.96"]
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"universal_silabs_flasher",
|
||||
"serialx"
|
||||
],
|
||||
"requirements": ["zha==1.3.0"],
|
||||
"requirements": ["zha==1.3.1"],
|
||||
"usb": [
|
||||
{
|
||||
"description": "*2652*",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1284,19 +1284,19 @@ def async_discover_single_value(
|
||||
continue
|
||||
|
||||
# check firmware_version_range
|
||||
if schema.firmware_version_range is not None and (
|
||||
(
|
||||
if schema.firmware_version_range is not None:
|
||||
# skip schema if device firmware version is unknown
|
||||
if value.node.firmware_version is None:
|
||||
continue
|
||||
node_firmware = AwesomeVersion(value.node.firmware_version)
|
||||
if (
|
||||
schema.firmware_version_range.min is not None
|
||||
and schema.firmware_version_range.min_ver
|
||||
> AwesomeVersion(value.node.firmware_version)
|
||||
)
|
||||
or (
|
||||
and schema.firmware_version_range.min_ver > node_firmware
|
||||
) or (
|
||||
schema.firmware_version_range.max is not None
|
||||
and schema.firmware_version_range.max_ver
|
||||
< AwesomeVersion(value.node.firmware_version)
|
||||
)
|
||||
):
|
||||
continue
|
||||
and schema.firmware_version_range.max_ver < node_firmware
|
||||
):
|
||||
continue
|
||||
|
||||
# check device_class_generic
|
||||
# If the value has an endpoint but it is missing on the node
|
||||
|
||||
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2026
|
||||
MINOR_VERSION: Final = 5
|
||||
PATCH_VERSION: Final = "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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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."
|
||||
|
||||
Generated
+1
-1
@@ -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
|
||||
|
||||
Generated
+20
-20
@@ -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
|
||||
|
||||
Generated
+20
-20
@@ -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,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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
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
Reference in New Issue
Block a user