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 from __future__ import annotations
import asyncio import asyncio
from collections import OrderedDict
from dataclasses import dataclass, field
import datetime import datetime
from functools import partial from functools import partial
from ipaddress import AddressValueError, IPv4Address from ipaddress import AddressValueError, IPv4Address
@ -25,9 +23,8 @@ import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components import ssdp from homeassistant.components import ssdp
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN 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.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 ( from homeassistant.helpers import (
config_validation as cv, config_validation as cv,
device_registry as dr, device_registry as dr,
@ -46,7 +43,6 @@ from homeassistant.util.async_ import create_eager_task
from .alarms import SonosAlarms from .alarms import SonosAlarms
from .const import ( from .const import (
AVAILABILITY_CHECK_INTERVAL, AVAILABILITY_CHECK_INTERVAL,
DATA_SONOS,
DATA_SONOS_DISCOVERY_MANAGER, DATA_SONOS_DISCOVERY_MANAGER,
DISCOVERY_INTERVAL, DISCOVERY_INTERVAL,
DOMAIN, DOMAIN,
@ -62,7 +58,7 @@ from .const import (
) )
from .exception import SonosUpdateError from .exception import SonosUpdateError
from .favorites import SonosFavorites from .favorites import SonosFavorites
from .helpers import sync_get_visible_zones from .helpers import SonosConfigEntry, SonosData, sync_get_visible_zones
from .speaker import SonosSpeaker from .speaker import SonosSpeaker
_LOGGER = logging.getLogger(__name__) _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: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Sonos component.""" """Set up the Sonos component."""
conf = config.get(DOMAIN) conf = config.get(DOMAIN)
@ -137,17 +107,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True 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.""" """Set up Sonos from a config entry."""
soco_config.EVENTS_MODULE = events_asyncio soco_config.EVENTS_MODULE = events_asyncio
soco_config.REQUEST_TIMEOUT = 9.5 soco_config.REQUEST_TIMEOUT = 9.5
soco_config.ZGT_EVENT_FALLBACK = False soco_config.ZGT_EVENT_FALLBACK = False
zonegroupstate.EVENT_CACHE_TIMEOUT = SUBSCRIPTION_TIMEOUT zonegroupstate.EVENT_CACHE_TIMEOUT = SUBSCRIPTION_TIMEOUT
if DATA_SONOS not in hass.data: data = entry.runtime_data = SonosData()
hass.data[DATA_SONOS] = SonosData()
data = hass.data[DATA_SONOS]
config = hass.data[DOMAIN].get("media_player", {}) config = hass.data[DOMAIN].get("media_player", {})
hosts = config.get(CONF_HOSTS, []) hosts = config.get(CONF_HOSTS, [])
_LOGGER.debug("Reached async_setup_entry, config=%s", config) _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 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 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() await hass.data[DATA_SONOS_DISCOVERY_MANAGER].async_shutdown()
hass.data.pop(DATA_SONOS)
hass.data.pop(DATA_SONOS_DISCOVERY_MANAGER)
return unload_ok return unload_ok
@ -185,7 +155,11 @@ class SonosDiscoveryManager:
"""Manage sonos discovery.""" """Manage sonos discovery."""
def __init__( def __init__(
self, hass: HomeAssistant, entry: ConfigEntry, data: SonosData, hosts: list[str] self,
hass: HomeAssistant,
entry: SonosConfigEntry,
data: SonosData,
hosts: list[str],
) -> None: ) -> None:
"""Init discovery manager.""" """Init discovery manager."""
self.hass = hass self.hass = hass
@ -380,7 +354,9 @@ class SonosDiscoveryManager:
if soco.uid not in self.data.boot_counts: if soco.uid not in self.data.boot_counts:
self.data.boot_counts[soco.uid] = soco.boot_seqnum self.data.boot_counts[soco.uid] = soco.boot_seqnum
_LOGGER.debug("Adding new speaker: %s", speaker_info) _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 self.data.discovered[soco.uid] = speaker
for coordinator, coord_dict in ( for coordinator, coord_dict in (
(SonosAlarms, self.data.alarms), (SonosAlarms, self.data.alarms),
@ -388,7 +364,9 @@ class SonosDiscoveryManager:
): ):
c_dict: dict[str, Any] = coord_dict c_dict: dict[str, Any] = coord_dict
if soco.household_id not in c_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) new_coordinator.setup(soco)
c_dict[soco.household_id] = new_coordinator c_dict[soco.household_id] = new_coordinator
speaker.setup(self.entry) speaker.setup(self.entry)
@ -622,10 +600,10 @@ class SonosDiscoveryManager:
async def async_remove_config_entry_device( 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: ) -> bool:
"""Remove Sonos config entry from a device.""" """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: for identifier in device_entry.identifiers:
if identifier[0] != DOMAIN: if identifier[0] != DOMAIN:
continue continue

View File

@ -12,7 +12,7 @@ from soco.events_base import Event as SonosEvent
from homeassistant.helpers.dispatcher import async_dispatcher_send 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 .helpers import soco_error
from .household_coordinator import SonosHouseholdCoordinator from .household_coordinator import SonosHouseholdCoordinator
@ -52,7 +52,7 @@ class SonosAlarms(SonosHouseholdCoordinator):
for alarm_id, alarm in self.alarms.alarms.items(): for alarm_id, alarm in self.alarms.alarms.items():
if alarm_id in self.created_alarm_ids: if alarm_id in self.created_alarm_ids:
continue 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: if speaker:
async_dispatcher_send( async_dispatcher_send(
self.hass, SONOS_CREATE_ALARM, speaker, [alarm_id] self.hass, SONOS_CREATE_ALARM, speaker, [alarm_id]

View File

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

View File

@ -5,11 +5,11 @@ from __future__ import annotations
import time import time
from typing import Any from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry 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 from .speaker import SonosSpeaker
MEDIA_DIAGNOSTIC_ATTRIBUTES = ( MEDIA_DIAGNOSTIC_ATTRIBUTES = (
@ -45,27 +45,29 @@ SPEAKER_DIAGNOSTIC_ATTRIBUTES = (
async def async_get_config_entry_diagnostics( async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry hass: HomeAssistant, config_entry: SonosConfigEntry
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return diagnostics for a config entry.""" """Return diagnostics for a config entry."""
payload: dict[str, Any] = {"current_timestamp": time.monotonic()} payload: dict[str, Any] = {"current_timestamp": time.monotonic()}
for section in ("discovered", "discovery_known"): for section in ("discovered", "discovery_known"):
payload[section] = {} 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): if isinstance(data, set):
payload[section] = data payload[section] = data
continue continue
for key, value in data.items(): for key, value in data.items():
if isinstance(value, SonosSpeaker): 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: else:
payload[section][key] = value payload[section][key] = value
return payload return payload
async def async_get_device_diagnostics( async def async_get_device_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry, device: DeviceEntry hass: HomeAssistant, config_entry: SonosConfigEntry, device: DeviceEntry
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return diagnostics for a device.""" """Return diagnostics for a device."""
uid = next( uid = next(
@ -75,10 +77,10 @@ async def async_get_device_diagnostics(
if uid is None: if uid is None:
return {} 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 {}
return await async_generate_speaker_info(hass, speaker) return await async_generate_speaker_info(hass, config_entry, speaker)
async def async_generate_media_info( async def async_generate_media_info(
@ -107,7 +109,7 @@ async def async_generate_media_info(
async def async_generate_speaker_info( async def async_generate_speaker_info(
hass: HomeAssistant, speaker: SonosSpeaker hass: HomeAssistant, config_entry: SonosConfigEntry, speaker: SonosSpeaker
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Generate the diagnostic payload for a specific speaker.""" """Generate the diagnostic payload for a specific speaker."""
payload: dict[str, Any] = {} payload: dict[str, Any] = {}
@ -132,7 +134,7 @@ async def async_generate_speaker_info(
payload["enabled_entities"] = sorted( payload["enabled_entities"] = sorted(
entity_id 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 if s is speaker
) )
payload["media"] = await async_generate_media_info(hass, 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.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity 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 .exception import SonosUpdateError
from .helpers import SonosConfigEntry
from .speaker import SonosSpeaker from .speaker import SonosSpeaker
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -26,13 +27,14 @@ class SonosEntity(Entity):
_attr_should_poll = False _attr_should_poll = False
_attr_has_entity_name = True _attr_has_entity_name = True
def __init__(self, speaker: SonosSpeaker) -> None: def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None:
"""Initialize a SonosEntity.""" """Initialize a SonosEntity."""
self.speaker = speaker self.speaker = speaker
self.config_entry = config_entry
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Handle common setup when added to hass.""" """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( self.async_on_remove(
async_dispatcher_connect( async_dispatcher_connect(
self.hass, self.hass,
@ -50,7 +52,7 @@ class SonosEntity(Entity):
async def async_will_remove_from_hass(self) -> None: async def async_will_remove_from_hass(self) -> None:
"""Clean up when entity is removed.""" """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: async def async_fallback_poll(self, now: datetime.datetime) -> None:
"""Poll the entity if subscriptions fail.""" """Poll the entity if subscriptions fail."""

View File

@ -2,7 +2,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from collections import OrderedDict
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass, field
import logging import logging
from typing import TYPE_CHECKING, Any, Concatenate, overload from typing import TYPE_CHECKING, Any, Concatenate, overload
@ -10,13 +13,17 @@ from requests.exceptions import Timeout
from soco import SoCo from soco import SoCo
from soco.exceptions import SoCoException, SoCoUPnPException 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 homeassistant.helpers.dispatcher import dispatcher_send
from .const import SONOS_SPEAKER_ACTIVITY from .const import SONOS_SPEAKER_ACTIVITY
from .exception import SonosUpdateError from .exception import SonosUpdateError
if TYPE_CHECKING: if TYPE_CHECKING:
from .alarms import SonosAlarms
from .entity import SonosEntity from .entity import SonosEntity
from .favorites import SonosFavorites
from .household_coordinator import SonosHouseholdCoordinator from .household_coordinator import SonosHouseholdCoordinator
from .media import SonosMedia from .media import SonosMedia
from .speaker import SonosSpeaker from .speaker import SonosSpeaker
@ -120,3 +127,30 @@ def sync_get_visible_zones(soco: SoCo) -> set[SoCo]:
_ = soco.household_id _ = soco.household_id
_ = soco.uid _ = soco.uid
return soco.visible_zones 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 import asyncio
from collections.abc import Callable, Coroutine from collections.abc import Callable, Coroutine
import logging import logging
from typing import Any from typing import TYPE_CHECKING, Any
from soco import SoCo from soco import SoCo
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.debounce import Debouncer
from .const import DATA_SONOS
from .exception import SonosUpdateError from .exception import SonosUpdateError
if TYPE_CHECKING:
from .helpers import SonosConfigEntry
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -23,12 +25,15 @@ class SonosHouseholdCoordinator:
cache_update_lock: asyncio.Lock 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.""" """Initialize the data."""
self.hass = hass self.hass = hass
self.household_id = household_id self.household_id = household_id
self.async_poll: Callable[[], Coroutine[None, None, None]] | None = None self.async_poll: Callable[[], Coroutine[None, None, None]] | None = None
self.last_processed_event_id: int | None = None self.last_processed_event_id: int | None = None
self.config_entry = config_entry
def setup(self, soco: SoCo) -> None: def setup(self, soco: SoCo) -> None:
"""Set up the SonosAlarm instance.""" """Set up the SonosAlarm instance."""
@ -54,7 +59,7 @@ class SonosHouseholdCoordinator:
async def _async_poll(self) -> None: async def _async_poll(self) -> None:
"""Poll any known speaker.""" """Poll any known speaker."""
discovered = self.hass.data[DATA_SONOS].discovered discovered = self.config_entry.runtime_data.discovered
for uid, speaker in discovered.items(): for uid, speaker in discovered.items():
_LOGGER.debug("Polling %s using %s", self.class_type, speaker.soco) _LOGGER.debug("Polling %s using %s", self.class_type, speaker.soco)

View File

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

View File

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

View File

@ -5,7 +5,6 @@ from __future__ import annotations
import logging import logging
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
@ -20,7 +19,7 @@ from .const import (
) )
from .entity import SonosEntity, SonosPollingEntity from .entity import SonosEntity, SonosPollingEntity
from .favorites import SonosFavorites from .favorites import SonosFavorites
from .helpers import soco_error from .helpers import SonosConfigEntry, soco_error
from .speaker import SonosSpeaker from .speaker import SonosSpeaker
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -28,7 +27,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: SonosConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up Sonos from a config entry.""" """Set up Sonos from a config entry."""
@ -38,13 +37,13 @@ async def async_setup_entry(
speaker: SonosSpeaker, audio_format: str speaker: SonosSpeaker, audio_format: str
) -> None: ) -> None:
_LOGGER.debug("Creating audio input format sensor on %s", speaker.zone_name) _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]) async_add_entities([entity])
@callback @callback
def _async_create_battery_sensor(speaker: SonosSpeaker) -> None: def _async_create_battery_sensor(speaker: SonosSpeaker) -> None:
_LOGGER.debug("Creating battery level sensor on %s", speaker.zone_name) _LOGGER.debug("Creating battery level sensor on %s", speaker.zone_name)
entity = SonosBatteryEntity(speaker) entity = SonosBatteryEntity(speaker, config_entry)
async_add_entities([entity]) async_add_entities([entity])
@callback @callback
@ -82,9 +81,9 @@ class SonosBatteryEntity(SonosEntity, SensorEntity):
_attr_entity_category = EntityCategory.DIAGNOSTIC _attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_native_unit_of_measurement = PERCENTAGE _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.""" """Initialize the battery sensor."""
super().__init__(speaker) super().__init__(speaker, config_entry)
self._attr_unique_id = f"{self.soco.uid}-battery" self._attr_unique_id = f"{self.soco.uid}-battery"
async def _async_fallback_poll(self) -> None: async def _async_fallback_poll(self) -> None:
@ -109,9 +108,11 @@ class SonosAudioInputFormatSensorEntity(SonosPollingEntity, SensorEntity):
_attr_translation_key = "audio_input_format" _attr_translation_key = "audio_input_format"
_attr_should_poll = True _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.""" """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_unique_id = f"{self.soco.uid}-audio-format"
self._attr_native_value = 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 sonos_websocket import SonosWebsocket
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
@ -38,7 +37,6 @@ from .alarms import SonosAlarms
from .const import ( from .const import (
AVAILABILITY_TIMEOUT, AVAILABILITY_TIMEOUT,
BATTERY_SCAN_INTERVAL, BATTERY_SCAN_INTERVAL,
DATA_SONOS,
DOMAIN, DOMAIN,
SCAN_INTERVAL, SCAN_INTERVAL,
SONOS_CHECK_ACTIVITY, SONOS_CHECK_ACTIVITY,
@ -66,7 +64,8 @@ from .media import SonosMedia
from .statistics import ActivityStatistics, EventStatistics from .statistics import ActivityStatistics, EventStatistics
if TYPE_CHECKING: if TYPE_CHECKING:
from . import SonosData from .helpers import SonosConfigEntry
NEVER_TIME = -1200.0 NEVER_TIME = -1200.0
RESUB_COOLDOWN_SECONDS = 10.0 RESUB_COOLDOWN_SECONDS = 10.0
@ -95,13 +94,15 @@ class SonosSpeaker:
def __init__( def __init__(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
config_entry: SonosConfigEntry,
soco: SoCo, soco: SoCo,
speaker_info: dict[str, Any], speaker_info: dict[str, Any],
zone_group_state_sub: SubscriptionBase | None, zone_group_state_sub: SubscriptionBase | None,
) -> None: ) -> None:
"""Initialize a SonosSpeaker.""" """Initialize a SonosSpeaker."""
self.hass = hass 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.soco = soco
self.websocket: SonosWebsocket | None = None self.websocket: SonosWebsocket | None = None
self.household_id: str = soco.household_id self.household_id: str = soco.household_id
@ -179,7 +180,10 @@ class SonosSpeaker:
self._group_members_missing: set[str] = set() self._group_members_missing: set[str] = set()
async def async_setup( 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: ) -> None:
"""Complete setup in async context.""" """Complete setup in async context."""
# Battery events can be infrequent, polling is still necessary # Battery events can be infrequent, polling is still necessary
@ -216,7 +220,7 @@ class SonosSpeaker:
await self.async_subscribe() await self.async_subscribe()
def setup(self, entry: ConfigEntry) -> None: def setup(self, entry: SonosConfigEntry) -> None:
"""Run initial setup of the speaker.""" """Run initial setup of the speaker."""
self.media.play_mode = self.soco.play_mode self.media.play_mode = self.soco.play_mode
self.update_volume() self.update_volume()
@ -961,15 +965,16 @@ class SonosSpeaker:
@staticmethod @staticmethod
async def join_multi( async def join_multi(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: SonosConfigEntry,
master: SonosSpeaker, master: SonosSpeaker,
speakers: list[SonosSpeaker], speakers: list[SonosSpeaker],
) -> None: ) -> None:
"""Form a group with other players.""" """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( group: list[SonosSpeaker] = await hass.async_add_executor_job(
master.join, speakers master.join, speakers
) )
await SonosSpeaker.wait_for_groups(hass, [group]) await SonosSpeaker.wait_for_groups(hass, config_entry, [group])
@soco_error() @soco_error()
def unjoin(self) -> None: def unjoin(self) -> None:
@ -980,7 +985,11 @@ class SonosSpeaker:
self.coordinator = None self.coordinator = None
@staticmethod @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.""" """Unjoin several players from their group."""
def _unjoin_all(speakers: list[SonosSpeaker]) -> None: def _unjoin_all(speakers: list[SonosSpeaker]) -> None:
@ -992,9 +1001,11 @@ class SonosSpeaker:
for speaker in joined_speakers + coordinators: for speaker in joined_speakers + coordinators:
speaker.unjoin() 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 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() @soco_error()
def snapshot(self, with_group: bool) -> None: def snapshot(self, with_group: bool) -> None:
@ -1008,7 +1019,10 @@ class SonosSpeaker:
@staticmethod @staticmethod
async def snapshot_multi( async def snapshot_multi(
hass: HomeAssistant, speakers: list[SonosSpeaker], with_group: bool hass: HomeAssistant,
config_entry: SonosConfigEntry,
speakers: list[SonosSpeaker],
with_group: bool,
) -> None: ) -> None:
"""Snapshot all the speakers and optionally their groups.""" """Snapshot all the speakers and optionally their groups."""
@ -1023,7 +1037,7 @@ class SonosSpeaker:
for speaker in list(speakers_set): for speaker in list(speakers_set):
speakers_set.update(speaker.sonos_group) 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) await hass.async_add_executor_job(_snapshot_all, speakers_set)
@soco_error() @soco_error()
@ -1041,7 +1055,10 @@ class SonosSpeaker:
@staticmethod @staticmethod
async def restore_multi( async def restore_multi(
hass: HomeAssistant, speakers: list[SonosSpeaker], with_group: bool hass: HomeAssistant,
config_entry: SonosConfigEntry,
speakers: list[SonosSpeaker],
with_group: bool,
) -> None: ) -> None:
"""Restore snapshots for all the speakers.""" """Restore snapshots for all the speakers."""
@ -1119,16 +1136,18 @@ class SonosSpeaker:
assert len(speaker.snapshot_group) assert len(speaker.snapshot_group)
speakers_set.update(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( groups = await hass.async_add_executor_job(
_restore_groups, speakers_set, with_group _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) await hass.async_add_executor_job(_restore_players, speakers_set)
@staticmethod @staticmethod
async def wait_for_groups( async def wait_for_groups(
hass: HomeAssistant, groups: list[list[SonosSpeaker]] hass: HomeAssistant,
config_entry: SonosConfigEntry,
groups: list[list[SonosSpeaker]],
) -> None: ) -> None:
"""Wait until all groups are present, or timeout.""" """Wait until all groups are present, or timeout."""
@ -1151,11 +1170,11 @@ class SonosSpeaker:
try: try:
async with asyncio.timeout(5): async with asyncio.timeout(5):
while not _test_groups(groups): while not _test_groups(groups):
await hass.data[DATA_SONOS].topology_condition.wait() await config_entry.runtime_data.topology_condition.wait()
except TimeoutError: except TimeoutError:
_LOGGER.warning("Timeout waiting for target groups %s", groups) _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() 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 soco.exceptions import SoCoSlaveException, SoCoUPnPException
from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TIME, EntityCategory from homeassistant.const import ATTR_TIME, EntityCategory
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er 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.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_time_change from homeassistant.helpers.event import async_track_time_change
from .alarms import SonosAlarms
from .const import ( from .const import (
DATA_SONOS,
DOMAIN, DOMAIN,
SONOS_ALARMS_UPDATED, SONOS_ALARMS_UPDATED,
SONOS_CREATE_ALARM, SONOS_CREATE_ALARM,
SONOS_CREATE_SWITCHES, SONOS_CREATE_SWITCHES,
) )
from .entity import SonosEntity, SonosPollingEntity from .entity import SonosEntity, SonosPollingEntity
from .helpers import soco_error from .helpers import SonosConfigEntry, soco_error
from .speaker import SonosSpeaker from .speaker import SonosSpeaker
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -73,22 +72,22 @@ WEEKEND_DAYS = (0, 6)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: SonosConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up Sonos from a config entry.""" """Set up Sonos from a config entry."""
async def _async_create_alarms(speaker: SonosSpeaker, alarm_ids: list[str]) -> None: async def _async_create_alarms(speaker: SonosSpeaker, alarm_ids: list[str]) -> None:
entities = [] entities = []
created_alarms = ( created_alarms = config_entry.runtime_data.alarms[
hass.data[DATA_SONOS].alarms[speaker.household_id].created_alarm_ids speaker.household_id
) ].created_alarm_ids
for alarm_id in alarm_ids: for alarm_id in alarm_ids:
if alarm_id in created_alarms: if alarm_id in created_alarms:
continue continue
_LOGGER.debug("Creating alarm %s on %s", alarm_id, speaker.zone_name) _LOGGER.debug("Creating alarm %s on %s", alarm_id, speaker.zone_name)
created_alarms.add(alarm_id) created_alarms.add(alarm_id)
entities.append(SonosAlarmEntity(alarm_id, speaker)) entities.append(SonosAlarmEntity(alarm_id, speaker, config_entry))
async_add_entities(entities) async_add_entities(entities)
def available_soco_attributes(speaker: SonosSpeaker) -> list[str]: def available_soco_attributes(speaker: SonosSpeaker) -> list[str]:
@ -113,7 +112,7 @@ async def async_setup_entry(
feature_type, feature_type,
speaker.zone_name, speaker.zone_name,
) )
entities.append(SonosSwitchEntity(feature_type, speaker)) entities.append(SonosSwitchEntity(feature_type, speaker, config_entry))
async_add_entities(entities) async_add_entities(entities)
config_entry.async_on_unload( config_entry.async_on_unload(
@ -127,9 +126,11 @@ async def async_setup_entry(
class SonosSwitchEntity(SonosPollingEntity, SwitchEntity): class SonosSwitchEntity(SonosPollingEntity, SwitchEntity):
"""Representation of a Sonos feature switch.""" """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.""" """Initialize the switch."""
super().__init__(speaker) super().__init__(speaker, config_entry)
self.feature_type = feature_type self.feature_type = feature_type
self.needs_coordinator = feature_type in COORDINATOR_FEATURES self.needs_coordinator = feature_type in COORDINATOR_FEATURES
self._attr_entity_category = EntityCategory.CONFIG self._attr_entity_category = EntityCategory.CONFIG
@ -185,9 +186,11 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity):
_attr_entity_category = EntityCategory.CONFIG _attr_entity_category = EntityCategory.CONFIG
_attr_icon = "mdi:alarm" _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.""" """Initialize the switch."""
super().__init__(speaker) super().__init__(speaker, config_entry)
self._attr_unique_id = f"alarm-{speaker.household_id}:{alarm_id}" self._attr_unique_id = f"alarm-{speaker.household_id}:{alarm_id}"
self.alarm_id = alarm_id self.alarm_id = alarm_id
self.household_id = speaker.household_id self.household_id = speaker.household_id
@ -218,7 +221,9 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity):
@property @property
def alarm(self) -> Alarm: def alarm(self) -> Alarm:
"""Return the alarm instance.""" """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 @property
def name(self) -> str: def name(self) -> str:
@ -230,7 +235,9 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity):
async def _async_fallback_poll(self) -> None: async def _async_fallback_poll(self) -> None:
"""Call the central alarm polling method.""" """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 @callback
def async_check_if_available(self) -> bool: def async_check_if_available(self) -> bool:
@ -252,9 +259,9 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity):
return return
if self.speaker.soco.uid != self.alarm.zone.uid: if self.speaker.soco.uid != self.alarm.zone.uid:
self.speaker = self.hass.data[DATA_SONOS].discovered.get( speaker = self.config_entry.runtime_data.discovered.get(self.alarm.zone.uid)
self.alarm.zone.uid assert speaker
) self.speaker = speaker
if self.speaker is None: if self.speaker is None:
raise RuntimeError( raise RuntimeError(
"No configured Sonos speaker has been found to match the alarm." "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 import pytest
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN, SERVICE_JOIN 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.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError 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.""" """Test join service."""
valid_entity_id = "media_player.zone_a" valid_entity_id = "media_player.zone_a"
mocked_entity_id = "media_player.mocked" 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} mock_entity_id_mappings = {mocked_entity_id: mocked_speaker}
with ( 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( patch(
"homeassistant.components.sonos.speaker.SonosSpeaker.join_multi" "homeassistant.components.sonos.speaker.SonosSpeaker.join_multi"
) as mock_join_multi, ) as mock_join_multi,
@ -41,5 +47,7 @@ async def test_media_player_join(hass: HomeAssistant, async_autosetup_sonos) ->
blocking=True, blocking=True,
) )
found_speaker = hass.data[DATA_SONOS].entity_id_mappings[valid_entity_id] found_speaker = config_entry.runtime_data.entity_id_mappings[valid_entity_id]
mock_join_multi.assert_called_with(hass, found_speaker, [mocked_speaker]) 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, SERVICE_MEDIA_PLAY,
) )
from homeassistant.components.sonos import DOMAIN 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.core import HomeAssistant
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from .conftest import MockSoCo, SonosMockEvent 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( async def test_fallback_to_polling(
@ -33,7 +38,7 @@ async def test_fallback_to_polling(
await hass.async_block_till_done() await hass.async_block_till_done()
await fire_zgs_event() 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.soco is soco
assert speaker._subscriptions assert speaker._subscriptions
assert not speaker.subscriptions_failed assert not speaker.subscriptions_failed
@ -56,7 +61,7 @@ async def test_fallback_to_polling(
async def test_subscription_creation_fails( async def test_subscription_creation_fails(
hass: HomeAssistant, async_setup_sonos hass: HomeAssistant, async_setup_sonos, config_entry: MockConfigEntry
) -> None: ) -> None:
"""Test that subscription creation failures are handled.""" """Test that subscription creation failures are handled."""
with patch( with patch(
@ -66,7 +71,7 @@ async def test_subscription_creation_fails(
await async_setup_sonos() await async_setup_sonos()
await hass.async_block_till_done(wait_background_tasks=True) 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 assert not speaker._subscriptions
with patch.object(speaker, "_resub_cooldown_expires_at", None): with patch.object(speaker, "_resub_cooldown_expires_at", None):

View File

@ -1,14 +1,19 @@
"""Tests for the Sonos statistics.""" """Tests for the Sonos statistics."""
from homeassistant.components.sonos.const import DATA_SONOS
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def test_statistics_duplicate( 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: ) -> None:
"""Test Sonos statistics.""" """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 subscription = soco.deviceProperties.subscribe.return_value
sub_callback = subscription.callback sub_callback = subscription.callback