diff --git a/homeassistant/components/climate/condition.py b/homeassistant/components/climate/condition.py index 51a5bfcf6d11fa..449996bb82952d 100644 --- a/homeassistant/components/climate/condition.py +++ b/homeassistant/components/climate/condition.py @@ -59,6 +59,13 @@ 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 diff --git a/homeassistant/components/climate/trigger.py b/homeassistant/components/climate/trigger.py index b27711cc3bb070..26c074e8b85fef 100644 --- a/homeassistant/components/climate/trigger.py +++ b/homeassistant/components/climate/trigger.py @@ -56,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 diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index ea033f34a39d55..a2199ee5f4d13d 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -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"] } diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json index 08e73c9bf4fe1c..284615c014b3f8 100644 --- a/homeassistant/components/gardena_bluetooth/manifest.json +++ b/homeassistant/components/gardena_bluetooth/manifest.json @@ -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"] } diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index 85d8bd2297429a..461fd763fb7f30 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -44,14 +44,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: HiveConfigEntry) -> bool except HiveReauthRequired as err: raise ConfigEntryAuthFailed from err + hub_data = devices["parent"][0] + connections: set[tuple[str, str]] = set() + if mac := hub_data.get("macAddress"): + connections.add((dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac))) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, - identifiers={(DOMAIN, devices["parent"][0]["device_id"])}, - name=devices["parent"][0]["hiveName"], - model=devices["parent"][0]["deviceData"]["model"], - sw_version=devices["parent"][0]["deviceData"]["version"], - manufacturer=devices["parent"][0]["deviceData"]["manufacturer"], + identifiers={(DOMAIN, hub_data["device_id"])}, + connections=connections, + name=hub_data["hiveName"], + model=hub_data["deviceData"]["model"], + sw_version=hub_data["deviceData"]["version"], + manufacturer=hub_data["deviceData"]["manufacturer"], ) await hass.config_entries.async_forward_entry_setups( diff --git a/homeassistant/components/humidifier/condition.py b/homeassistant/components/humidifier/condition.py index 2a96eaffe376d2..406bdd88b8c771 100644 --- a/homeassistant/components/humidifier/condition.py +++ b/homeassistant/components/humidifier/condition.py @@ -5,7 +5,7 @@ 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 @@ 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 @@ def entity_filter(self, entities: set[str]) -> set[str]: {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, } diff --git a/homeassistant/components/humidity/condition.py b/homeassistant/components/humidity/condition.py index ab5d03c9ab42d4..b06c0b285e1123 100644 --- a/homeassistant/components/humidity/condition.py +++ b/homeassistant/components/humidity/condition.py @@ -14,9 +14,9 @@ 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( @@ -31,8 +31,31 @@ ), } + +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, } diff --git a/homeassistant/components/humidity/trigger.py b/homeassistant/components/humidity/trigger.py index 3785723cb28052..69c22ebdbd31e7 100644 --- a/homeassistant/components/humidity/trigger.py +++ b/homeassistant/components/humidity/trigger.py @@ -13,12 +13,13 @@ 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] = { @@ -36,13 +37,46 @@ ), } + +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, } diff --git a/homeassistant/components/husqvarna_automower_ble/manifest.json b/homeassistant/components/husqvarna_automower_ble/manifest.json index e3e710a4fed3d6..190891e3b3c794 100644 --- a/homeassistant/components/husqvarna_automower_ble/manifest.json +++ b/homeassistant/components/husqvarna_automower_ble/manifest.json @@ -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"] } diff --git a/homeassistant/components/media_player/condition.py b/homeassistant/components/media_player/condition.py index d63f569642ae34..2b405be804df46 100644 --- a/homeassistant/components/media_player/condition.py +++ b/homeassistant/components/media_player/condition.py @@ -1,34 +1,123 @@ """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_off": make_entity_state_condition(DOMAIN, MediaPlayerState.OFF), - "is_on": make_entity_state_condition( + "is_muted": MediaPlayerIsMutedCondition, + "is_not_playing": make_entity_state_condition( DOMAIN, { MediaPlayerState.BUFFERING, MediaPlayerState.IDLE, + MediaPlayerState.OFF, MediaPlayerState.ON, MediaPlayerState.PAUSED, - MediaPlayerState.PLAYING, }, ), - "is_not_playing": make_entity_state_condition( + "is_off": make_entity_state_condition(DOMAIN, MediaPlayerState.OFF), + "is_on": make_entity_state_condition( DOMAIN, { MediaPlayerState.BUFFERING, MediaPlayerState.IDLE, - MediaPlayerState.OFF, MediaPlayerState.ON, MediaPlayerState.PAUSED, + MediaPlayerState.PLAYING, }, ), "is_paused": make_entity_state_condition(DOMAIN, MediaPlayerState.PAUSED), "is_playing": make_entity_state_condition(DOMAIN, MediaPlayerState.PLAYING), + "is_unmuted": MediaPlayerIsUnmutedCondition, + "is_volume": MediaPlayerIsVolumeCondition, } diff --git a/homeassistant/components/media_player/conditions.yaml b/homeassistant/components/media_player/conditions.yaml index 732ef10dd49308..eb5c39cd5a72b5 100644 --- a/homeassistant/components/media_player/conditions.yaml +++ b/homeassistant/components/media_player/conditions.yaml @@ -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 diff --git a/homeassistant/components/media_player/icons.json b/homeassistant/components/media_player/icons.json index c6c1b066b09a47..b767cc9904f047 100644 --- a/homeassistant/components/media_player/icons.json +++ b/homeassistant/components/media_player/icons.json @@ -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": { diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index 346e76ccc074d3..2347f3a2ecd5d3 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -2,11 +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_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": { @@ -66,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": { diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index a4ed3ea598bd45..46c730a314ab90 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -82,6 +82,7 @@ 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" diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index cd9270fdba5c2e..9c19e93ee914c8 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -21,9 +21,13 @@ ) 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 @@ -46,6 +50,7 @@ DATA_NOTIFY, DATA_PUSH_CHANNEL, DOMAIN, + SIGNAL_RECORD_NOTIFICATION, ) from .helpers import device_info from .push_notification import PushChannel @@ -111,6 +116,21 @@ async def async_send_message(self, message: str, title: str | None = None) -> No 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.""" @@ -195,6 +215,7 @@ async def async_send_message(self, message: str = "", **kwargs: Any) -> None: data, partial(self._async_send_remote_message_target, entry), ) + async_dispatcher_send(self.hass, SIGNAL_RECORD_NOTIFICATION, target) continue # Test if local push only. @@ -203,6 +224,7 @@ async def async_send_message(self, message: str = "", **kwargs: Any) -> None: continue await self._async_send_remote_message_target(entry, data) + async_dispatcher_send(self.hass, SIGNAL_RECORD_NOTIFICATION, target) if failed_targets: raise HomeAssistantError( diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index cb3c3b6d8f47ac..08d9af23be8f8c 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -5371,12 +5371,9 @@ async def _async_validate_broker_settings( description={"suggested_value": current_pass}, ) ] = PASSWORD_SELECTOR - # show advanced options checkbox if requested and - # advanced options are enabled - # or when the defaults of advanced options are overridden + # show advanced options checkbox if no defaults + # of the advanced options are overridden if not advanced_broker_options: - if not flow.show_advanced_options: - return False fields[ vol.Optional( ADVANCED_OPTIONS, diff --git a/homeassistant/components/satel_integra/manifest.json b/homeassistant/components/satel_integra/manifest.json index 4b5e4775789297..1520a7c87bafcf 100644 --- a/homeassistant/components/satel_integra/manifest.json +++ b/homeassistant/components/satel_integra/manifest.json @@ -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"] } diff --git a/homeassistant/components/temperature/condition.py b/homeassistant/components/temperature/condition.py index acb87a1cdc0ef3..589883baf572ec 100644 --- a/homeassistant/components/temperature/condition.py +++ b/homeassistant/components/temperature/condition.py @@ -46,6 +46,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: diff --git a/homeassistant/components/temperature/trigger.py b/homeassistant/components/temperature/trigger.py index 02430d14ef2c30..a3a4aa9777c187 100644 --- a/homeassistant/components/temperature/trigger.py +++ b/homeassistant/components/temperature/trigger.py @@ -46,6 +46,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: diff --git a/homeassistant/components/template/coordinator.py b/homeassistant/components/template/coordinator.py index 4c90870dac81f5..730f5615a49393 100644 --- a/homeassistant/components/template/coordinator.py +++ b/homeassistant/components/template/coordinator.py @@ -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 @@ def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: 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 @@ -69,7 +69,9 @@ async def async_shutdown(self) -> None: self._unsub_trigger() self._unsub_trigger = None if self._script is not None: - await self._script.async_stop() + 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.""" @@ -158,7 +160,7 @@ async def _handle_triggered( 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", diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index 9cd86cc25fc8b5..951e2e19195963 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -169,9 +169,15 @@ def add_script( ) async def async_will_remove_from_hass(self) -> None: - """Stop scripts when removing from Home Assistant.""" - for action_script in self._action_scripts.values(): - await action_script.async_stop() + """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, diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 71886719111362..00b4120a727efc 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tibber"], - "requirements": ["pyTibber==0.37.3"] + "requirements": ["pyTibber==0.37.4"] } diff --git a/homeassistant/components/tuya/coordinator.py b/homeassistant/components/tuya/coordinator.py index 31cc158a3ea9a9..6b8cf501d4f78a 100644 --- a/homeassistant/components/tuya/coordinator.py +++ b/homeassistant/components/tuya/coordinator.py @@ -28,6 +28,7 @@ TUYA_DISCOVERY_NEW, TUYA_HA_SIGNAL_UPDATE_ENTITY, ) +from .util import get_device_info type TuyaConfigEntry = ConfigEntry[DeviceListener] @@ -145,14 +146,7 @@ def async_register_device( device_registry.async_get_or_create( config_entry_id=self._entry.entry_id, - identifiers={(DOMAIN, device.id)}, - manufacturer="Tuya", - name=device.name, - # Note: the model is overridden via entity.device_info property - # when the entity is created. If no entities are generated, it will - # stay as unsupported - model=f"{device.product_name} (unsupported)", - model_id=device.product_id, + **get_device_info(device, initial=True), ) def remove_device(self, device_id: str) -> None: diff --git a/homeassistant/components/tuya/entity.py b/homeassistant/components/tuya/entity.py index 81e94870f9301f..b00bbbc0d4de84 100644 --- a/homeassistant/components/tuya/entity.py +++ b/homeassistant/components/tuya/entity.py @@ -5,11 +5,11 @@ from tuya_device_handlers.device_wrapper import DeviceWrapper from tuya_sharing import CustomerDevice, Manager -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity, EntityDescription -from .const import DOMAIN, LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY +from .const import LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY +from .util import get_device_info class TuyaEntity(Entity): @@ -25,6 +25,7 @@ def __init__( description: EntityDescription, ) -> None: """Init TuyaEntity.""" + self._attr_device_info = get_device_info(device) self._attr_unique_id = f"tuya.{device.id}{description.key}" self.entity_description = description # TuyaEntity initialize mq can subscribe @@ -32,17 +33,6 @@ def __init__( self.device = device self.device_manager = device_manager - @property - def device_info(self) -> DeviceInfo: - """Return a device description for device registry.""" - return DeviceInfo( - identifiers={(DOMAIN, self.device.id)}, - manufacturer="Tuya", - name=self.device.name, - model=self.device.product_name, - model_id=self.device.product_id, - ) - @property def available(self) -> bool: """Return if the device is available.""" diff --git a/homeassistant/components/tuya/util.py b/homeassistant/components/tuya/util.py index 824ff6bc91d74c..9743b8a1e649b6 100644 --- a/homeassistant/components/tuya/util.py +++ b/homeassistant/components/tuya/util.py @@ -3,6 +3,7 @@ from tuya_sharing import CustomerDevice from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.device_registry import DeviceInfo from .const import DOMAIN, DPCode @@ -31,3 +32,22 @@ def __init__( "available": str(sorted(device.function.keys())), }, ) + + +def get_device_info(device: CustomerDevice, *, initial: bool = False) -> DeviceInfo: + """Get device info.""" + model = device.product_name + + if initial: + # Note: the model is overridden via entity.device_info property + # when the entity is created. If no entities are generated, it will + # stay as unsupported + model = f"{device.product_name} (unsupported)" + + return DeviceInfo( + identifiers={(DOMAIN, device.id)}, + manufacturer="Tuya", + name=device.name, + model=model, + model_id=device.product_id, + ) diff --git a/homeassistant/components/water_heater/condition.py b/homeassistant/components/water_heater/condition.py index c800d6a923ac5c..6b5754f168e024 100644 --- a/homeassistant/components/water_heater/condition.py +++ b/homeassistant/components/water_heater/condition.py @@ -74,6 +74,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 diff --git a/homeassistant/components/water_heater/trigger.py b/homeassistant/components/water_heater/trigger.py index 0a434b498b5dee..72b5efb741b54b 100644 --- a/homeassistant/components/water_heater/trigger.py +++ b/homeassistant/components/water_heater/trigger.py @@ -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 diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index d33a793d72524b..8f29a86a73a7f6 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -487,20 +487,22 @@ def _needs_duration_tracking(self) -> bool: """ 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 @@ -510,11 +512,7 @@ def _update_valid_since(self, entity_id: str, _state: State | None) -> None: # 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) @@ -562,13 +560,6 @@ def async_unload(self) -> None: 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. @@ -654,6 +645,13 @@ def _needs_duration_tracking(self) -> bool: 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 diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a632826c2797df..8ab0eea85f9f93 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==6.1.0 hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==2.0.0 -home-assistant-frontend==20260429.2 +home-assistant-frontend==20260429.3 home-assistant-intents==2026.5.5 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 5bd38f7ebb1f87..843ecc3862e9e4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1051,7 +1051,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 @@ -1248,7 +1248,7 @@ hole==0.9.0 holidays==0.95 # homeassistant.components.frontend -home-assistant-frontend==20260429.2 +home-assistant-frontend==20260429.3 # homeassistant.components.conversation home-assistant-intents==2026.5.5 @@ -1947,7 +1947,7 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.37.3 +pyTibber==0.37.4 # homeassistant.components.dlink pyW215==0.8.0 @@ -2906,7 +2906,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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7c25e24afe574f..8976d4e927a064 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -933,7 +933,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 @@ -1112,7 +1112,7 @@ hole==0.9.0 holidays==0.95 # homeassistant.components.frontend -home-assistant-frontend==20260429.2 +home-assistant-frontend==20260429.3 # homeassistant.components.conversation home-assistant-intents==2026.5.5 @@ -1690,7 +1690,7 @@ pyHomee==1.3.8 pyRFXtrx==0.31.1 # homeassistant.components.tibber -pyTibber==0.37.3 +pyTibber==0.37.4 # homeassistant.components.dlink pyW215==0.8.0 @@ -2478,7 +2478,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 diff --git a/tests/components/climate/test_condition.py b/tests/components/climate/test_condition.py index 32abb574ac36f5..f2c9c9230bac07 100644 --- a/tests/components/climate/test_condition.py +++ b/tests/components/climate/test_condition.py @@ -339,6 +339,7 @@ async def test_climate_attribute_condition_behavior_all( HVACMode.AUTO, ATTR_TEMPERATURE, threshold_unit=UnitOfTemperature.CELSIUS, + attribute_required=True, ), ], ) @@ -384,6 +385,7 @@ async def test_climate_numerical_condition_behavior_any( HVACMode.AUTO, ATTR_TEMPERATURE, threshold_unit=UnitOfTemperature.CELSIUS, + attribute_required=True, ), ], ) diff --git a/tests/components/climate/test_trigger.py b/tests/components/climate/test_trigger.py index bba2e00b3cfde2..f89fed3fe03372 100644 --- a/tests/components/climate/test_trigger.py +++ b/tests/components/climate/test_trigger.py @@ -228,6 +228,7 @@ async def test_climate_state_trigger_behavior_any( HVACMode.AUTO, ATTR_TEMPERATURE, threshold_unit=UnitOfTemperature.CELSIUS, + attribute_required=True, ), *parametrize_numerical_attribute_crossed_threshold_trigger_states( "climate.target_humidity_crossed_threshold", @@ -240,6 +241,7 @@ async def test_climate_state_trigger_behavior_any( HVACMode.AUTO, ATTR_TEMPERATURE, threshold_unit=UnitOfTemperature.CELSIUS, + attribute_required=True, ), *parametrize_trigger_states( trigger="climate.started_cooling", @@ -358,6 +360,7 @@ async def test_climate_state_trigger_behavior_first( HVACMode.AUTO, ATTR_TEMPERATURE, threshold_unit=UnitOfTemperature.CELSIUS, + attribute_required=True, ), *parametrize_trigger_states( trigger="climate.started_cooling", @@ -476,6 +479,7 @@ async def test_climate_state_trigger_behavior_last( HVACMode.AUTO, ATTR_TEMPERATURE, threshold_unit=UnitOfTemperature.CELSIUS, + attribute_required=True, ), *parametrize_trigger_states( trigger="climate.started_cooling", diff --git a/tests/components/common.py b/tests/components/common.py index 946c1a750bf256..73c67e8cd85b54 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -2151,6 +2151,7 @@ def parametrize_numerical_attribute_condition_above_below_any( threshold_unit: str | None | UndefinedType = UNDEFINED, unit_attributes: dict | None = None, attribute_required: bool = False, + attribute_value_scale: float = 1.0, ) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: """Parametrize above/below/between threshold cases for attribute-based numerical conditions under behavior=any. @@ -2196,9 +2197,18 @@ def parametrize_numerical_attribute_condition_above_below_any( than treated as just-missing. Set this for conditions whose `_should_include` skips entities lacking the tracked attribute. + attribute_value_scale: Multiplier applied to the helper's fixed + attribute values before they are written to the state. Use + this when the condition stores its tracked value on a + different scale than the threshold — e.g. `media_player` + volume is stored as 0.0–1.0 but the threshold is in percent, + so pass `attribute_value_scale=0.01`; light brightness is + stored as 0–255 but the threshold is in percent, so pass + `attribute_value_scale=255/100`. """ condition_options = condition_options or {} unit_attributes = unit_attributes or {} + s = attribute_value_scale extra_excluded_states = ( [(state, {attribute: None} | unit_attributes)] if attribute_required else None ) @@ -2214,14 +2224,14 @@ def parametrize_numerical_attribute_condition_above_below_any( threshold_unit, ), target_states=[ - (state, {attribute: 21} | unit_attributes), - (state, {attribute: 50} | unit_attributes), - (state, {attribute: 100} | unit_attributes), + (state, {attribute: 21 * s} | unit_attributes), + (state, {attribute: 50 * s} | unit_attributes), + (state, {attribute: 100 * s} | unit_attributes), ], other_states=[ - (state, {attribute: 0} | unit_attributes), - (state, {attribute: 10} | unit_attributes), - (state, {attribute: 20} | unit_attributes), + (state, {attribute: 0 * s} | unit_attributes), + (state, {attribute: 10 * s} | unit_attributes), + (state, {attribute: 20 * s} | unit_attributes), ], extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, @@ -2236,14 +2246,14 @@ def parametrize_numerical_attribute_condition_above_below_any( threshold_unit, ), target_states=[ - (state, {attribute: 0} | unit_attributes), - (state, {attribute: 50} | unit_attributes), - (state, {attribute: 79} | unit_attributes), + (state, {attribute: 0 * s} | unit_attributes), + (state, {attribute: 50 * s} | unit_attributes), + (state, {attribute: 79 * s} | unit_attributes), ], other_states=[ - (state, {attribute: 80} | unit_attributes), - (state, {attribute: 90} | unit_attributes), - (state, {attribute: 100} | unit_attributes), + (state, {attribute: 80 * s} | unit_attributes), + (state, {attribute: 90 * s} | unit_attributes), + (state, {attribute: 100 * s} | unit_attributes), ], extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, @@ -2262,15 +2272,15 @@ def parametrize_numerical_attribute_condition_above_below_any( threshold_unit, ), target_states=[ - (state, {attribute: 21} | unit_attributes), - (state, {attribute: 50} | unit_attributes), - (state, {attribute: 79} | unit_attributes), + (state, {attribute: 21 * s} | unit_attributes), + (state, {attribute: 50 * s} | unit_attributes), + (state, {attribute: 79 * s} | unit_attributes), ], other_states=[ - (state, {attribute: 0} | unit_attributes), - (state, {attribute: 20} | unit_attributes), - (state, {attribute: 80} | unit_attributes), - (state, {attribute: 100} | unit_attributes), + (state, {attribute: 0 * s} | unit_attributes), + (state, {attribute: 20 * s} | unit_attributes), + (state, {attribute: 80 * s} | unit_attributes), + (state, {attribute: 100 * s} | unit_attributes), ], extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, @@ -2288,6 +2298,7 @@ def parametrize_numerical_attribute_condition_above_below_all( threshold_unit: str | None | UndefinedType = UNDEFINED, unit_attributes: dict | None = None, attribute_required: bool = False, + attribute_value_scale: float = 1.0, ) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: """Parametrize above/below/between threshold cases for attribute-based numerical conditions under behavior=all. @@ -2331,9 +2342,18 @@ def parametrize_numerical_attribute_condition_above_below_all( than treated as just-missing. Set this for conditions whose `_should_include` skips entities lacking the tracked attribute. + attribute_value_scale: Multiplier applied to the helper's fixed + attribute values before they are written to the state. Use + this when the condition stores its tracked value on a + different scale than the threshold — e.g. `media_player` + volume is stored as 0.0–1.0 but the threshold is in percent, + so pass `attribute_value_scale=0.01`; light brightness is + stored as 0–255 but the threshold is in percent, so pass + `attribute_value_scale=255/100`. """ condition_options = condition_options or {} unit_attributes = unit_attributes or {} + s = attribute_value_scale extra_excluded_states = ( [(state, {attribute: None} | unit_attributes)] if attribute_required else None ) @@ -2349,14 +2369,14 @@ def parametrize_numerical_attribute_condition_above_below_all( threshold_unit, ), target_states=[ - (state, {attribute: 21} | unit_attributes), - (state, {attribute: 50} | unit_attributes), - (state, {attribute: 100} | unit_attributes), + (state, {attribute: 21 * s} | unit_attributes), + (state, {attribute: 50 * s} | unit_attributes), + (state, {attribute: 100 * s} | unit_attributes), ], other_states=[ - (state, {attribute: 0} | unit_attributes), - (state, {attribute: 10} | unit_attributes), - (state, {attribute: 20} | unit_attributes), + (state, {attribute: 0 * s} | unit_attributes), + (state, {attribute: 10 * s} | unit_attributes), + (state, {attribute: 20 * s} | unit_attributes), ], extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, @@ -2371,14 +2391,14 @@ def parametrize_numerical_attribute_condition_above_below_all( threshold_unit, ), target_states=[ - (state, {attribute: 0} | unit_attributes), - (state, {attribute: 50} | unit_attributes), - (state, {attribute: 79} | unit_attributes), + (state, {attribute: 0 * s} | unit_attributes), + (state, {attribute: 50 * s} | unit_attributes), + (state, {attribute: 79 * s} | unit_attributes), ], other_states=[ - (state, {attribute: 80} | unit_attributes), - (state, {attribute: 90} | unit_attributes), - (state, {attribute: 100} | unit_attributes), + (state, {attribute: 80 * s} | unit_attributes), + (state, {attribute: 90 * s} | unit_attributes), + (state, {attribute: 100 * s} | unit_attributes), ], extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, @@ -2397,15 +2417,15 @@ def parametrize_numerical_attribute_condition_above_below_all( threshold_unit, ), target_states=[ - (state, {attribute: 21} | unit_attributes), - (state, {attribute: 50} | unit_attributes), - (state, {attribute: 79} | unit_attributes), + (state, {attribute: 21 * s} | unit_attributes), + (state, {attribute: 50 * s} | unit_attributes), + (state, {attribute: 79 * s} | unit_attributes), ], other_states=[ - (state, {attribute: 0} | unit_attributes), - (state, {attribute: 20} | unit_attributes), - (state, {attribute: 80} | unit_attributes), - (state, {attribute: 100} | unit_attributes), + (state, {attribute: 0 * s} | unit_attributes), + (state, {attribute: 20 * s} | unit_attributes), + (state, {attribute: 80 * s} | unit_attributes), + (state, {attribute: 100 * s} | unit_attributes), ], extra_excluded_states=extra_excluded_states, required_filter_attributes=required_filter_attributes, diff --git a/tests/components/hive/test_init.py b/tests/components/hive/test_init.py new file mode 100644 index 00000000000000..20c4cb89ec4be1 --- /dev/null +++ b/tests/components/hive/test_init.py @@ -0,0 +1,93 @@ +"""Tests for the Hive integration __init__.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant.components.hive.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + +_ENTRY_DATA = { + CONF_USERNAME: "user@example.com", + CONF_PASSWORD: "password", + "tokens": { + "AuthenticationResult": { + "AccessToken": "mock-access-token", + "RefreshToken": "mock-refresh-token", + }, + "ChallengeName": "SUCCESS", + }, +} + +_HUB_BASE = { + "device_id": "hive-hub-id", + "hiveName": "Hive Hub", + "deviceData": { + "model": "Hub", + "version": "1.2.3", + "manufacturer": "Hive", + "online": True, + }, +} + + +def _make_mock_hive(hub_extra: dict) -> MagicMock: + """Return a mocked Hive instance whose startSession returns a minimal devices dict.""" + hub_data = {**_HUB_BASE, **hub_extra} + mock_hive = MagicMock() + mock_hive.session.startSession = AsyncMock(return_value={"parent": [hub_data]}) + return mock_hive + + +async def test_hub_device_registers_mac_connection( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Hub device entry includes a MAC connection when macAddress is present.""" + entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA) + entry.add_to_hass(hass) + + mock_hive = _make_mock_hive({"macAddress": "00:1C:2B:1C:2E:68"}) + + with ( + patch( + "homeassistant.components.hive.Hive", + return_value=mock_hive, + ), + patch("homeassistant.components.hive.aiohttp_client.async_get_clientsession"), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, "hive-hub-id")}) + assert device is not None + assert (dr.CONNECTION_NETWORK_MAC, "00:1c:2b:1c:2e:68") in device.connections + + +async def test_hub_device_no_mac_connection_when_absent( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Hub device entry has no MAC connection when macAddress is absent.""" + entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA) + entry.add_to_hass(hass) + + mock_hive = _make_mock_hive({}) # no macAddress key + + with ( + patch( + "homeassistant.components.hive.Hive", + return_value=mock_hive, + ), + patch("homeassistant.components.hive.aiohttp_client.async_get_clientsession"), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, "hive-hub-id")}) + assert device is not None + assert not any( + conn_type == dr.CONNECTION_NETWORK_MAC for conn_type, _ in device.connections + ) diff --git a/tests/components/humidifier/test_condition.py b/tests/components/humidifier/test_condition.py index 917475b6292859..4eebf5d4a5bc0d 100644 --- a/tests/components/humidifier/test_condition.py +++ b/tests/components/humidifier/test_condition.py @@ -302,6 +302,7 @@ async def test_humidifier_attribute_condition_behavior_all( "humidifier.is_target_humidity", STATE_ON, ATTR_HUMIDITY, + attribute_required=True, ), ) async def test_humidifier_numerical_condition_behavior_any( @@ -338,6 +339,7 @@ async def test_humidifier_numerical_condition_behavior_any( "humidifier.is_target_humidity", STATE_ON, ATTR_HUMIDITY, + attribute_required=True, ), ) async def test_humidifier_numerical_condition_behavior_all( diff --git a/tests/components/humidity/test_condition.py b/tests/components/humidity/test_condition.py index ea079d87cff4cc..f280e390637271 100644 --- a/tests/components/humidity/test_condition.py +++ b/tests/components/humidity/test_condition.py @@ -179,6 +179,7 @@ async def test_humidity_sensor_condition_behavior_all( "humidity.is_value", HVACMode.AUTO, CLIMATE_ATTR_CURRENT_HUMIDITY, + attribute_required=True, ), ) async def test_humidity_climate_condition_behavior_any( @@ -215,6 +216,7 @@ async def test_humidity_climate_condition_behavior_any( "humidity.is_value", HVACMode.AUTO, CLIMATE_ATTR_CURRENT_HUMIDITY, + attribute_required=True, ), ) async def test_humidity_climate_condition_behavior_all( @@ -251,6 +253,7 @@ async def test_humidity_climate_condition_behavior_all( "humidity.is_value", STATE_ON, HUMIDIFIER_ATTR_CURRENT_HUMIDITY, + attribute_required=True, ), ) async def test_humidity_humidifier_condition_behavior_any( @@ -287,6 +290,7 @@ async def test_humidity_humidifier_condition_behavior_any( "humidity.is_value", STATE_ON, HUMIDIFIER_ATTR_CURRENT_HUMIDITY, + attribute_required=True, ), ) async def test_humidity_humidifier_condition_behavior_all( @@ -323,6 +327,7 @@ async def test_humidity_humidifier_condition_behavior_all( "humidity.is_value", "sunny", ATTR_WEATHER_HUMIDITY, + attribute_required=True, ), ) async def test_humidity_weather_condition_behavior_any( @@ -359,6 +364,7 @@ async def test_humidity_weather_condition_behavior_any( "humidity.is_value", "sunny", ATTR_WEATHER_HUMIDITY, + attribute_required=True, ), ) async def test_humidity_weather_condition_behavior_all( diff --git a/tests/components/humidity/test_trigger.py b/tests/components/humidity/test_trigger.py index c0d766c50f65ab..edd303a0da262d 100644 --- a/tests/components/humidity/test_trigger.py +++ b/tests/components/humidity/test_trigger.py @@ -240,12 +240,16 @@ async def test_humidity_trigger_sensor_crossed_threshold_behavior_last( ("trigger", "trigger_options", "states"), [ *parametrize_numerical_attribute_changed_trigger_states( - "humidity.changed", HVACMode.AUTO, CLIMATE_ATTR_CURRENT_HUMIDITY + "humidity.changed", + HVACMode.AUTO, + CLIMATE_ATTR_CURRENT_HUMIDITY, + attribute_required=True, ), *parametrize_numerical_attribute_crossed_threshold_trigger_states( "humidity.crossed_threshold", HVACMode.AUTO, CLIMATE_ATTR_CURRENT_HUMIDITY, + attribute_required=True, ), ], ) @@ -284,6 +288,7 @@ async def test_humidity_trigger_climate_behavior_any( "humidity.crossed_threshold", HVACMode.AUTO, CLIMATE_ATTR_CURRENT_HUMIDITY, + attribute_required=True, ), ], ) @@ -322,6 +327,7 @@ async def test_humidity_trigger_climate_crossed_threshold_behavior_first( "humidity.crossed_threshold", HVACMode.AUTO, CLIMATE_ATTR_CURRENT_HUMIDITY, + attribute_required=True, ), ], ) @@ -360,12 +366,16 @@ async def test_humidity_trigger_climate_crossed_threshold_behavior_last( ("trigger", "trigger_options", "states"), [ *parametrize_numerical_attribute_changed_trigger_states( - "humidity.changed", STATE_ON, HUMIDIFIER_ATTR_CURRENT_HUMIDITY + "humidity.changed", + STATE_ON, + HUMIDIFIER_ATTR_CURRENT_HUMIDITY, + attribute_required=True, ), *parametrize_numerical_attribute_crossed_threshold_trigger_states( "humidity.crossed_threshold", STATE_ON, HUMIDIFIER_ATTR_CURRENT_HUMIDITY, + attribute_required=True, ), ], ) @@ -404,6 +414,7 @@ async def test_humidity_trigger_humidifier_behavior_any( "humidity.crossed_threshold", STATE_ON, HUMIDIFIER_ATTR_CURRENT_HUMIDITY, + attribute_required=True, ), ], ) @@ -442,6 +453,7 @@ async def test_humidity_trigger_humidifier_crossed_threshold_behavior_first( "humidity.crossed_threshold", STATE_ON, HUMIDIFIER_ATTR_CURRENT_HUMIDITY, + attribute_required=True, ), ], ) @@ -480,12 +492,16 @@ async def test_humidity_trigger_humidifier_crossed_threshold_behavior_last( ("trigger", "trigger_options", "states"), [ *parametrize_numerical_attribute_changed_trigger_states( - "humidity.changed", "sunny", ATTR_WEATHER_HUMIDITY + "humidity.changed", + "sunny", + ATTR_WEATHER_HUMIDITY, + attribute_required=True, ), *parametrize_numerical_attribute_crossed_threshold_trigger_states( "humidity.crossed_threshold", "sunny", ATTR_WEATHER_HUMIDITY, + attribute_required=True, ), ], ) @@ -524,6 +540,7 @@ async def test_humidity_trigger_weather_behavior_any( "humidity.crossed_threshold", "sunny", ATTR_WEATHER_HUMIDITY, + attribute_required=True, ), ], ) @@ -562,6 +579,7 @@ async def test_humidity_trigger_weather_crossed_threshold_behavior_first( "humidity.crossed_threshold", "sunny", ATTR_WEATHER_HUMIDITY, + attribute_required=True, ), ], ) diff --git a/tests/components/light/test_condition.py b/tests/components/light/test_condition.py index e52d9b60f62e90..b8f19f8e4ee08b 100644 --- a/tests/components/light/test_condition.py +++ b/tests/components/light/test_condition.py @@ -16,119 +16,14 @@ assert_condition_options_supported, parametrize_condition_states_all, parametrize_condition_states_any, + parametrize_numerical_attribute_condition_above_below_all, + parametrize_numerical_attribute_condition_above_below_any, parametrize_target_entities, target_entities, ) - -def parametrize_brightness_condition_states_any( - condition: str, state: str, attribute: str -) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: - """Parametrize above/below threshold test cases for brightness conditions. - - Note: The brightness in the condition configuration is in percentage (0-100) scale, - the underlying attribute in the state is in uint8 (0-255) scale. - """ - return [ - *parametrize_condition_states_any( - condition=condition, - condition_options={"threshold": {"type": "above", "value": {"number": 10}}}, - target_states=[ - (state, {attribute: 128}), - (state, {attribute: 255}), - ], - other_states=[ - (state, {attribute: 0}), - (state, {attribute: None}), - ], - ), - *parametrize_condition_states_any( - condition=condition, - condition_options={"threshold": {"type": "below", "value": {"number": 90}}}, - target_states=[ - (state, {attribute: 0}), - (state, {attribute: 128}), - ], - other_states=[ - (state, {attribute: 255}), - (state, {attribute: None}), - ], - ), - *parametrize_condition_states_any( - condition=condition, - condition_options={ - "threshold": { - "type": "between", - "value_min": {"number": 10}, - "value_max": {"number": 90}, - } - }, - target_states=[ - (state, {attribute: 128}), - (state, {attribute: 153}), - ], - other_states=[ - (state, {attribute: 0}), - (state, {attribute: 255}), - (state, {attribute: None}), - ], - ), - ] - - -def parametrize_brightness_condition_states_all( - condition: str, state: str, attribute: str -) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: - """Parametrize above/below threshold test cases for brightness conditions with 'all' behavior. - - Note: The brightness in the condition configuration is in percentage (0-100) scale, - the underlying attribute in the state is in uint8 (0-255) scale. - """ - return [ - *parametrize_condition_states_all( - condition=condition, - condition_options={"threshold": {"type": "above", "value": {"number": 10}}}, - target_states=[ - (state, {attribute: 128}), - (state, {attribute: 255}), - ], - other_states=[ - (state, {attribute: 0}), - (state, {attribute: None}), - ], - ), - *parametrize_condition_states_all( - condition=condition, - condition_options={"threshold": {"type": "below", "value": {"number": 90}}}, - target_states=[ - (state, {attribute: 0}), - (state, {attribute: 128}), - ], - other_states=[ - (state, {attribute: 255}), - (state, {attribute: None}), - ], - ), - *parametrize_condition_states_all( - condition=condition, - condition_options={ - "threshold": { - "type": "between", - "value_min": {"number": 10}, - "value_max": {"number": 90}, - } - }, - target_states=[ - (state, {attribute: 128}), - (state, {attribute: 153}), - ], - other_states=[ - (state, {attribute: 0}), - (state, {attribute: 255}), - (state, {attribute: None}), - ], - ), - ] +# Brightness is stored as a uint8 (0-255) but the threshold is in percent. +_BRIGHTNESS_VALUE_SCALE = 255 / 100 @pytest.fixture @@ -275,8 +170,11 @@ async def test_light_state_condition_behavior_all( @pytest.mark.parametrize( ("condition", "condition_options", "states"), [ - *parametrize_brightness_condition_states_any( - "light.is_brightness", STATE_ON, ATTR_BRIGHTNESS + *parametrize_numerical_attribute_condition_above_below_any( + "light.is_brightness", + STATE_ON, + ATTR_BRIGHTNESS, + attribute_value_scale=_BRIGHTNESS_VALUE_SCALE, ), ], ) @@ -311,8 +209,11 @@ async def test_light_brightness_condition_behavior_any( @pytest.mark.parametrize( ("condition", "condition_options", "states"), [ - *parametrize_brightness_condition_states_all( - "light.is_brightness", STATE_ON, ATTR_BRIGHTNESS + *parametrize_numerical_attribute_condition_above_below_all( + "light.is_brightness", + STATE_ON, + ATTR_BRIGHTNESS, + attribute_value_scale=_BRIGHTNESS_VALUE_SCALE, ), ], ) diff --git a/tests/components/media_player/test_condition.py b/tests/components/media_player/test_condition.py index 800e811ba74b14..13ec4467a049ef 100644 --- a/tests/components/media_player/test_condition.py +++ b/tests/components/media_player/test_condition.py @@ -4,6 +4,10 @@ import pytest +from homeassistant.components.media_player import ( + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, +) from homeassistant.components.media_player.const import MediaPlayerState from homeassistant.core import HomeAssistant @@ -16,10 +20,75 @@ other_states, parametrize_condition_states_all, parametrize_condition_states_any, + parametrize_numerical_attribute_condition_above_below_all, + parametrize_numerical_attribute_condition_above_below_any, parametrize_target_entities, target_entities, ) +# Volume is stored as 0.0–1.0 but the threshold is in percent. +_VOLUME_VALUE_SCALE = 0.01 + +_IS_VOLUME_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} + +# is_muted=True states (mute attr True OR volume_level == 0) +_IS_MUTED_STATES = [ + (MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: True}), + (MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_LEVEL: 0}), + ( + MediaPlayerState.PLAYING, + {ATTR_MEDIA_VOLUME_LEVEL: 0, ATTR_MEDIA_VOLUME_MUTED: True}, + ), + ( + MediaPlayerState.PLAYING, + {ATTR_MEDIA_VOLUME_LEVEL: 0, ATTR_MEDIA_VOLUME_MUTED: False}, + ), + ( + MediaPlayerState.PLAYING, + {ATTR_MEDIA_VOLUME_LEVEL: 1, ATTR_MEDIA_VOLUME_MUTED: True}, + ), +] + +# is_muted=False states (mute attr False/missing AND volume_level != 0) +_IS_NOT_MUTED_STATES = [ + (MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: False}), + (MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_LEVEL: 1}), + ( + MediaPlayerState.PLAYING, + {ATTR_MEDIA_VOLUME_LEVEL: 1, ATTR_MEDIA_VOLUME_MUTED: False}, + ), +] + + +def parametrize_muted_condition_states_any( + condition: str, target_muted: bool +) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: + """Parametrize behavior=any condition states for is_muted/is_unmuted.""" + return parametrize_condition_states_any( + condition=condition, + target_states=_IS_MUTED_STATES if target_muted else _IS_NOT_MUTED_STATES, + other_states=_IS_NOT_MUTED_STATES if target_muted else _IS_MUTED_STATES, + extra_excluded_states=[ + # State without any volume attributes — filtered by _should_include + MediaPlayerState.PLAYING, + ], + ) + + +def parametrize_muted_condition_states_all( + condition: str, target_muted: bool +) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]: + """Parametrize behavior=all condition states for is_muted/is_unmuted.""" + return parametrize_condition_states_all( + condition=condition, + target_states=_IS_MUTED_STATES if target_muted else _IS_NOT_MUTED_STATES, + other_states=_IS_NOT_MUTED_STATES if target_muted else _IS_MUTED_STATES, + extra_excluded_states=[ + # State without any volume attributes — filtered by _should_include + MediaPlayerState.PLAYING, + ], + ) + @pytest.fixture async def target_media_players(hass: HomeAssistant) -> dict[str, list[str]]: @@ -30,11 +99,14 @@ async def target_media_players(hass: HomeAssistant) -> dict[str, list[str]]: @pytest.mark.parametrize( "condition", [ + "media_player.is_muted", "media_player.is_off", "media_player.is_on", "media_player.is_not_playing", "media_player.is_paused", "media_player.is_playing", + "media_player.is_unmuted", + "media_player.is_volume", ], ) async def test_media_player_conditions_gated_by_labs_flag( @@ -48,11 +120,14 @@ async def test_media_player_conditions_gated_by_labs_flag( @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ + ("media_player.is_muted", {}, True, True), ("media_player.is_off", {}, True, True), ("media_player.is_on", {}, True, True), ("media_player.is_not_playing", {}, True, True), ("media_player.is_paused", {}, True, True), ("media_player.is_playing", {}, True, True), + ("media_player.is_unmuted", {}, True, True), + ("media_player.is_volume", _IS_VOLUME_THRESHOLD, True, True), ], ) async def test_media_player_condition_options_validation( @@ -80,6 +155,9 @@ async def test_media_player_condition_options_validation( @pytest.mark.parametrize( ("condition", "condition_options", "states"), [ + *parametrize_muted_condition_states_any( + "media_player.is_muted", target_muted=True + ), *parametrize_condition_states_any( condition="media_player.is_off", target_states=[MediaPlayerState.OFF], @@ -117,6 +195,16 @@ async def test_media_player_condition_options_validation( target_states=[MediaPlayerState.PLAYING], other_states=other_states(MediaPlayerState.PLAYING), ), + *parametrize_muted_condition_states_any( + "media_player.is_unmuted", target_muted=False + ), + *parametrize_numerical_attribute_condition_above_below_any( + "media_player.is_volume", + MediaPlayerState.PLAYING, + ATTR_MEDIA_VOLUME_LEVEL, + attribute_required=True, + attribute_value_scale=_VOLUME_VALUE_SCALE, + ), ], ) async def test_media_player_state_condition_behavior_any( @@ -150,6 +238,9 @@ async def test_media_player_state_condition_behavior_any( @pytest.mark.parametrize( ("condition", "condition_options", "states"), [ + *parametrize_muted_condition_states_all( + "media_player.is_muted", target_muted=True + ), *parametrize_condition_states_all( condition="media_player.is_off", target_states=[MediaPlayerState.OFF], @@ -187,6 +278,16 @@ async def test_media_player_state_condition_behavior_any( target_states=[MediaPlayerState.PLAYING], other_states=other_states(MediaPlayerState.PLAYING), ), + *parametrize_muted_condition_states_all( + "media_player.is_unmuted", target_muted=False + ), + *parametrize_numerical_attribute_condition_above_below_all( + "media_player.is_volume", + MediaPlayerState.PLAYING, + ATTR_MEDIA_VOLUME_LEVEL, + attribute_required=True, + attribute_value_scale=_VOLUME_VALUE_SCALE, + ), ], ) async def test_media_player_state_condition_behavior_all( diff --git a/tests/components/mobile_app/test_notify.py b/tests/components/mobile_app/test_notify.py index 405be4ef0e9593..8e710080f71eb0 100644 --- a/tests/components/mobile_app/test_notify.py +++ b/tests/components/mobile_app/test_notify.py @@ -167,10 +167,16 @@ async def notify_only() -> AsyncGenerator[None]: yield +@pytest.mark.freeze_time("1970-01-01T00:00:00.000Z") async def test_notify_works( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, setup_push_receiver ) -> None: """Test notify works.""" + + state = hass.states.get("notify.test") + assert state + assert state.state == STATE_UNKNOWN + assert hass.services.has_service("notify", "mobile_app_test") is True await hass.services.async_call( "notify", @@ -197,6 +203,11 @@ async def test_notify_works( assert call_json["registration_info"]["app_version"] == "1.0" assert call_json["registration_info"]["webhook_id"] == "mock-webhook_id" + await hass.async_block_till_done() + state = hass.states.get("notify.test") + assert state + assert state.state == "1970-01-01T00:00:00+00:00" + async def test_notify_ws_works( hass: HomeAssistant, @@ -413,6 +424,7 @@ async def test_notify_ws_not_confirming( assert len(aioclient_mock.mock_calls) == 3 +@pytest.mark.freeze_time("1970-01-01T00:00:00.000Z") async def test_local_push_only( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -443,6 +455,10 @@ async def test_local_push_only( assert sub_result["success"] sub_id = sub_result["id"] + state = hass.states.get("notify.websocket_push_name") + assert state + assert state.state == STATE_UNKNOWN + await hass.services.async_call( "notify", "mobile_app_websocket_push_name", @@ -455,6 +471,10 @@ async def test_local_push_only( assert msg["type"] == "event" assert msg["event"] == {"message": "Hello world 1"} + state = hass.states.get("notify.websocket_push_name") + assert state + assert state.state == "1970-01-01T00:00:00+00:00" + @pytest.mark.parametrize( "target", [["webhook_id_2", "mock-webhook_id", "websocket-push-webhook-id"], None] @@ -815,7 +835,6 @@ async def test_send_message_exceptions( @pytest.mark.usefixtures("setup_websocket_channel_only_push") -@pytest.mark.freeze_time("1970-01-01T00:00:00.000Z") async def test_send_message_local_push_exception(hass: HomeAssistant) -> None: """Test sending message via notify.send_message action through local push with exceptions.""" with pytest.raises(HomeAssistantError) as err: diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 3a5c6df408d460..c1b8b25c5153e7 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -1272,7 +1272,7 @@ def _side_effect_on_client_cert(data: bytes) -> MagicMock: mqtt_mock.async_connect.reset_mock() - result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) + result = await config_entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" @@ -1345,7 +1345,7 @@ async def test_keepalive_validation( mqtt_mock.async_connect.reset_mock() - result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) + result = await config_entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" @@ -1603,47 +1603,6 @@ async def test_option_flow_default_suggested_values( await hass.async_block_till_done() -@pytest.mark.parametrize( - ("advanced_options", "flow_result"), - [(False, FlowResultType.ABORT), (True, FlowResultType.FORM)], -) -@pytest.mark.usefixtures("mock_reload_after_entry_update") -async def test_skipping_advanced_options( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - mock_try_connection: MagicMock, - advanced_options: bool, - flow_result: FlowResultType, -) -> None: - """Test advanced options option.""" - - test_input = { - mqtt.CONF_BROKER: "another-broker", - CONF_PORT: 2345, - } - if advanced_options: - test_input["advanced_options"] = True - - mqtt_mock = await mqtt_mock_entry() - mock_try_connection.return_value = True - config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] - mqtt_mock.async_connect.reset_mock() - - result = await config_entry.start_reconfigure_flow( - hass, show_advanced_options=advanced_options - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "broker" - - assert ("advanced_options" in result["data_schema"].schema) == advanced_options - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=test_input, - ) - assert result["type"] is flow_result - - @pytest.mark.parametrize( ("test_input", "user_input", "new_password"), [ @@ -1897,7 +1856,7 @@ async def test_reconfigure_user_connection_fails( CONF_PORT: 1234, }, ) - result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) + result = await config_entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM mock_try_connection_time_out.reset_mock() @@ -2044,7 +2003,7 @@ async def test_try_connection_with_advanced_parameters( ) # Test default/suggested values from config - result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) + result = await config_entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" defaults = { @@ -2159,7 +2118,7 @@ async def test_setup_with_advanced_settings( mock_try_connection.return_value = True - result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) + result = await config_entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" assert result["data_schema"].schema["advanced_options"] @@ -2340,7 +2299,7 @@ async def test_setup_with_certificates( mock_try_connection.return_value = True - result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) + result = await config_entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" assert result["data_schema"].schema["advanced_options"] @@ -2464,7 +2423,7 @@ async def test_change_websockets_transport_to_tcp( mock_try_connection.return_value = True - result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) + result = await config_entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" assert result["data_schema"].schema["transport"] @@ -2515,7 +2474,7 @@ async def test_reconfigure_flow_form( """Test reconfigure flow.""" await mqtt_mock_entry() entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] - result = await entry.start_reconfigure_flow(hass, show_advanced_options=True) + result = await entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" assert result["errors"] == {} @@ -2569,7 +2528,7 @@ async def test_reconfigure_no_changed_password( """Test reconfigure flow.""" await mqtt_mock_entry() entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] - result = await entry.start_reconfigure_flow(hass, show_advanced_options=True) + result = await entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "broker" assert result["errors"] == {} diff --git a/tests/components/temperature/test_condition.py b/tests/components/temperature/test_condition.py index 9224f3d664a8a4..7e77dda53b9675 100644 --- a/tests/components/temperature/test_condition.py +++ b/tests/components/temperature/test_condition.py @@ -180,6 +180,7 @@ async def test_temperature_sensor_condition_behavior_all( HVACMode.AUTO, "current_temperature", threshold_unit=UnitOfTemperature.CELSIUS, + attribute_required=True, ), ) async def test_temperature_climate_condition_behavior_any( @@ -217,6 +218,7 @@ async def test_temperature_climate_condition_behavior_any( HVACMode.AUTO, "current_temperature", threshold_unit=UnitOfTemperature.CELSIUS, + attribute_required=True, ), ) async def test_temperature_climate_condition_behavior_all( @@ -254,6 +256,7 @@ async def test_temperature_climate_condition_behavior_all( "eco", "current_temperature", threshold_unit=UnitOfTemperature.CELSIUS, + attribute_required=True, ), ) async def test_temperature_water_heater_condition_behavior_any( @@ -291,6 +294,7 @@ async def test_temperature_water_heater_condition_behavior_any( "eco", "current_temperature", threshold_unit=UnitOfTemperature.CELSIUS, + attribute_required=True, ), ) async def test_temperature_water_heater_condition_behavior_all( @@ -329,6 +333,7 @@ async def test_temperature_water_heater_condition_behavior_all( "temperature", threshold_unit=UnitOfTemperature.CELSIUS, unit_attributes=_WEATHER_UNIT_ATTRIBUTES, + attribute_required=True, ), ) async def test_temperature_weather_condition_behavior_any( @@ -367,6 +372,7 @@ async def test_temperature_weather_condition_behavior_any( "temperature", threshold_unit=UnitOfTemperature.CELSIUS, unit_attributes=_WEATHER_UNIT_ATTRIBUTES, + attribute_required=True, ), ) async def test_temperature_weather_condition_behavior_all( diff --git a/tests/components/temperature/test_trigger.py b/tests/components/temperature/test_trigger.py index 993bda6068b609..8de4317a1f5101 100644 --- a/tests/components/temperature/test_trigger.py +++ b/tests/components/temperature/test_trigger.py @@ -263,12 +263,14 @@ async def test_temperature_trigger_sensor_crossed_threshold_behavior_last( HVACMode.AUTO, CLIMATE_ATTR_CURRENT_TEMPERATURE, threshold_unit=UnitOfTemperature.CELSIUS, + attribute_required=True, ), *parametrize_numerical_attribute_crossed_threshold_trigger_states( "temperature.crossed_threshold", HVACMode.AUTO, CLIMATE_ATTR_CURRENT_TEMPERATURE, threshold_unit=UnitOfTemperature.CELSIUS, + attribute_required=True, ), ], ) @@ -308,6 +310,7 @@ async def test_temperature_trigger_climate_behavior_any( HVACMode.AUTO, CLIMATE_ATTR_CURRENT_TEMPERATURE, threshold_unit=UnitOfTemperature.CELSIUS, + attribute_required=True, ), ], ) @@ -347,6 +350,7 @@ async def test_temperature_trigger_climate_crossed_threshold_behavior_first( HVACMode.AUTO, CLIMATE_ATTR_CURRENT_TEMPERATURE, threshold_unit=UnitOfTemperature.CELSIUS, + attribute_required=True, ), ], ) @@ -389,12 +393,14 @@ async def test_temperature_trigger_climate_crossed_threshold_behavior_last( "eco", WATER_HEATER_ATTR_CURRENT_TEMPERATURE, threshold_unit=UnitOfTemperature.CELSIUS, + attribute_required=True, ), *parametrize_numerical_attribute_crossed_threshold_trigger_states( "temperature.crossed_threshold", "eco", WATER_HEATER_ATTR_CURRENT_TEMPERATURE, threshold_unit=UnitOfTemperature.CELSIUS, + attribute_required=True, ), ], ) @@ -434,6 +440,7 @@ async def test_temperature_trigger_water_heater_behavior_any( "eco", WATER_HEATER_ATTR_CURRENT_TEMPERATURE, threshold_unit=UnitOfTemperature.CELSIUS, + attribute_required=True, ), ], ) @@ -473,6 +480,7 @@ async def test_temperature_trigger_water_heater_crossed_threshold_behavior_first "eco", WATER_HEATER_ATTR_CURRENT_TEMPERATURE, threshold_unit=UnitOfTemperature.CELSIUS, + attribute_required=True, ), ], ) @@ -516,6 +524,7 @@ async def test_temperature_trigger_water_heater_crossed_threshold_behavior_last( ATTR_WEATHER_TEMPERATURE, threshold_unit=UnitOfTemperature.CELSIUS, unit_attributes=_WEATHER_UNIT_ATTRIBUTES, + attribute_required=True, ), *parametrize_numerical_attribute_crossed_threshold_trigger_states( "temperature.crossed_threshold", @@ -523,6 +532,7 @@ async def test_temperature_trigger_water_heater_crossed_threshold_behavior_last( ATTR_WEATHER_TEMPERATURE, threshold_unit=UnitOfTemperature.CELSIUS, unit_attributes=_WEATHER_UNIT_ATTRIBUTES, + attribute_required=True, ), ], ) @@ -563,6 +573,7 @@ async def test_temperature_trigger_weather_behavior_any( ATTR_WEATHER_TEMPERATURE, threshold_unit=UnitOfTemperature.CELSIUS, unit_attributes=_WEATHER_UNIT_ATTRIBUTES, + attribute_required=True, ), ], ) @@ -603,6 +614,7 @@ async def test_temperature_trigger_weather_crossed_threshold_behavior_first( ATTR_WEATHER_TEMPERATURE, threshold_unit=UnitOfTemperature.CELSIUS, unit_attributes=_WEATHER_UNIT_ATTRIBUTES, + attribute_required=True, ), ], ) diff --git a/tests/components/template/test_entity.py b/tests/components/template/test_entity.py index 5e0a8235742861..680db0ec4a19ca 100644 --- a/tests/components/template/test_entity.py +++ b/tests/components/template/test_entity.py @@ -82,4 +82,5 @@ async def test_reload_stops_entity_action_scripts( await hass.async_block_till_done() assert not turn_on_script.is_running + assert turn_on_script._unloaded assert hass.data["light"].get_entity("light.test_light") is None diff --git a/tests/components/template/test_trigger_entity.py b/tests/components/template/test_trigger_entity.py index ece23f573e51c0..7b4b9cb4ed0b4c 100644 --- a/tests/components/template/test_trigger_entity.py +++ b/tests/components/template/test_trigger_entity.py @@ -1,7 +1,7 @@ """Test trigger template entity.""" import asyncio -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest @@ -17,7 +17,8 @@ STATE_ON, ) from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import template +from homeassistant.helpers import condition, template +from homeassistant.helpers.script import Script from homeassistant.helpers.trigger_template_entity import CONF_PICTURE from homeassistant.setup import async_setup_component @@ -243,6 +244,23 @@ async def test_multiple_template_validators(hass: HomeAssistant) -> None: assert state.attributes["current_tilt_position"] == 49 +async def test_coordinator_shutdown_unloads_script_and_condition( + hass: HomeAssistant, +) -> None: + """Test that coordinator shutdown stops and unloads script and condition.""" + coordinator = TriggerUpdateCoordinator(hass, {}) + + mock_script = Mock(spec=Script) + mock_cond = Mock(spec=condition.ConditionsChecker) + coordinator._script = mock_script + coordinator._cond_func = mock_cond + + await coordinator.async_shutdown() + + mock_script.async_unload.assert_called_once() + mock_cond.async_unload.assert_called_once() + + async def test_shutdown_stops_script_and_keeps_triggers_subscribed( hass: HomeAssistant, ) -> None: @@ -281,13 +299,15 @@ async def test_shutdown_stops_script_and_keeps_triggers_subscribed( coordinators = hass.data[DATA_COORDINATORS] assert len(coordinators) == 1 assert coordinators[0]._script.is_running + assert not coordinators[0]._script._unloaded # Fire shutdown hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() - # Script should be stopped - this is handled by the script helper + # Script should be stopped but not unloaded - this is handled by the script helper assert not coordinators[0]._script.is_running + assert not coordinators[0]._script._unloaded # Triggers are not unsubscribed on shutdown listeners = hass.bus.async_listeners() @@ -332,6 +352,7 @@ async def test_reload_stops_script_and_unsubscribes_triggers( assert len(coordinators) == 1 coordinator = coordinators[0] assert coordinator._script.is_running + assert not coordinator._script._unloaded # Reload with empty config with patch( @@ -342,8 +363,9 @@ async def test_reload_stops_script_and_unsubscribes_triggers( await hass.services.async_call("template", SERVICE_RELOAD, blocking=True) await hass.async_block_till_done() - # Script should be stopped + # Script should be stopped and unloaded assert not coordinator._script.is_running + assert coordinator._script._unloaded # Old trigger should be unsubscribed listeners = hass.bus.async_listeners() diff --git a/tests/components/water_heater/test_condition.py b/tests/components/water_heater/test_condition.py index 10736d1bd464d8..79bce1e431d493 100644 --- a/tests/components/water_heater/test_condition.py +++ b/tests/components/water_heater/test_condition.py @@ -228,6 +228,7 @@ async def test_water_heater_state_condition_behavior_all( "eco", ATTR_TEMPERATURE, threshold_unit=UnitOfTemperature.CELSIUS, + attribute_required=True, ), ], ) @@ -267,6 +268,7 @@ async def test_water_heater_numerical_condition_behavior_any( "eco", ATTR_TEMPERATURE, threshold_unit=UnitOfTemperature.CELSIUS, + attribute_required=True, ), ], ) diff --git a/tests/components/water_heater/test_trigger.py b/tests/components/water_heater/test_trigger.py index 9bf4356658e2d4..f0a28bbeed94f8 100644 --- a/tests/components/water_heater/test_trigger.py +++ b/tests/components/water_heater/test_trigger.py @@ -164,12 +164,14 @@ async def test_water_heater_state_trigger_behavior_any( STATE_ECO, ATTR_TEMPERATURE, threshold_unit=UnitOfTemperature.CELSIUS, + attribute_required=True, ), *parametrize_numerical_attribute_crossed_threshold_trigger_states( "water_heater.target_temperature_crossed_threshold", STATE_ECO, ATTR_TEMPERATURE, threshold_unit=UnitOfTemperature.CELSIUS, + attribute_required=True, ), ], ) @@ -270,6 +272,7 @@ async def test_water_heater_state_trigger_behavior_first( STATE_ECO, ATTR_TEMPERATURE, threshold_unit=UnitOfTemperature.CELSIUS, + attribute_required=True, ), ], ) @@ -370,6 +373,7 @@ async def test_water_heater_state_trigger_behavior_last( STATE_ECO, ATTR_TEMPERATURE, threshold_unit=UnitOfTemperature.CELSIUS, + attribute_required=True, ), ], ) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 20274fa04209f2..2381e0c2fca41d 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -3,7 +3,7 @@ from collections.abc import Mapping from contextlib import AbstractContextManager, nullcontext as does_not_raise from dataclasses import dataclass, field -from datetime import timedelta +from datetime import datetime, timedelta import io import logging from typing import Any @@ -62,6 +62,7 @@ CONDITIONS, Condition, ConditionChecker, + EntityConditionBase, EntityNumericalConditionWithUnitBase, _async_get_condition_platform, async_validate_condition_config, @@ -5141,6 +5142,94 @@ async def test_state_condition_attr_duration_unrelated_attr_update( assert test.async_check() is True +class _AttributeBackedStateCondition(EntityConditionBase): + """Test condition that reads an attribute directly in `is_valid_state`. + + Used by `test_state_condition_state_valid_since_anchors_duration` to + drive the default `_state_valid_since` path (`last_changed`-anchored) + for an attribute-source condition. + """ + + _domain_specs = {"test": DomainSpec()} + + def is_valid_state(self, entity_state: State) -> bool: + return entity_state.attributes.get("flag") is True + + +class _AttributeBackedStateConditionLastUpdated(_AttributeBackedStateCondition): + """Test condition that overrides `_state_valid_since` to use `last_updated`.""" + + def _state_valid_since(self, state: State) -> datetime: + return state.last_updated + + +@pytest.mark.parametrize( + ("condition_cls", "duration_met_after_attr_flip"), + [ + # Default `_state_valid_since` returns `last_changed` for the + # state-source domain. With `state.state` unchanged for 60s, the + # duration is satisfied as soon as the attribute flips — + # demonstrates the false-positive bug for attribute-reading + # conditions. + (_AttributeBackedStateCondition, True), + # Override returning `last_updated` resets the anchor on every + # state update (including attribute-only updates), so the `for:` + # window correctly starts at the moment of the flip. + (_AttributeBackedStateConditionLastUpdated, False), + ], +) +async def test_state_condition_state_valid_since_anchors_duration( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + condition_cls: type[EntityConditionBase], + duration_met_after_attr_flip: bool, +) -> None: + """Verify `_state_valid_since` is consulted to anchor the `for:` duration. + + Drives a condition that becomes valid via an attribute flip while + `state.state` is unchanged, then checks whether the duration is + satisfied immediately after the flip. The result depends entirely on + which timestamp `_state_valid_since` returns: the default + (`last_changed`, far in the past) satisfies the duration immediately, + while an override returning `last_updated` anchors to the flip and + requires the full window to elapse. + """ + + async def async_get_conditions( + hass: HomeAssistant, + ) -> dict[str, type[Condition]]: + return {"_": condition_cls} + + mock_integration(hass, MockModule("test")) + mock_platform( + hass, "test.condition", Mock(async_get_conditions=async_get_conditions) + ) + + # state.state is set well before the attribute flip — its + # last_changed will be far in the past by the time the attribute + # flips the condition true. + hass.states.async_set("test.entity_1", STATE_ON, {"flag": False}) + await hass.async_block_till_done() + + config: dict[str, Any] = { + CONF_CONDITION: "test", + CONF_TARGET: {CONF_ENTITY_ID: "test.entity_1"}, + CONF_OPTIONS: {CONF_FOR: {"seconds": 5}}, + } + config = await async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + assert test is not None + + freezer.tick(timedelta(seconds=60)) + + hass.states.async_set("test.entity_1", STATE_ON, {"flag": True}) + await hass.async_block_till_done() + + # Just after the flip, well within the 5-second `for:` window. + freezer.tick(timedelta(seconds=1)) + assert test.async_check() is duration_met_after_attr_flip + + @pytest.mark.parametrize(("primary_entities_only"), [True, False]) async def test_state_condition_primary_entities_only( hass: HomeAssistant, primary_entities_only: bool