From d8258924f776973063c40afda6dcd1b8eea97fed Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Wed, 25 Jun 2025 12:29:23 -0400 Subject: [PATCH] Remove mapping of entity_ids to speakers in Sonos (#147506) * fix * fix: change entity_id mappings * fix: translate errors * fix:merge issues * fix: translate error messages * fix: improve test coverage * fix: remove unneeded strings --- homeassistant/components/sonos/diagnostics.py | 19 +++++++++-- homeassistant/components/sonos/entity.py | 8 +++-- homeassistant/components/sonos/helpers.py | 3 +- .../components/sonos/media_player.py | 32 +++++++++++++++---- homeassistant/components/sonos/strings.json | 6 ++++ tests/components/sonos/test_services.py | 26 +++++++++++++++ 6 files changed, 82 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/sonos/diagnostics.py b/homeassistant/components/sonos/diagnostics.py index 35d81edbea0..fafa142273a 100644 --- a/homeassistant/components/sonos/diagnostics.py +++ b/homeassistant/components/sonos/diagnostics.py @@ -6,6 +6,7 @@ import time from typing import Any from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN @@ -132,11 +133,23 @@ async def async_generate_speaker_info( value = getattr(speaker, attrib) payload[attrib] = get_contents(value) + entity_registry = er.async_get(hass) payload["enabled_entities"] = sorted( - entity_id - for entity_id, s in config_entry.runtime_data.entity_id_mappings.items() - if s is speaker + registry_entry.entity_id + for registry_entry in entity_registry.entities.get_entries_for_config_entry_id( + config_entry.entry_id + ) + if ( + ( + entity_speaker + := config_entry.runtime_data.unique_id_speaker_mappings.get( + registry_entry.unique_id + ) + ) + and speaker.uid == entity_speaker.uid + ) ) + payload["media"] = await async_generate_media_info(hass, speaker) payload["activity_stats"] = speaker.activity_stats.report() payload["event_stats"] = speaker.event_stats.report() diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index 58108f9974c..5f7a2fb2d70 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -34,7 +34,10 @@ class SonosEntity(Entity): async def async_added_to_hass(self) -> None: """Handle common setup when added to hass.""" - self.config_entry.runtime_data.entity_id_mappings[self.entity_id] = self.speaker + assert self.unique_id + self.config_entry.runtime_data.unique_id_speaker_mappings[self.unique_id] = ( + self.speaker + ) self.async_on_remove( async_dispatcher_connect( self.hass, @@ -52,7 +55,8 @@ class SonosEntity(Entity): async def async_will_remove_from_hass(self) -> None: """Clean up when entity is removed.""" - del self.config_entry.runtime_data.entity_id_mappings[self.entity_id] + assert self.unique_id + del self.config_entry.runtime_data.unique_id_speaker_mappings[self.unique_id] async def async_fallback_poll(self, now: datetime.datetime) -> None: """Poll the entity if subscriptions fail.""" diff --git a/homeassistant/components/sonos/helpers.py b/homeassistant/components/sonos/helpers.py index 3350df430f8..1fb3bb3d5e7 100644 --- a/homeassistant/components/sonos/helpers.py +++ b/homeassistant/components/sonos/helpers.py @@ -149,7 +149,8 @@ class SonosData: discovery_known: set[str] = field(default_factory=set) boot_counts: dict[str, int] = field(default_factory=dict) mdns_names: dict[str, str] = field(default_factory=dict) - entity_id_mappings: dict[str, SonosSpeaker] = field(default_factory=dict) + # Maps the entity unique id to the associated SonosSpeaker instance. + unique_id_speaker_mappings: dict[str, SonosSpeaker] = field(default_factory=dict) unjoin_data: dict[str, UnjoinData] = field(default_factory=dict) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 96e4d34ddc4..6fb7bf00589 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -43,7 +43,12 @@ from homeassistant.components.plex.services import process_plex_payload from homeassistant.const import ATTR_TIME from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import config_validation as cv, entity_platform, service +from homeassistant.helpers import ( + config_validation as cv, + entity_platform, + entity_registry as er, + service, +) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later @@ -880,13 +885,28 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): async def async_join_players(self, group_members: list[str]) -> None: """Join `group_members` as a player group with the current player.""" speakers = [] + + entity_registry = er.async_get(self.hass) for entity_id in group_members: - if speaker := self.config_entry.runtime_data.entity_id_mappings.get( - entity_id + if not (entity_reg_entry := entity_registry.async_get(entity_id)): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="entity_not_found", + translation_placeholders={"entity_id": entity_id}, + ) + if not ( + speaker + := self.config_entry.runtime_data.unique_id_speaker_mappings.get( + entity_reg_entry.unique_id + ) ): - speakers.append(speaker) - else: - raise HomeAssistantError(f"Not a known Sonos entity_id: {entity_id}") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="speaker_not_found", + translation_placeholders={"entity_id": entity_id}, + ) + + speakers.append(speaker) await SonosSpeaker.join_multi( self.hass, self.config_entry, self.speaker, speakers diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index c40f5ccd416..4fb8037ab64 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -195,6 +195,12 @@ "announce_media_error": { "message": "Announcing clip {media_id} failed {response}" }, + "entity_not_found": { + "message": "Entity {entity_id} not found." + }, + "speaker_not_found": { + "message": "{entity_id} is not a known Sonos speaker." + }, "timeout_join": { "message": "Timeout while waiting for Sonos player to join the group {group_description}" } diff --git a/tests/components/sonos/test_services.py b/tests/components/sonos/test_services.py index 48e4cc139f3..a94a03b95a0 100644 --- a/tests/components/sonos/test_services.py +++ b/tests/components/sonos/test_services.py @@ -15,6 +15,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from .conftest import MockSoCo, group_speakers, ungroup_speakers @@ -85,6 +86,31 @@ async def test_media_player_join_bad_entity( assert "media_player.bad_entity" in str(excinfo.value) +async def test_media_player_join_entity_no_speaker( + hass: HomeAssistant, + sonos_setup_two_speakers: list[MockSoCo], + entity_registry: er.EntityRegistry, +) -> None: + """Test error handling of joining with no associated speaker.""" + + bad_media_player = entity_registry.async_get_or_create( + "media_player", "demo", "1234" + ) + + # Ensure an error is raised if the entity does not have a speaker + with pytest.raises(HomeAssistantError) as excinfo: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_JOIN, + { + "entity_id": "media_player.living_room", + "group_members": bad_media_player.entity_id, + }, + blocking=True, + ) + assert bad_media_player.entity_id in str(excinfo.value) + + @asynccontextmanager async def instant_timeout(*args, **kwargs) -> None: """Mock a timeout error."""