From 4dd996b7283a181e8f319a226237a4caee796ef8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 May 2026 09:45:45 +0200 Subject: [PATCH] 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 ad56e1d4710f..068a0910c3c7 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 d34badb5a547..02e055ea01cb 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 bc5f3ea32461..5b0d4527a77b 100644 --- a/homeassistant/components/media_player/trigger.py +++ b/homeassistant/components/media_player/trigger.py @@ -13,10 +13,11 @@ from . import ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, MediaPlayerState 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 @@ class MediaPlayerMutedTrigger(EntityTriggerBase): """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 7d6384c70f26..d719c61d10fe 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 cee9fe05ac48..163c199450f5 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