Fix Sonos media_player control may fail when grouping speakers (#121853)

This commit is contained in:
Pete Sage 2024-07-31 14:36:59 -04:00 committed by GitHub
parent 0189a05297
commit f1084a57df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 241 additions and 6 deletions

View file

@ -826,9 +826,6 @@ class SonosSpeaker:
f"{SONOS_VANISHED}-{uid}",
reason,
)
if "zone_player_uui_ds_in_group" not in event.variables:
return
self.event_stats.process(event)
self.hass.async_create_background_task(
self.create_update_groups_coro(event),
@ -857,8 +854,7 @@ class SonosSpeaker:
async def _async_extract_group(event: SonosEvent | None) -> list[str]:
"""Extract group layout from a topology event."""
group = event and event.zone_player_uui_ds_in_group
if group:
if group := (event and getattr(event, "zone_player_uui_ds_in_group", None)):
assert isinstance(group, str)
return group.split(",")
@ -867,11 +863,21 @@ class SonosSpeaker:
@callback
def _async_regroup(group: list[str]) -> None:
"""Rebuild internal group layout."""
_LOGGER.debug("async_regroup %s %s", self.zone_name, group)
if (
group == [self.soco.uid]
and self.sonos_group == [self]
and self.sonos_group_entities
):
# Single speakers do not have a coodinator, check and clear
if self.coordinator is not None:
_LOGGER.debug(
"Zone %s Cleared coordinator [%s]",
self.zone_name,
self.coordinator.zone_name,
)
self.coordinator = None
self.async_write_entity_states()
# Skip updating existing single speakers in polling mode
return
@ -912,6 +918,11 @@ class SonosSpeaker:
joined_speaker.coordinator = self
joined_speaker.sonos_group = sonos_group
joined_speaker.sonos_group_entities = sonos_group_entities
_LOGGER.debug(
"Zone %s Set coordinator [%s]",
joined_speaker.zone_name,
self.zone_name,
)
joined_speaker.async_write_entity_states()
_LOGGER.debug("Regrouped %s: %s", self.zone_name, self.sonos_group_entities)

View file

@ -17,6 +17,7 @@ from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.components.sonos import DOMAIN
from homeassistant.const import CONF_HOSTS
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, load_fixture, load_json_value_fixture
@ -661,3 +662,26 @@ def zgs_event_fixture(hass: HomeAssistant, soco: SoCo, zgs_discovery: str):
await hass.async_block_till_done(wait_background_tasks=True)
return _wrapper
@pytest.fixture(name="sonos_setup_two_speakers")
async def sonos_setup_two_speakers(
hass: HomeAssistant, soco_factory: SoCoMockFactory
) -> list[MockSoCo]:
"""Set up home assistant with two Sonos Speakers."""
soco_lr = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Living Room")
soco_br = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom")
await async_setup_component(
hass,
DOMAIN,
{
DOMAIN: {
"media_player": {
"interface_addr": "127.0.0.1",
"hosts": ["10.10.10.1", "10.10.10.2"],
}
}
},
)
await hass.async_block_till_done()
return [soco_lr, soco_br]

View file

@ -0,0 +1,38 @@
{
"transport_state": "PLAYING",
"current_play_mode": "NORMAL",
"current_crossfade_mode": "0",
"number_of_tracks": "1",
"current_track": "1",
"current_section": "0",
"current_track_uri": "x-rincon:RINCON_test_10.10.10.2",
"current_track_duration": "",
"current_track_meta_data": "",
"next_track_uri": "",
"next_track_meta_data": "",
"enqueued_transport_uri": "",
"enqueued_transport_uri_meta_data": "",
"playback_storage_medium": "NETWORK",
"av_transport_uri": "x-rincon:RINCON_test_10.10.10.2",
"av_transport_uri_meta_data": "",
"next_av_transport_uri": "",
"next_av_transport_uri_meta_data": "",
"current_transport_actions": "Stop, Play",
"current_valid_play_modes": "CROSSFADE",
"direct_control_client_id": "",
"direct_control_is_suspended": "0",
"direct_control_account_id": "",
"transport_status": "OK",
"sleep_timer_generation": "0",
"alarm_running": "0",
"snooze_running": "0",
"restart_pending": "0",
"transport_play_speed": "NOT_IMPLEMENTED",
"current_media_duration": "NOT_IMPLEMENTED",
"record_storage_medium": "NOT_IMPLEMENTED",
"possible_playback_storage_media": "NONE, NETWORK",
"possible_record_storage_media": "NOT_IMPLEMENTED",
"record_medium_write_status": "NOT_IMPLEMENTED",
"current_record_quality_mode": "NOT_IMPLEMENTED",
"possible_record_quality_modes": "NOT_IMPLEMENTED"
}

View file

@ -0,0 +1,8 @@
<ZoneGroupState>
<ZoneGroups>
<ZoneGroup Coordinator="RINCON_test_10.10.10.1" ID="RINCON_test:1384750254">
<ZoneGroupMember UUID="RINCON_test_10.10.10.1" Location="http://192.168.4.2:1400/xml/device_description.xml" ZoneName="Living Room" Icon="" Configuration="1" SoftwareVersion="70.4-36090" SWGen="2" MinCompatibleVersion="69.0-00000" LegacyCompatibleVersion="58.0-00000" BootSeq="1234" TVConfigurationError="0" HdmiCecAvailable="0" WirelessMode="1" WirelessLeafOnly="0" ChannelFreq="5180" BehindWifiExtender="0" WifiEnabled="1" EthLink="0" Orientation="0" RoomCalibrationState="4" SecureRegState="3" VoiceConfigState="0" MicEnabled="1" AirPlayEnabled="1" IdleState="1" MoreInfo="" SSLPort="1443" HHSSLPort="1843"/>
<ZoneGroupMember UUID="RINCON_test_10.10.10.2" Location="http://192.168.4.2:1400/xml/device_description.xml" ZoneName="Bedroom" Icon="" Configuration="1" SoftwareVersion="70.4-36090" SWGen="2" MinCompatibleVersion="69.0-00000" LegacyCompatibleVersion="58.0-00000" BootSeq="1234" TVConfigurationError="0" HdmiCecAvailable="0" WirelessMode="1" WirelessLeafOnly="0" ChannelFreq="5180" BehindWifiExtender="0" WifiEnabled="1" EthLink="0" Orientation="0" RoomCalibrationState="4" SecureRegState="3" VoiceConfigState="0" MicEnabled="1" AirPlayEnabled="1" IdleState="1" MoreInfo="" SSLPort="1443" HHSSLPort="1843"/>
</ZoneGroup>
</ZoneGroups>
</ZoneGroupState>

View file

@ -0,0 +1,10 @@
<ZoneGroupState>
<ZoneGroups>
<ZoneGroup Coordinator="RINCON_test_10.10.10.1" ID="RINCON_test:123">
<ZoneGroupMember UUID="RINCON_test_10.10.10.1" Location="http://192.168.4.2:1400/xml/device_description.xml" ZoneName="Living Room" Icon="" Configuration="1" SoftwareVersion="70.4-36090" SWGen="2" MinCompatibleVersion="69.0-00000" LegacyCompatibleVersion="58.0-00000" BootSeq="1234" TVConfigurationError="0" HdmiCecAvailable="0" WirelessMode="1" WirelessLeafOnly="0" ChannelFreq="5180" BehindWifiExtender="0" WifiEnabled="1" EthLink="0" Orientation="0" RoomCalibrationState="4" SecureRegState="3" VoiceConfigState="0" MicEnabled="1" AirPlayEnabled="1" IdleState="1" MoreInfo="" SSLPort="1443" HHSSLPort="1843"/>
</ZoneGroup>
<ZoneGroup Coordinator="RINCON_test_10.10.10.2" ID="RINCON_test:456">
<ZoneGroupMember UUID="RINCON_test_10.10.10.2" Location="http://192.168.4.2:1400/xml/device_description.xml" ZoneName="Bedroom" Icon="" Configuration="1" SoftwareVersion="70.4-36090" SWGen="2" MinCompatibleVersion="69.0-00000" LegacyCompatibleVersion="58.0-00000" BootSeq="1234" TVConfigurationError="0" HdmiCecAvailable="0" WirelessMode="1" WirelessLeafOnly="0" ChannelFreq="5180" BehindWifiExtender="0" WifiEnabled="1" EthLink="0" Orientation="0" RoomCalibrationState="4" SecureRegState="3" VoiceConfigState="0" MicEnabled="1" AirPlayEnabled="1" IdleState="1" MoreInfo="" SSLPort="1443" HHSSLPort="1843"/>
</ZoneGroup>
</ZoneGroups>
</ZoneGroupState>

View file

@ -4,11 +4,18 @@ from unittest.mock import patch
import pytest
from homeassistant.components.media_player import (
DOMAIN as MP_DOMAIN,
SERVICE_MEDIA_PLAY,
)
from homeassistant.components.sonos import DOMAIN
from homeassistant.components.sonos.const import DATA_SONOS, SCAN_INTERVAL
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from tests.common import async_fire_time_changed
from .conftest import MockSoCo, SonosMockEvent
from tests.common import async_fire_time_changed, load_fixture, load_json_value_fixture
async def test_fallback_to_polling(
@ -67,3 +74,140 @@ async def test_subscription_creation_fails(
await hass.async_block_till_done()
assert speaker._subscriptions
def _create_zgs_sonos_event(
fixture_file: str, soco_1: MockSoCo, soco_2: MockSoCo, create_uui_ds: bool = True
) -> SonosMockEvent:
"""Create a Sonos Event for zone group state, with the option of creating the uui_ds_in_group."""
zgs = load_fixture(fixture_file, DOMAIN)
variables = {}
variables["ZoneGroupState"] = zgs
# Sonos does not always send this variable with zgs events
if create_uui_ds:
variables["zone_player_uui_ds_in_group"] = f"{soco_1.uid},{soco_2.uid}"
event = SonosMockEvent(soco_1, soco_1.zoneGroupTopology, variables)
if create_uui_ds:
event.zone_player_uui_ds_in_group = f"{soco_1.uid},{soco_2.uid}"
return event
def _create_avtransport_sonos_event(
fixture_file: str, soco: MockSoCo
) -> SonosMockEvent:
"""Create a Sonos Event for an AVTransport update."""
variables = load_json_value_fixture(fixture_file, DOMAIN)
return SonosMockEvent(soco, soco.avTransport, variables)
async def _media_play(hass: HomeAssistant, entity: str) -> None:
"""Call media play service."""
await hass.services.async_call(
MP_DOMAIN,
SERVICE_MEDIA_PLAY,
{
"entity_id": entity,
},
blocking=True,
)
async def test_zgs_event_group_speakers(
hass: HomeAssistant, sonos_setup_two_speakers: list[MockSoCo]
) -> None:
"""Tests grouping and ungrouping two speakers."""
# When Sonos speakers are grouped; one of the speakers is the coordinator and is in charge
# of playback across both speakers. Hence, service calls to play or pause on media_players
# that are part of the group are routed to the coordinator.
soco_lr = sonos_setup_two_speakers[0]
soco_br = sonos_setup_two_speakers[1]
# Test 1 - Initial state - speakers are not grouped
state = hass.states.get("media_player.living_room")
assert state.attributes["group_members"] == ["media_player.living_room"]
state = hass.states.get("media_player.bedroom")
assert state.attributes["group_members"] == ["media_player.bedroom"]
# Each speaker is its own coordinator and calls should route to their SoCos
await _media_play(hass, "media_player.living_room")
assert soco_lr.play.call_count == 1
await _media_play(hass, "media_player.bedroom")
assert soco_br.play.call_count == 1
soco_lr.play.reset_mock()
soco_br.play.reset_mock()
# Test 2 - Group the speakers, living room is the coordinator
event = _create_zgs_sonos_event(
"zgs_group.xml", soco_lr, soco_br, create_uui_ds=True
)
soco_lr.zoneGroupTopology.subscribe.return_value._callback(event)
soco_br.zoneGroupTopology.subscribe.return_value._callback(event)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get("media_player.living_room")
assert state.attributes["group_members"] == [
"media_player.living_room",
"media_player.bedroom",
]
state = hass.states.get("media_player.bedroom")
assert state.attributes["group_members"] == [
"media_player.living_room",
"media_player.bedroom",
]
# Play calls should route to the living room SoCo
await _media_play(hass, "media_player.living_room")
await _media_play(hass, "media_player.bedroom")
assert soco_lr.play.call_count == 2
assert soco_br.play.call_count == 0
soco_lr.play.reset_mock()
soco_br.play.reset_mock()
# Test 3 - Ungroup the speakers
event = _create_zgs_sonos_event(
"zgs_two_single.xml", soco_lr, soco_br, create_uui_ds=False
)
soco_lr.zoneGroupTopology.subscribe.return_value._callback(event)
soco_br.zoneGroupTopology.subscribe.return_value._callback(event)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get("media_player.living_room")
assert state.attributes["group_members"] == ["media_player.living_room"]
state = hass.states.get("media_player.bedroom")
assert state.attributes["group_members"] == ["media_player.bedroom"]
# Calls should route to each speakers Soco
await _media_play(hass, "media_player.living_room")
assert soco_lr.play.call_count == 1
await _media_play(hass, "media_player.bedroom")
assert soco_br.play.call_count == 1
async def test_zgs_avtransport_group_speakers(
hass: HomeAssistant, sonos_setup_two_speakers: list[MockSoCo]
) -> None:
"""Test processing avtransport and zgs events to change group membership."""
soco_lr = sonos_setup_two_speakers[0]
soco_br = sonos_setup_two_speakers[1]
# Test 1 - Send a transport event changing the coordinator
# for the living room speaker to the bedroom speaker.
event = _create_avtransport_sonos_event("av_transport.json", soco_lr)
soco_lr.avTransport.subscribe.return_value._callback(event)
await hass.async_block_till_done(wait_background_tasks=True)
# Call should route to the new coodinator which is the bedroom
await _media_play(hass, "media_player.living_room")
assert soco_lr.play.call_count == 0
assert soco_br.play.call_count == 1
soco_lr.play.reset_mock()
soco_br.play.reset_mock()
# Test 2- Send a zgs event to return living room to its own coordinator
event = _create_zgs_sonos_event(
"zgs_two_single.xml", soco_lr, soco_br, create_uui_ds=False
)
soco_lr.zoneGroupTopology.subscribe.return_value._callback(event)
soco_br.zoneGroupTopology.subscribe.return_value._callback(event)
await hass.async_block_till_done(wait_background_tasks=True)
# Call should route to the living room
await _media_play(hass, "media_player.living_room")
assert soco_lr.play.call_count == 1
assert soco_br.play.call_count == 0