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
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):

View File

@ -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"

View File

@ -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