Detect Sonos reboots and recreate subscriptions (#51377)

This commit is contained in:
jjlawren 2021-06-07 20:51:42 -05:00 committed by GitHub
parent d0a8e27036
commit 4ffa0dd199
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 86 additions and 30 deletions

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections import OrderedDict, deque from collections import OrderedDict, deque
import datetime import datetime
from enum import Enum
import logging import logging
import socket import socket
from urllib.parse import urlparse from urllib.parse import urlparse
@ -26,7 +27,7 @@ from homeassistant.const import (
) )
from homeassistant.core import Event, HomeAssistant, callback from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv 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 ( from .const import (
DATA_SONOS, DATA_SONOS,
@ -35,6 +36,7 @@ from .const import (
PLATFORMS, PLATFORMS,
SONOS_ALARM_UPDATE, SONOS_ALARM_UPDATE,
SONOS_GROUP_UPDATE, SONOS_GROUP_UPDATE,
SONOS_REBOOTED,
SONOS_SEEN, SONOS_SEEN,
UPNP_ST, 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: class SonosData:
"""Storage class for platform global data.""" """Storage class for platform global data."""
@ -80,6 +90,7 @@ class SonosData:
self.topology_condition = asyncio.Condition() self.topology_condition = asyncio.Condition()
self.hosts_heartbeat = None self.hosts_heartbeat = None
self.ssdp_known: set[str] = set() self.ssdp_known: set[str] = set()
self.boot_counts: dict[str, int] = {}
async def async_setup(hass, config): async def async_setup(hass, config):
@ -129,7 +140,6 @@ async def async_setup_entry( # noqa: C901
def _discovered_player(soco: SoCo) -> None: def _discovered_player(soco: SoCo) -> None:
"""Handle a (re)discovered player.""" """Handle a (re)discovered player."""
try: try:
_LOGGER.debug("Reached _discovered_player, soco=%s", soco)
speaker_info = soco.get_speaker_info(True) speaker_info = soco.get_speaker_info(True)
_LOGGER.debug("Adding new speaker: %s", speaker_info) _LOGGER.debug("Adding new speaker: %s", speaker_info)
speaker = SonosSpeaker(hass, soco, speaker_info) speaker = SonosSpeaker(hass, soco, speaker_info)
@ -143,22 +153,40 @@ async def async_setup_entry( # noqa: C901
except SoCoException as ex: except SoCoException as ex:
_LOGGER.debug("SoCoException, ex=%s", 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: def _manual_hosts(now: datetime.datetime | None = None) -> None:
"""Players from network configuration.""" """Players from network configuration."""
for host in hosts: for host in hosts:
try: ip_addr = socket.gethostbyname(host)
_LOGGER.debug("Testing %s", host) known_uid = next(
player = pysonos.SoCo(socket.gethostbyname(host)) (
if player.is_visible: uid
# Make sure that the player is available for uid, speaker in data.discovered.items()
_ = player.volume if speaker.soco.ip_address == ip_addr
_discovered_player(player) ),
except (OSError, SoCoException) as ex: None,
_LOGGER.debug("Issue connecting to '%s': %s", host, ex) )
if now is None:
_LOGGER.warning("Failed to initialize '%s'", host) 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( data.hosts_heartbeat = hass.helpers.event.call_later(
DISCOVERY_INTERVAL.total_seconds(), _manual_hosts DISCOVERY_INTERVAL.total_seconds(), _manual_hosts
) )
@ -168,32 +196,41 @@ async def async_setup_entry( # noqa: C901
async_dispatcher_send(hass, SONOS_GROUP_UPDATE) async_dispatcher_send(hass, SONOS_GROUP_UPDATE)
def _discovered_ip(ip_address): def _discovered_ip(ip_address):
try: soco = _create_soco(ip_address, SoCoCreationSource.DISCOVERED)
player = pysonos.SoCo(ip_address) if soco and soco.is_visible:
except (OSError, SoCoException): _discovered_player(soco)
_LOGGER.debug("Failed to connect to discovered player '%s'", ip_address)
return
if player.is_visible:
_discovered_player(player)
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.""" """Only create one player at a time."""
async with discovery_lock: async with discovery_lock:
if uid in data.discovered: if uid not in data.discovered:
async_dispatcher_send(hass, f"{SONOS_SEEN}-{uid}") await hass.async_add_executor_job(_discovered_ip, discovered_ip)
return 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 @callback
def _async_discovered_player(info): def _async_discovered_player(info):
uid = info.get(ssdp.ATTR_UPNP_UDN) uid = info.get(ssdp.ATTR_UPNP_UDN)
if uid.startswith("uuid:"): if uid.startswith("uuid:"):
uid = uid[5:] 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: if uid not in data.ssdp_known:
_LOGGER.debug("New discovery: %s", info) _LOGGER.debug("New discovery: %s", info)
data.ssdp_known.add(uid) data.ssdp_known.add(uid)
discovered_ip = urlparse(info[ssdp.ATTR_SSDP_LOCATION]).hostname 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 @callback
def _async_signal_update_alarms(event): def _async_signal_update_alarms(event):

View File

@ -143,6 +143,7 @@ SONOS_GROUP_UPDATE = "sonos_group_update"
SONOS_HOUSEHOLD_UPDATED = "sonos_household_updated" SONOS_HOUSEHOLD_UPDATED = "sonos_household_updated"
SONOS_ALARM_UPDATE = "sonos_alarm_update" SONOS_ALARM_UPDATE = "sonos_alarm_update"
SONOS_STATE_UPDATED = "sonos_state_updated" SONOS_STATE_UPDATED = "sonos_state_updated"
SONOS_REBOOTED = "sonos_rebooted"
SONOS_SEEN = "sonos_seen" SONOS_SEEN = "sonos_seen"
SOURCE_LINEIN = "Line-in" SOURCE_LINEIN = "Line-in"

View File

@ -47,6 +47,7 @@ from .const import (
SONOS_ENTITY_CREATED, SONOS_ENTITY_CREATED,
SONOS_GROUP_UPDATE, SONOS_GROUP_UPDATE,
SONOS_POLL_UPDATE, SONOS_POLL_UPDATE,
SONOS_REBOOTED,
SONOS_SEEN, SONOS_SEEN,
SONOS_STATE_PLAYING, SONOS_STATE_PLAYING,
SONOS_STATE_TRANSITIONING, SONOS_STATE_TRANSITIONING,
@ -164,6 +165,7 @@ class SonosSpeaker:
# Dispatcher handles # Dispatcher handles
self._entity_creation_dispatcher: Callable | None = None self._entity_creation_dispatcher: Callable | None = None
self._group_dispatcher: Callable | None = None self._group_dispatcher: Callable | None = None
self._reboot_dispatcher: Callable | None = None
self._seen_dispatcher: Callable | None = None self._seen_dispatcher: Callable | None = None
# Device information # Device information
@ -207,6 +209,9 @@ class SonosSpeaker:
self._seen_dispatcher = dispatcher_connect( self._seen_dispatcher = dispatcher_connect(
self.hass, f"{SONOS_SEEN}-{self.soco.uid}", self.async_seen 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): if battery_info := fetch_battery_info_or_none(self.soco):
self.battery_info = battery_info self.battery_info = battery_info
@ -449,10 +454,10 @@ class SonosSpeaker:
self.async_write_entity_states() 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.""" """Make this player unavailable when it was not seen recently."""
self.async_write_entity_states()
if self._seen_timer: if self._seen_timer:
self._seen_timer() self._seen_timer()
self._seen_timer = None self._seen_timer = None
@ -465,7 +470,20 @@ class SonosSpeaker:
await subscription.unsubscribe() await subscription.unsubscribe()
self._subscriptions = [] 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 # Alarm management