/
/
/
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, PlayerFeature
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 CONF_HOUSEHOLD_ID, CONF_NETWORK_SCAN, SUBSCRIPTION_TIMEOUT
21from .player import SonosPlayer
22
23
24class SonosPlayerProvider(PlayerProvider):
25 """Sonos S1 Player Provider for legacy Sonos speakers."""
26
27 _discovery_running: bool = False
28 _discovery_reschedule_timer: asyncio.TimerHandle | None = None
29
30 def __init__(self, *args: Any, **kwargs: Any) -> None:
31 """Initialize the provider."""
32 super().__init__(*args, **kwargs)
33
34 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
35 """Return Config entries to setup this provider."""
36 household_ids = await self._discover_household_ids()
37 return (
38 CONF_ENTRY_MANUAL_DISCOVERY_IPS,
39 ConfigEntry(
40 key=CONF_NETWORK_SCAN,
41 type=ConfigEntryType.BOOLEAN,
42 default_value=False,
43 ),
44 ConfigEntry(
45 key=CONF_HOUSEHOLD_ID,
46 type=ConfigEntryType.STRING,
47 default_value=household_ids[0] if household_ids else None,
48 advanced=True,
49 required=False,
50 ),
51 )
52
53 async def handle_async_init(self) -> None:
54 """Handle async initialization of the provider."""
55 # Configure SoCo to use async event system
56 soco_config.EVENTS_MODULE = events_asyncio
57 zonegroupstate.EVENT_CACHE_TIMEOUT = SUBSCRIPTION_TIMEOUT
58 self.topology_condition = asyncio.Condition()
59
60 # Set up SoCo logging
61 if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
62 logging.getLogger("soco").setLevel(logging.DEBUG)
63 else:
64 logging.getLogger("soco").setLevel(self.logger.level + 10)
65
66 # Disable SoCo cache to prevent stale data
67 soco_config.CACHE_ENABLED = False
68
69 # Start discovery
70 await self.discover_players()
71
72 async def unload(self, is_removed: bool = False) -> None:
73 """Handle unload/close of the provider."""
74 if self._discovery_reschedule_timer:
75 self._discovery_reschedule_timer.cancel()
76 self._discovery_reschedule_timer = None
77 # await any in-progress discovery
78 while self._discovery_running:
79 await asyncio.sleep(0.5)
80 # Stop the async event listener
81 if events_asyncio.event_listener:
82 await events_asyncio.event_listener.async_stop()
83
84 async def discover_players(self) -> None:
85 """Discover Sonos players on the network."""
86 if self._discovery_running:
87 return
88
89 # Handle config option for manual IP's
90 manual_ip_config = cast(
91 "list[str]", self.config.get_value(CONF_ENTRY_MANUAL_DISCOVERY_IPS.key)
92 )
93 for ip_address in manual_ip_config:
94 try:
95 player = SoCo(ip_address)
96 await self._setup_player(player)
97 except RequestException as err:
98 # player is offline
99 self.logger.debug("Failed to add SonosPlayer %s: %s", player, err)
100 except Exception as err:
101 self.logger.warning(
102 "Failed to add SonosPlayer %s: %s",
103 player,
104 err,
105 exc_info=err if self.logger.isEnabledFor(10) else None,
106 )
107
108 allow_network_scan = self.config.get_value(CONF_NETWORK_SCAN)
109 if not (household_id := self.config.get_value(CONF_HOUSEHOLD_ID)):
110 household_id = "Sonos"
111
112 def do_discover() -> None:
113 """Run discovery and add players in executor thread."""
114 self._discovery_running = True
115 try:
116 self.logger.debug("Sonos discovery started...")
117 discovered_devices: set[SoCo] = (
118 discover(
119 timeout=30, household_id=household_id, allow_network_scan=allow_network_scan
120 )
121 or set()
122 )
123
124 # process new players
125 for soco in discovered_devices:
126 try:
127 asyncio.run_coroutine_threadsafe(
128 self._setup_player(soco), self.mass.loop
129 ).result()
130 except RequestException as err:
131 # player is offline
132 self.logger.debug("Failed to add SonosPlayer %s: %s", soco, err)
133 except Exception as err:
134 self.logger.warning(
135 "Failed to add SonosPlayer %s: %s",
136 soco,
137 err,
138 exc_info=err if self.logger.isEnabledFor(10) else None,
139 )
140 finally:
141 self._discovery_running = False
142
143 await asyncio.to_thread(do_discover)
144
145 def reschedule() -> None:
146 self._discovery_reschedule_timer = None
147 self.mass.create_task(self.discover_players())
148
149 # reschedule self once finished
150 self._discovery_reschedule_timer = self.mass.loop.call_later(1800, reschedule)
151
152 async def _setup_player(self, soco: SoCo) -> None:
153 """Set up a discovered Sonos player."""
154 player_id = soco.uid
155
156 if existing := cast("SonosPlayer", self.mass.players.get_player(player_id=player_id)):
157 if existing.soco.ip_address != soco.ip_address:
158 existing.update_ip(soco.ip_address)
159 return
160 if not soco.is_visible:
161 return
162 enabled = self.mass.config.get_raw_player_config_value(player_id, "enabled", True)
163 if not enabled:
164 self.logger.debug("Ignoring disabled player: %s", player_id)
165 return
166 try:
167 # Ensure speaker info is available during setup
168 if not soco.speaker_info:
169 soco.get_speaker_info(True, timeout=7)
170 sonos_player = SonosPlayer(self, soco)
171 if not soco.fixed_volume:
172 sonos_player._attr_supported_features = {
173 *sonos_player._attr_supported_features,
174 PlayerFeature.VOLUME_SET,
175 }
176
177 # Register with Music Assistant
178 await sonos_player.setup()
179
180 except Exception as err:
181 self.logger.error("Error setting up Sonos player %s: %s", player_id, err)
182
183 async def _discover_household_ids(self, prefer_s1: bool = True) -> list[str]:
184 """Discover the HouseHold ID of S1 speaker(s) the network."""
185 if cache := await self.mass.cache.get("sonos_household_ids"):
186 return cast("list[str]", cache)
187 household_ids: list[str] = []
188
189 def get_all_sonos_ips() -> set[SoCo]:
190 """Run full network discovery and return IP's of all devices found on the network."""
191 discovered_zones: set[SoCo] | None
192 if discovered_zones := scan_network(multi_household=True):
193 return {zone.ip_address for zone in discovered_zones}
194 return set()
195
196 all_sonos_ips = await asyncio.to_thread(get_all_sonos_ips)
197 for ip_address in all_sonos_ips:
198 async with self.mass.http_session.get(
199 f"http://{format_ip_for_url(ip_address)}:1400/status/zp"
200 ) as resp:
201 if resp.status == 200:
202 data = await resp.text()
203 if prefer_s1 and "<SWGen>2</SWGen>" in data:
204 continue
205 if "HouseholdControlID" in data:
206 household_id = data.split("<HouseholdControlID>")[1].split(
207 "</HouseholdControlID>"
208 )[0]
209 household_ids.append(household_id)
210 await self.mass.cache.set("sonos_household_ids", household_ids, 3600)
211 return household_ids
212