1 """Support to embed Sonos."""
3 from __future__
import annotations
6 from collections
import OrderedDict
7 from dataclasses
import dataclass, field
9 from functools
import partial
12 from typing
import Any, cast
13 from urllib.parse
import urlparse
15 from aiohttp
import ClientError
16 from requests.exceptions
import Timeout
17 from soco
import events_asyncio, zonegroupstate
18 import soco.config
as soco_config
19 from soco.core
import SoCo
20 from soco.events_base
import Event
as SonosEvent, SubscriptionBase
21 from soco.exceptions
import SoCoException
22 import voluptuous
as vol
24 from homeassistant
import config_entries
31 config_validation
as cv,
32 device_registry
as dr,
40 from .alarms
import SonosAlarms
42 AVAILABILITY_CHECK_INTERVAL,
44 DATA_SONOS_DISCOVERY_MANAGER,
50 SONOS_SPEAKER_ACTIVITY,
57 from .exception
import SonosUpdateError
58 from .favorites
import SonosFavorites
59 from .helpers
import sync_get_visible_zones
60 from .speaker
import SonosSpeaker
62 _LOGGER = logging.getLogger(__name__)
64 CONF_ADVERTISE_ADDR =
"advertise_addr"
65 CONF_INTERFACE_ADDR =
"interface_addr"
66 DISCOVERY_IGNORED_MODELS = [
"Sonos Boost"]
67 ZGS_SUBSCRIPTION_TIMEOUT = 2
69 CONFIG_SCHEMA = vol.Schema(
74 cv.deprecated(CONF_INTERFACE_ADDR),
77 vol.Optional(CONF_ADVERTISE_ADDR): cv.string,
78 vol.Optional(CONF_INTERFACE_ADDR): cv.string,
79 vol.Optional(CONF_HOSTS): vol.All(
80 cv.ensure_list_csv, [cv.string]
88 extra=vol.ALLOW_EXTRA,
94 """Class to track data necessary for unjoin coalescing."""
96 speakers: list[SonosSpeaker]
97 event: asyncio.Event = field(default_factory=asyncio.Event)
101 """Storage class for platform global data."""
104 """Initialize the data."""
106 self.discovered: OrderedDict[str, SonosSpeaker] = OrderedDict()
107 self.favorites: dict[str, SonosFavorites] = {}
108 self.alarms: dict[str, SonosAlarms] = {}
110 self.hosts_heartbeat: CALLBACK_TYPE |
None =
None
111 self.discovery_known: set[str] = set()
112 self.boot_counts: dict[str, int] = {}
113 self.mdns_names: dict[str, str] = {}
114 self.entity_id_mappings: dict[str, SonosSpeaker] = {}
115 self.unjoin_data: dict[str, UnjoinData] = {}
118 async
def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
119 """Set up the Sonos component."""
120 conf = config.get(DOMAIN)
122 hass.data[DOMAIN] = conf
or {}
125 hass.async_create_task(
126 hass.config_entries.flow.async_init(
127 DOMAIN, context={
"source": config_entries.SOURCE_IMPORT}
135 """Set up Sonos from a config entry."""
136 soco_config.EVENTS_MODULE = events_asyncio
137 soco_config.REQUEST_TIMEOUT = 9.5
138 soco_config.ZGT_EVENT_FALLBACK =
False
139 zonegroupstate.EVENT_CACHE_TIMEOUT = SUBSCRIPTION_TIMEOUT
141 if DATA_SONOS
not in hass.data:
144 data = hass.data[DATA_SONOS]
145 config = hass.data[DOMAIN].
get(
"media_player", {})
146 hosts = config.get(CONF_HOSTS, [])
147 _LOGGER.debug(
"Reached async_setup_entry, config=%s", config)
149 if advertise_addr := config.get(CONF_ADVERTISE_ADDR):
150 soco_config.EVENT_ADVERTISE_IP = advertise_addr
152 if deprecated_address := config.get(CONF_INTERFACE_ADDR):
155 "'%s' is deprecated, enable %s in the Network integration"
156 " (https://www.home-assistant.io/integrations/network/)"
163 hass, entry, data, hosts
165 await manager.setup_platforms_and_discovery()
170 """Unload a Sonos config entry."""
171 unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
172 await hass.data[DATA_SONOS_DISCOVERY_MANAGER].async_shutdown()
173 hass.data.pop(DATA_SONOS)
174 hass.data.pop(DATA_SONOS_DISCOVERY_MANAGER)
179 """Manage sonos discovery."""
182 self, hass: HomeAssistant, entry: ConfigEntry, data: SonosData, hosts: list[str]
184 """Init discovery manager."""
189 self.hosts_in_error: dict[str, bool] = {}
196 """Stop all running tasks."""
201 """Check if device at provided IP is known to be invisible."""
202 return any(x
for x
in self.
_known_invisible_known_invisible
if x.ip_address == ip_address)
205 """Test subscriptions and create SonosSpeakers based on results."""
206 soco = SoCo(ip_address)
208 await self.
hasshass.async_add_executor_job(
213 sub = await soco.zoneGroupTopology.subscribe()
216 def _async_add_visible_zones(subscription_succeeded: bool =
False) ->
None:
217 """Determine visible zones and create SonosSpeaker instances."""
220 if subscription_succeeded:
223 visible_zones = soco.visible_zones
225 for zone
in visible_zones:
226 if zone.uid
not in self.
datadata.discovered:
227 zones_to_add.add(zone)
232 self.
hasshass.async_create_task(
237 async
def async_subscription_failed(now: datetime.datetime) ->
None:
238 """Fallback logic if the subscription callback never arrives."""
239 addr, port = sub.event_listener.address
240 listener_address = f
"{addr}:{port}"
241 if advertise_ip := soco_config.EVENT_ADVERTISE_IP:
242 listener_address += f
" (advertising as {advertise_ip})"
243 ir.async_create_issue(
248 severity=ir.IssueSeverity.ERROR,
249 translation_key=
"subscriptions_failed",
250 translation_placeholders={
251 "device_ip": ip_address,
252 "listener_address": listener_address,
253 "sub_fail_url": SUB_FAIL_URL,
258 "Subscription to %s failed, attempting to poll directly", ip_address
261 await sub.unsubscribe()
262 except (ClientError, OSError, Timeout)
as ex:
263 _LOGGER.debug(
"Unsubscription from %s failed: %s", ip_address, ex)
266 await self.
hasshass.async_add_executor_job(soco.zone_group_state.poll, soco)
267 except (OSError, SoCoException, Timeout)
as ex:
269 "Fallback pollling to %s failed, setup cannot continue: %s",
274 _LOGGER.debug(
"Fallback ZoneGroupState poll to %s succeeded", ip_address)
275 _async_add_visible_zones()
278 self.
hasshass, ZGS_SUBSCRIPTION_TIMEOUT, async_subscription_failed
282 def _async_subscription_succeeded(event: SonosEvent) ->
None:
283 """Create SonosSpeakers when subscription callbacks successfully arrive."""
284 _LOGGER.debug(
"Subscription to %s succeeded", ip_address)
285 cancel_failure_callback()
286 ir.async_delete_issue(
291 _async_add_visible_zones(subscription_succeeded=
True)
293 sub.callback = _async_subscription_succeeded
295 await asyncio.sleep(ZGS_SUBSCRIPTION_TIMEOUT * 2)
299 await sub.unsubscribe()
300 except ClientError
as ex:
303 "Cleanup unsubscription from %s was rejected: %s", ip_address, ex
305 except (OSError, Timeout)
as ex:
306 _LOGGER.error(
"Cleanup unsubscription from %s failed: %s", ip_address, ex)
309 for speaker
in self.
datadata.discovered.values():
310 speaker.activity_stats.log_report()
311 speaker.event_stats.log_report()
314 speaker.soco.zone_group_state
315 for speaker
in self.
datadata.discovered.values()
320 "ZoneGroupState stats: (%s/%s) processed",
324 await asyncio.gather(
326 create_eager_task(speaker.async_offline())
327 for speaker
in self.
datadata.discovered.values()
330 if events_asyncio.event_listener:
331 await events_asyncio.event_listener.async_stop()
335 if self.
datadata.hosts_heartbeat:
336 self.
datadata.hosts_heartbeat()
337 self.
datadata.hosts_heartbeat =
None
342 zgs_subscription: SubscriptionBase |
None,
343 zgs_subscription_uid: str |
None,
345 """Create and set up new SonosSpeaker instances."""
348 """Add all speakers in a single executor job."""
350 if soco.uid
in self.
datadata.discovered:
353 if soco.uid == zgs_subscription_uid
and zgs_subscription:
354 sub = zgs_subscription
358 await self.
hasshass.async_add_executor_job(_add_speakers)
361 self, soco: SoCo, zone_group_state_sub: SubscriptionBase |
None
363 """Create and set up a new SonosSpeaker instance."""
365 speaker_info = soco.get_speaker_info(
True, timeout=7)
366 if soco.uid
not in self.
datadata.boot_counts:
367 self.
datadata.boot_counts[soco.uid] = soco.boot_seqnum
368 _LOGGER.debug(
"Adding new speaker: %s", speaker_info)
369 speaker =
SonosSpeaker(self.
hasshass, soco, speaker_info, zone_group_state_sub)
370 self.
datadata.discovered[soco.uid] = speaker
371 for coordinator, coord_dict
in (
372 (SonosAlarms, self.
datadata.alarms),
373 (SonosFavorites, self.
datadata.favorites),
375 c_dict: dict[str, Any] = coord_dict
376 if soco.household_id
not in c_dict:
378 new_coordinator.setup(soco)
379 c_dict[soco.household_id] = new_coordinator
380 speaker.setup(self.
entryentry)
381 except (OSError, SoCoException, Timeout)
as ex:
382 _LOGGER.warning(
"Failed to add SonosSpeaker using %s: %s", soco, ex)
385 self, now: datetime.datetime |
None =
None
387 """Add and maintain Sonos devices from a manual configuration."""
390 for host
in self.
hostshosts.copy():
391 ip_addr = await self.
hasshass.async_add_executor_job(socket.gethostbyname, host)
394 visible_zones = await self.
hasshass.async_add_executor_job(
395 sync_get_visible_zones,
404 if not self.hosts_in_error.
get(ip_addr):
406 "Could not get visible Sonos devices from %s: %s", ip_addr, ex
408 self.hosts_in_error[ip_addr] =
True
411 "Could not get visible Sonos devices from %s: %s", ip_addr, ex
415 if self.hosts_in_error.pop(ip_addr,
None):
416 _LOGGER.warning(
"Connection reestablished to Sonos device %s", ip_addr)
420 x.ip_address
for x
in visible_zones
if x.ip_address
not in self.
hostshosts
422 _LOGGER.debug(
"Adding to manual hosts: %s", new_hosts)
426 _LOGGER.debug(
"Discarding %s from manual hosts", ip_addr)
427 self.
hostshosts.discard(ip_addr)
431 for host
in self.
hostshosts.copy():
432 ip_addr = await self.
hasshass.async_add_executor_job(socket.gethostbyname, host)
435 if self.hosts_in_error.
get(ip_addr):
437 known_speaker = next(
440 for speaker
in self.
datadata.discovered.values()
441 if speaker.soco.ip_address == ip_addr
445 if not known_speaker:
458 _LOGGER.warning(
"Discovery message failed to %s : %s", ip_addr, ex)
459 elif not known_speaker.available:
461 await self.
hasshass.async_add_executor_job(known_speaker.ping)
465 f
"{SONOS_SPEAKER_ACTIVITY}-{soco.uid}",
468 except SonosUpdateError:
470 "Manual poll to %s failed, keeping unavailable", ip_addr
482 boot_seqnum: int |
None =
None,
484 """Handle discovered player creation and activity."""
486 if not self.
datadata.discovered:
489 elif uid
not in self.
datadata.discovered:
493 elif boot_seqnum
and boot_seqnum > self.
datadata.boot_counts[uid]:
494 self.
datadata.boot_counts[uid] = boot_seqnum
498 self.
hasshass, f
"{SONOS_SPEAKER_ACTIVITY}-{uid}", source
503 self, info: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange
505 uid = info.upnp[ssdp.ATTR_UPNP_UDN]
506 if not uid.startswith(
"uuid:RINCON_"):
510 if change == ssdp.SsdpChange.BYEBYE:
512 "ssdp:byebye received from %s", info.upnp.get(
"friendlyName", uid)
514 reason = info.ssdp_headers.get(
"X-RINCON-REASON",
"ssdp:byebye")
521 cast(str, urlparse(info.ssdp_location).hostname),
523 info.ssdp_headers.get(
"X-RINCON-BOOTSEQ"),
524 cast(str, info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME)),
535 boot_seqnum: str | int |
None,
537 mdns_name: str |
None,
539 """Handle discovery via ssdp or zeroconf."""
542 "Automatic discovery is working, Sonos hosts in configuration.yaml are"
546 if model
in DISCOVERY_IGNORED_MODELS:
547 _LOGGER.debug(
"Ignoring device: %s", info)
553 boot_seqnum =
int(boot_seqnum)
554 self.
datadata.boot_counts.setdefault(uid, boot_seqnum)
556 self.
datadata.mdns_names[uid] = mdns_name
558 if uid
not in self.
datadata.discovery_known:
559 _LOGGER.debug(
"New %s discovery uid=%s: %s", source, uid, info)
560 self.
datadata.discovery_known.add(uid)
561 self.
entryentry.async_create_background_task(
567 boot_seqnum=cast(int |
None, boot_seqnum),
569 "sonos-handle_discovery_message",
573 """Set up platforms and discovery."""
574 await self.
hasshass.config_entries.async_forward_entry_setups(self.
entryentry, PLATFORMS)
575 self.
entryentry.async_on_unload(
576 self.
hasshass.bus.async_listen_once(
577 EVENT_HOMEASSISTANT_STOP,
581 _LOGGER.debug(
"Adding discovery job")
583 self.
entryentry.async_on_unload(
584 self.
hasshass.bus.async_listen_once(
585 EVENT_HOMEASSISTANT_STOP,
591 self.
entryentry.async_on_unload(
592 await ssdp.async_register_callback(
597 self.
entryentry.async_on_unload(
601 async_dispatcher_send,
603 SONOS_CHECK_ACTIVITY,
605 AVAILABILITY_CHECK_INTERVAL,
611 hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
613 """Remove Sonos config entry from a device."""
614 known_devices = hass.data[DATA_SONOS].discovered.keys()
615 for identifier
in device_entry.identifiers:
616 if identifier[0] != DOMAIN:
619 if uid
not in known_devices:
bool is_device_invisible(self, str ip_address)
None _add_speaker(self, SoCo soco, SubscriptionBase|None zone_group_state_sub)
None setup_platforms_and_discovery(self)
None async_add_speakers(self, set[SoCo] socos, SubscriptionBase|None zgs_subscription, str|None zgs_subscription_uid)
None _async_stop_event_listener(self, Event|None event=None)
None __init__(self, HomeAssistant hass, ConfigEntry entry, SonosData data, list[str] hosts)
None async_shutdown(self)
None async_poll_manual_hosts(self, datetime.datetime|None now=None)
None async_discovered_player(self, str source, ssdp.SsdpServiceInfo info, str discovered_ip, str uid, str|int|None boot_seqnum, str model, str|None mdns_name)
None _async_ssdp_discovered_player(self, ssdp.SsdpServiceInfo info, ssdp.SsdpChange change)
None async_subscribe_to_zone_updates(self, str ip_address)
None _async_handle_discovery_message(self, str uid, str discovered_ip, str source, int|None boot_seqnum=None)
None _stop_manual_heartbeat(self, Event|None event=None)
web.Response get(self, web.Request request, str config_key)
IssData update(pyiss.ISS iss)
bool async_setup(HomeAssistant hass, ConfigType config)
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
bool async_remove_config_entry_device(HomeAssistant hass, ConfigEntry config_entry, dr.DeviceEntry device_entry)
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
CALLBACK_TYPE async_call_later(HomeAssistant hass, float|timedelta delay, HassJob[[datetime], Coroutine[Any, Any, None]|None]|Callable[[datetime], Coroutine[Any, Any, None]|None] action)
CALLBACK_TYPE async_track_time_interval(HomeAssistant hass, Callable[[datetime], Coroutine[Any, Any, None]|None] action, timedelta interval, *str|None name=None, bool|None cancel_on_shutdown=None)