Update Sonos to use SonosConfigEntry and runtime data (#145512)

* fix: initial

* fix: cleanup

* fix: cleanup

* fix: cleanup

* fix: SonosConfigEntry

* add config_entry.py

* fix: sonos_data to runtime_data

* fix: move to helpers.py
This commit is contained in:
Pete Sage 2025-06-12 08:05:51 -04:00 committed by GitHub
parent 78ed1097c4
commit afc0a2789d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 234 additions and 158 deletions

View File

@ -3,8 +3,6 @@
from __future__ import annotations
import asyncio
from collections import OrderedDict
from dataclasses import dataclass, field
import datetime
from functools import partial
from ipaddress import AddressValueError, IPv4Address
@ -25,9 +23,8 @@ import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components import ssdp
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOSTS, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
@ -46,7 +43,6 @@ from homeassistant.util.async_ import create_eager_task
from .alarms import SonosAlarms
from .const import (
AVAILABILITY_CHECK_INTERVAL,
DATA_SONOS,
DATA_SONOS_DISCOVERY_MANAGER,
DISCOVERY_INTERVAL,
DOMAIN,
@ -62,7 +58,7 @@ from .const import (
)
from .exception import SonosUpdateError
from .favorites import SonosFavorites
from .helpers import sync_get_visible_zones
from .helpers import SonosConfigEntry, SonosData, sync_get_visible_zones
from .speaker import SonosSpeaker
_LOGGER = logging.getLogger(__name__)
@ -95,32 +91,6 @@ CONFIG_SCHEMA = vol.Schema(
)
@dataclass
class UnjoinData:
"""Class to track data necessary for unjoin coalescing."""
speakers: list[SonosSpeaker]
event: asyncio.Event = field(default_factory=asyncio.Event)
class SonosData:
"""Storage class for platform global data."""
def __init__(self) -> None:
"""Initialize the data."""
# OrderedDict behavior used by SonosAlarms and SonosFavorites
self.discovered: OrderedDict[str, SonosSpeaker] = OrderedDict()
self.favorites: dict[str, SonosFavorites] = {}
self.alarms: dict[str, SonosAlarms] = {}
self.topology_condition = asyncio.Condition()
self.hosts_heartbeat: CALLBACK_TYPE | None = None
self.discovery_known: set[str] = set()
self.boot_counts: dict[str, int] = {}
self.mdns_names: dict[str, str] = {}
self.entity_id_mappings: dict[str, SonosSpeaker] = {}
self.unjoin_data: dict[str, UnjoinData] = {}
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Sonos component."""
conf = config.get(DOMAIN)
@ -137,17 +107,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: SonosConfigEntry) -> bool:
"""Set up Sonos from a config entry."""
soco_config.EVENTS_MODULE = events_asyncio
soco_config.REQUEST_TIMEOUT = 9.5
soco_config.ZGT_EVENT_FALLBACK = False
zonegroupstate.EVENT_CACHE_TIMEOUT = SUBSCRIPTION_TIMEOUT
if DATA_SONOS not in hass.data:
hass.data[DATA_SONOS] = SonosData()
data = entry.runtime_data = SonosData()
data = hass.data[DATA_SONOS]
config = hass.data[DOMAIN].get("media_player", {})
hosts = config.get(CONF_HOSTS, [])
_LOGGER.debug("Reached async_setup_entry, config=%s", config)
@ -172,12 +140,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(
hass: HomeAssistant, config_entry: SonosConfigEntry
) -> bool:
"""Unload a Sonos config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
unload_ok = await hass.config_entries.async_unload_platforms(
config_entry, PLATFORMS
)
await hass.data[DATA_SONOS_DISCOVERY_MANAGER].async_shutdown()
hass.data.pop(DATA_SONOS)
hass.data.pop(DATA_SONOS_DISCOVERY_MANAGER)
return unload_ok
@ -185,7 +155,11 @@ class SonosDiscoveryManager:
"""Manage sonos discovery."""
def __init__(
self, hass: HomeAssistant, entry: ConfigEntry, data: SonosData, hosts: list[str]
self,
hass: HomeAssistant,
entry: SonosConfigEntry,
data: SonosData,
hosts: list[str],
) -> None:
"""Init discovery manager."""
self.hass = hass
@ -380,7 +354,9 @@ class SonosDiscoveryManager:
if soco.uid not in self.data.boot_counts:
self.data.boot_counts[soco.uid] = soco.boot_seqnum
_LOGGER.debug("Adding new speaker: %s", speaker_info)
speaker = SonosSpeaker(self.hass, soco, speaker_info, zone_group_state_sub)
speaker = SonosSpeaker(
self.hass, self.entry, soco, speaker_info, zone_group_state_sub
)
self.data.discovered[soco.uid] = speaker
for coordinator, coord_dict in (
(SonosAlarms, self.data.alarms),
@ -388,7 +364,9 @@ class SonosDiscoveryManager:
):
c_dict: dict[str, Any] = coord_dict
if soco.household_id not in c_dict:
new_coordinator = coordinator(self.hass, soco.household_id)
new_coordinator = coordinator(
self.hass, soco.household_id, self.entry
)
new_coordinator.setup(soco)
c_dict[soco.household_id] = new_coordinator
speaker.setup(self.entry)
@ -622,10 +600,10 @@ class SonosDiscoveryManager:
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
hass: HomeAssistant, config_entry: SonosConfigEntry, device_entry: dr.DeviceEntry
) -> bool:
"""Remove Sonos config entry from a device."""
known_devices = hass.data[DATA_SONOS].discovered.keys()
known_devices = config_entry.runtime_data.discovered.keys()
for identifier in device_entry.identifiers:
if identifier[0] != DOMAIN:
continue

View File

@ -12,7 +12,7 @@ from soco.events_base import Event as SonosEvent
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import DATA_SONOS, SONOS_ALARMS_UPDATED, SONOS_CREATE_ALARM
from .const import SONOS_ALARMS_UPDATED, SONOS_CREATE_ALARM
from .helpers import soco_error
from .household_coordinator import SonosHouseholdCoordinator
@ -52,7 +52,7 @@ class SonosAlarms(SonosHouseholdCoordinator):
for alarm_id, alarm in self.alarms.alarms.items():
if alarm_id in self.created_alarm_ids:
continue
speaker = self.hass.data[DATA_SONOS].discovered.get(alarm.zone.uid)
speaker = self.config_entry.runtime_data.discovered.get(alarm.zone.uid)
if speaker:
async_dispatcher_send(
self.hass, SONOS_CREATE_ALARM, speaker, [alarm_id]

View File

@ -9,7 +9,6 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@ -17,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import SONOS_CREATE_BATTERY, SONOS_CREATE_MIC_SENSOR
from .entity import SonosEntity
from .helpers import soco_error
from .helpers import SonosConfigEntry, soco_error
from .speaker import SonosSpeaker
ATTR_BATTERY_POWER_SOURCE = "power_source"
@ -27,7 +26,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: SonosConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sonos from a config entry."""
@ -35,13 +34,13 @@ async def async_setup_entry(
@callback
def _async_create_battery_entity(speaker: SonosSpeaker) -> None:
_LOGGER.debug("Creating battery binary_sensor on %s", speaker.zone_name)
entity = SonosPowerEntity(speaker)
entity = SonosPowerEntity(speaker, config_entry)
async_add_entities([entity])
@callback
def _async_create_mic_entity(speaker: SonosSpeaker) -> None:
_LOGGER.debug("Creating microphone binary_sensor on %s", speaker.zone_name)
async_add_entities([SonosMicrophoneSensorEntity(speaker)])
async_add_entities([SonosMicrophoneSensorEntity(speaker, config_entry)])
config_entry.async_on_unload(
async_dispatcher_connect(
@ -62,9 +61,9 @@ class SonosPowerEntity(SonosEntity, BinarySensorEntity):
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING
def __init__(self, speaker: SonosSpeaker) -> None:
def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None:
"""Initialize the power entity binary sensor."""
super().__init__(speaker)
super().__init__(speaker, config_entry)
self._attr_unique_id = f"{self.soco.uid}-power"
async def _async_fallback_poll(self) -> None:
@ -95,9 +94,9 @@ class SonosMicrophoneSensorEntity(SonosEntity, BinarySensorEntity):
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_translation_key = "microphone"
def __init__(self, speaker: SonosSpeaker) -> None:
def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None:
"""Initialize the microphone binary sensor entity."""
super().__init__(speaker)
super().__init__(speaker, config_entry)
self._attr_unique_id = f"{self.soco.uid}-microphone"
async def _async_fallback_poll(self) -> None:

View File

@ -10,7 +10,6 @@ from homeassistant.const import Platform
UPNP_ST = "urn:schemas-upnp-org:device:ZonePlayer:1"
DOMAIN = "sonos"
DATA_SONOS = "sonos_media_player"
DATA_SONOS_DISCOVERY_MANAGER = "sonos_discovery_manager"
PLATFORMS = [
Platform.BINARY_SENSOR,

View File

@ -5,11 +5,11 @@ from __future__ import annotations
import time
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
from .const import DATA_SONOS, DOMAIN
from .const import DOMAIN
from .helpers import SonosConfigEntry
from .speaker import SonosSpeaker
MEDIA_DIAGNOSTIC_ATTRIBUTES = (
@ -45,27 +45,29 @@ SPEAKER_DIAGNOSTIC_ATTRIBUTES = (
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry
hass: HomeAssistant, config_entry: SonosConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
payload: dict[str, Any] = {"current_timestamp": time.monotonic()}
for section in ("discovered", "discovery_known"):
payload[section] = {}
data: set[Any] | dict[str, Any] = getattr(hass.data[DATA_SONOS], section)
data: set[Any] | dict[str, Any] = getattr(config_entry.runtime_data, section)
if isinstance(data, set):
payload[section] = data
continue
for key, value in data.items():
if isinstance(value, SonosSpeaker):
payload[section][key] = await async_generate_speaker_info(hass, value)
payload[section][key] = await async_generate_speaker_info(
hass, config_entry, value
)
else:
payload[section][key] = value
return payload
async def async_get_device_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry, device: DeviceEntry
hass: HomeAssistant, config_entry: SonosConfigEntry, device: DeviceEntry
) -> dict[str, Any]:
"""Return diagnostics for a device."""
uid = next(
@ -75,10 +77,10 @@ async def async_get_device_diagnostics(
if uid is None:
return {}
if (speaker := hass.data[DATA_SONOS].discovered.get(uid)) is None:
if (speaker := config_entry.runtime_data.discovered.get(uid)) is None:
return {}
return await async_generate_speaker_info(hass, speaker)
return await async_generate_speaker_info(hass, config_entry, speaker)
async def async_generate_media_info(
@ -107,7 +109,7 @@ async def async_generate_media_info(
async def async_generate_speaker_info(
hass: HomeAssistant, speaker: SonosSpeaker
hass: HomeAssistant, config_entry: SonosConfigEntry, speaker: SonosSpeaker
) -> dict[str, Any]:
"""Generate the diagnostic payload for a specific speaker."""
payload: dict[str, Any] = {}
@ -132,7 +134,7 @@ async def async_generate_speaker_info(
payload["enabled_entities"] = sorted(
entity_id
for entity_id, s in hass.data[DATA_SONOS].entity_id_mappings.items()
for entity_id, s in config_entry.runtime_data.entity_id_mappings.items()
if s is speaker
)
payload["media"] = await async_generate_media_info(hass, speaker)

View File

@ -13,8 +13,9 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from .const import DATA_SONOS, DOMAIN, SONOS_FALLBACK_POLL, SONOS_STATE_UPDATED
from .const import DOMAIN, SONOS_FALLBACK_POLL, SONOS_STATE_UPDATED
from .exception import SonosUpdateError
from .helpers import SonosConfigEntry
from .speaker import SonosSpeaker
_LOGGER = logging.getLogger(__name__)
@ -26,13 +27,14 @@ class SonosEntity(Entity):
_attr_should_poll = False
_attr_has_entity_name = True
def __init__(self, speaker: SonosSpeaker) -> None:
def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None:
"""Initialize a SonosEntity."""
self.speaker = speaker
self.config_entry = config_entry
async def async_added_to_hass(self) -> None:
"""Handle common setup when added to hass."""
self.hass.data[DATA_SONOS].entity_id_mappings[self.entity_id] = self.speaker
self.config_entry.runtime_data.entity_id_mappings[self.entity_id] = self.speaker
self.async_on_remove(
async_dispatcher_connect(
self.hass,
@ -50,7 +52,7 @@ class SonosEntity(Entity):
async def async_will_remove_from_hass(self) -> None:
"""Clean up when entity is removed."""
del self.hass.data[DATA_SONOS].entity_id_mappings[self.entity_id]
del self.config_entry.runtime_data.entity_id_mappings[self.entity_id]
async def async_fallback_poll(self, now: datetime.datetime) -> None:
"""Poll the entity if subscriptions fail."""

View File

@ -2,7 +2,10 @@
from __future__ import annotations
import asyncio
from collections import OrderedDict
from collections.abc import Callable
from dataclasses import dataclass, field
import logging
from typing import TYPE_CHECKING, Any, Concatenate, overload
@ -10,13 +13,17 @@ from requests.exceptions import Timeout
from soco import SoCo
from soco.exceptions import SoCoException, SoCoUPnPException
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE
from homeassistant.helpers.dispatcher import dispatcher_send
from .const import SONOS_SPEAKER_ACTIVITY
from .exception import SonosUpdateError
if TYPE_CHECKING:
from .alarms import SonosAlarms
from .entity import SonosEntity
from .favorites import SonosFavorites
from .household_coordinator import SonosHouseholdCoordinator
from .media import SonosMedia
from .speaker import SonosSpeaker
@ -120,3 +127,30 @@ def sync_get_visible_zones(soco: SoCo) -> set[SoCo]:
_ = soco.household_id
_ = soco.uid
return soco.visible_zones
@dataclass
class UnjoinData:
"""Class to track data necessary for unjoin coalescing."""
speakers: list[SonosSpeaker] = field(default_factory=list)
event: asyncio.Event = field(default_factory=asyncio.Event)
@dataclass
class SonosData:
"""Storage class for platform global data."""
discovered: OrderedDict[str, SonosSpeaker] = field(default_factory=OrderedDict)
favorites: dict[str, SonosFavorites] = field(default_factory=dict)
alarms: dict[str, SonosAlarms] = field(default_factory=dict)
topology_condition: asyncio.Condition = field(default_factory=asyncio.Condition)
hosts_heartbeat: CALLBACK_TYPE | None = None
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)
unjoin_data: dict[str, UnjoinData] = field(default_factory=dict)
type SonosConfigEntry = ConfigEntry[SonosData]

View File

@ -5,16 +5,18 @@ from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine
import logging
from typing import Any
from typing import TYPE_CHECKING, Any
from soco import SoCo
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.debounce import Debouncer
from .const import DATA_SONOS
from .exception import SonosUpdateError
if TYPE_CHECKING:
from .helpers import SonosConfigEntry
_LOGGER = logging.getLogger(__name__)
@ -23,12 +25,15 @@ class SonosHouseholdCoordinator:
cache_update_lock: asyncio.Lock
def __init__(self, hass: HomeAssistant, household_id: str) -> None:
def __init__(
self, hass: HomeAssistant, household_id: str, config_entry: SonosConfigEntry
) -> None:
"""Initialize the data."""
self.hass = hass
self.household_id = household_id
self.async_poll: Callable[[], Coroutine[None, None, None]] | None = None
self.last_processed_event_id: int | None = None
self.config_entry = config_entry
def setup(self, soco: SoCo) -> None:
"""Set up the SonosAlarm instance."""
@ -54,7 +59,7 @@ class SonosHouseholdCoordinator:
async def _async_poll(self) -> None:
"""Poll any known speaker."""
discovered = self.hass.data[DATA_SONOS].discovered
discovered = self.config_entry.runtime_data.discovered
for uid, speaker in discovered.items():
_LOGGER.debug("Polling %s using %s", self.class_type, speaker.soco)

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import datetime
from functools import partial
import logging
from typing import Any
from typing import TYPE_CHECKING, Any
from soco import SoCo, alarms
from soco.core import (
@ -40,7 +40,6 @@ from homeassistant.components.media_player import (
)
from homeassistant.components.plex import PLEX_URI_SCHEME
from homeassistant.components.plex.services import process_plex_payload
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TIME
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
@ -49,9 +48,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from . import UnjoinData, media_browser
from . import media_browser
from .const import (
DATA_SONOS,
DOMAIN,
MEDIA_TYPE_DIRECTORY,
MEDIA_TYPES_TO_SONOS,
@ -67,9 +65,12 @@ from .const import (
SOURCE_TV,
)
from .entity import SonosEntity
from .helpers import soco_error
from .helpers import UnjoinData, soco_error
from .speaker import SonosMedia, SonosSpeaker
if TYPE_CHECKING:
from .helpers import SonosConfigEntry
_LOGGER = logging.getLogger(__name__)
LONG_SERVICE_TIMEOUT = 30.0
@ -108,7 +109,7 @@ ATTR_QUEUE_POSITION = "queue_position"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: SonosConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sonos from a config entry."""
@ -118,7 +119,7 @@ async def async_setup_entry(
def async_create_entities(speaker: SonosSpeaker) -> None:
"""Handle device discovery and create entities."""
_LOGGER.debug("Creating media_player on %s", speaker.zone_name)
async_add_entities([SonosMediaPlayerEntity(speaker)])
async_add_entities([SonosMediaPlayerEntity(speaker, config_entry)])
@service.verify_domain_control(hass, DOMAIN)
async def async_service_handle(service_call: ServiceCall) -> None:
@ -136,11 +137,11 @@ async def async_setup_entry(
if service_call.service == SERVICE_SNAPSHOT:
await SonosSpeaker.snapshot_multi(
hass, speakers, service_call.data[ATTR_WITH_GROUP]
hass, config_entry, speakers, service_call.data[ATTR_WITH_GROUP]
)
elif service_call.service == SERVICE_RESTORE:
await SonosSpeaker.restore_multi(
hass, speakers, service_call.data[ATTR_WITH_GROUP]
hass, config_entry, speakers, service_call.data[ATTR_WITH_GROUP]
)
config_entry.async_on_unload(
@ -231,9 +232,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
_attr_media_content_type = MediaType.MUSIC
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
def __init__(self, speaker: SonosSpeaker) -> None:
def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None:
"""Initialize the media player entity."""
super().__init__(speaker)
super().__init__(speaker, config_entry)
self._attr_unique_id = self.soco.uid
async def async_added_to_hass(self) -> None:
@ -298,9 +299,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
async def _async_fallback_poll(self) -> None:
"""Retrieve latest state by polling."""
await (
self.hass.data[DATA_SONOS].favorites[self.speaker.household_id].async_poll()
)
favorites = self.config_entry.runtime_data.favorites[self.speaker.household_id]
assert favorites.async_poll
await favorites.async_poll()
await self.hass.async_add_executor_job(self._update)
def _update(self) -> None:
@ -880,12 +881,16 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
"""Join `group_members` as a player group with the current player."""
speakers = []
for entity_id in group_members:
if speaker := self.hass.data[DATA_SONOS].entity_id_mappings.get(entity_id):
if speaker := self.config_entry.runtime_data.entity_id_mappings.get(
entity_id
):
speakers.append(speaker)
else:
raise HomeAssistantError(f"Not a known Sonos entity_id: {entity_id}")
await SonosSpeaker.join_multi(self.hass, self.speaker, speakers)
await SonosSpeaker.join_multi(
self.hass, self.config_entry, self.speaker, speakers
)
async def async_unjoin_player(self) -> None:
"""Remove this player from any group.
@ -894,7 +899,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
which optimizes the order in which speakers are removed from their groups.
Removing coordinators last better preserves playqueues on the speakers.
"""
sonos_data = self.hass.data[DATA_SONOS]
sonos_data = self.config_entry.runtime_data
household_id = self.speaker.household_id
async def async_process_unjoin(now: datetime.datetime) -> None:
@ -903,7 +908,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
_LOGGER.debug(
"Processing unjoins for %s", [x.zone_name for x in unjoin_data.speakers]
)
await SonosSpeaker.unjoin_multi(self.hass, unjoin_data.speakers)
await SonosSpeaker.unjoin_multi(
self.hass, self.config_entry, unjoin_data.speakers
)
unjoin_data.event.set()
if unjoin_data := sonos_data.unjoin_data.get(household_id):

View File

@ -6,7 +6,6 @@ import logging
from typing import cast
from homeassistant.components.number import NumberEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@ -14,7 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import SONOS_CREATE_LEVELS
from .entity import SonosEntity
from .helpers import soco_error
from .helpers import SonosConfigEntry, soco_error
from .speaker import SonosSpeaker
LEVEL_TYPES = {
@ -69,7 +68,7 @@ LEVEL_FROM_NUMBER = {"balance": _balance_from_number}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: SonosConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Sonos number platform from a config entry."""
@ -93,7 +92,9 @@ async def async_setup_entry(
_LOGGER.debug(
"Creating %s number control on %s", level_type, speaker.zone_name
)
entities.append(SonosLevelEntity(speaker, level_type, valid_range))
entities.append(
SonosLevelEntity(speaker, config_entry, level_type, valid_range)
)
async_add_entities(entities)
config_entry.async_on_unload(
@ -107,10 +108,14 @@ class SonosLevelEntity(SonosEntity, NumberEntity):
_attr_entity_category = EntityCategory.CONFIG
def __init__(
self, speaker: SonosSpeaker, level_type: str, valid_range: tuple[int, int]
self,
speaker: SonosSpeaker,
config_entry: SonosConfigEntry,
level_type: str,
valid_range: tuple[int, int],
) -> None:
"""Initialize the level entity."""
super().__init__(speaker)
super().__init__(speaker, config_entry)
self._attr_unique_id = f"{self.soco.uid}-{level_type}"
self._attr_translation_key = level_type
self.level_type = level_type

View File

@ -5,7 +5,6 @@ from __future__ import annotations
import logging
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@ -20,7 +19,7 @@ from .const import (
)
from .entity import SonosEntity, SonosPollingEntity
from .favorites import SonosFavorites
from .helpers import soco_error
from .helpers import SonosConfigEntry, soco_error
from .speaker import SonosSpeaker
_LOGGER = logging.getLogger(__name__)
@ -28,7 +27,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: SonosConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sonos from a config entry."""
@ -38,13 +37,13 @@ async def async_setup_entry(
speaker: SonosSpeaker, audio_format: str
) -> None:
_LOGGER.debug("Creating audio input format sensor on %s", speaker.zone_name)
entity = SonosAudioInputFormatSensorEntity(speaker, audio_format)
entity = SonosAudioInputFormatSensorEntity(speaker, config_entry, audio_format)
async_add_entities([entity])
@callback
def _async_create_battery_sensor(speaker: SonosSpeaker) -> None:
_LOGGER.debug("Creating battery level sensor on %s", speaker.zone_name)
entity = SonosBatteryEntity(speaker)
entity = SonosBatteryEntity(speaker, config_entry)
async_add_entities([entity])
@callback
@ -82,9 +81,9 @@ class SonosBatteryEntity(SonosEntity, SensorEntity):
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_native_unit_of_measurement = PERCENTAGE
def __init__(self, speaker: SonosSpeaker) -> None:
def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None:
"""Initialize the battery sensor."""
super().__init__(speaker)
super().__init__(speaker, config_entry)
self._attr_unique_id = f"{self.soco.uid}-battery"
async def _async_fallback_poll(self) -> None:
@ -109,9 +108,11 @@ class SonosAudioInputFormatSensorEntity(SonosPollingEntity, SensorEntity):
_attr_translation_key = "audio_input_format"
_attr_should_poll = True
def __init__(self, speaker: SonosSpeaker, audio_format: str) -> None:
def __init__(
self, speaker: SonosSpeaker, config_entry: SonosConfigEntry, audio_format: str
) -> None:
"""Initialize the audio input format sensor."""
super().__init__(speaker)
super().__init__(speaker, config_entry)
self._attr_unique_id = f"{self.soco.uid}-audio-format"
self._attr_native_value = audio_format

View File

@ -21,7 +21,6 @@ from soco.snapshot import Snapshot
from sonos_websocket import SonosWebsocket
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
@ -38,7 +37,6 @@ from .alarms import SonosAlarms
from .const import (
AVAILABILITY_TIMEOUT,
BATTERY_SCAN_INTERVAL,
DATA_SONOS,
DOMAIN,
SCAN_INTERVAL,
SONOS_CHECK_ACTIVITY,
@ -66,7 +64,8 @@ from .media import SonosMedia
from .statistics import ActivityStatistics, EventStatistics
if TYPE_CHECKING:
from . import SonosData
from .helpers import SonosConfigEntry
NEVER_TIME = -1200.0
RESUB_COOLDOWN_SECONDS = 10.0
@ -95,13 +94,15 @@ class SonosSpeaker:
def __init__(
self,
hass: HomeAssistant,
config_entry: SonosConfigEntry,
soco: SoCo,
speaker_info: dict[str, Any],
zone_group_state_sub: SubscriptionBase | None,
) -> None:
"""Initialize a SonosSpeaker."""
self.hass = hass
self.data: SonosData = hass.data[DATA_SONOS]
self.config_entry = config_entry
self.data = config_entry.runtime_data
self.soco = soco
self.websocket: SonosWebsocket | None = None
self.household_id: str = soco.household_id
@ -179,7 +180,10 @@ class SonosSpeaker:
self._group_members_missing: set[str] = set()
async def async_setup(
self, entry: ConfigEntry, has_battery: bool, dispatches: list[tuple[Any, ...]]
self,
entry: SonosConfigEntry,
has_battery: bool,
dispatches: list[tuple[Any, ...]],
) -> None:
"""Complete setup in async context."""
# Battery events can be infrequent, polling is still necessary
@ -216,7 +220,7 @@ class SonosSpeaker:
await self.async_subscribe()
def setup(self, entry: ConfigEntry) -> None:
def setup(self, entry: SonosConfigEntry) -> None:
"""Run initial setup of the speaker."""
self.media.play_mode = self.soco.play_mode
self.update_volume()
@ -961,15 +965,16 @@ class SonosSpeaker:
@staticmethod
async def join_multi(
hass: HomeAssistant,
config_entry: SonosConfigEntry,
master: SonosSpeaker,
speakers: list[SonosSpeaker],
) -> None:
"""Form a group with other players."""
async with hass.data[DATA_SONOS].topology_condition:
async with config_entry.runtime_data.topology_condition:
group: list[SonosSpeaker] = await hass.async_add_executor_job(
master.join, speakers
)
await SonosSpeaker.wait_for_groups(hass, [group])
await SonosSpeaker.wait_for_groups(hass, config_entry, [group])
@soco_error()
def unjoin(self) -> None:
@ -980,7 +985,11 @@ class SonosSpeaker:
self.coordinator = None
@staticmethod
async def unjoin_multi(hass: HomeAssistant, speakers: list[SonosSpeaker]) -> None:
async def unjoin_multi(
hass: HomeAssistant,
config_entry: SonosConfigEntry,
speakers: list[SonosSpeaker],
) -> None:
"""Unjoin several players from their group."""
def _unjoin_all(speakers: list[SonosSpeaker]) -> None:
@ -992,9 +1001,11 @@ class SonosSpeaker:
for speaker in joined_speakers + coordinators:
speaker.unjoin()
async with hass.data[DATA_SONOS].topology_condition:
async with config_entry.runtime_data.topology_condition:
await hass.async_add_executor_job(_unjoin_all, speakers)
await SonosSpeaker.wait_for_groups(hass, [[s] for s in speakers])
await SonosSpeaker.wait_for_groups(
hass, config_entry, [[s] for s in speakers]
)
@soco_error()
def snapshot(self, with_group: bool) -> None:
@ -1008,7 +1019,10 @@ class SonosSpeaker:
@staticmethod
async def snapshot_multi(
hass: HomeAssistant, speakers: list[SonosSpeaker], with_group: bool
hass: HomeAssistant,
config_entry: SonosConfigEntry,
speakers: list[SonosSpeaker],
with_group: bool,
) -> None:
"""Snapshot all the speakers and optionally their groups."""
@ -1023,7 +1037,7 @@ class SonosSpeaker:
for speaker in list(speakers_set):
speakers_set.update(speaker.sonos_group)
async with hass.data[DATA_SONOS].topology_condition:
async with config_entry.runtime_data.topology_condition:
await hass.async_add_executor_job(_snapshot_all, speakers_set)
@soco_error()
@ -1041,7 +1055,10 @@ class SonosSpeaker:
@staticmethod
async def restore_multi(
hass: HomeAssistant, speakers: list[SonosSpeaker], with_group: bool
hass: HomeAssistant,
config_entry: SonosConfigEntry,
speakers: list[SonosSpeaker],
with_group: bool,
) -> None:
"""Restore snapshots for all the speakers."""
@ -1119,16 +1136,18 @@ class SonosSpeaker:
assert len(speaker.snapshot_group)
speakers_set.update(speaker.snapshot_group)
async with hass.data[DATA_SONOS].topology_condition:
async with config_entry.runtime_data.topology_condition:
groups = await hass.async_add_executor_job(
_restore_groups, speakers_set, with_group
)
await SonosSpeaker.wait_for_groups(hass, groups)
await SonosSpeaker.wait_for_groups(hass, config_entry, groups)
await hass.async_add_executor_job(_restore_players, speakers_set)
@staticmethod
async def wait_for_groups(
hass: HomeAssistant, groups: list[list[SonosSpeaker]]
hass: HomeAssistant,
config_entry: SonosConfigEntry,
groups: list[list[SonosSpeaker]],
) -> None:
"""Wait until all groups are present, or timeout."""
@ -1151,11 +1170,11 @@ class SonosSpeaker:
try:
async with asyncio.timeout(5):
while not _test_groups(groups):
await hass.data[DATA_SONOS].topology_condition.wait()
await config_entry.runtime_data.topology_condition.wait()
except TimeoutError:
_LOGGER.warning("Timeout waiting for target groups %s", groups)
any_speaker = next(iter(hass.data[DATA_SONOS].discovered.values()))
any_speaker = next(iter(config_entry.runtime_data.discovered.values()))
any_speaker.soco.zone_group_state.clear_cache()
#

View File

@ -10,7 +10,6 @@ from soco.alarms import Alarm
from soco.exceptions import SoCoSlaveException, SoCoUPnPException
from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TIME, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
@ -18,15 +17,15 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_time_change
from .alarms import SonosAlarms
from .const import (
DATA_SONOS,
DOMAIN,
SONOS_ALARMS_UPDATED,
SONOS_CREATE_ALARM,
SONOS_CREATE_SWITCHES,
)
from .entity import SonosEntity, SonosPollingEntity
from .helpers import soco_error
from .helpers import SonosConfigEntry, soco_error
from .speaker import SonosSpeaker
_LOGGER = logging.getLogger(__name__)
@ -73,22 +72,22 @@ WEEKEND_DAYS = (0, 6)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: SonosConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sonos from a config entry."""
async def _async_create_alarms(speaker: SonosSpeaker, alarm_ids: list[str]) -> None:
entities = []
created_alarms = (
hass.data[DATA_SONOS].alarms[speaker.household_id].created_alarm_ids
)
created_alarms = config_entry.runtime_data.alarms[
speaker.household_id
].created_alarm_ids
for alarm_id in alarm_ids:
if alarm_id in created_alarms:
continue
_LOGGER.debug("Creating alarm %s on %s", alarm_id, speaker.zone_name)
created_alarms.add(alarm_id)
entities.append(SonosAlarmEntity(alarm_id, speaker))
entities.append(SonosAlarmEntity(alarm_id, speaker, config_entry))
async_add_entities(entities)
def available_soco_attributes(speaker: SonosSpeaker) -> list[str]:
@ -113,7 +112,7 @@ async def async_setup_entry(
feature_type,
speaker.zone_name,
)
entities.append(SonosSwitchEntity(feature_type, speaker))
entities.append(SonosSwitchEntity(feature_type, speaker, config_entry))
async_add_entities(entities)
config_entry.async_on_unload(
@ -127,9 +126,11 @@ async def async_setup_entry(
class SonosSwitchEntity(SonosPollingEntity, SwitchEntity):
"""Representation of a Sonos feature switch."""
def __init__(self, feature_type: str, speaker: SonosSpeaker) -> None:
def __init__(
self, feature_type: str, speaker: SonosSpeaker, config_entry: SonosConfigEntry
) -> None:
"""Initialize the switch."""
super().__init__(speaker)
super().__init__(speaker, config_entry)
self.feature_type = feature_type
self.needs_coordinator = feature_type in COORDINATOR_FEATURES
self._attr_entity_category = EntityCategory.CONFIG
@ -185,9 +186,11 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity):
_attr_entity_category = EntityCategory.CONFIG
_attr_icon = "mdi:alarm"
def __init__(self, alarm_id: str, speaker: SonosSpeaker) -> None:
def __init__(
self, alarm_id: str, speaker: SonosSpeaker, config_entry: SonosConfigEntry
) -> None:
"""Initialize the switch."""
super().__init__(speaker)
super().__init__(speaker, config_entry)
self._attr_unique_id = f"alarm-{speaker.household_id}:{alarm_id}"
self.alarm_id = alarm_id
self.household_id = speaker.household_id
@ -218,7 +221,9 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity):
@property
def alarm(self) -> Alarm:
"""Return the alarm instance."""
return self.hass.data[DATA_SONOS].alarms[self.household_id].get(self.alarm_id)
return self.config_entry.runtime_data.alarms[self.household_id].get(
self.alarm_id
)
@property
def name(self) -> str:
@ -230,7 +235,9 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity):
async def _async_fallback_poll(self) -> None:
"""Call the central alarm polling method."""
await self.hass.data[DATA_SONOS].alarms[self.household_id].async_poll()
alarms: SonosAlarms = self.config_entry.runtime_data.alarms[self.household_id]
assert alarms.async_poll
await alarms.async_poll()
@callback
def async_check_if_available(self) -> bool:
@ -252,9 +259,9 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity):
return
if self.speaker.soco.uid != self.alarm.zone.uid:
self.speaker = self.hass.data[DATA_SONOS].discovered.get(
self.alarm.zone.uid
)
speaker = self.config_entry.runtime_data.discovered.get(self.alarm.zone.uid)
assert speaker
self.speaker = speaker
if self.speaker is None:
raise RuntimeError(
"No configured Sonos speaker has been found to match the alarm."

View File

@ -5,12 +5,15 @@ from unittest.mock import Mock, patch
import pytest
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN, SERVICE_JOIN
from homeassistant.components.sonos.const import DATA_SONOS
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from tests.common import MockConfigEntry
async def test_media_player_join(hass: HomeAssistant, async_autosetup_sonos) -> None:
async def test_media_player_join(
hass: HomeAssistant, async_autosetup_sonos, config_entry: MockConfigEntry
) -> None:
"""Test join service."""
valid_entity_id = "media_player.zone_a"
mocked_entity_id = "media_player.mocked"
@ -29,7 +32,10 @@ async def test_media_player_join(hass: HomeAssistant, async_autosetup_sonos) ->
mock_entity_id_mappings = {mocked_entity_id: mocked_speaker}
with (
patch.dict(hass.data[DATA_SONOS].entity_id_mappings, mock_entity_id_mappings),
patch.dict(
config_entry.runtime_data.entity_id_mappings,
mock_entity_id_mappings,
),
patch(
"homeassistant.components.sonos.speaker.SonosSpeaker.join_multi"
) as mock_join_multi,
@ -41,5 +47,7 @@ async def test_media_player_join(hass: HomeAssistant, async_autosetup_sonos) ->
blocking=True,
)
found_speaker = hass.data[DATA_SONOS].entity_id_mappings[valid_entity_id]
mock_join_multi.assert_called_with(hass, found_speaker, [mocked_speaker])
found_speaker = config_entry.runtime_data.entity_id_mappings[valid_entity_id]
mock_join_multi.assert_called_with(
hass, config_entry, found_speaker, [mocked_speaker]
)

View File

@ -9,13 +9,18 @@ from homeassistant.components.media_player import (
SERVICE_MEDIA_PLAY,
)
from homeassistant.components.sonos import DOMAIN
from homeassistant.components.sonos.const import DATA_SONOS, SCAN_INTERVAL
from homeassistant.components.sonos.const import SCAN_INTERVAL
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from .conftest import MockSoCo, SonosMockEvent
from tests.common import async_fire_time_changed, load_fixture, load_json_value_fixture
from tests.common import (
MockConfigEntry,
async_fire_time_changed,
load_fixture,
load_json_value_fixture,
)
async def test_fallback_to_polling(
@ -33,7 +38,7 @@ async def test_fallback_to_polling(
await hass.async_block_till_done()
await fire_zgs_event()
speaker = list(hass.data[DATA_SONOS].discovered.values())[0]
speaker = list(config_entry.runtime_data.discovered.values())[0]
assert speaker.soco is soco
assert speaker._subscriptions
assert not speaker.subscriptions_failed
@ -56,7 +61,7 @@ async def test_fallback_to_polling(
async def test_subscription_creation_fails(
hass: HomeAssistant, async_setup_sonos
hass: HomeAssistant, async_setup_sonos, config_entry: MockConfigEntry
) -> None:
"""Test that subscription creation failures are handled."""
with patch(
@ -66,7 +71,7 @@ async def test_subscription_creation_fails(
await async_setup_sonos()
await hass.async_block_till_done(wait_background_tasks=True)
speaker = list(hass.data[DATA_SONOS].discovered.values())[0]
speaker = list(config_entry.runtime_data.discovered.values())[0]
assert not speaker._subscriptions
with patch.object(speaker, "_resub_cooldown_expires_at", None):

View File

@ -1,14 +1,19 @@
"""Tests for the Sonos statistics."""
from homeassistant.components.sonos.const import DATA_SONOS
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def test_statistics_duplicate(
hass: HomeAssistant, async_autosetup_sonos, soco, device_properties_event
hass: HomeAssistant,
async_autosetup_sonos,
soco,
device_properties_event,
config_entry: MockConfigEntry,
) -> None:
"""Test Sonos statistics."""
speaker = list(hass.data[DATA_SONOS].discovered.values())[0]
speaker = list(config_entry.runtime_data.discovered.values())[0]
subscription = soco.deviceProperties.subscribe.return_value
sub_callback = subscription.callback