mirror of
https://github.com/home-assistant/core.git
synced 2026-08-07 05:46:41 +01:00
Add Sonos button entity to cancel active announcements (#176337)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
co-authored by
Joost Lekkerkerker
parent
32c06395a2
commit
577d344b56
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user