"""Support to embed Sonos.""" # pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio import datetime from functools import partial from http import HTTPStatus from ipaddress import AddressValueError, IPv4Address import logging import socket import threading from typing import Any, cast from urllib.parse import urlparse from aiohttp import ClientError from requests.exceptions import HTTPError, Timeout from soco import events_asyncio, zonegroupstate import soco.config as soco_config from soco.core import SoCo, soco_reset from soco.events_base import Event as SonosEvent, SubscriptionBase from soco.exceptions import SoCoException import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.const import CONF_HOSTS, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, device_registry as dr, issue_registry as ir, ) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_MODEL_NAME, ATTR_UPNP_UDN, SsdpServiceInfo, ) from homeassistant.helpers.typing import ConfigType from homeassistant.util.async_ import create_eager_task from .alarms import SonosAlarms from .const import ( AVAILABILITY_CHECK_INTERVAL, DATA_SONOS_DISCOVERY_MANAGER, DISCOVERY_INTERVAL, DOMAIN, PLATFORMS, SONOS_CHECK_ACTIVITY, SONOS_REBOOTED, SONOS_SPEAKER_ACTIVITY, SONOS_VANISHED, SUB_FAIL_ISSUE_ID, SUB_FAIL_URL, SUBSCRIPTION_TIMEOUT, UPNP_DOCUMENTATION_URL, UPNP_ISSUE_ID, UPNP_ST, ) from .exception import SonosUpdateError from .favorites import SonosFavorites from .helpers import SonosConfigEntry, SonosData, sync_get_visible_zones from .services import async_setup_services from .speaker import SonosSpeaker _LOGGER = logging.getLogger(__name__) CONF_ADVERTISE_ADDR = "advertise_addr" CONF_INTERFACE_ADDR = "interface_addr" DISCOVERY_IGNORED_MODELS = ["Sonos Boost"] ZGS_SUBSCRIPTION_TIMEOUT = 2 SHUTDOWN_TIMEOUT = 10 def _get_soco_uid(soco: SoCo) -> str: """Get SoCo uid as a typed helper for executor jobs.""" return soco.uid CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( { MP_DOMAIN: vol.All( cv.deprecated(CONF_INTERFACE_ADDR), vol.Schema( { vol.Optional(CONF_ADVERTISE_ADDR): cv.string, vol.Optional(CONF_INTERFACE_ADDR): cv.string, vol.Optional(CONF_HOSTS): vol.All( cv.ensure_list_csv, [cv.string] ), } ), ) } ) }, extra=vol.ALLOW_EXTRA, ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Sonos component.""" conf = config.get(DOMAIN) hass.data[DOMAIN] = conf or {} if conf is not None: hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT} ) ) async_setup_services(hass) return True async def async_setup_entry(hass: HomeAssistant, entry: SonosConfigEntry) -> bool: """Set up Sonos from a config entry.""" _LOGGER.debug("Setting up Sonos config entry: %s", entry.entry_id) soco_reset() soco_config.EVENTS_MODULE = events_asyncio soco_config.REQUEST_TIMEOUT = 9.5 soco_config.ZGT_EVENT_FALLBACK = False zonegroupstate.EVENT_CACHE_TIMEOUT = SUBSCRIPTION_TIMEOUT data = entry.runtime_data = SonosData() config = hass.data[DOMAIN].get("media_player", {}) hosts = config.get(CONF_HOSTS, []) _LOGGER.debug("Reached async_setup_entry, config=%s", config) if advertise_addr := config.get(CONF_ADVERTISE_ADDR): soco_config.EVENT_ADVERTISE_IP = advertise_addr if deprecated_address := config.get(CONF_INTERFACE_ADDR): _LOGGER.warning( ( "'%s' is deprecated, enable %s in the Network integration" " (https://www.home-assistant.io/integrations/network/)" ), CONF_INTERFACE_ADDR, deprecated_address, ) manager = hass.data[DATA_SONOS_DISCOVERY_MANAGER] = SonosDiscoveryManager( hass, entry, data, hosts ) await manager.setup_platforms_and_discovery() return True async def async_unload_entry( hass: HomeAssistant, config_entry: SonosConfigEntry ) -> bool: """Unload a Sonos config entry.""" unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ) await hass.data[DATA_SONOS_DISCOVERY_MANAGER].async_shutdown() soco_reset() _LOGGER.debug("Sonos config entry unloaded: %s", config_entry.entry_id) return unload_ok class SonosDiscoveryManager: """Manage sonos discovery.""" def __init__( self, hass: HomeAssistant, entry: SonosConfigEntry, data: SonosData, hosts: list[str], ) -> None: """Init discovery manager.""" self.hass = hass self.entry = entry self.data = data self.hosts = set(hosts) self.hosts_in_error: dict[str, bool] = {} self.discovery_lock = asyncio.Lock() self.creation_lock = asyncio.Lock() self._known_invisible: set[SoCo] = set() self._manual_config_required = bool(hosts) self._stop_event = threading.Event() async def async_shutdown(self) -> None: """Stop all running tasks.""" self._stop_event.set() # Stop the event listener first so new topology events cannot schedule # additional async_add_speakers runs while shutdown is waiting for # creation_lock to drain existing work. await self._async_stop_event_listener() # Wait for any in-flight _add_speakers executor job to finish before # tearing down speakers and the event listener. Every async_add_speakers # call holds creation_lock for its entire duration (including blocking # network IO), so acquiring it here serializes cleanup after creation. # Bound the wait so shutdown stays responsive under poor network conditions. try: async with asyncio.timeout(SHUTDOWN_TIMEOUT): async with self.creation_lock: pass except TimeoutError: _LOGGER.warning( "Timed out waiting for in-flight speaker discovery to complete" ) self._stop_manual_heartbeat() def is_device_invisible(self, ip_address: str) -> bool: """Check if device at provided IP is known to be invisible.""" return any(x for x in self._known_invisible if x.ip_address == ip_address) async def _process_http_connection_error( self, err: HTTPError, ip_address: str ) -> None: """Process HTTP Errors when connecting to a Sonos speaker.""" response = err.response # When UPnP is disabled, Sonos returns HTTP 403 Forbidden error. # Create issue advising user to enable UPnP on Sonos system. if response is not None and response.status_code == HTTPStatus.FORBIDDEN: ir.async_create_issue( self.hass, DOMAIN, f"{UPNP_ISSUE_ID}_{ip_address}", is_fixable=False, severity=ir.IssueSeverity.ERROR, translation_key="upnp_disabled", translation_placeholders={ "device_ip": ip_address, "documentation_url": UPNP_DOCUMENTATION_URL, }, ) _LOGGER.error( "HTTP error connecting to Sonos speaker at %s: %s", ip_address, err, ) async def async_subscribe_to_zone_updates(self, ip_address: str) -> None: """Test subscriptions and create SonosSpeakers based on results.""" try: _ = IPv4Address(ip_address) except AddressValueError: _LOGGER.debug( "Sonos integration only supports IPv4 addresses," " invalid ip_address received: %s", ip_address, ) return soco = SoCo(ip_address) try: # Cache now to avoid household ID lookup during # first ZoneGroupState processing await self.hass.async_add_executor_job( getattr, soco, "household_id", ) sub = await soco.zoneGroupTopology.subscribe() except HTTPError as err: await self._process_http_connection_error(err, ip_address) return except ( OSError, SoCoException, Timeout, TimeoutError, ) as err: _LOGGER.error( "Error connecting to discovered Sonos speaker at %s: %s", ip_address, err, ) return @callback def _async_add_visible_zones(subscription_succeeded: bool = False) -> None: """Determine visible zones and create SonosSpeaker instances.""" zones_to_add = set() subscription = None if subscription_succeeded: subscription = sub visible_zones = soco.visible_zones self._known_invisible = soco.all_zones - visible_zones for zone in visible_zones: if zone.uid in self.data.discovered or self.is_device_disabled( zone.uid ): continue zones_to_add.add(zone) if not zones_to_add: return self.hass.async_create_task( self.async_add_speakers(zones_to_add, subscription, soco.uid), eager_start=True, ) async def async_subscription_failed(now: datetime.datetime) -> None: """Fallback logic if the subscription callback never arrives.""" addr, port = sub.event_listener.address listener_address = f"{addr}:{port}" if advertise_ip := soco_config.EVENT_ADVERTISE_IP: listener_address += f" (advertising as {advertise_ip})" ir.async_create_issue( self.hass, DOMAIN, SUB_FAIL_ISSUE_ID, is_fixable=False, severity=ir.IssueSeverity.ERROR, translation_key="subscriptions_failed", translation_placeholders={ "device_ip": ip_address, "listener_address": listener_address, "sub_fail_url": SUB_FAIL_URL, }, ) _LOGGER.warning( "Subscription to %s failed, attempting to poll directly", ip_address ) try: await sub.unsubscribe() except (ClientError, OSError, Timeout) as ex: _LOGGER.debug("Unsubscription from %s failed: %s", ip_address, ex) try: await self.hass.async_add_executor_job(soco.zone_group_state.poll, soco) except (OSError, SoCoException, Timeout) as ex: _LOGGER.warning( "Fallback pollling to %s failed, setup cannot continue: %s", ip_address, ex, ) return _LOGGER.debug("Fallback ZoneGroupState poll to %s succeeded", ip_address) _async_add_visible_zones() cancel_failure_callback = async_call_later( self.hass, ZGS_SUBSCRIPTION_TIMEOUT, async_subscription_failed ) @callback def _async_subscription_succeeded(event: SonosEvent) -> None: """Create SonosSpeakers when subscription callbacks successfully arrive.""" _LOGGER.debug("Subscription to %s succeeded", ip_address) cancel_failure_callback() ir.async_delete_issue( self.hass, DOMAIN, SUB_FAIL_ISSUE_ID, ) _async_add_visible_zones(subscription_succeeded=True) sub.callback = _async_subscription_succeeded # Hold lock to prevent concurrent subscription attempts await asyncio.sleep(ZGS_SUBSCRIPTION_TIMEOUT * 2) try: # Cancel this subscription as we create an autorenewing # subscription when setting up the SonosSpeaker instance await sub.unsubscribe() except ClientError as ex: # Will be rejected if already replaced by new subscription _LOGGER.debug( "Cleanup unsubscription from %s was rejected: %s", ip_address, ex ) except (OSError, Timeout) as ex: _LOGGER.error("Cleanup unsubscription from %s failed: %s", ip_address, ex) async def _async_stop_event_listener(self, event: Event | None = None) -> None: for speaker in self.data.discovered.values(): speaker.activity_stats.log_report() speaker.event_stats.log_report() if zgs := next( ( speaker.soco.zone_group_state for speaker in self.data.discovered.values() ), None, ): _LOGGER.debug( "ZoneGroupState stats: (%s/%s) processed", zgs.processed_count, zgs.total_requests, ) await asyncio.gather( *( create_eager_task(speaker.async_offline()) for speaker in self.data.discovered.values() ) ) if events_asyncio.event_listener: await events_asyncio.event_listener.async_stop() @callback def _stop_manual_heartbeat(self, event: Event | None = None) -> None: if self.data.hosts_heartbeat: self.data.hosts_heartbeat() self.data.hosts_heartbeat = None async def async_add_speakers( self, socos: set[SoCo], zgs_subscription: SubscriptionBase | None, zgs_subscription_uid: str | None, ) -> None: """Create and set up new SonosSpeaker instances.""" def _add_speakers(): """Add all speakers in a single executor job.""" for soco in socos: if soco.uid in self.data.discovered: continue sub = None if soco.uid == zgs_subscription_uid and zgs_subscription: sub = zgs_subscription if self._stop_event.is_set(): # Entry was unloaded during IO; skip adding this speaker. _LOGGER.debug( "Config entry unloaded while adding speakers speaker %s, skipping", soco.uid, ) return self._add_speaker(soco, sub) async with self.creation_lock: await self.hass.async_add_executor_job(_add_speakers) def _add_speaker( self, soco: SoCo, zone_group_state_sub: SubscriptionBase | None ) -> None: """Create and set up a new SonosSpeaker instance.""" try: speaker_info = soco.get_speaker_info(True, timeout=7) if self._stop_event.is_set(): # Entry was unloaded during IO; skip adding this speaker. _LOGGER.debug( "Config entry unloaded while adding speaker %s, skipping", soco.uid ) return if soco.uid not in self.data.boot_counts: self.data.boot_counts[soco.uid] = soco.boot_seqnum _LOGGER.debug("Adding new speaker: %s", speaker_info) speaker = SonosSpeaker( self.hass, self.entry, soco, speaker_info, zone_group_state_sub ) self.data.discovered[soco.uid] = speaker for coordinator, coord_dict in ( (SonosAlarms, self.data.alarms), (SonosFavorites, self.data.favorites), ): c_dict: dict[str, Any] = coord_dict if soco.household_id not in c_dict: new_coordinator = coordinator( self.hass, soco.household_id, self.entry ) new_coordinator.setup(soco) c_dict[soco.household_id] = new_coordinator c_dict[soco.household_id].add_speaker(soco) speaker.setup(self.entry) except (OSError, SoCoException, Timeout) as ex: _LOGGER.warning("Failed to add SonosSpeaker using %s: %s", soco, ex) async def async_poll_manual_hosts( self, now: datetime.datetime | None = None ) -> None: """Add and maintain Sonos devices from a manual configuration.""" # Loop through each configured host and verify that # Soco attributes are available for it. for host in self.hosts.copy(): ip_addr = await self.hass.async_add_executor_job(socket.gethostbyname, host) soco = SoCo(ip_addr) try: visible_zones = await self.hass.async_add_executor_job( sync_get_visible_zones, soco, ) except HTTPError as err: await self._process_http_connection_error(err, ip_addr) continue except ( OSError, SoCoException, Timeout, TimeoutError, ) as ex: if not self.hosts_in_error.get(ip_addr): _LOGGER.warning( "Could not get visible Sonos devices from %s: %s", ip_addr, ex ) self.hosts_in_error[ip_addr] = True else: _LOGGER.debug( "Could not get visible Sonos devices from %s: %s", ip_addr, ex ) continue if self.hosts_in_error.pop(ip_addr, None): _LOGGER.warning("Connection reestablished to Sonos device %s", ip_addr) # Each speaker has the topology for other online # speakers, so add them in here if they were not # configured. The metadata is already in Soco. if new_hosts := { x.ip_address for x in visible_zones if x.ip_address not in self.hosts }: _LOGGER.debug("Adding to manual hosts: %s", new_hosts) self.hosts.update(new_hosts) if self.is_device_invisible(ip_addr): _LOGGER.debug("Discarding %s from manual hosts", ip_addr) self.hosts.discard(ip_addr) # Loop through each configured host that is not in # error. Send a discovery message if a speaker does # not already exist, or ping if it is unavailable. for host in self.hosts.copy(): ip_addr = await self.hass.async_add_executor_job(socket.gethostbyname, host) soco = SoCo(ip_addr) # Skip hosts that are in error to avoid blocking # call on soco.uuid in event loop if self.hosts_in_error.get(ip_addr): continue known_speaker = next( ( speaker for speaker in self.data.discovered.values() if speaker.soco.ip_address == ip_addr ), None, ) if known_speaker: uid = known_speaker.uid else: try: uid = await self.hass.async_add_executor_job(_get_soco_uid, soco) except HTTPError as err: await self._process_http_connection_error(err, ip_addr) continue except ( OSError, SoCoException, Timeout, TimeoutError, ) as ex: _LOGGER.warning("Could not get Sonos uid from %s: %s", ip_addr, ex) continue if self.is_device_disabled(uid): _LOGGER.debug( "Skipping manual poll for disabled Sonos device: %s", uid, ) continue if not known_speaker: try: await self._async_handle_discovery_message( uid, ip_addr, "manual zone scan", ) except ( OSError, SoCoException, Timeout, TimeoutError, ) as ex: _LOGGER.warning("Discovery message failed to %s : %s", ip_addr, ex) elif not known_speaker.available: try: await self.hass.async_add_executor_job(known_speaker.ping) # Only send the message if the ping was successful. async_dispatcher_send( self.hass, f"{SONOS_SPEAKER_ACTIVITY}-{known_speaker.uid}", "manual zone scan", ) except SonosUpdateError: _LOGGER.debug( "Manual poll to %s failed, keeping unavailable", ip_addr ) self.data.hosts_heartbeat = async_call_later( self.hass, DISCOVERY_INTERVAL.total_seconds(), self.async_poll_manual_hosts ) def is_device_disabled(self, uid: str) -> bool: """Check if the Sonos device is disabled in the device registry.""" if not ( device := dr.async_get(self.hass).async_get_device_by_identifier( (DOMAIN, uid), self.entry.entry_id ) ): return False return device.disabled async def _async_handle_discovery_message( self, uid: str, discovered_ip: str, source: str, boot_seqnum: int | None = None, ) -> None: """Handle discovered player creation and activity.""" if self.is_device_disabled(uid): _LOGGER.debug("Skipping %s for disabled Sonos device: %s", source, uid) return async with self.discovery_lock: if not self.data.discovered: # Initial discovery, attempt to add all visible zones await self.async_subscribe_to_zone_updates(discovered_ip) elif uid not in self.data.discovered: if self.is_device_invisible(discovered_ip): return await self.async_subscribe_to_zone_updates(discovered_ip) elif boot_seqnum and boot_seqnum > self.data.boot_counts[uid]: self.data.boot_counts[uid] = boot_seqnum async_dispatcher_send(self.hass, f"{SONOS_REBOOTED}-{uid}") else: async_dispatcher_send( self.hass, f"{SONOS_SPEAKER_ACTIVITY}-{uid}", source ) @callback def _async_ssdp_discovered_player( self, info: SsdpServiceInfo, change: ssdp.SsdpChange ) -> None: uid = info.upnp[ATTR_UPNP_UDN] if not uid.startswith("uuid:RINCON_"): return uid = uid[5:] if change is ssdp.SsdpChange.BYEBYE: _LOGGER.debug( "ssdp:byebye received from %s", info.upnp.get("friendlyName", uid) ) reason = info.ssdp_headers.get("X-RINCON-REASON", "ssdp:byebye") async_dispatcher_send(self.hass, f"{SONOS_VANISHED}-{uid}", reason) return self.async_discovered_player( "SSDP", info, cast(str, urlparse(info.ssdp_location).hostname), uid, info.ssdp_headers.get("X-RINCON-BOOTSEQ"), cast(str, info.upnp.get(ATTR_UPNP_MODEL_NAME)), None, ) @callback def async_discovered_player( self, source: str, info: SsdpServiceInfo, discovered_ip: str, uid: str, boot_seqnum: str | int | None, model: str, mdns_name: str | None, ) -> None: """Handle discovery via ssdp or zeroconf.""" if self._manual_config_required: _LOGGER.warning( "Automatic discovery is working, Sonos hosts in configuration.yaml are" " not needed" ) self._manual_config_required = False if model in DISCOVERY_IGNORED_MODELS: _LOGGER.debug("Ignoring device: %s", info) return if self.is_device_invisible(discovered_ip): return if boot_seqnum: boot_seqnum = int(boot_seqnum) self.data.boot_counts.setdefault(uid, boot_seqnum) if mdns_name: self.data.mdns_names[uid] = mdns_name if uid not in self.data.discovery_known: _LOGGER.debug("New %s discovery uid=%s: %s", source, uid, info) self.data.discovery_known.add(uid) self.entry.async_create_background_task( self.hass, self._async_handle_discovery_message( uid, discovered_ip, "discovery", boot_seqnum=cast(int | None, boot_seqnum), ), "sonos-handle_discovery_message", ) async def setup_platforms_and_discovery(self) -> None: """Set up platforms and discovery.""" await self.hass.config_entries.async_forward_entry_setups(self.entry, PLATFORMS) self.entry.async_on_unload( self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, self._async_stop_event_listener, ) ) _LOGGER.debug("Adding discovery job") if self.hosts: self.entry.async_on_unload( self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, self._stop_manual_heartbeat, ) ) await self.async_poll_manual_hosts() self.entry.async_on_unload( await ssdp.async_register_callback( self.hass, self._async_ssdp_discovered_player, {"st": UPNP_ST} ) ) self.entry.async_on_unload( async_track_time_interval( self.hass, partial( async_dispatcher_send, self.hass, SONOS_CHECK_ACTIVITY, ), AVAILABILITY_CHECK_INTERVAL, ) ) async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: SonosConfigEntry, device_entry: dr.AnyDeviceEntry ) -> bool: """Remove Sonos config entry from a device.""" known_devices = config_entry.runtime_data.discovered.keys() for identifier in device_entry.identifiers: if identifier[0] != DOMAIN: continue uid = identifier[1] if uid not in known_devices: return True return False