From 38de48ac9d4b0b22d2f9d850369cd5a26f24fc2c Mon Sep 17 00:00:00 2001 From: HoffmanEl <140370244+HoffmanEl@users.noreply.github.com> Date: Tue, 5 May 2026 06:43:18 +0100 Subject: [PATCH 01/14] Add data_description to airnow config flow strings (#169783) --- homeassistant/components/airnow/strings.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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}." } } }, From ab4162601fb0b0a391ab19e802928a2764fc68d4 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 5 May 2026 07:45:40 +0200 Subject: [PATCH 02/14] Remove YAML import from Duck DNS integration (#169769) --- homeassistant/components/duckdns/__init__.py | 26 +---- .../components/duckdns/config_flow.py | 13 --- homeassistant/components/duckdns/issue.py | 36 +----- homeassistant/components/duckdns/strings.json | 4 - tests/components/duckdns/test_config_flow.py | 108 +----------------- 5 files changed, 4 insertions(+), 183 deletions(-) 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/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, From 9075c6a5cb6ded6c9a0a00347f9070e0a6b74744 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 May 2026 08:22:03 +0200 Subject: [PATCH 03/14] Add trigger media_player.muted (#156736) --- .../components/media_player/icons.json | 3 + .../components/media_player/strings.json | 12 ++ .../components/media_player/trigger.py | 79 ++++++- .../components/media_player/triggers.yaml | 1 + tests/components/media_player/test_trigger.py | 204 +++++++++++++++++- 5 files changed, 295 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/media_player/icons.json b/homeassistant/components/media_player/icons.json index 45e89487227dfa..ad56e1d4710f95 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" }, diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index be6cc8e21d3f9d..d34badb5a547ab 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": { diff --git a/homeassistant/components/media_player/trigger.py b/homeassistant/components/media_player/trigger.py index 16a96b126ac1c3..bc5f3ea3246136 100644 --- a/homeassistant/components/media_player/trigger.py +++ b/homeassistant/components/media_player/trigger.py @@ -1,12 +1,85 @@ """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 MediaPlayerMutedTrigger(EntityTriggerBase): + """Class for media player muted triggers.""" + + _domain_specs = {DOMAIN: DomainSpec()} + + 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) + + TRIGGERS: dict[str, type[Trigger]] = { + "muted": MediaPlayerMutedTrigger, "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..7d6384c70f2607 100644 --- a/homeassistant/components/media_player/triggers.yaml +++ b/homeassistant/components/media_player/triggers.yaml @@ -15,6 +15,7 @@ selector: duration: +muted: *trigger_common paused_playing: *trigger_common started_playing: *trigger_common stopped_playing: *trigger_common diff --git a/tests/components/media_player/test_trigger.py b/tests/components/media_player/test_trigger.py index 3d14c1bc63391c..cee9fe05ac4804 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,7 @@ async def target_media_players(hass: HomeAssistant) -> dict[str, list[str]]: @pytest.mark.parametrize( "trigger_key", [ + "media_player.muted", "media_player.paused_playing", "media_player.started_playing", "media_player.stopped_playing", @@ -43,10 +50,59 @@ 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.paused_playing", {}, True, True), ("media_player.started_playing", {}, True, True), ("media_player.stopped_playing", {}, True, True), @@ -79,6 +135,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 +231,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 +278,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 +315,146 @@ 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") +async def test_muted_trigger_last_skips_entities_without_volume_attributes( + hass: HomeAssistant, +) -> 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 are muted, 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] = [] + + # Set initial states + hass.states.async_set( + entity_a, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: False} + ) + hass.states.async_set( + entity_b, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: False} + ) + hass.states.async_set(entity_c, MediaPlayerState.PLAYING, {}) + await hass.async_block_till_done() + + await arm_trigger( + hass, + "media_player.muted", + {"behavior": "last"}, + {CONF_ENTITY_ID: [entity_a, entity_b, entity_c]}, + calls, + ) + + # Mute entity a — not all mutable entities muted yet + hass.states.async_set( + entity_a, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: True} + ) + await hass.async_block_till_done() + assert len(calls) == 0 + + # Mute entity b — now all mutable entities are muted, trigger fires + hass.states.async_set( + entity_b, 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_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 From 1f5d80ca44f56bebd46887c1facf2e830e5f168b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Tue, 5 May 2026 08:54:12 +0200 Subject: [PATCH 04/14] Add missing code for miele washing machine (#169795) --- homeassistant/components/miele/const.py | 1 + tests/components/miele/snapshots/test_sensor.ambr | 4 ++++ 2 files changed, 5 insertions(+) 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/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', From 74971ebcd126a4db0a17044f64325a2086bcc066 Mon Sep 17 00:00:00 2001 From: Ronald van der Meer Date: Tue, 5 May 2026 08:55:22 +0200 Subject: [PATCH 05/14] Bump python-duco-client to 0.4.0 (#169776) --- homeassistant/components/duco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/duco/snapshots/test_diagnostics.ambr | 2 ++ 4 files changed, 5 insertions(+), 3 deletions(-) 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/requirements_all.txt b/requirements_all.txt index 7b0625d746192e..26891a978f86c0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 45f6ddcd308db8..9679eaa6d86d49 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 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({ From 7b51b929efc1e11eb25a8362d3469687d8e01a33 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Tue, 5 May 2026 01:05:16 -0600 Subject: [PATCH 06/14] Bump pylitterbot to 2025.4.0 (#169652) --- homeassistant/components/litterrobot/binary_sensor.py | 2 +- homeassistant/components/litterrobot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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/requirements_all.txt b/requirements_all.txt index 26891a978f86c0..11169caae1cc11 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9679eaa6d86d49..7288cf4e0decb9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 From 9c9a058eb09ad3dd0cc5f6d62b19939f73281481 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Tue, 5 May 2026 09:10:13 +0200 Subject: [PATCH 07/14] Add missing initialization charging power status option to Volvo (#169727) --- homeassistant/components/volvo/sensor.py | 1 + homeassistant/components/volvo/strings.json | 1 + tests/components/volvo/snapshots/test_sensor.ambr | 6 ++++++ 3 files changed, 8 insertions(+) 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/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', From c99f261a2d41ed30c84c2725816137367dceb394 Mon Sep 17 00:00:00 2001 From: kw6423 <211177151+kw6423@users.noreply.github.com> Date: Tue, 5 May 2026 09:44:53 +0200 Subject: [PATCH 08/14] Restore OwnTracks custom device tracker attributes (#169753) Co-authored-by: Ariel Ebersberger --- homeassistant/components/owntracks/const.py | 12 +++++-- .../components/owntracks/device_tracker.py | 27 +++++++++++++++- .../components/owntracks/messages.py | 19 ++++++++---- .../owntracks/test_device_tracker.py | 31 +++++++++++++++++-- 4 files changed, 78 insertions(+), 11 deletions(-) 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/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( From 416d4e02a0713ec3fce1510ecf49d2cf678b861d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 May 2026 09:45:45 +0200 Subject: [PATCH 09/14] Add trigger media_player.unmuted (#169797) --- .../components/media_player/icons.json | 3 + .../components/media_player/strings.json | 12 +++ .../components/media_player/trigger.py | 20 ++++- .../components/media_player/triggers.yaml | 1 + tests/components/media_player/test_trigger.py | 83 +++++++++++++++++-- 5 files changed, 107 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/media_player/icons.json b/homeassistant/components/media_player/icons.json index ad56e1d4710f95..068a0910c3c7d1 100644 --- a/homeassistant/components/media_player/icons.json +++ b/homeassistant/components/media_player/icons.json @@ -140,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 d34badb5a547ab..02e055ea01cb6f 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -508,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 bc5f3ea3246136..5b0d4527a77b1e 100644 --- a/homeassistant/components/media_player/trigger.py +++ b/homeassistant/components/media_player/trigger.py @@ -13,10 +13,11 @@ from .const import DOMAIN -class MediaPlayerMutedTrigger(EntityTriggerBase): - """Class for media player muted triggers.""" +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.""" @@ -75,11 +76,24 @@ 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) + 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 7d6384c70f2607..d719c61d10fe67 100644 --- a/homeassistant/components/media_player/triggers.yaml +++ b/homeassistant/components/media_player/triggers.yaml @@ -16,6 +16,7 @@ duration: muted: *trigger_common +unmuted: *trigger_common paused_playing: *trigger_common started_playing: *trigger_common stopped_playing: *trigger_common diff --git a/tests/components/media_player/test_trigger.py b/tests/components/media_player/test_trigger.py index cee9fe05ac4804..163c199450f5f7 100644 --- a/tests/components/media_player/test_trigger.py +++ b/tests/components/media_player/test_trigger.py @@ -36,6 +36,7 @@ async def target_media_players(hass: HomeAssistant) -> dict[str, list[str]]: "trigger_key", [ "media_player.muted", + "media_player.unmuted", "media_player.paused_playing", "media_player.started_playing", "media_player.stopped_playing", @@ -103,6 +104,7 @@ def parametrize_muted_trigger_states() -> list[ ("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), @@ -385,47 +387,56 @@ async def test_muted_trigger_fires_when_entity_gains_volume_attributes( @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 are muted, regardless of c. + 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] = [] - # Set initial states hass.states.async_set( - entity_a, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: False} + entity_a, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: initial_muted} ) hass.states.async_set( - entity_b, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: False} + 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, - "media_player.muted", + trigger, {"behavior": "last"}, {CONF_ENTITY_ID: [entity_a, entity_b, entity_c]}, calls, ) - # Mute entity a — not all mutable entities muted yet + # Transition entity a — not all mutable entities transitioned yet hass.states.async_set( - entity_a, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: True} + entity_a, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: target_muted} ) await hass.async_block_till_done() assert len(calls) == 0 - # Mute entity b — now all mutable entities are muted, trigger fires + # Transition entity b — now all mutable entities have transitioned, fires hass.states.async_set( - entity_b, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: True} + entity_b, MediaPlayerState.PLAYING, {ATTR_MEDIA_VOLUME_MUTED: target_muted} ) await hass.async_block_till_done() assert len(calls) == 1 @@ -458,3 +469,57 @@ async def test_muted_trigger_does_not_fire_on_losing_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 From 280b5ef388c854915df7d70af71af2e9ee6d6df6 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Tue, 5 May 2026 10:09:24 +0200 Subject: [PATCH 10/14] Update xknxproject to 3.9.0 (#169775) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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/requirements_all.txt b/requirements_all.txt index 11169caae1cc11..5f79f967d9b0db 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 7288cf4e0decb9..a239c61d6cc18c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 From 6bb759b887f8ab34e54cd5d48165e4ebb827662e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 10:11:52 +0200 Subject: [PATCH 11/14] Update infrared-protocols to 2.1.0 (#169785) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- homeassistant/components/infrared/manifest.json | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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/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 5f79f967d9b0db..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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a239c61d6cc18c..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 From bd61c893e41842afb16b0d8aba74322ae886bf2c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 10:12:07 +0200 Subject: [PATCH 12/14] Bump dawidd6/action-download-artifact from 20 to 21 (#169793) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 64a3f9113241cf4190153bea05969be200cc9319 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 May 2026 10:16:22 +0200 Subject: [PATCH 13/14] Improve template reload (#169480) --- homeassistant/components/template/__init__.py | 2 +- .../components/template/coordinator.py | 10 +- homeassistant/components/template/entity.py | 5 + tests/components/template/test_entity.py | 72 +++++++++++ .../template/test_trigger_entity.py | 122 +++++++++++++++++- 5 files changed, 205 insertions(+), 6 deletions(-) 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/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 From 2c2e8db19f2240bc804444d1e67ba4db2ec9a3d8 Mon Sep 17 00:00:00 2001 From: wollew Date: Tue, 5 May 2026 11:08:00 +0200 Subject: [PATCH 14/14] Remove deprecated reboot service for Velux gateway (#169796) --- homeassistant/components/velux/__init__.py | 59 ++------------------ homeassistant/components/velux/icons.json | 7 --- homeassistant/components/velux/services.yaml | 3 - homeassistant/components/velux/strings.json | 15 ----- tests/components/velux/test_init.py | 42 -------------- 5 files changed, 4 insertions(+), 122 deletions(-) delete mode 100644 homeassistant/components/velux/icons.json delete mode 100644 homeassistant/components/velux/services.yaml 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/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, - )