music-assistant-server

9.4 KBPY
provider.py
9.4 KB235 lines • python
1"""Sonos S1 Player Provider implementation."""
2
3from __future__ import annotations
4
5import asyncio
6import logging
7from typing import Any, cast
8
9from music_assistant_models.config_entries import ConfigEntry
10from music_assistant_models.enums import ConfigEntryType
11from requests.exceptions import RequestException
12from soco import SoCo, events_asyncio, zonegroupstate
13from soco import config as soco_config
14from soco.discovery import discover, scan_network
15
16from music_assistant.constants import CONF_ENTRY_MANUAL_DISCOVERY_IPS, VERBOSE_LOG_LEVEL
17from music_assistant.helpers.util import format_ip_for_url
18from music_assistant.models.player_provider import PlayerProvider
19
20from .constants import (
21    CONF_HOUSEHOLD_ID,
22    CONF_NETWORK_SCAN,
23    DISCOVERY_INTERVAL,
24    SUBSCRIPTION_TIMEOUT,
25)
26from .player import SonosPlayer
27
28
29class SonosPlayerProvider(PlayerProvider):
30    """Sonos S1 Player Provider for legacy Sonos speakers."""
31
32    _discovery_running: bool = False
33
34    def __init__(self, *args: Any, **kwargs: Any) -> None:
35        """Initialize the provider."""
36        super().__init__(*args, **kwargs)
37        self._discovery_task_id: str = f"sonos_s1_discovery_{self.instance_id}"
38        self._unloaded: bool = False
39
40    async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
41        """Return Config entries to setup this provider."""
42        household_ids = await self._discover_household_ids()
43        return (
44            CONF_ENTRY_MANUAL_DISCOVERY_IPS,
45            ConfigEntry(
46                key=CONF_NETWORK_SCAN,
47                type=ConfigEntryType.BOOLEAN,
48                default_value=False,
49            ),
50            ConfigEntry(
51                key=CONF_HOUSEHOLD_ID,
52                type=ConfigEntryType.STRING,
53                default_value=household_ids[0] if household_ids else None,
54                advanced=True,
55                required=False,
56            ),
57        )
58
59    async def handle_async_init(self) -> None:
60        """Handle async initialization of the provider."""
61        # Configure SoCo to use async event system
62        soco_config.EVENTS_MODULE = events_asyncio
63        zonegroupstate.EVENT_CACHE_TIMEOUT = SUBSCRIPTION_TIMEOUT
64        self.topology_condition = asyncio.Condition()
65
66        # Set up SoCo logging
67        if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
68            logging.getLogger("soco").setLevel(logging.DEBUG)
69        else:
70            logging.getLogger("soco").setLevel(self.logger.level + 10)
71
72        # Disable SoCo cache to prevent stale data
73        soco_config.CACHE_ENABLED = False
74
75        # Start discovery
76        await self.discover_players()
77
78    async def unload(self, is_removed: bool = False) -> None:
79        """Handle unload/close of the provider."""
80        # a discovery already running in its worker thread cannot be interrupted, so the
81        # flag is what stops it from arming a new reschedule once it resumes
82        self._unloaded = True
83        # a reschedule that already fired lives on as a task under the same id,
84        # so both are needed to cover the pending and the running case
85        self.mass.cancel_timer(self._discovery_task_id)
86        self.mass.cancel_task(self._discovery_task_id)
87        # await any in-progress discovery
88        while self._discovery_running:
89            await asyncio.sleep(0.5)
90        # Stop the async event listener
91        if events_asyncio.event_listener:
92            await events_asyncio.event_listener.async_stop()
93
94    async def discover_players(self) -> None:
95        """Discover Sonos players on the network."""
96        if self._discovery_running:
97            return
98
99        # Handle config option for manual IP's
100        manual_ip_config = cast(
101            "list[str]", self.config.get_value(CONF_ENTRY_MANUAL_DISCOVERY_IPS.key)
102        )
103        for ip_address in manual_ip_config:
104            try:
105                player = SoCo(ip_address)
106                await self._setup_player(player)
107            except RequestException as err:
108                # player is offline
109                self.logger.debug("Failed to add SonosPlayer %s: %s", player, err)
110            except Exception as err:
111                self.logger.warning(
112                    "Failed to add SonosPlayer %s: %s",
113                    player,
114                    err,
115                    exc_info=err if self.logger.isEnabledFor(10) else None,
116                )
117
118        allow_network_scan = self.config.get_value(CONF_NETWORK_SCAN)
119        if not (household_id := self.config.get_value(CONF_HOUSEHOLD_ID)):
120            household_id = "Sonos"
121
122        def do_discover() -> None:
123            """Run discovery and add players in executor thread."""
124            self._discovery_running = True
125            try:
126                self.logger.debug("Sonos discovery started...")
127                discovered_devices: set[SoCo] = (
128                    discover(
129                        timeout=30, household_id=household_id, allow_network_scan=allow_network_scan
130                    )
131                    or set()
132                )
133
134                # process new players
135                for soco in discovered_devices:
136                    try:
137                        asyncio.run_coroutine_threadsafe(
138                            self._setup_player(soco), self.mass.loop
139                        ).result()
140                    except RequestException as err:
141                        # player is offline
142                        self.logger.debug("Failed to add SonosPlayer %s: %s", soco, err)
143                    except Exception as err:
144                        self.logger.warning(
145                            "Failed to add SonosPlayer %s: %s",
146                            soco,
147                            err,
148                            exc_info=err if self.logger.isEnabledFor(10) else None,
149                        )
150            finally:
151                self._discovery_running = False
152
153        await asyncio.to_thread(do_discover)
154
155        if self._unloaded:
156            return
157        # reschedule self once finished, replacing any reschedule already armed
158        self.mass.call_later(
159            DISCOVERY_INTERVAL, self.discover_players, task_id=self._discovery_task_id
160        )
161
162    async def _setup_player(self, soco: SoCo) -> None:
163        """Set up a discovered Sonos player."""
164
165        def _read_uid() -> str:
166            """Read the unique id of the speaker (NOT async friendly)."""
167            return cast("str", soco.uid)
168
169        def _interrogate() -> tuple[bool, bool]:
170            """Read whether the speaker is visible and has a fixed volume (NOT async friendly)."""
171            if not soco.is_visible:
172                # a bridge or the follower of a stereo pair is never registered
173                return False, False
174            # Ensure speaker info is available during setup
175            if not soco.speaker_info:
176                soco.get_speaker_info(True, timeout=7)
177            fixed_volume: bool = soco.fixed_volume
178            # SonosPlayer reads these while it is constructed; the zone group lookup
179            # behind player_name is only cached briefly, so resolve them last
180            _ = soco.household_id
181            _ = soco.player_name
182            return True, fixed_volume
183
184        player_id = await asyncio.to_thread(_read_uid)
185
186        if existing := cast("SonosPlayer", self.mass.players.get_player(player_id=player_id)):
187            if existing.soco.ip_address != soco.ip_address:
188                await existing.update_ip(soco)
189            return
190        enabled = self.mass.config.get_raw_player_config_value(player_id, "enabled", True)
191        if not enabled:
192            self.logger.debug("Ignoring disabled player: %s", player_id)
193            return
194        is_visible, fixed_volume = await asyncio.to_thread(_interrogate)
195        if not is_visible:
196            return
197        try:
198            sonos_player = SonosPlayer(self, soco, fixed_volume=fixed_volume)
199
200            # Register with Music Assistant
201            await sonos_player.setup()
202
203        except Exception as err:
204            self.logger.error("Error setting up Sonos player %s: %s", player_id, err)
205
206    async def _discover_household_ids(self, prefer_s1: bool = True) -> list[str]:
207        """Discover the HouseHold ID of S1 speaker(s) the network."""
208        if cache := await self.mass.cache.get("sonos_household_ids"):
209            return cast("list[str]", cache)
210        household_ids: list[str] = []
211
212        def get_all_sonos_ips() -> set[SoCo]:
213            """Run full network discovery and return IP's of all devices found on the network."""
214            discovered_zones: set[SoCo] | None
215            if discovered_zones := scan_network(multi_household=True):
216                return {zone.ip_address for zone in discovered_zones}
217            return set()
218
219        all_sonos_ips = await asyncio.to_thread(get_all_sonos_ips)
220        for ip_address in all_sonos_ips:
221            async with self.mass.http_session.get(
222                f"http://{format_ip_for_url(ip_address)}:1400/status/zp"
223            ) as resp:
224                if resp.status == 200:
225                    data = await resp.text()
226                    if prefer_s1 and "<SWGen>2</SWGen>" in data:
227                        continue
228                    if "HouseholdControlID" in data:
229                        household_id = data.split("<HouseholdControlID>")[1].split(
230                            "</HouseholdControlID>"
231                        )[0]
232                        household_ids.append(household_id)
233        await self.mass.cache.set("sonos_household_ids", household_ids, 3600)
234        return household_ids
235