/
/
/
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