mirror of
https://github.com/home-assistant/core.git
synced 2025-07-25 22:27:07 +00:00
Replace sonos discovery thread with ssdp callback registration (#51033)
Co-authored-by: jjlawren <jjlawren@users.noreply.github.com>
This commit is contained in:
parent
255e13930c
commit
c5e5787e1d
@ -6,6 +6,7 @@ from collections import OrderedDict
|
|||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import pysonos
|
import pysonos
|
||||||
from pysonos import events_asyncio
|
from pysonos import events_asyncio
|
||||||
@ -15,6 +16,7 @@ from pysonos.exceptions import SoCoException
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.components import ssdp
|
||||||
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
|
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
@ -24,7 +26,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, dispatcher_send
|
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
DATA_SONOS,
|
DATA_SONOS,
|
||||||
@ -34,6 +36,7 @@ from .const import (
|
|||||||
SONOS_ALARM_UPDATE,
|
SONOS_ALARM_UPDATE,
|
||||||
SONOS_GROUP_UPDATE,
|
SONOS_GROUP_UPDATE,
|
||||||
SONOS_SEEN,
|
SONOS_SEEN,
|
||||||
|
UPNP_ST,
|
||||||
)
|
)
|
||||||
from .favorites import SonosFavorites
|
from .favorites import SonosFavorites
|
||||||
from .speaker import SonosSpeaker
|
from .speaker import SonosSpeaker
|
||||||
@ -74,7 +77,6 @@ class SonosData:
|
|||||||
self.favorites: dict[str, SonosFavorites] = {}
|
self.favorites: dict[str, SonosFavorites] = {}
|
||||||
self.alarms: dict[str, Alarm] = {}
|
self.alarms: dict[str, Alarm] = {}
|
||||||
self.topology_condition = asyncio.Condition()
|
self.topology_condition = asyncio.Condition()
|
||||||
self.discovery_thread = None
|
|
||||||
self.hosts_heartbeat = None
|
self.hosts_heartbeat = None
|
||||||
|
|
||||||
|
|
||||||
@ -94,89 +96,101 @@ async def async_setup(hass, config):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry( # noqa: C901
|
||||||
|
hass: HomeAssistant, entry: ConfigEntry
|
||||||
|
) -> bool:
|
||||||
"""Set up Sonos from a config entry."""
|
"""Set up Sonos from a config entry."""
|
||||||
pysonos.config.EVENTS_MODULE = events_asyncio
|
pysonos.config.EVENTS_MODULE = events_asyncio
|
||||||
|
|
||||||
if DATA_SONOS not in hass.data:
|
if DATA_SONOS not in hass.data:
|
||||||
hass.data[DATA_SONOS] = SonosData()
|
hass.data[DATA_SONOS] = SonosData()
|
||||||
|
|
||||||
|
data = hass.data[DATA_SONOS]
|
||||||
config = hass.data[DOMAIN].get("media_player", {})
|
config = hass.data[DOMAIN].get("media_player", {})
|
||||||
|
hosts = config.get(CONF_HOSTS, [])
|
||||||
|
discovery_lock = asyncio.Lock()
|
||||||
_LOGGER.debug("Reached async_setup_entry, config=%s", config)
|
_LOGGER.debug("Reached async_setup_entry, config=%s", config)
|
||||||
|
|
||||||
advertise_addr = config.get(CONF_ADVERTISE_ADDR)
|
advertise_addr = config.get(CONF_ADVERTISE_ADDR)
|
||||||
if advertise_addr:
|
if advertise_addr:
|
||||||
pysonos.config.EVENT_ADVERTISE_IP = advertise_addr
|
pysonos.config.EVENT_ADVERTISE_IP = advertise_addr
|
||||||
|
|
||||||
def _stop_discovery(event: Event) -> None:
|
async def _async_stop_event_listener(event: Event) -> None:
|
||||||
data = hass.data[DATA_SONOS]
|
if events_asyncio.event_listener:
|
||||||
if data.discovery_thread:
|
await events_asyncio.event_listener.async_stop()
|
||||||
data.discovery_thread.stop()
|
|
||||||
data.discovery_thread = None
|
def _stop_manual_heartbeat(event: Event) -> None:
|
||||||
if data.hosts_heartbeat:
|
if data.hosts_heartbeat:
|
||||||
data.hosts_heartbeat()
|
data.hosts_heartbeat()
|
||||||
data.hosts_heartbeat = None
|
data.hosts_heartbeat = None
|
||||||
|
|
||||||
def _discovery(now: datetime.datetime | None = None) -> None:
|
def _discovered_player(soco: SoCo) -> None:
|
||||||
"""Discover players from network or configuration."""
|
"""Handle a (re)discovered player."""
|
||||||
hosts = config.get(CONF_HOSTS)
|
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)
|
||||||
|
data.discovered[soco.uid] = speaker
|
||||||
|
if soco.household_id not in data.favorites:
|
||||||
|
data.favorites[soco.household_id] = SonosFavorites(
|
||||||
|
hass, soco.household_id
|
||||||
|
)
|
||||||
|
data.favorites[soco.household_id].update()
|
||||||
|
speaker.setup()
|
||||||
|
except SoCoException as ex:
|
||||||
|
_LOGGER.debug("SoCoException, ex=%s", ex)
|
||||||
|
|
||||||
def _discovered_player(soco: SoCo) -> None:
|
def _manual_hosts(now: datetime.datetime | None = None) -> None:
|
||||||
"""Handle a (re)discovered player."""
|
"""Players from network configuration."""
|
||||||
|
for host in hosts:
|
||||||
try:
|
try:
|
||||||
_LOGGER.debug("Reached _discovered_player, soco=%s", soco)
|
_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)
|
||||||
|
|
||||||
data = hass.data[DATA_SONOS]
|
_LOGGER.debug("Tested all hosts")
|
||||||
|
data.hosts_heartbeat = hass.helpers.event.call_later(
|
||||||
if soco.uid not in data.discovered:
|
DISCOVERY_INTERVAL.total_seconds(), _manual_hosts
|
||||||
speaker_info = soco.get_speaker_info(True)
|
)
|
||||||
_LOGGER.debug("Adding new speaker: %s", speaker_info)
|
|
||||||
speaker = SonosSpeaker(hass, soco, speaker_info)
|
|
||||||
data.discovered[soco.uid] = speaker
|
|
||||||
if soco.household_id not in data.favorites:
|
|
||||||
data.favorites[soco.household_id] = SonosFavorites(
|
|
||||||
hass, soco.household_id
|
|
||||||
)
|
|
||||||
data.favorites[soco.household_id].update()
|
|
||||||
speaker.setup()
|
|
||||||
else:
|
|
||||||
dispatcher_send(hass, f"{SONOS_SEEN}-{soco.uid}", soco)
|
|
||||||
|
|
||||||
except SoCoException as ex:
|
|
||||||
_LOGGER.debug("SoCoException, ex=%s", ex)
|
|
||||||
|
|
||||||
if hosts:
|
|
||||||
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("Exception %s", ex)
|
|
||||||
if now is None:
|
|
||||||
_LOGGER.warning("Failed to initialize '%s'", host)
|
|
||||||
|
|
||||||
_LOGGER.debug("Tested all hosts")
|
|
||||||
hass.data[DATA_SONOS].hosts_heartbeat = hass.helpers.event.call_later(
|
|
||||||
DISCOVERY_INTERVAL.total_seconds(), _discovery
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
_LOGGER.debug("Starting discovery thread")
|
|
||||||
hass.data[DATA_SONOS].discovery_thread = pysonos.discover_thread(
|
|
||||||
_discovered_player,
|
|
||||||
interval=DISCOVERY_INTERVAL.total_seconds(),
|
|
||||||
interface_addr=config.get(CONF_INTERFACE_ADDR),
|
|
||||||
)
|
|
||||||
hass.data[DATA_SONOS].discovery_thread.name = "Sonos-Discovery"
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_signal_update_groups(event):
|
def _async_signal_update_groups(event):
|
||||||
async_dispatcher_send(hass, SONOS_GROUP_UPDATE)
|
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)
|
||||||
|
|
||||||
|
async def _async_create_discovered_player(uid, discovered_ip):
|
||||||
|
"""Only create one player at a time."""
|
||||||
|
async with discovery_lock:
|
||||||
|
if uid in data.discovered:
|
||||||
|
async_dispatcher_send(hass, f"{SONOS_SEEN}-{uid}")
|
||||||
|
return
|
||||||
|
await hass.async_add_executor_job(_discovered_ip, discovered_ip)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_discovered_player(info):
|
||||||
|
_LOGGER.debug("Sonos Discovery: %s", info)
|
||||||
|
uid = info.get(ssdp.ATTR_UPNP_UDN)
|
||||||
|
if uid.startswith("uuid:"):
|
||||||
|
uid = uid[5:]
|
||||||
|
discovered_ip = urlparse(info[ssdp.ATTR_SSDP_LOCATION]).hostname
|
||||||
|
asyncio.create_task(_async_create_discovered_player(uid, discovered_ip))
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_signal_update_alarms(event):
|
def _async_signal_update_alarms(event):
|
||||||
async_dispatcher_send(hass, SONOS_ALARM_UPDATE)
|
async_dispatcher_send(hass, SONOS_ALARM_UPDATE)
|
||||||
@ -188,9 +202,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
for platform in PLATFORMS
|
for platform in PLATFORMS
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
entry.async_on_unload(
|
|
||||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_discovery)
|
|
||||||
)
|
|
||||||
entry.async_on_unload(
|
entry.async_on_unload(
|
||||||
hass.bus.async_listen_once(
|
hass.bus.async_listen_once(
|
||||||
EVENT_HOMEASSISTANT_START, _async_signal_update_groups
|
EVENT_HOMEASSISTANT_START, _async_signal_update_groups
|
||||||
@ -201,8 +212,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
EVENT_HOMEASSISTANT_START, _async_signal_update_alarms
|
EVENT_HOMEASSISTANT_START, _async_signal_update_alarms
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
entry.async_on_unload(
|
||||||
|
hass.bus.async_listen_once(
|
||||||
|
EVENT_HOMEASSISTANT_STOP, _async_stop_event_listener
|
||||||
|
)
|
||||||
|
)
|
||||||
_LOGGER.debug("Adding discovery job")
|
_LOGGER.debug("Adding discovery job")
|
||||||
await hass.async_add_executor_job(_discovery)
|
if hosts:
|
||||||
|
entry.async_on_unload(
|
||||||
|
hass.bus.async_listen_once(
|
||||||
|
EVENT_HOMEASSISTANT_STOP, _stop_manual_heartbeat
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await hass.async_add_executor_job(_manual_hosts)
|
||||||
|
return
|
||||||
|
|
||||||
|
entry.async_on_unload(
|
||||||
|
ssdp.async_register_callback(
|
||||||
|
hass, _async_discovered_player, {"st": UPNP_ST}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
hass.async_create_task(setup_platforms_and_discovery())
|
hass.async_create_task(setup_platforms_and_discovery())
|
||||||
|
|
||||||
|
@ -22,6 +22,8 @@ from homeassistant.components.media_player.const import (
|
|||||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||||
|
|
||||||
|
UPNP_ST = "urn:schemas-upnp-org:device:ZonePlayer:1"
|
||||||
|
|
||||||
DOMAIN = "sonos"
|
DOMAIN = "sonos"
|
||||||
DATA_SONOS = "sonos_media_player"
|
DATA_SONOS = "sonos_media_player"
|
||||||
PLATFORMS = {BINARY_SENSOR_DOMAIN, MP_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN}
|
PLATFORMS = {BINARY_SENSOR_DOMAIN, MP_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN}
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/sonos",
|
"documentation": "https://www.home-assistant.io/integrations/sonos",
|
||||||
"requirements": ["pysonos==0.0.50"],
|
"requirements": ["pysonos==0.0.50"],
|
||||||
|
"dependencies": ["ssdp"],
|
||||||
"after_dependencies": ["plex"],
|
"after_dependencies": ["plex"],
|
||||||
"ssdp": [
|
"ssdp": [
|
||||||
{
|
{
|
||||||
|
@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch as patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components import ssdp
|
||||||
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
|
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
|
||||||
from homeassistant.components.sonos import DOMAIN
|
from homeassistant.components.sonos import DOMAIN
|
||||||
from homeassistant.const import CONF_HOSTS
|
from homeassistant.const import CONF_HOSTS
|
||||||
@ -44,6 +45,7 @@ def soco_fixture(music_library, speaker_info, battery_info, alarm_clock):
|
|||||||
"socket.gethostbyname", return_value="192.168.42.2"
|
"socket.gethostbyname", return_value="192.168.42.2"
|
||||||
):
|
):
|
||||||
mock_soco = mock.return_value
|
mock_soco = mock.return_value
|
||||||
|
mock_soco.ip_address = "192.168.42.2"
|
||||||
mock_soco.uid = "RINCON_test"
|
mock_soco.uid = "RINCON_test"
|
||||||
mock_soco.play_mode = "NORMAL"
|
mock_soco.play_mode = "NORMAL"
|
||||||
mock_soco.music_library = music_library
|
mock_soco.music_library = music_library
|
||||||
@ -67,11 +69,18 @@ def soco_fixture(music_library, speaker_info, battery_info, alarm_clock):
|
|||||||
def discover_fixture(soco):
|
def discover_fixture(soco):
|
||||||
"""Create a mock pysonos discover fixture."""
|
"""Create a mock pysonos discover fixture."""
|
||||||
|
|
||||||
def do_callback(callback, **kwargs):
|
def do_callback(hass, callback, *args, **kwargs):
|
||||||
callback(soco)
|
callback(
|
||||||
|
{
|
||||||
|
ssdp.ATTR_UPNP_UDN: soco.uid,
|
||||||
|
ssdp.ATTR_SSDP_LOCATION: f"http://{soco.ip_address}/",
|
||||||
|
}
|
||||||
|
)
|
||||||
return MagicMock()
|
return MagicMock()
|
||||||
|
|
||||||
with patch("pysonos.discover_thread", side_effect=do_callback) as mock:
|
with patch(
|
||||||
|
"homeassistant.components.ssdp.async_register_callback", side_effect=do_callback
|
||||||
|
) as mock:
|
||||||
yield mock
|
yield mock
|
||||||
|
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user