diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 286eddb2e91f9c..0e5b70737839ab 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -108,7 +108,7 @@ jobs: - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20 + uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/frontend @@ -119,7 +119,7 @@ jobs: - name: Download nightly wheels of intents if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20 + uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: OHF-Voice/intents-package diff --git a/homeassistant/components/airnow/strings.json b/homeassistant/components/airnow/strings.json index 20aed65cc0f51c..2b3d417abcf467 100644 --- a/homeassistant/components/airnow/strings.json +++ b/homeassistant/components/airnow/strings.json @@ -17,7 +17,13 @@ "longitude": "[%key:common::config_flow::data::longitude%]", "radius": "Station radius (miles; optional)" }, - "description": "To generate API key go to {api_key_url}" + "data_description": { + "api_key": "To generate an API key, go to {api_key_url}.", + "latitude": "The latitude of your location.", + "longitude": "The longitude of your location.", + "radius": "The radius in miles around your location to search for reporting stations." + }, + "description": "To generate an API key, go to {api_key_url}." } } }, diff --git a/homeassistant/components/duckdns/__init__.py b/homeassistant/components/duckdns/__init__.py index 6843a9348d45db..e7fab85777d930 100644 --- a/homeassistant/components/duckdns/__init__.py +++ b/homeassistant/components/duckdns/__init__.py @@ -2,10 +2,6 @@ import logging -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -16,18 +12,7 @@ _LOGGER = logging.getLogger(__name__) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_DOMAIN): cv.string, - vol.Required(CONF_ACCESS_TOKEN): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -35,15 +20,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_setup_services(hass) - if DOMAIN not in config: - return True - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] - ) - ) - return True diff --git a/homeassistant/components/duckdns/config_flow.py b/homeassistant/components/duckdns/config_flow.py index 84e2815f60fc86..4a2bf9036f2799 100644 --- a/homeassistant/components/duckdns/config_flow.py +++ b/homeassistant/components/duckdns/config_flow.py @@ -16,7 +16,6 @@ from .const import DOMAIN from .helpers import update_duckdns -from .issue import deprecate_yaml_issue _LOGGER = logging.getLogger(__name__) @@ -68,18 +67,6 @@ async def async_step_user( description_placeholders={"url": "https://www.duckdns.org/"}, ) - async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult: - """Import config from yaml.""" - - self._async_abort_entries_match({CONF_DOMAIN: import_info[CONF_DOMAIN]}) - result = await self.async_step_user(import_info) - if errors := result.get("errors"): - deprecate_yaml_issue(self.hass, import_success=False) - return self.async_abort(reason=errors["base"]) - - deprecate_yaml_issue(self.hass, import_success=True) - return result - async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/duckdns/issue.py b/homeassistant/components/duckdns/issue.py index 34a23fdbc639b0..af764ae7f38384 100644 --- a/homeassistant/components/duckdns/issue.py +++ b/homeassistant/components/duckdns/issue.py @@ -1,45 +1,11 @@ """Issues for Duck DNS integration.""" -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import DOMAIN -@callback -def deprecate_yaml_issue(hass: HomeAssistant, *, import_success: bool) -> None: - """Deprecate yaml issue.""" - if import_success: - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - is_fixable=False, - issue_domain=DOMAIN, - breaks_in_ha_version="2026.6.0", - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Duck DNS", - }, - ) - else: - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml_import_issue_error", - breaks_in_ha_version="2026.6.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml_import_issue_error", - translation_placeholders={ - "url": "/config/integrations/dashboard/add?domain=duckdns" - }, - ) - - def action_called_without_config_entry(hass: HomeAssistant) -> None: """Deprecate the use of action without config entry.""" diff --git a/homeassistant/components/duckdns/strings.json b/homeassistant/components/duckdns/strings.json index 87262c913e32c4..9581a1cc1117f1 100644 --- a/homeassistant/components/duckdns/strings.json +++ b/homeassistant/components/duckdns/strings.json @@ -49,10 +49,6 @@ "deprecated_call_without_config_entry": { "description": "Calling the `duckdns.set_txt` action without specifying a config entry is deprecated.\n\nThe `config_entry_id` field will be required in a future release.\n\nPlease update your automations and scripts to include the `config_entry_id` parameter.", "title": "Detected deprecated use of action without config entry" - }, - "deprecated_yaml_import_issue_error": { - "description": "Configuring Duck DNS using YAML is being removed but there was an error when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the Duck DNS YAML configuration from your `configuration.yaml` file and continue to [set up the integration]({url}) manually.", - "title": "The Duck DNS YAML configuration import failed" } }, "services": { diff --git a/homeassistant/components/duco/manifest.json b/homeassistant/components/duco/manifest.json index aa7749962888d8..a047e3f87a714b 100644 --- a/homeassistant/components/duco/manifest.json +++ b/homeassistant/components/duco/manifest.json @@ -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.0"], "zeroconf": [ { "name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*", diff --git a/homeassistant/components/infrared/manifest.json b/homeassistant/components/infrared/manifest.json index 0fa1428d7b16cb..284a4a19c7d1fe 100644 --- a/homeassistant/components/infrared/manifest.json +++ b/homeassistant/components/infrared/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/infrared", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["infrared-protocols==2.0.0"] + "requirements": ["infrared-protocols==2.1.0"] } diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 950099aa6d755e..2fb9d53ee0d417 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -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 diff --git a/homeassistant/components/litterrobot/binary_sensor.py b/homeassistant/components/litterrobot/binary_sensor.py index 6cc68a7d87a2a6..c4063e65d0bb67 100644 --- a/homeassistant/components/litterrobot/binary_sensor.py +++ b/homeassistant/components/litterrobot/binary_sensor.py @@ -66,7 +66,7 @@ class RobotBinarySensorEntityDescription( 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", ), ), } diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index 8518d9781d1c56..04440098585246 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -16,5 +16,5 @@ "iot_class": "cloud_push", "loggers": ["pylitterbot"], "quality_scale": "platinum", - "requirements": ["pylitterbot==2025.3.2"] + "requirements": ["pylitterbot==2025.4.0"] } diff --git a/homeassistant/components/media_player/icons.json b/homeassistant/components/media_player/icons.json index 45e89487227dfa..068a0910c3c7d1 100644 --- a/homeassistant/components/media_player/icons.json +++ b/homeassistant/components/media_player/icons.json @@ -123,6 +123,9 @@ } }, "triggers": { + "muted": { + "trigger": "mdi:volume-mute" + }, "paused_playing": { "trigger": "mdi:pause" }, @@ -137,6 +140,9 @@ }, "turned_on": { "trigger": "mdi:power" + }, + "unmuted": { + "trigger": "mdi:volume-high" } } } diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index be6cc8e21d3f9d..02e055ea01cb6f 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -437,6 +437,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 +508,18 @@ } }, "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" } } } diff --git a/homeassistant/components/media_player/trigger.py b/homeassistant/components/media_player/trigger.py index 16a96b126ac1c3..5b0d4527a77b1e 100644 --- a/homeassistant/components/media_player/trigger.py +++ b/homeassistant/components/media_player/trigger.py @@ -1,12 +1,99 @@ """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 ( + EntityTriggerBase, + Trigger, + make_entity_transition_trigger, +) -from . import MediaPlayerState +from . import ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, MediaPlayerState from .const import DOMAIN + +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 state.state not in self._excluded_states and self._has_volume_attributes( + state + ) + + def check_all_match(self, entity_ids: set[str]) -> bool: + """Check if all mutable 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 self._should_include(state) + ) + + def count_matches(self, entity_ids: set[str]) -> int: + """Count matching mutable entities.""" + 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 self._should_include(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 + + TRIGGERS: dict[str, type[Trigger]] = { + "muted": MediaPlayerMutedTrigger, + "unmuted": MediaPlayerUnmutedTrigger, "paused_playing": make_entity_transition_trigger( DOMAIN, from_states={ diff --git a/homeassistant/components/media_player/triggers.yaml b/homeassistant/components/media_player/triggers.yaml index 94ceb1e98aa783..d719c61d10fe67 100644 --- a/homeassistant/components/media_player/triggers.yaml +++ b/homeassistant/components/media_player/triggers.yaml @@ -15,6 +15,8 @@ selector: duration: +muted: *trigger_common +unmuted: *trigger_common paused_playing: *trigger_common started_playing: *trigger_common stopped_playing: *trigger_common diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index 015a6bbc5e5f96..b0461c8d3e77e3 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -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 diff --git a/homeassistant/components/owntracks/const.py b/homeassistant/components/owntracks/const.py index 339295b89d53ef..49864e5cce2ace 100644 --- a/homeassistant/components/owntracks/const.py +++ b/homeassistant/components/owntracks/const.py @@ -1,4 +1,12 @@ """Constants for OwnTracks.""" -DOMAIN = "owntracks" -ATTR_UPDATE_TIMESTAMP = "update_timestamp" +from typing import Final + +DOMAIN: Final = "owntracks" + +ATTR_ADDRESS: Final = "address" +ATTR_BATTERY_STATUS: Final = "battery_status" +ATTR_COURSE: Final = "course" +ATTR_TID: Final = "tid" +ATTR_UPDATE_TIMESTAMP: Final = "update_timestamp" +ATTR_VELOCITY: Final = "velocity" diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index eed146b9a31f7c..bdd69930fa6ff5 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -21,8 +21,26 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util import dt as dt_util + +from .const import ( + ATTR_ADDRESS, + ATTR_BATTERY_STATUS, + ATTR_COURSE, + ATTR_TID, + ATTR_UPDATE_TIMESTAMP, + ATTR_VELOCITY, + DOMAIN, +) -from .const import DOMAIN +_RESTORED_OWNTRACKS_ATTRIBUTES: tuple[str, ...] = ( + ATTR_ADDRESS, + ATTR_BATTERY_STATUS, + ATTR_COURSE, + ATTR_TID, + ATTR_UPDATE_TIMESTAMP, + ATTR_VELOCITY, +) async def async_setup_entry( @@ -141,12 +159,19 @@ async def async_added_to_hass(self) -> None: return attr = state.attributes + attributes = { + key: attr[key] for key in _RESTORED_OWNTRACKS_ATTRIBUTES if key in attr + } + if isinstance(update_timestamp := attributes.get(ATTR_UPDATE_TIMESTAMP), str): + attributes[ATTR_UPDATE_TIMESTAMP] = dt_util.parse_datetime(update_timestamp) + self._data = { "host_name": state.name, "gps": (attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)), "gps_accuracy": attr.get(ATTR_GPS_ACCURACY), "battery": attr.get(ATTR_BATTERY_LEVEL), "source_type": attr.get(ATTR_SOURCE_TYPE), + "attributes": attributes, } @callback diff --git a/homeassistant/components/owntracks/messages.py b/homeassistant/components/owntracks/messages.py index c06dcee1099b8e..b59ec84749d872 100644 --- a/homeassistant/components/owntracks/messages.py +++ b/homeassistant/components/owntracks/messages.py @@ -11,7 +11,14 @@ from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, STATE_HOME from homeassistant.util import decorator, dt as dt_util, slugify -from .const import ATTR_UPDATE_TIMESTAMP +from .const import ( + ATTR_ADDRESS, + ATTR_BATTERY_STATUS, + ATTR_COURSE, + ATTR_TID, + ATTR_UPDATE_TIMESTAMP, + ATTR_VELOCITY, +) from .helper import supports_encryption _LOGGER = logging.getLogger(__name__) @@ -72,15 +79,15 @@ def _parse_see_args(message, subscribe_topic): if "batt" in message: kwargs["battery"] = message["batt"] if "vel" in message: - kwargs["attributes"]["velocity"] = message["vel"] + kwargs["attributes"][ATTR_VELOCITY] = message["vel"] if "tid" in message: - kwargs["attributes"]["tid"] = message["tid"] + kwargs["attributes"][ATTR_TID] = message["tid"] if "addr" in message: - kwargs["attributes"]["address"] = message["addr"] + kwargs["attributes"][ATTR_ADDRESS] = message["addr"] if "cog" in message: - kwargs["attributes"]["course"] = message["cog"] + kwargs["attributes"][ATTR_COURSE] = message["cog"] if "bs" in message: - kwargs["attributes"]["battery_status"] = message["bs"] + kwargs["attributes"][ATTR_BATTERY_STATUS] = message["bs"] if "t" in message: if message["t"] in ("c", "u"): kwargs["source_type"] = SourceType.GPS diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 2cfc16ceb6b452..ba880009e86315 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -204,7 +204,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] diff --git a/homeassistant/components/template/coordinator.py b/homeassistant/components/template/coordinator.py index a2823233336a36..4c90870dac81f5 100644 --- a/homeassistant/components/template/coordinator.py +++ b/homeassistant/components/template/coordinator.py @@ -59,13 +59,17 @@ def unique_id(self) -> str | None: """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_stop() async def async_setup(self, hass_config: ConfigType) -> None: """Set up the trigger and create entities.""" diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index f7b5c3ff989c85..9cd86cc25fc8b5 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -168,6 +168,11 @@ def add_script( domain, ) + 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() + async def async_run_script( self, script: Script, diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index 31b9c0219f222c..863971ac1bef14 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -2,26 +2,16 @@ from pyvlx import PyVLX, PyVLXException -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_MAC, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import Event, HomeAssistant, ServiceCall -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, - ServiceValidationError, -) -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - issue_registry as ir, -) -from homeassistant.helpers.typing import ConfigType +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import DOMAIN, LOGGER, PLATFORMS @@ -30,47 +20,6 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Velux component.""" - - async def async_reboot_gateway(service_call: ServiceCall) -> None: - """Reboot the gateway (deprecated - use button entity instead).""" - ir.async_create_issue( - hass, - DOMAIN, - "deprecated_reboot_service", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_reboot_service", - breaks_in_ha_version="2026.6.0", - ) - - # Find a loaded config entry to get the PyVLX instance - # We assume only one gateway is set up or we just reboot the first one found - # (this is no change to the previous behavior, the alternative would be to reboot all) - for entry in hass.config_entries.async_entries(DOMAIN): - if entry.state is ConfigEntryState.LOADED: - try: - await entry.runtime_data.reboot_gateway() - except (OSError, PyVLXException) as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="reboot_failed", - ) from err - else: - return - - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_gateway_loaded", - ) - - hass.services.async_register(DOMAIN, "reboot_gateway", async_reboot_gateway) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> bool: """Set up the velux component.""" host = entry.data[CONF_HOST] diff --git a/homeassistant/components/velux/icons.json b/homeassistant/components/velux/icons.json deleted file mode 100644 index 78cb5b148385d3..00000000000000 --- a/homeassistant/components/velux/icons.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "services": { - "reboot_gateway": { - "service": "mdi:restart" - } - } -} diff --git a/homeassistant/components/velux/services.yaml b/homeassistant/components/velux/services.yaml deleted file mode 100644 index 7aee1694061aae..00000000000000 --- a/homeassistant/components/velux/services.yaml +++ /dev/null @@ -1,3 +0,0 @@ -# Velux Integration services - -reboot_gateway: diff --git a/homeassistant/components/velux/strings.json b/homeassistant/components/velux/strings.json index a52fb0a245c9d9..f833503aaac9b6 100644 --- a/homeassistant/components/velux/strings.json +++ b/homeassistant/components/velux/strings.json @@ -59,23 +59,8 @@ "device_communication_error": { "message": "Failed to communicate with Velux device: {error}" }, - "no_gateway_loaded": { - "message": "No loaded Velux gateway found" - }, "reboot_failed": { "message": "Failed to reboot gateway. Try again in a few moments or power cycle the device manually" } - }, - "issues": { - "deprecated_reboot_service": { - "description": "The `velux.reboot_gateway` action is deprecated and will be removed in Home Assistant 2026.6.0. Please use the 'Restart' button entity instead. You can find this button in the device page for your KLF 200 Gateway or by searching for 'restart' in your entity list.", - "title": "Velux 'Reboot gateway' action deprecated" - } - }, - "services": { - "reboot_gateway": { - "description": "Reboots the KLF200 Gateway", - "name": "Reboot gateway" - } } } diff --git a/homeassistant/components/volvo/sensor.py b/homeassistant/components/volvo/sensor.py index c78450c994ea7e..2820095cba28dc 100644 --- a/homeassistant/components/volvo/sensor.py +++ b/homeassistant/components/volvo/sensor.py @@ -99,6 +99,7 @@ def _direction_value(field: VolvoCarsApiBaseModel) -> str | None: _CHARGING_POWER_STATUS_OPTIONS = [ "fault", + "initialization", "power_available_but_not_activated", "providing_power", "no_power_available", diff --git a/homeassistant/components/volvo/strings.json b/homeassistant/components/volvo/strings.json index 2c41bdb3fd25cd..445fd04cb9c048 100644 --- a/homeassistant/components/volvo/strings.json +++ b/homeassistant/components/volvo/strings.json @@ -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" diff --git a/requirements.txt b/requirements.txt index 5acebacf0e00ac..5bfddd729a3860 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,7 +30,7 @@ home-assistant-bluetooth==2.0.0 home-assistant-intents==2026.3.24 httpx==0.28.1 ifaddr==0.2.0 -infrared-protocols==2.0.0 +infrared-protocols==2.1.0 Jinja2==3.1.6 lru-dict==1.4.1 mutagen==1.47.0 diff --git a/requirements_all.txt b/requirements_all.txt index 7b0625d746192e..415afb743ac2f5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1341,7 +1341,7 @@ influxdb-client==1.50.0 influxdb==5.3.1 # homeassistant.components.infrared -infrared-protocols==2.0.0 +infrared-protocols==2.1.0 # homeassistant.components.inkbird inkbird-ble==1.1.1 @@ -2279,7 +2279,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 @@ -2587,7 +2587,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.0 # homeassistant.components.ecobee python-ecobee-api==0.3.2 @@ -3352,7 +3352,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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 45f6ddcd308db8..d823cf74475b83 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1193,7 +1193,7 @@ influxdb-client==1.50.0 influxdb==5.3.1 # homeassistant.components.infrared -infrared-protocols==2.0.0 +infrared-protocols==2.1.0 # homeassistant.components.inkbird inkbird-ble==1.1.1 @@ -1956,7 +1956,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 @@ -2213,7 +2213,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.0 # homeassistant.components.ecobee python-ecobee-api==0.3.2 @@ -2852,7 +2852,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 diff --git a/tests/components/duckdns/test_config_flow.py b/tests/components/duckdns/test_config_flow.py index d5011e6bedcaa5..9909aa2b5a2b19 100644 --- a/tests/components/duckdns/test_config_flow.py +++ b/tests/components/duckdns/test_config_flow.py @@ -5,12 +5,10 @@ import pytest from homeassistant.components.duckdns import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component from .conftest import NEW_TOKEN, TEST_SUBDOMAIN, TEST_TOKEN @@ -120,108 +118,6 @@ async def test_form_errors( assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.usefixtures("mock_update_duckdns") -async def test_import( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - issue_registry: ir.IssueRegistry, -) -> None: - """Test import flow.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_DOMAIN: TEST_SUBDOMAIN, - CONF_ACCESS_TOKEN: TEST_TOKEN, - }, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == f"{TEST_SUBDOMAIN}.duckdns.org" - assert result["data"] == { - CONF_DOMAIN: TEST_SUBDOMAIN, - CONF_ACCESS_TOKEN: TEST_TOKEN, - } - assert len(mock_setup_entry.mock_calls) == 1 - assert issue_registry.async_get_issue( - domain=HOMEASSISTANT_DOMAIN, - issue_id=f"deprecated_yaml_{DOMAIN}", - ) - - -async def test_import_failed( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - issue_registry: ir.IssueRegistry, - mock_update_duckdns: AsyncMock, -) -> None: - """Test import flow failed.""" - mock_update_duckdns.return_value = False - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_DOMAIN: TEST_SUBDOMAIN, - CONF_ACCESS_TOKEN: TEST_TOKEN, - }, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "update_failed" - - assert len(mock_setup_entry.mock_calls) == 0 - - assert issue_registry.async_get_issue( - domain=DOMAIN, - issue_id="deprecated_yaml_import_issue_error", - ) - - -async def test_import_exception( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - issue_registry: ir.IssueRegistry, - mock_update_duckdns: AsyncMock, -) -> None: - """Test import flow failed unknown.""" - mock_update_duckdns.side_effect = ValueError - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_DOMAIN: TEST_SUBDOMAIN, - CONF_ACCESS_TOKEN: TEST_TOKEN, - }, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" - - assert len(mock_setup_entry.mock_calls) == 0 - - assert issue_registry.async_get_issue( - domain=DOMAIN, - issue_id="deprecated_yaml_import_issue_error", - ) - - -@pytest.mark.usefixtures("mock_update_duckdns") -async def test_init_import_flow( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, -) -> None: - """Test yaml triggers import flow.""" - - await async_setup_component( - hass, - DOMAIN, - {"duckdns": {CONF_DOMAIN: TEST_SUBDOMAIN, CONF_ACCESS_TOKEN: TEST_TOKEN}}, - ) - assert len(mock_setup_entry.mock_calls) == 1 - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - - @pytest.mark.usefixtures("mock_update_duckdns", "mock_setup_entry") async def test_flow_reconfigure( hass: HomeAssistant, diff --git a/tests/components/duco/snapshots/test_diagnostics.ambr b/tests/components/duco/snapshots/test_diagnostics.ambr index 029af1a1798c41..76b108b65e3b39 100644 --- a/tests/components/duco/snapshots/test_diagnostics.ambr +++ b/tests/components/duco/snapshots/test_diagnostics.ambr @@ -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({ diff --git a/tests/components/media_player/test_trigger.py b/tests/components/media_player/test_trigger.py index 3d14c1bc63391c..163c199450f5f7 100644 --- a/tests/components/media_player/test_trigger.py +++ b/tests/components/media_player/test_trigger.py @@ -4,11 +4,17 @@ import pytest -from homeassistant.components.media_player import MediaPlayerState +from homeassistant.components.media_player import ( + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + MediaPlayerState, +) +from homeassistant.const import CONF_ENTITY_ID from homeassistant.core import HomeAssistant from tests.components.common import ( TriggerStateDescription, + arm_trigger, assert_trigger_behavior_any, assert_trigger_behavior_first, assert_trigger_behavior_last, @@ -29,6 +35,8 @@ async def target_media_players(hass: HomeAssistant) -> dict[str, list[str]]: @pytest.mark.parametrize( "trigger_key", [ + "media_player.muted", + "media_player.unmuted", "media_player.paused_playing", "media_player.started_playing", "media_player.stopped_playing", @@ -43,10 +51,60 @@ async def test_media_player_triggers_gated_by_labs_flag( await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key) +def parametrize_muted_trigger_states() -> list[ + tuple[str, list[TriggerStateDescription]] +]: + """Parametrize states and expected service call counts. + + Only states with volume attributes are used as other_states, because + entities without volume attributes are excluded from all/last checks + and would cause those tests to fire prematurely. + + Returns a list of tuples with (trigger, list of states), + where states is a list of TriggerStateDescription dicts. + """ + trigger = "media_player.muted" + return parametrize_trigger_states( + trigger=trigger, + target_states=[ + # States with muted attribute + (MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: True}), + # States with volume attribute + (MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_LEVEL: 0}), + # States with muted and volume attribute + ( + 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}, + ), + ], + other_states=[ + # States with muted attribute (not muted) + (MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: False}), + # States with volume attribute (not muted) + (MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_LEVEL: 1}), + # States with muted and volume attribute (not muted) + ( + MediaPlayerState.PLAYING, + {ATTR_MEDIA_VOLUME_LEVEL: 1, ATTR_MEDIA_VOLUME_MUTED: False}, + ), + ], + ) + + @pytest.mark.usefixtures("enable_labs_preview_features") @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ + ("media_player.muted", {}, True, True), + ("media_player.unmuted", {}, True, True), ("media_player.paused_playing", {}, True, True), ("media_player.started_playing", {}, True, True), ("media_player.stopped_playing", {}, True, True), @@ -79,6 +137,7 @@ async def test_media_player_trigger_options_validation( @pytest.mark.parametrize( ("trigger", "trigger_options", "states"), [ + *parametrize_muted_trigger_states(), *parametrize_trigger_states( trigger="media_player.paused_playing", target_states=[ @@ -174,6 +233,7 @@ async def test_media_player_state_trigger_behavior_any( @pytest.mark.parametrize( ("trigger", "trigger_options", "states"), [ + *parametrize_muted_trigger_states(), *parametrize_trigger_states( trigger="media_player.stopped_playing", target_states=[ @@ -220,6 +280,7 @@ async def test_media_player_state_trigger_behavior_first( @pytest.mark.parametrize( ("trigger", "trigger_options", "states"), [ + *parametrize_muted_trigger_states(), *parametrize_trigger_states( trigger="media_player.stopped_playing", target_states=[ @@ -256,3 +317,209 @@ async def test_media_player_state_trigger_behavior_last( trigger_options=trigger_options, states=states, ) + + +@pytest.mark.usefixtures("enable_labs_preview_features") +async def test_muted_trigger_ignores_entities_without_volume_attributes( + hass: HomeAssistant, +) -> None: + """Test that the muted trigger does not fire for entities without volume attributes.""" + entity_id = "media_player.no_volume" + calls: list[str] = [] + + hass.states.async_set(entity_id, MediaPlayerState.PLAYING, {}) + await hass.async_block_till_done() + + await arm_trigger( + hass, + "media_player.muted", + None, + {CONF_ENTITY_ID: [entity_id]}, + calls, + ) + + # Transition without volume attributes — should not fire + hass.states.async_set(entity_id, MediaPlayerState.IDLE, {}) + await hass.async_block_till_done() + assert len(calls) == 0 + + # Transition with volume attributes — should not fire (not muted) + hass.states.async_set( + entity_id, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: False} + ) + await hass.async_block_till_done() + assert len(calls) == 0 + + # Transition to muted — should fire + hass.states.async_set( + entity_id, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: True} + ) + await hass.async_block_till_done() + assert len(calls) == 1 + + +@pytest.mark.usefixtures("enable_labs_preview_features") +async def test_muted_trigger_fires_when_entity_gains_volume_attributes( + hass: HomeAssistant, +) -> None: + """Test that the trigger fires when an entity gains volume attributes and becomes muted.""" + entity_id = "media_player.gains_volume" + calls: list[str] = [] + + # Start without volume attributes + hass.states.async_set(entity_id, MediaPlayerState.PLAYING, {}) + await hass.async_block_till_done() + + await arm_trigger( + hass, + "media_player.muted", + None, + {CONF_ENTITY_ID: [entity_id]}, + calls, + ) + + # Gain volume attributes and become muted in one transition + hass.states.async_set( + entity_id, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: True} + ) + await hass.async_block_till_done() + assert len(calls) == 1 + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger", "initial_muted", "target_muted"), + [ + ("media_player.muted", False, True), + ("media_player.unmuted", True, False), + ], +) +async def test_muted_trigger_last_skips_entities_without_volume_attributes( + hass: HomeAssistant, + trigger: str, + initial_muted: bool, + target_muted: bool, +) -> None: + """Test that 'last' behavior skips entities without volume attributes. + + With entities a (has volume), b (has volume), c (no volume): + The trigger should fire when both a and b transition, regardless of c. + """ + entity_a = "media_player.with_volume_a" + entity_b = "media_player.with_volume_b" + entity_c = "media_player.no_volume" + calls: list[str] = [] + + hass.states.async_set( + entity_a, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: initial_muted} + ) + hass.states.async_set( + entity_b, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: initial_muted} + ) + hass.states.async_set(entity_c, MediaPlayerState.PLAYING, {}) + await hass.async_block_till_done() + + await arm_trigger( + hass, + trigger, + {"behavior": "last"}, + {CONF_ENTITY_ID: [entity_a, entity_b, entity_c]}, + calls, + ) + + # Transition entity a — not all mutable entities transitioned yet + hass.states.async_set( + entity_a, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: target_muted} + ) + await hass.async_block_till_done() + assert len(calls) == 0 + + # Transition entity b — now all mutable entities have transitioned, fires + hass.states.async_set( + entity_b, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: target_muted} + ) + await hass.async_block_till_done() + assert len(calls) == 1 + + +@pytest.mark.usefixtures("enable_labs_preview_features") +async def test_muted_trigger_does_not_fire_on_losing_volume_attributes( + hass: HomeAssistant, +) -> None: + """Test that the trigger does not fire when a muted entity loses volume attributes.""" + entity_id = "media_player.loses_volume" + calls: list[str] = [] + + # Start muted + hass.states.async_set( + entity_id, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: True} + ) + await hass.async_block_till_done() + + await arm_trigger( + hass, + "media_player.muted", + None, + {CONF_ENTITY_ID: [entity_id]}, + calls, + ) + + # Lose volume attributes — should not fire (transition to no-attributes + # is not a valid transition because to_state has no volume attributes) + hass.states.async_set(entity_id, MediaPlayerState.PLAYING, {}) + await hass.async_block_till_done() + assert len(calls) == 0 + + +@pytest.mark.usefixtures("enable_labs_preview_features") +@pytest.mark.parametrize( + ("trigger", "initial_muted", "target_muted"), + [ + ("media_player.muted", False, True), + ("media_player.unmuted", True, False), + ], +) +async def test_muted_trigger_first_skips_entities_without_volume_attributes( + hass: HomeAssistant, + trigger: str, + initial_muted: bool, + target_muted: bool, +) -> None: + """Test that 'first' behavior skips entities without volume attributes.""" + entity_a = "media_player.with_volume_a" + entity_b = "media_player.with_volume_b" + entity_c = "media_player.no_volume" + calls: list[str] = [] + + hass.states.async_set( + entity_a, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: initial_muted} + ) + hass.states.async_set( + entity_b, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: initial_muted} + ) + hass.states.async_set(entity_c, MediaPlayerState.PLAYING, {}) + await hass.async_block_till_done() + + await arm_trigger( + hass, + trigger, + {"behavior": "first"}, + {CONF_ENTITY_ID: [entity_a, entity_b, entity_c]}, + calls, + ) + + # Transition entity a — first mutable entity transitions, fires + hass.states.async_set( + entity_a, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: target_muted} + ) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0] == entity_a + calls.clear() + + # Transition entity b — first behavior already armed, does not fire again + hass.states.async_set( + entity_b, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: target_muted} + ) + await hass.async_block_till_done() + assert len(calls) == 0 diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index 1cdea6c5b80926..34842d6c88425e 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -7620,6 +7620,7 @@ 'powerfresh', 'pre_ironing', 'proofing', + 'quick_intense', 'quick_power_wash', 'rinse', 'rinse_out_lint', @@ -7705,6 +7706,7 @@ 'powerfresh', 'pre_ironing', 'proofing', + 'quick_intense', 'quick_power_wash', 'rinse', 'rinse_out_lint', @@ -11428,6 +11430,7 @@ 'powerfresh', 'pre_ironing', 'proofing', + 'quick_intense', 'quick_power_wash', 'rinse', 'rinse_out_lint', @@ -11513,6 +11516,7 @@ 'powerfresh', 'pre_ironing', 'proofing', + 'quick_intense', 'quick_power_wash', 'rinse', 'rinse_out_lint', diff --git a/tests/components/owntracks/test_device_tracker.py b/tests/components/owntracks/test_device_tracker.py index 04f998352dd8cd..8a93ea5753fce3 100644 --- a/tests/components/owntracks/test_device_tracker.py +++ b/tests/components/owntracks/test_device_tracker.py @@ -13,7 +13,14 @@ from homeassistant.components import owntracks from homeassistant.components.device_tracker.legacy import Device -from homeassistant.components.owntracks.const import ATTR_UPDATE_TIMESTAMP +from homeassistant.components.owntracks.const import ( + ATTR_ADDRESS, + ATTR_BATTERY_STATUS, + ATTR_COURSE, + ATTR_TID, + ATTR_UPDATE_TIMESTAMP, + ATTR_VELOCITY, +) from homeassistant.const import STATE_NOT_HOME from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -142,6 +149,14 @@ def build_message(test_params, default_params): # Location messages LOCATION_MESSAGE = DEFAULT_LOCATION_MESSAGE +LOCATION_MESSAGE_WITH_CUSTOM_ATTRIBUTES = build_message( + { + "addr": "123 Main Street", + "bs": 3, + }, + LOCATION_MESSAGE, +) + LOCATION_MESSAGE_INACCURATE = build_message( { "lat": INNER_ZONE["latitude"] - ZONE_EDGE, @@ -1601,7 +1616,7 @@ async def test_restore_state( client = await hass_client() resp = await client.post( "/api/webhook/owntracks_test", - json=LOCATION_MESSAGE, + json=LOCATION_MESSAGE_WITH_CUSTOM_ATTRIBUTES, headers={"X-Limit-u": "Paulus", "X-Limit-d": "Pixel"}, ) assert resp.status == 200 @@ -1624,6 +1639,18 @@ async def test_restore_state( assert state_1.attributes["longitude"] == state_2.attributes["longitude"] assert state_1.attributes["battery_level"] == state_2.attributes["battery_level"] assert state_1.attributes["source_type"] == state_2.attributes["source_type"] + assert state_1.attributes[ATTR_TID] == state_2.attributes[ATTR_TID] + assert state_1.attributes[ATTR_VELOCITY] == state_2.attributes[ATTR_VELOCITY] + assert state_1.attributes[ATTR_COURSE] == state_2.attributes[ATTR_COURSE] + assert state_1.attributes[ATTR_ADDRESS] == state_2.attributes[ATTR_ADDRESS] + assert ( + state_1.attributes[ATTR_UPDATE_TIMESTAMP] + == state_2.attributes[ATTR_UPDATE_TIMESTAMP] + ) + assert ( + state_1.attributes[ATTR_BATTERY_STATUS] + == state_2.attributes[ATTR_BATTERY_STATUS] + ) async def test_returns_empty_friends( diff --git a/tests/components/template/test_entity.py b/tests/components/template/test_entity.py index 8e98d8c94a7bf1..5e0a8235742861 100644 --- a/tests/components/template/test_entity.py +++ b/tests/components/template/test_entity.py @@ -1,9 +1,14 @@ """Test abstract template entity.""" +import asyncio +from unittest.mock import patch + import pytest from homeassistant.components.template import entity as abstract_entity +from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component async def test_template_entity_not_implemented(hass: HomeAssistant) -> None: @@ -11,3 +16,70 @@ async def test_template_entity_not_implemented(hass: HomeAssistant) -> None: with pytest.raises(TypeError): _ = abstract_entity.AbstractTemplateEntity(hass, {}) + + +@pytest.mark.parametrize( + "config", + [ + # State-based template light + { + "template": { + "light": { + "name": "test_light", + "state": "{{ true }}", + "turn_on": [ + {"delay": {"seconds": 120}}, + ], + "turn_off": {"event": "turn_off"}, + }, + } + }, + # Trigger-based template light + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "light": { + "name": "test_light", + "state": "{{ true }}", + "turn_on": [ + {"delay": {"seconds": 120}}, + ], + "turn_off": {"event": "turn_off"}, + }, + } + }, + ], + ids=["state_based", "trigger_based"], +) +async def test_reload_stops_entity_action_scripts( + hass: HomeAssistant, config: dict +) -> None: + """Test that reloading stops template entity action scripts.""" + assert await async_setup_component(hass, "template", config) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + entity = hass.data["light"].get_entity("light.test_light") + assert entity is not None + + # Call turn_on — script will start and hang on delay + hass.async_create_task( + hass.services.async_call("light", "turn_on", {"entity_id": "light.test_light"}) + ) + await asyncio.sleep(0) + + turn_on_script = entity._action_scripts["turn_on"] + assert turn_on_script.is_running + + # Reload with empty config removes the entity and stops scripts + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value={"template": []}, + ): + await hass.services.async_call("template", SERVICE_RELOAD, blocking=True) + await hass.async_block_till_done() + + assert not turn_on_script.is_running + 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 b67b4803dfd81c..ece23f573e51c0 100644 --- a/tests/components/template/test_trigger_entity.py +++ b/tests/components/template/test_trigger_entity.py @@ -1,10 +1,21 @@ """Test trigger template entity.""" +import asyncio +from unittest.mock import patch + import pytest -from homeassistant.components.template import DOMAIN, trigger_entity +from homeassistant.components.template import DATA_COORDINATORS, DOMAIN, trigger_entity from homeassistant.components.template.coordinator import TriggerUpdateCoordinator -from homeassistant.const import CONF_ICON, CONF_NAME, CONF_STATE, STATE_OFF, STATE_ON +from homeassistant.const import ( + CONF_ICON, + CONF_NAME, + CONF_STATE, + EVENT_HOMEASSISTANT_STOP, + SERVICE_RELOAD, + STATE_OFF, + STATE_ON, +) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import template from homeassistant.helpers.trigger_template_entity import CONF_PICTURE @@ -230,3 +241,110 @@ async def test_multiple_template_validators(hass: HomeAssistant) -> None: assert state.state == "opening" assert state.attributes["current_position"] == 50 assert state.attributes["current_tilt_position"] == 49 + + +async def test_shutdown_stops_script_and_keeps_triggers_subscribed( + hass: HomeAssistant, +) -> None: + """Test that HA shutdown stops coordinator scripts without unsubscribing triggers.""" + assert await async_setup_component( + hass, + "template", + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [ + {"event": "action_event"}, + {"delay": {"seconds": 120}}, + ], + "sensor": { + "name": "test", + "state": "{{ trigger.event.data.value }}", + }, + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + # Verify trigger is active + listeners = hass.bus.async_listeners() + assert listeners.get("test_event", 0) == 1 + + # Fire the trigger to start the action script, then yield without + # waiting for the script to finish + hass.bus.async_fire("test_event", {"value": "hello"}) + await asyncio.sleep(0) + + # Script should be running (stuck on delay) + coordinators = hass.data[DATA_COORDINATORS] + assert len(coordinators) == 1 + assert coordinators[0]._script.is_running + + # 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 + assert not coordinators[0]._script.is_running + + # Triggers are not unsubscribed on shutdown + listeners = hass.bus.async_listeners() + assert listeners.get("test_event", 0) == 1 + + +async def test_reload_stops_script_and_unsubscribes_triggers( + hass: HomeAssistant, +) -> None: + """Test that reloading stops coordinator scripts and unsubscribes old triggers.""" + assert await async_setup_component( + hass, + "template", + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [ + {"event": "action_event"}, + {"delay": {"seconds": 120}}, + ], + "sensor": { + "name": "test", + "state": "{{ trigger.event.data.value }}", + }, + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + # Verify trigger is active + listeners = hass.bus.async_listeners() + assert listeners.get("test_event", 0) == 1 + + # Fire the trigger to start the action script + hass.bus.async_fire("test_event", {"value": "hello"}) + await asyncio.sleep(0) + + # Script should be running + coordinators = hass.data[DATA_COORDINATORS] + assert len(coordinators) == 1 + coordinator = coordinators[0] + assert coordinator._script.is_running + + # Reload with empty config + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value={"template": []}, + ): + await hass.services.async_call("template", SERVICE_RELOAD, blocking=True) + await hass.async_block_till_done() + + # Script should be stopped + assert not coordinator._script.is_running + + # Old trigger should be unsubscribed + listeners = hass.bus.async_listeners() + assert listeners.get("test_event", 0) == 0 diff --git a/tests/components/velux/test_init.py b/tests/components/velux/test_init.py index 39e034793ba46d..8711ad9a7c8a4d 100644 --- a/tests/components/velux/test_init.py +++ b/tests/components/velux/test_init.py @@ -11,12 +11,9 @@ import pytest from pyvlx.exception import PyVLXException -from homeassistant.components.velux.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.setup import async_setup_component from tests.common import AsyncMock, ConfigEntry, MockConfigEntry @@ -125,42 +122,3 @@ async def test_unload_does_not_disconnect_if_platform_unload_fails( # Verify disconnect was NOT called since platform unload failed mock_pyvlx.disconnect.assert_not_awaited() - - -@pytest.mark.usefixtures("setup_integration") -async def test_reboot_gateway_service_raises_on_exception( - hass: HomeAssistant, mock_pyvlx: AsyncMock -) -> None: - """Test that reboot_gateway service raises HomeAssistantError on exception.""" - - mock_pyvlx.reboot_gateway.side_effect = OSError("Connection failed") - with pytest.raises(HomeAssistantError, match="Failed to reboot gateway"): - await hass.services.async_call( - "velux", - "reboot_gateway", - blocking=True, - ) - - mock_pyvlx.reboot_gateway.side_effect = PyVLXException("Reboot failed") - with pytest.raises(HomeAssistantError, match="Failed to reboot gateway"): - await hass.services.async_call( - "velux", - "reboot_gateway", - blocking=True, - ) - - -async def test_reboot_gateway_service_raises_validation_error( - hass: HomeAssistant, -) -> None: - """Test that reboot_gateway service raises ServiceValidationError when no gateway is loaded.""" - # Set up the velux integration's async_setup to register the service - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - with pytest.raises(ServiceValidationError, match="No loaded Velux gateway found"): - await hass.services.async_call( - "velux", - "reboot_gateway", - blocking=True, - ) diff --git a/tests/components/volvo/snapshots/test_sensor.ambr b/tests/components/volvo/snapshots/test_sensor.ambr index c670e0c62dee30..56911d6cfe86e4 100644 --- a/tests/components/volvo/snapshots/test_sensor.ambr +++ b/tests/components/volvo/snapshots/test_sensor.ambr @@ -307,6 +307,7 @@ 'capabilities': dict({ 'options': list([ 'fault', + 'initialization', 'power_available_but_not_activated', 'providing_power', 'no_power_available', @@ -349,6 +350,7 @@ 'friendly_name': 'Volvo EX30 Charging power status', 'options': list([ 'fault', + 'initialization', 'power_available_but_not_activated', 'providing_power', 'no_power_available', @@ -2574,6 +2576,7 @@ 'capabilities': dict({ 'options': list([ 'fault', + 'initialization', 'power_available_but_not_activated', 'providing_power', 'no_power_available', @@ -2616,6 +2619,7 @@ 'friendly_name': 'Volvo XC40 Charging power status', 'options': list([ 'fault', + 'initialization', 'power_available_but_not_activated', 'providing_power', 'no_power_available', @@ -5857,6 +5861,7 @@ 'capabilities': dict({ 'options': list([ 'fault', + 'initialization', 'power_available_but_not_activated', 'providing_power', 'no_power_available', @@ -5899,6 +5904,7 @@ 'friendly_name': 'Volvo XC90 Charging power status', 'options': list([ 'fault', + 'initialization', 'power_available_but_not_activated', 'providing_power', 'no_power_available',