From 6a8d18ab359dc4c99b5acc74a90e92bb69af3e4d Mon Sep 17 00:00:00 2001 From: PeteRager <76050312+PeteRager@users.noreply.github.com> Date: Tue, 30 May 2023 11:09:13 -0400 Subject: [PATCH] Sonos setup fails with unhandled exceptions on discovery messages (#90648) Co-authored-by: J. Nick Koston --- homeassistant/components/sonos/__init__.py | 70 +++-- tests/components/sonos/conftest.py | 140 +++++++-- tests/components/sonos/test_init.py | 316 ++++++++++++++++++++- 3 files changed, 471 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index ea0a16229c1..e6b328cbcb0 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -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 diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 4e01ba02edd..730f0f5e8f3 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -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") diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index 596946e9f8b..d4072055407 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -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