Add Sonos button entity to cancel active announcements (#176337)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Pete Sage
2026-07-15 15:44:12 +02:00
committed by GitHub
co-authored by Joost Lekkerkerker
parent 32c06395a2
commit 577d344b56
8 changed files with 257 additions and 2 deletions
+50
View File
@@ -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()
+2
View File
@@ -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"
@@ -5,6 +5,11 @@
"default": "mdi:microphone"
}
},
"button": {
"cancel_announcement": {
"default": "mdi:cancel"
}
},
"sensor": {
"audio_input_format": {
"default": "mdi:import"
@@ -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
+36 -1
View File
@@ -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
#
@@ -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."
},
@@ -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',
+143
View File
@@ -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,
)