From 577d344b56998fe67d7c563023f2fd2c00a36ff1 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:44:12 -0400 Subject: [PATCH] Add Sonos button entity to cancel active announcements (#176337) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/sonos/button.py | 50 ++++++ homeassistant/components/sonos/const.py | 2 + homeassistant/components/sonos/icons.json | 5 + .../components/sonos/media_player.py | 6 +- homeassistant/components/sonos/speaker.py | 37 ++++- homeassistant/components/sonos/strings.json | 14 ++ .../sonos/snapshots/test_diagnostics.ambr | 2 + tests/components/sonos/test_button.py | 143 ++++++++++++++++++ 8 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/sonos/button.py create mode 100644 tests/components/sonos/test_button.py diff --git a/homeassistant/components/sonos/button.py b/homeassistant/components/sonos/button.py new file mode 100644 index 000000000000..c286a9363c9f --- /dev/null +++ b/homeassistant/components/sonos/button.py @@ -0,0 +1,50 @@ +"""Button entities for Sonos.""" + +from typing import override + +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import SONOS_CREATE_BUTTON +from .entity import SonosEntity +from .helpers import SonosConfigEntry +from .speaker import SonosSpeaker + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SonosConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Sonos button entities from a config entry.""" + + @callback + def async_create_entities(speaker: SonosSpeaker) -> None: + """Handle device discovery and create button entities.""" + async_add_entities([SonosCancelAnnouncementButton(speaker, config_entry)]) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, SONOS_CREATE_BUTTON, async_create_entities) + ) + + +class SonosCancelAnnouncementButton(SonosEntity, ButtonEntity): + """Button to cancel the current Sonos announcement.""" + + _attr_translation_key = "cancel_announcement" + + def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None: + """Initialize the cancel announcement button.""" + super().__init__(speaker, config_entry) + self._attr_unique_id = f"{self.soco.uid}-cancel_announcement" + + @override + async def _async_fallback_poll(self) -> None: + """No-op: button state does not need polling.""" + + @override + async def async_press(self) -> None: + """Cancel the current announcement audio clip.""" + await self.speaker.async_cancel_announcement() diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 3142e72e6854..07d7d11ea461 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -11,6 +11,7 @@ DOMAIN = "sonos" DATA_SONOS_DISCOVERY_MANAGER = "sonos_discovery_manager" PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.NUMBER, Platform.SELECT, @@ -159,6 +160,7 @@ PLAYABLE_MEDIA_TYPES = [ SONOS_CHECK_ACTIVITY = "sonos_check_activity" SONOS_CREATE_ALARM = "sonos_create_alarm" +SONOS_CREATE_BUTTON = "sonos_create_button" SONOS_CREATE_AUDIO_FORMAT_SENSOR = "sonos_create_audio_format_sensor" SONOS_CREATE_BATTERY = "sonos_create_battery" SONOS_CREATE_FAVORITES_SENSOR = "sonos_create_favorites_sensor" diff --git a/homeassistant/components/sonos/icons.json b/homeassistant/components/sonos/icons.json index e28e4c305a99..2c16c854be9a 100644 --- a/homeassistant/components/sonos/icons.json +++ b/homeassistant/components/sonos/icons.json @@ -5,6 +5,11 @@ "default": "mdi:microphone" } }, + "button": { + "cancel_announcement": { + "default": "mdi:cancel" + } + }, "sensor": { "audio_input_format": { "default": "mdi:import" diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index d1f5fb1b2cc4..94de448dc6ae 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -17,6 +17,7 @@ from soco.core import ( from soco.data_structures import DidlFavorite, DidlMusicTrack from soco.exceptions import SoCoException from soco.ms_data_structures import MusicServiceItem +from sonos_websocket import CLIP_ID_KEY from sonos_websocket.exception import SonosWebsocketError from homeassistant.components import media_source, spotify @@ -528,8 +529,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): ) _LOGGER.debug("Playing %s using websocket audioclip", media_id) try: + self.speaker.last_announce_id = None assert self.speaker.websocket - response, _ = await self.speaker.websocket.play_clip( + response, data = await self.speaker.websocket.play_clip( async_process_play_media_url(self.hass, media_id), volume=volume, ) @@ -538,6 +540,8 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): f"Error when calling Sonos websocket: {exc}" ) from exc if response.get("success"): + if data: + self.speaker.last_announce_id = data.get(CLIP_ID_KEY) return if response.get("type") in ANNOUNCE_NOT_SUPPORTED_ERRORS: # If the speaker does not support announce do not raise and diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index f55204491156..24cd43bbcb3e 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -17,10 +17,11 @@ from soco.plugins.plex import PlexPlugin from soco.plugins.sharelink import ShareLinkPlugin from soco.snapshot import Snapshot from sonos_websocket import SonosWebsocket +from sonos_websocket.exception import SonosWebsocketError from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( @@ -44,6 +45,7 @@ from .const import ( SONOS_CREATE_ALARM, SONOS_CREATE_AUDIO_FORMAT_SENSOR, SONOS_CREATE_BATTERY, + SONOS_CREATE_BUTTON, SONOS_CREATE_LEVELS, SONOS_CREATE_MEDIA_PLAYER, SONOS_CREATE_MIC_SENSOR, @@ -186,6 +188,9 @@ class SonosSpeaker: self.snapshot_group: list[SonosSpeaker] = [] self._group_members_missing: set[str] = set() + # Announcement tracking + self.last_announce_id: str | None = None + async def async_setup( self, entry: SonosConfigEntry, @@ -261,6 +266,7 @@ class SonosSpeaker: dispatches.append((SONOS_CREATE_SELECTS, self)) dispatches.append((SONOS_CREATE_SWITCHES, self)) + dispatches.append((SONOS_CREATE_BUTTON, self)) dispatches.append((SONOS_CREATE_MEDIA_PLAYER, self)) dispatches.append((SONOS_SPEAKER_ADDED, self.soco.uid)) @@ -1294,6 +1300,35 @@ class SonosSpeaker: any_speaker = next(iter(config_entry.runtime_data.discovered.values())) any_speaker.soco.zone_group_state.clear_cache() + async def async_cancel_announcement(self) -> None: + """Cancel the current announcement audio clip.""" + if self.last_announce_id is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="cancel_announcement_no_id", + ) + if not self.websocket: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="announcement_connection_error", + translation_placeholders={"error": "websocket not available"}, + ) + try: + response, _ = await self.websocket.cancel_clip(self.last_announce_id) + except SonosWebsocketError as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="announcement_connection_error", + translation_placeholders={"error": str(exc)}, + ) from exc + if not response.get("success"): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cancel_announcement_error", + translation_placeholders={"response": str(response)}, + ) + self.last_announce_id = None + # # Media and playback state handlers # diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index 44276051d9da..82c99a7e1d23 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -18,6 +18,11 @@ "name": "Microphone" } }, + "button": { + "cancel_announcement": { + "name": "Cancel announcement" + } + }, "number": { "audio_delay": { "name": "Audio delay" @@ -109,9 +114,18 @@ "announce_media_error": { "message": "Announcing clip {media_id} failed {response}" }, + "announcement_connection_error": { + "message": "Failed to reach Sonos speaker for announcement: {error}" + }, "call_failed": { "message": "Error on {target}: {error}" }, + "cancel_announcement_error": { + "message": "Cancelling announcement failed: {response}" + }, + "cancel_announcement_no_id": { + "message": "No active announcement to cancel" + }, "entity_not_found": { "message": "Entity {entity_id} not found." }, diff --git a/tests/components/sonos/snapshots/test_diagnostics.ambr b/tests/components/sonos/snapshots/test_diagnostics.ambr index 9e3dfcb47e79..a4b1500de962 100644 --- a/tests/components/sonos/snapshots/test_diagnostics.ambr +++ b/tests/components/sonos/snapshots/test_diagnostics.ambr @@ -20,6 +20,7 @@ 'enabled_entities': list([ 'binary_sensor.zone_a_charging', 'binary_sensor.zone_a_microphone', + 'button.zone_a_cancel_announcement', 'media_player.zone_a', 'number.zone_a_audio_delay', 'number.zone_a_balance', @@ -112,6 +113,7 @@ 'enabled_entities': list([ 'binary_sensor.zone_a_charging', 'binary_sensor.zone_a_microphone', + 'button.zone_a_cancel_announcement', 'media_player.zone_a', 'number.zone_a_audio_delay', 'number.zone_a_balance', diff --git a/tests/components/sonos/test_button.py b/tests/components/sonos/test_button.py new file mode 100644 index 000000000000..60a2617bcd8c --- /dev/null +++ b/tests/components/sonos/test_button.py @@ -0,0 +1,143 @@ +"""Tests for the Sonos button platform.""" + +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from sonos_websocket import CLIP_ID_KEY +from sonos_websocket.exception import SonosWebsocketError + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.media_player import ( + ATTR_MEDIA_ANNOUNCE, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + DOMAIN as MP_DOMAIN, + SERVICE_PLAY_MEDIA, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +CANCEL_ANNOUNCEMENT_BUTTON = "button.zone_a_cancel_announcement" + + +async def _announce_clip(hass: HomeAssistant, content_id: str) -> None: + """Play an announcement clip to set the active clip id.""" + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.zone_a", + ATTR_MEDIA_CONTENT_TYPE: "music", + ATTR_MEDIA_CONTENT_ID: content_id, + ATTR_MEDIA_ANNOUNCE: True, + }, + blocking=True, + ) + + +async def test_cancel_announcement_no_prior( + hass: HomeAssistant, + async_autosetup_sonos, +) -> None: + """Test cancelling when no announcement has been played.""" + with pytest.raises( + ServiceValidationError, match="No active announcement to cancel" + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: CANCEL_ANNOUNCEMENT_BUTTON}, + blocking=True, + ) + + +async def test_cancel_announcement( + hass: HomeAssistant, + async_autosetup_sonos, + sonos_websocket, +) -> None: + """Test cancelling a currently playing announcement.""" + content_id = "http://10.0.0.1:8123/local/sounds/doorbell.mp3" + sonos_websocket.play_clip.return_value = [ + {"success": 1}, + {CLIP_ID_KEY: "clip-123"}, + ] + await _announce_clip(hass, content_id) + + sonos_websocket.cancel_clip = AsyncMock(return_value=[{"success": 1}, {}]) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: CANCEL_ANNOUNCEMENT_BUTTON}, + blocking=True, + ) + sonos_websocket.cancel_clip.assert_called_once_with("clip-123") + + +async def test_cancel_announcement_no_clip_id_from_announce_response( + hass: HomeAssistant, + async_autosetup_sonos, + sonos_websocket, +) -> None: + """Test cancelling fails when the announce response has no clip ID.""" + content_id = "http://10.0.0.1:8123/local/sounds/doorbell.mp3" + sonos_websocket.play_clip.return_value = [{"success": 1}, None] + await _announce_clip(hass, content_id) + + with pytest.raises( + ServiceValidationError, match="No active announcement to cancel" + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: CANCEL_ANNOUNCEMENT_BUTTON}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("cancel_clip_side_effect", "cancel_clip_return", "error_match"), + [ + pytest.param( + SonosWebsocketError("Connection lost"), + None, + "Failed to reach Sonos speaker for announcement: Connection lost", + id="websocket_error", + ), + pytest.param( + None, + [{"success": 0}, {}], + "Cancelling announcement failed", + id="non_success_response", + ), + ], +) +async def test_cancel_announcement_errors( + hass: HomeAssistant, + async_autosetup_sonos, + sonos_websocket, + cancel_clip_side_effect: SonosWebsocketError | None, + cancel_clip_return: list[dict[str, Any]] | None, + error_match: str, +) -> None: + """Test error handling when cancelling an announcement.""" + content_id = "http://10.0.0.1:8123/local/sounds/doorbell.mp3" + sonos_websocket.play_clip.return_value = [ + {"success": 1}, + {CLIP_ID_KEY: "clip-123"}, + ] + await _announce_clip(hass, content_id) + + sonos_websocket.cancel_clip = AsyncMock( + side_effect=cancel_clip_side_effect, + return_value=cancel_clip_return, + ) + with pytest.raises(HomeAssistantError, match=error_match): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: CANCEL_ANNOUNCEMENT_BUTTON}, + blocking=True, + )