diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index bbeaceb08cd..abb0696360b 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -160,6 +160,7 @@ SONOS_SPEAKER_ACTIVITY = "sonos_speaker_activity" SONOS_SPEAKER_ADDED = "sonos_speaker_added" SONOS_STATE_UPDATED = "sonos_state_updated" SONOS_REBOOTED = "sonos_rebooted" +SONOS_VANISHED = "sonos_vanished" SOURCE_LINEIN = "Line-in" SOURCE_TV = "TV" diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 5f06fe976ac..7777265a124 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -12,6 +12,7 @@ from typing import Any import urllib.parse import async_timeout +import defusedxml.ElementTree as ET from soco.core import MUSIC_SRC_LINE_IN, MUSIC_SRC_RADIO, MUSIC_SRC_TV, SoCo from soco.data_structures import DidlAudioBroadcast, DidlPlaylistContainer from soco.events_base import Event as SonosEvent, SubscriptionBase @@ -56,6 +57,7 @@ from .const import ( SONOS_STATE_PLAYING, SONOS_STATE_TRANSITIONING, SONOS_STATE_UPDATED, + SONOS_VANISHED, SOURCE_LINEIN, SOURCE_TV, SUBSCRIPTION_TIMEOUT, @@ -225,6 +227,7 @@ class SonosSpeaker: (SONOS_SPEAKER_ADDED, self.update_group_for_uid), (f"{SONOS_REBOOTED}-{self.soco.uid}", self.async_rebooted), (f"{SONOS_SPEAKER_ACTIVITY}-{self.soco.uid}", self.speaker_activity), + (f"{SONOS_VANISHED}-{self.soco.uid}", self.async_vanished), ) for (signal, target) in dispatch_pairs: @@ -388,6 +391,8 @@ class SonosSpeaker: async def async_unsubscribe(self) -> None: """Cancel all subscriptions.""" + if not self._subscriptions: + return _LOGGER.debug("Unsubscribing from events for %s", self.zone_name) results = await asyncio.gather( *(subscription.unsubscribe() for subscription in self._subscriptions), @@ -572,6 +577,15 @@ class SonosSpeaker: self.hass.data[DATA_SONOS].discovery_known.discard(self.soco.uid) self.async_write_entity_states() + async def async_vanished(self, reason: str) -> None: + """Handle removal of speaker when marked as vanished.""" + if not self.available: + return + _LOGGER.debug( + "%s has vanished (%s), marking unavailable", self.zone_name, reason + ) + await self.async_offline() + async def async_rebooted(self, soco: SoCo) -> None: """Handle a detected speaker reboot.""" _LOGGER.warning( @@ -685,7 +699,25 @@ class SonosSpeaker: @callback def async_update_groups(self, event: SonosEvent) -> None: """Handle callback for topology change event.""" - if not hasattr(event, "zone_player_uui_ds_in_group"): + if xml := event.variables.get("zone_group_state"): + zgs = ET.fromstring(xml) + for vanished_device in zgs.find("VanishedDevices"): + if (reason := vanished_device.get("Reason")) != "sleeping": + _LOGGER.debug( + "Ignoring %s marked %s as vanished with reason: %s", + self.zone_name, + vanished_device.get("ZoneName"), + reason, + ) + continue + uid = vanished_device.get("UUID") + async_dispatcher_send( + self.hass, + f"{SONOS_VANISHED}-{uid}", + reason, + ) + + if "zone_player_uui_ds_in_group" not in event.variables: return self.event_stats.process(event) self.hass.async_create_task(self.create_update_groups_coro(event))