From 4ffa0dd1996cc7d66e207bbb430c6d37d9206f1a Mon Sep 17 00:00:00 2001 From: jjlawren Date: Mon, 7 Jun 2021 20:51:42 -0500 Subject: [PATCH] Detect Sonos reboots and recreate subscriptions (#51377) --- homeassistant/components/sonos/__init__.py | 89 +++++++++++++++------- homeassistant/components/sonos/const.py | 1 + homeassistant/components/sonos/speaker.py | 26 ++++++- 3 files changed, 86 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index d26d7d0c47a..4bb9b475b9a 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections import OrderedDict, deque import datetime +from enum import Enum import logging import socket from urllib.parse import urlparse @@ -26,7 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send from .const import ( DATA_SONOS, @@ -35,6 +36,7 @@ from .const import ( PLATFORMS, SONOS_ALARM_UPDATE, SONOS_GROUP_UPDATE, + SONOS_REBOOTED, SONOS_SEEN, UPNP_ST, ) @@ -67,6 +69,14 @@ CONFIG_SCHEMA = vol.Schema( ) +class SoCoCreationSource(Enum): + """Represent the creation source of a SoCo instance.""" + + CONFIGURED = "configured" + DISCOVERED = "discovered" + REBOOTED = "rebooted" + + class SonosData: """Storage class for platform global data.""" @@ -80,6 +90,7 @@ class SonosData: self.topology_condition = asyncio.Condition() self.hosts_heartbeat = None self.ssdp_known: set[str] = set() + self.boot_counts: dict[str, int] = {} async def async_setup(hass, config): @@ -129,7 +140,6 @@ async def async_setup_entry( # noqa: C901 def _discovered_player(soco: SoCo) -> None: """Handle a (re)discovered player.""" try: - _LOGGER.debug("Reached _discovered_player, soco=%s", soco) speaker_info = soco.get_speaker_info(True) _LOGGER.debug("Adding new speaker: %s", speaker_info) speaker = SonosSpeaker(hass, soco, speaker_info) @@ -143,22 +153,40 @@ async def async_setup_entry( # noqa: C901 except SoCoException as ex: _LOGGER.debug("SoCoException, ex=%s", ex) + def _create_soco(ip_address: str, source: SoCoCreationSource) -> SoCo | None: + """Create a soco instance and return if successful.""" + try: + soco = pysonos.SoCo(ip_address) + # Ensure that the player is available and UID is cached + _ = soco.uid + _ = soco.volume + return soco + except (OSError, SoCoException) as ex: + _LOGGER.warning( + "Failed to connect to %s player '%s': %s", source.value, ip_address, ex + ) + return None + def _manual_hosts(now: datetime.datetime | None = None) -> None: """Players from network configuration.""" for host in hosts: - try: - _LOGGER.debug("Testing %s", host) - player = pysonos.SoCo(socket.gethostbyname(host)) - if player.is_visible: - # Make sure that the player is available - _ = player.volume - _discovered_player(player) - except (OSError, SoCoException) as ex: - _LOGGER.debug("Issue connecting to '%s': %s", host, ex) - if now is None: - _LOGGER.warning("Failed to initialize '%s'", host) + ip_addr = socket.gethostbyname(host) + known_uid = next( + ( + uid + for uid, speaker in data.discovered.items() + if speaker.soco.ip_address == ip_addr + ), + None, + ) + + if known_uid: + dispatcher_send(hass, f"{SONOS_SEEN}-{known_uid}") + else: + soco = _create_soco(ip_addr, SoCoCreationSource.CONFIGURED) + if soco and soco.is_visible: + _discovered_player(soco) - _LOGGER.debug("Tested all hosts") data.hosts_heartbeat = hass.helpers.event.call_later( DISCOVERY_INTERVAL.total_seconds(), _manual_hosts ) @@ -168,32 +196,41 @@ async def async_setup_entry( # noqa: C901 async_dispatcher_send(hass, SONOS_GROUP_UPDATE) def _discovered_ip(ip_address): - try: - player = pysonos.SoCo(ip_address) - except (OSError, SoCoException): - _LOGGER.debug("Failed to connect to discovered player '%s'", ip_address) - return - if player.is_visible: - _discovered_player(player) + soco = _create_soco(ip_address, SoCoCreationSource.DISCOVERED) + if soco and soco.is_visible: + _discovered_player(soco) - async def _async_create_discovered_player(uid, discovered_ip): + async def _async_create_discovered_player(uid, discovered_ip, boot_seqnum): """Only create one player at a time.""" async with discovery_lock: - if uid in data.discovered: - async_dispatcher_send(hass, f"{SONOS_SEEN}-{uid}") + if uid not in data.discovered: + await hass.async_add_executor_job(_discovered_ip, discovered_ip) return - await hass.async_add_executor_job(_discovered_ip, discovered_ip) + + if boot_seqnum and boot_seqnum > data.boot_counts[uid]: + data.boot_counts[uid] = boot_seqnum + if soco := await hass.async_add_executor_job( + _create_soco, discovered_ip, SoCoCreationSource.REBOOTED + ): + async_dispatcher_send(hass, f"{SONOS_REBOOTED}-{uid}", soco) + else: + async_dispatcher_send(hass, f"{SONOS_SEEN}-{uid}") @callback def _async_discovered_player(info): uid = info.get(ssdp.ATTR_UPNP_UDN) if uid.startswith("uuid:"): uid = uid[5:] + if boot_seqnum := info.get("X-RINCON-BOOTSEQ"): + boot_seqnum = int(boot_seqnum) + data.boot_counts.setdefault(uid, boot_seqnum) if uid not in data.ssdp_known: _LOGGER.debug("New discovery: %s", info) data.ssdp_known.add(uid) discovered_ip = urlparse(info[ssdp.ATTR_SSDP_LOCATION]).hostname - asyncio.create_task(_async_create_discovered_player(uid, discovered_ip)) + asyncio.create_task( + _async_create_discovered_player(uid, discovered_ip, boot_seqnum) + ) @callback def _async_signal_update_alarms(event): diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index d32dda6a53b..84ccb99baed 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -143,6 +143,7 @@ SONOS_GROUP_UPDATE = "sonos_group_update" SONOS_HOUSEHOLD_UPDATED = "sonos_household_updated" SONOS_ALARM_UPDATE = "sonos_alarm_update" SONOS_STATE_UPDATED = "sonos_state_updated" +SONOS_REBOOTED = "sonos_rebooted" SONOS_SEEN = "sonos_seen" SOURCE_LINEIN = "Line-in" diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index d7eb6ea8358..47c9af62761 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -47,6 +47,7 @@ from .const import ( SONOS_ENTITY_CREATED, SONOS_GROUP_UPDATE, SONOS_POLL_UPDATE, + SONOS_REBOOTED, SONOS_SEEN, SONOS_STATE_PLAYING, SONOS_STATE_TRANSITIONING, @@ -164,6 +165,7 @@ class SonosSpeaker: # Dispatcher handles self._entity_creation_dispatcher: Callable | None = None self._group_dispatcher: Callable | None = None + self._reboot_dispatcher: Callable | None = None self._seen_dispatcher: Callable | None = None # Device information @@ -207,6 +209,9 @@ class SonosSpeaker: self._seen_dispatcher = dispatcher_connect( self.hass, f"{SONOS_SEEN}-{self.soco.uid}", self.async_seen ) + self._reboot_dispatcher = dispatcher_connect( + self.hass, f"{SONOS_REBOOTED}-{self.soco.uid}", self.async_rebooted + ) if battery_info := fetch_battery_info_or_none(self.soco): self.battery_info = battery_info @@ -449,10 +454,10 @@ class SonosSpeaker: self.async_write_entity_states() - async def async_unseen(self, now: datetime.datetime | None = None) -> None: + async def async_unseen( + self, now: datetime.datetime | None = None, will_reconnect: bool = False + ) -> None: """Make this player unavailable when it was not seen recently.""" - self.async_write_entity_states() - if self._seen_timer: self._seen_timer() self._seen_timer = None @@ -465,7 +470,20 @@ class SonosSpeaker: await subscription.unsubscribe() self._subscriptions = [] - self.hass.data[DATA_SONOS].ssdp_known.remove(self.soco.uid) + + if not will_reconnect: + self.hass.data[DATA_SONOS].ssdp_known.remove(self.soco.uid) + self.async_write_entity_states() + + async def async_rebooted(self, soco: SoCo) -> None: + """Handle a detected speaker reboot.""" + _LOGGER.warning( + "%s rebooted or lost network connectivity, reconnecting with %s", + self.zone_name, + soco, + ) + await self.async_unseen(will_reconnect=True) + await self.async_seen(soco) # # Alarm management