mirror of
https://github.com/home-assistant/core.git
synced 2025-07-22 20:57:21 +00:00
Detect Sonos reboots and recreate subscriptions (#51377)
This commit is contained in:
parent
d0a8e27036
commit
4ffa0dd199
@ -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):
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user