mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +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
|
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):
|
||||||
|
@ -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"
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user