Sonos setup fails with unhandled exceptions on discovery messages (#90648)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
PeteRager 2023-05-30 11:09:13 -04:00 committed by GitHub
parent 11299c4537
commit 6a8d18ab35
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 471 additions and 55 deletions

View File

@ -368,7 +368,9 @@ class SonosDiscoveryManager:
self, now: datetime.datetime | None = None
) -> None:
"""Add and maintain Sonos devices from a manual configuration."""
for host in self.hosts:
# Loop through each configured host and verify that Soco attributes are available for it.
for host in self.hosts.copy():
ip_addr = await self.hass.async_add_executor_job(socket.gethostbyname, host)
soco = SoCo(ip_addr)
try:
@ -376,7 +378,12 @@ class SonosDiscoveryManager:
sync_get_visible_zones,
soco,
)
except (OSError, SoCoException, Timeout) as ex:
except (
OSError,
SoCoException,
Timeout,
asyncio.TimeoutError,
) as ex:
if not self.hosts_in_error.get(ip_addr):
_LOGGER.warning(
"Could not get visible Sonos devices from %s: %s", ip_addr, ex
@ -386,31 +393,30 @@ class SonosDiscoveryManager:
_LOGGER.debug(
"Could not get visible Sonos devices from %s: %s", ip_addr, ex
)
continue
else:
if self.hosts_in_error.pop(ip_addr, None):
_LOGGER.info("Connection restablished to Sonos device %s", ip_addr)
if new_hosts := {
x.ip_address
for x in visible_zones
if x.ip_address not in self.hosts
}:
_LOGGER.debug("Adding to manual hosts: %s", new_hosts)
self.hosts.update(new_hosts)
async_dispatcher_send(
self.hass,
f"{SONOS_SPEAKER_ACTIVITY}-{soco.uid}",
"manual zone scan",
)
break
if self.hosts_in_error.pop(ip_addr, None):
_LOGGER.info("Connection reestablished to Sonos device %s", ip_addr)
# Each speaker has the topology for other online speakers, so add them in here if they were not
# configured. The metadata is already in Soco for these.
if new_hosts := {
x.ip_address for x in visible_zones if x.ip_address not in self.hosts
}:
_LOGGER.debug("Adding to manual hosts: %s", new_hosts)
self.hosts.update(new_hosts)
for host in self.hosts.copy():
ip_addr = await self.hass.async_add_executor_job(socket.gethostbyname, host)
if self.is_device_invisible(ip_addr):
_LOGGER.debug("Discarding %s from manual hosts", ip_addr)
self.hosts.discard(ip_addr)
continue
# Loop through each configured host that is not in error. Send a discovery message
# if a speaker does not already exist, or ping the speaker if it is unavailable.
for host in self.hosts.copy():
ip_addr = await self.hass.async_add_executor_job(socket.gethostbyname, host)
soco = SoCo(ip_addr)
# Skip hosts that are in error to avoid blocking call on soco.uuid in event loop
if self.hosts_in_error.get(ip_addr):
continue
known_speaker = next(
(
speaker
@ -420,12 +426,28 @@ class SonosDiscoveryManager:
None,
)
if not known_speaker:
await self._async_handle_discovery_message(
soco.uid, ip_addr, "manual zone scan"
)
try:
await self._async_handle_discovery_message(
soco.uid,
ip_addr,
"manual zone scan",
)
except (
OSError,
SoCoException,
Timeout,
asyncio.TimeoutError,
) as ex:
_LOGGER.warning("Discovery message failed to %s : %s", ip_addr, ex)
elif not known_speaker.available:
try:
await self.hass.async_add_executor_job(known_speaker.ping)
# Only send the message if the ping was successful.
async_dispatcher_send(
self.hass,
f"{SONOS_SPEAKER_ACTIVITY}-{soco.uid}",
"manual zone scan",
)
except SonosUpdateError:
_LOGGER.debug(
"Manual poll to %s failed, keeping unavailable", ip_addr

View File

@ -13,13 +13,33 @@ from homeassistant.const import CONF_HOSTS
from tests.common import MockConfigEntry, load_fixture
class SonosMockEventListener:
"""Mock the event listener."""
def __init__(self, ip_address: str) -> None:
"""Initialize the mock event listener."""
self.address = [ip_address, "8080"]
class SonosMockSubscribe:
"""Mock the subscription."""
def __init__(self, ip_address: str, *args, **kwargs) -> None:
"""Initialize the mock subscriber."""
self.event_listener = SonosMockEventListener(ip_address)
self.service = Mock()
async def unsubscribe(self) -> None:
"""Unsubscribe mock."""
class SonosMockService:
"""Mock a Sonos Service used in callbacks."""
def __init__(self, service_type):
def __init__(self, service_type, ip_address="192.168.42.2") -> None:
"""Initialize the instance."""
self.service_type = service_type
self.subscribe = AsyncMock()
self.subscribe = AsyncMock(return_value=SonosMockSubscribe(ip_address))
class SonosMockEvent:
@ -84,28 +104,59 @@ def config_entry_fixture():
return MockConfigEntry(domain=DOMAIN, title="Sonos")
@pytest.fixture(name="soco")
def soco_fixture(
music_library, speaker_info, current_track_info_empty, battery_info, alarm_clock
):
"""Create a mock soco SoCo fixture."""
with patch("homeassistant.components.sonos.SoCo", autospec=True) as mock, patch(
"socket.gethostbyname", return_value="192.168.42.2"
):
mock_soco = mock.return_value
mock_soco.ip_address = "192.168.42.2"
mock_soco.uid = "RINCON_test"
class MockSoCo(MagicMock):
"""Mock the Soco Object."""
@property
def visible_zones(self):
"""Return visible zones and allow property to be overridden by device classes."""
return {self}
class SoCoMockFactory:
"""Factory for creating SoCo Mocks."""
def __init__(
self,
music_library,
speaker_info,
current_track_info_empty,
battery_info,
alarm_clock,
) -> None:
"""Initialize the mock factory."""
self.mock_list: dict[str, MockSoCo] = {}
self.music_library = music_library
self.speaker_info = speaker_info
self.current_track_info = current_track_info_empty
self.battery_info = battery_info
self.alarm_clock = alarm_clock
def cache_mock(
self, mock_soco: MockSoCo, ip_address: str, name: str = "Zone A"
) -> MockSoCo:
"""Put a user created mock into the cache."""
mock_soco.mock_add_spec(SoCo)
mock_soco.ip_address = ip_address
if ip_address != "192.168.42.2":
mock_soco.uid = f"RINCON_test_{ip_address}"
else:
mock_soco.uid = "RINCON_test"
mock_soco.play_mode = "NORMAL"
mock_soco.music_library = music_library
mock_soco.get_current_track_info.return_value = current_track_info_empty
mock_soco.music_library = self.music_library
mock_soco.get_current_track_info.return_value = self.current_track_info
mock_soco.music_source_from_uri = SoCo.music_source_from_uri
mock_soco.get_speaker_info.return_value = speaker_info
mock_soco.avTransport = SonosMockService("AVTransport")
mock_soco.renderingControl = SonosMockService("RenderingControl")
mock_soco.zoneGroupTopology = SonosMockService("ZoneGroupTopology")
mock_soco.contentDirectory = SonosMockService("ContentDirectory")
mock_soco.deviceProperties = SonosMockService("DeviceProperties")
mock_soco.alarmClock = alarm_clock
my_speaker_info = self.speaker_info.copy()
my_speaker_info["zone_name"] = name
my_speaker_info["uid"] = mock_soco.uid
mock_soco.get_speaker_info = Mock(return_value=my_speaker_info)
mock_soco.avTransport = SonosMockService("AVTransport", ip_address)
mock_soco.renderingControl = SonosMockService("RenderingControl", ip_address)
mock_soco.zoneGroupTopology = SonosMockService("ZoneGroupTopology", ip_address)
mock_soco.contentDirectory = SonosMockService("ContentDirectory", ip_address)
mock_soco.deviceProperties = SonosMockService("DeviceProperties", ip_address)
mock_soco.alarmClock = self.alarm_clock
mock_soco.mute = False
mock_soco.night_mode = True
mock_soco.dialog_level = True
@ -123,11 +174,48 @@ def soco_fixture(
mock_soco.surround_level = 3
mock_soco.music_surround_level = 4
mock_soco.soundbar_audio_input_format = "Dolby 5.1"
mock_soco.get_battery_info.return_value = battery_info
mock_soco.get_battery_info.return_value = self.battery_info
mock_soco.all_zones = {mock_soco}
mock_soco.visible_zones = {mock_soco}
mock_soco.group.coordinator = mock_soco
yield mock_soco
self.mock_list[ip_address] = mock_soco
return mock_soco
def get_mock(self, *args) -> SoCo:
"""Return a mock."""
if len(args) > 0:
ip_address = args[0]
else:
ip_address = "192.168.42.2"
if ip_address in self.mock_list:
return self.mock_list[ip_address]
mock_soco = MockSoCo(name=f"Soco Mock {ip_address}")
self.cache_mock(mock_soco, ip_address)
return mock_soco
def patch_gethostbyname(host: str) -> str:
"""Mock to return host name as ip address for testing."""
return host
@pytest.fixture(name="soco_factory")
def soco_factory(
music_library, speaker_info, current_track_info_empty, battery_info, alarm_clock
):
"""Create factory for instantiating SoCo mocks."""
factory = SoCoMockFactory(
music_library, speaker_info, current_track_info_empty, battery_info, alarm_clock
)
with patch("homeassistant.components.sonos.SoCo", new=factory.get_mock), patch(
"socket.gethostbyname", side_effect=patch_gethostbyname
), patch("homeassistant.components.sonos.ZGS_SUBSCRIPTION_TIMEOUT", 0):
yield factory
@pytest.fixture(name="soco")
def soco_fixture(soco_factory):
"""Create a default mock soco SoCo fixture."""
return soco_factory.get_mock()
@pytest.fixture(autouse=True)
@ -172,7 +260,7 @@ def discover_fixture(soco):
@pytest.fixture(name="config")
def config_fixture():
"""Create hass config fixture."""
return {DOMAIN: {MP_DOMAIN: {CONF_HOSTS: ["192.168.42.1"]}}}
return {DOMAIN: {MP_DOMAIN: {CONF_HOSTS: ["192.168.42.2"]}}}
@pytest.fixture(name="music_library")

View File

@ -1,16 +1,31 @@
"""Tests for the Sonos config flow."""
import asyncio
import logging
from unittest.mock import patch
import sys
from unittest.mock import Mock, patch
if sys.version_info[:2] < (3, 11):
from async_timeout import timeout as asyncio_timeout
else:
from asyncio import timeout as asyncio_timeout
import pytest
from homeassistant import config_entries, data_entry_flow
from homeassistant.components import sonos, zeroconf
from homeassistant.components.sonos import SonosDiscoveryManager
from homeassistant.components.sonos.const import DATA_SONOS_DISCOVERY_MANAGER
from homeassistant.core import HomeAssistant
from homeassistant.components.sonos.const import (
DATA_SONOS_DISCOVERY_MANAGER,
SONOS_SPEAKER_ACTIVITY,
)
from homeassistant.components.sonos.exception import SonosUpdateError
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.setup import async_setup_component
from .conftest import MockSoCo, SoCoMockFactory
async def test_creating_entry_sets_up_media_player(
hass: HomeAssistant, zeroconf_payload: zeroconf.ZeroconfServiceInfo
@ -84,7 +99,9 @@ async def test_async_poll_manual_hosts_warnings(
manager.hosts.add("10.10.10.10")
with caplog.at_level(logging.DEBUG), patch.object(
manager, "_async_handle_discovery_message"
), patch("homeassistant.components.sonos.async_call_later"), patch(
), patch(
"homeassistant.components.sonos.async_call_later"
) as mock_async_call_later, patch(
"homeassistant.components.sonos.async_dispatcher_send"
), patch(
"homeassistant.components.sonos.sync_get_visible_zones",
@ -103,6 +120,7 @@ async def test_async_poll_manual_hosts_warnings(
record = caplog.records[0]
assert record.levelname == "WARNING"
assert "Could not get visible Sonos devices from" in record.message
assert mock_async_call_later.call_count == 1
# Second call fails again, it should be logged as a DEBUG message
caplog.clear()
@ -111,6 +129,7 @@ async def test_async_poll_manual_hosts_warnings(
record = caplog.records[0]
assert record.levelname == "DEBUG"
assert "Could not get visible Sonos devices from" in record.message
assert mock_async_call_later.call_count == 2
# Third call succeeds, it should log an info message
caplog.clear()
@ -118,12 +137,14 @@ async def test_async_poll_manual_hosts_warnings(
assert len(caplog.messages) == 1
record = caplog.records[0]
assert record.levelname == "INFO"
assert "Connection restablished to Sonos device" in record.message
assert "Connection reestablished to Sonos device" in record.message
assert mock_async_call_later.call_count == 3
# Fourth call succeeds again, no need to log
caplog.clear()
await manager.async_poll_manual_hosts()
assert len(caplog.messages) == 0
assert mock_async_call_later.call_count == 4
# Fifth call fail again again, should be logged as a WARNING message
caplog.clear()
@ -132,3 +153,288 @@ async def test_async_poll_manual_hosts_warnings(
record = caplog.records[0]
assert record.levelname == "WARNING"
assert "Could not get visible Sonos devices from" in record.message
assert mock_async_call_later.call_count == 5
class _MockSoCoOsError(MockSoCo):
@property
def visible_zones(self):
raise OSError()
class _MockSoCoVisibleZones(MockSoCo):
def set_visible_zones(self, visible_zones) -> None:
"""Set visible zones."""
self.vz_return = visible_zones # pylint: disable=attribute-defined-outside-init
@property
def visible_zones(self):
return self.vz_return
async def _setup_hass(hass: HomeAssistant):
await async_setup_component(
hass,
sonos.DOMAIN,
{
"sonos": {
"media_player": {
"interface_addr": "127.0.0.1",
"hosts": ["10.10.10.1", "10.10.10.2"],
}
}
},
)
await hass.async_block_till_done()
async def test_async_poll_manual_hosts_1(
hass: HomeAssistant,
soco_factory: SoCoMockFactory,
entity_registry: er.EntityRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Tests first device fails, second device successful, speakers do not exist."""
soco_1 = soco_factory.cache_mock(_MockSoCoOsError(), "10.10.10.1", "Living Room")
soco_2 = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom")
with caplog.at_level(logging.WARNING):
await _setup_hass(hass)
assert "media_player.bedroom" in entity_registry.entities
assert "media_player.living_room" not in entity_registry.entities
assert (
f"Could not get visible Sonos devices from {soco_1.ip_address}"
in caplog.text
)
assert (
f"Could not get visible Sonos devices from {soco_2.ip_address}"
not in caplog.text
)
async def test_async_poll_manual_hosts_2(
hass: HomeAssistant,
soco_factory: SoCoMockFactory,
entity_registry: er.EntityRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test first device success, second device fails, speakers do not exist."""
soco_1 = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Living Room")
soco_2 = soco_factory.cache_mock(_MockSoCoOsError(), "10.10.10.2", "Bedroom")
with caplog.at_level(logging.WARNING):
await _setup_hass(hass)
assert "media_player.bedroom" not in entity_registry.entities
assert "media_player.living_room" in entity_registry.entities
assert (
f"Could not get visible Sonos devices from {soco_1.ip_address}"
not in caplog.text
)
assert (
f"Could not get visible Sonos devices from {soco_2.ip_address}"
in caplog.text
)
async def test_async_poll_manual_hosts_3(
hass: HomeAssistant,
soco_factory: SoCoMockFactory,
entity_registry: er.EntityRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test both devices fail, speakers do not exist."""
soco_1 = soco_factory.cache_mock(_MockSoCoOsError(), "10.10.10.1", "Living Room")
soco_2 = soco_factory.cache_mock(_MockSoCoOsError(), "10.10.10.2", "Bedroom")
with caplog.at_level(logging.WARNING):
await _setup_hass(hass)
assert "media_player.bedroom" not in entity_registry.entities
assert "media_player.living_room" not in entity_registry.entities
assert (
f"Could not get visible Sonos devices from {soco_1.ip_address}"
in caplog.text
)
assert (
f"Could not get visible Sonos devices from {soco_2.ip_address}"
in caplog.text
)
async def test_async_poll_manual_hosts_4(
hass: HomeAssistant,
soco_factory: SoCoMockFactory,
entity_registry: er.EntityRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test both devices are successful, speakers do not exist."""
soco_1 = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Living Room")
soco_2 = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom")
with caplog.at_level(logging.WARNING):
await _setup_hass(hass)
assert "media_player.bedroom" in entity_registry.entities
assert "media_player.living_room" in entity_registry.entities
assert (
f"Could not get visible Sonos devices from {soco_1.ip_address}"
not in caplog.text
)
assert (
f"Could not get visible Sonos devices from {soco_2.ip_address}"
not in caplog.text
)
class SpeakerActivity:
"""Unit test class to track speaker activity messages."""
def __init__(self, hass: HomeAssistant, soco: MockSoCo) -> None:
"""Create the object from soco."""
self.soco = soco
self.hass = hass
self.call_count: int = 0
self.event = asyncio.Event()
async_dispatcher_connect(
self.hass,
f"{SONOS_SPEAKER_ACTIVITY}-{self.soco.uid}",
self.speaker_activity,
)
@callback
def speaker_activity(self, source: str) -> None:
"""Track the last activity on this speaker, set availability and resubscribe."""
if source == "manual zone scan":
self.event.set()
self.call_count += 1
async def test_async_poll_manual_hosts_5(
hass: HomeAssistant,
soco_factory: SoCoMockFactory,
entity_registry: er.EntityRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test both succeed, speakers exist and unavailable, ping succeeds."""
soco_1 = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Living Room")
soco_1.renderingControl = Mock()
soco_1.renderingControl.GetVolume = Mock()
speaker_1_activity = SpeakerActivity(hass, soco_1)
soco_2 = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom")
soco_2.renderingControl = Mock()
soco_2.renderingControl.GetVolume = Mock()
speaker_2_activity = SpeakerActivity(hass, soco_2)
with patch(
"homeassistant.components.sonos.DISCOVERY_INTERVAL"
) as mock_discovery_interval:
# Speed up manual discovery interval so second iteration runs sooner
mock_discovery_interval.total_seconds = Mock(side_effect=[0.5, 60])
await _setup_hass(hass)
assert "media_player.bedroom" in entity_registry.entities
assert "media_player.living_room" in entity_registry.entities
with caplog.at_level(logging.DEBUG):
caplog.clear()
await speaker_1_activity.event.wait()
await speaker_2_activity.event.wait()
await hass.async_block_till_done()
assert speaker_1_activity.call_count == 1
assert speaker_2_activity.call_count == 1
assert "Activity on Living Room" in caplog.text
assert "Activity on Bedroom" in caplog.text
async def test_async_poll_manual_hosts_6(
hass: HomeAssistant,
soco_factory: SoCoMockFactory,
entity_registry: er.EntityRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test both succeed, speakers exist and unavailable, pings fail."""
soco_1 = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Living Room")
# Rendering Control Get Volume is what speaker ping calls.
soco_1.renderingControl = Mock()
soco_1.renderingControl.GetVolume = Mock()
soco_1.renderingControl.GetVolume.side_effect = SonosUpdateError()
speaker_1_activity = SpeakerActivity(hass, soco_1)
soco_2 = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom")
soco_2.renderingControl = Mock()
soco_2.renderingControl.GetVolume = Mock()
soco_2.renderingControl.GetVolume.side_effect = SonosUpdateError()
speaker_2_activity = SpeakerActivity(hass, soco_2)
with patch(
"homeassistant.components.sonos.DISCOVERY_INTERVAL"
) as mock_discovery_interval:
# Speed up manual discovery interval so second iteration runs sooner
mock_discovery_interval.total_seconds = Mock(side_effect=[0.5, 60])
await _setup_hass(hass)
assert "media_player.bedroom" in entity_registry.entities
assert "media_player.living_room" in entity_registry.entities
with caplog.at_level(logging.DEBUG):
caplog.clear()
# The discovery events should not fire, wait with a timeout.
with pytest.raises(asyncio.TimeoutError):
async with asyncio_timeout(1.0):
await speaker_1_activity.event.wait()
await hass.async_block_till_done()
assert "Activity on Living Room" not in caplog.text
assert "Activity on Bedroom" not in caplog.text
assert speaker_1_activity.call_count == 0
assert speaker_2_activity.call_count == 0
async def test_async_poll_manual_hosts_7(
hass: HomeAssistant,
soco_factory: SoCoMockFactory,
entity_registry: er.EntityRegistry,
) -> None:
"""Test both succeed, speaker do not exist, new hosts found in visible zones."""
soco_1 = soco_factory.cache_mock(
_MockSoCoVisibleZones(), "10.10.10.1", "Living Room"
)
soco_2 = soco_factory.cache_mock(_MockSoCoVisibleZones(), "10.10.10.2", "Bedroom")
soco_3 = soco_factory.cache_mock(MockSoCo(), "10.10.10.3", "Basement")
soco_4 = soco_factory.cache_mock(MockSoCo(), "10.10.10.4", "Garage")
soco_5 = soco_factory.cache_mock(MockSoCo(), "10.10.10.5", "Studio")
soco_1.set_visible_zones({soco_1, soco_2, soco_3, soco_4, soco_5})
soco_2.set_visible_zones({soco_1, soco_2, soco_3, soco_4, soco_5})
await _setup_hass(hass)
await hass.async_block_till_done()
assert "media_player.bedroom" in entity_registry.entities
assert "media_player.living_room" in entity_registry.entities
assert "media_player.basement" in entity_registry.entities
assert "media_player.garage" in entity_registry.entities
assert "media_player.studio" in entity_registry.entities
async def test_async_poll_manual_hosts_8(
hass: HomeAssistant,
soco_factory: SoCoMockFactory,
entity_registry: er.EntityRegistry,
) -> None:
"""Test both succeed, speaker do not exist, invisible zone."""
soco_1 = soco_factory.cache_mock(
_MockSoCoVisibleZones(), "10.10.10.1", "Living Room"
)
soco_2 = soco_factory.cache_mock(_MockSoCoVisibleZones(), "10.10.10.2", "Bedroom")
soco_3 = soco_factory.cache_mock(MockSoCo(), "10.10.10.3", "Basement")
soco_4 = soco_factory.cache_mock(MockSoCo(), "10.10.10.4", "Garage")
soco_5 = soco_factory.cache_mock(MockSoCo(), "10.10.10.5", "Studio")
soco_1.set_visible_zones({soco_2, soco_3, soco_4, soco_5})
soco_2.set_visible_zones({soco_2, soco_3, soco_4, soco_5})
await _setup_hass(hass)
await hass.async_block_till_done()
assert "media_player.bedroom" in entity_registry.entities
assert "media_player.living_room" not in entity_registry.entities
assert "media_player.basement" in entity_registry.entities
assert "media_player.garage" in entity_registry.entities
assert "media_player.studio" in entity_registry.entities