/
/
/
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.enums import PlayerFeature
10from requests.exceptions import RequestException
11from soco import SoCo, events_asyncio, zonegroupstate
12from soco import config as soco_config
13from soco.discovery import discover
14
15from music_assistant.constants import CONF_ENTRY_MANUAL_DISCOVERY_IPS, VERBOSE_LOG_LEVEL
16from music_assistant.models.player_provider import PlayerProvider
17
18from .constants import CONF_HOUSEHOLD_ID, CONF_NETWORK_SCAN, SUBSCRIPTION_TIMEOUT
19from .player import SonosPlayer
20
21
22class SonosPlayerProvider(PlayerProvider):
23 """Sonos S1 Player Provider for legacy Sonos speakers."""
24
25 _discovery_running: bool = False
26 _discovery_reschedule_timer: asyncio.TimerHandle | None = None
27
28 def __init__(self, *args: Any, **kwargs: Any) -> None:
29 """Initialize the provider."""
30 super().__init__(*args, **kwargs)
31
32 async def handle_async_init(self) -> None:
33 """Handle async initialization of the provider."""
34 # Configure SoCo to use async event system
35 soco_config.EVENTS_MODULE = events_asyncio
36 zonegroupstate.EVENT_CACHE_TIMEOUT = SUBSCRIPTION_TIMEOUT
37 self.topology_condition = asyncio.Condition()
38
39 # Set up SoCo logging
40 if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
41 logging.getLogger("soco").setLevel(logging.DEBUG)
42 else:
43 logging.getLogger("soco").setLevel(self.logger.level + 10)
44
45 # Disable SoCo cache to prevent stale data
46 soco_config.CACHE_ENABLED = False
47
48 # Start discovery
49 await self.discover_players()
50
51 async def unload(self, is_removed: bool = False) -> None:
52 """Handle unload/close of the provider."""
53 if self._discovery_reschedule_timer:
54 self._discovery_reschedule_timer.cancel()
55 self._discovery_reschedule_timer = None
56 # await any in-progress discovery
57 while self._discovery_running:
58 await asyncio.sleep(0.5)
59 # Clean up subscriptions and connections
60 for sonos_player in self.mass.players.all_players(provider_filter=self.instance_id):
61 sonos_player = cast("SonosPlayer", sonos_player)
62 await sonos_player.offline()
63 # Stop the async event listener
64 if events_asyncio.event_listener:
65 await events_asyncio.event_listener.async_stop()
66
67 async def discover_players(self) -> None:
68 """Discover Sonos players on the network."""
69 if self._discovery_running:
70 return
71
72 # Handle config option for manual IP's
73 manual_ip_config = cast(
74 "list[str]", self.config.get_value(CONF_ENTRY_MANUAL_DISCOVERY_IPS.key)
75 )
76 for ip_address in manual_ip_config:
77 try:
78 player = SoCo(ip_address)
79 await self._setup_player(player)
80 except RequestException as err:
81 # player is offline
82 self.logger.debug("Failed to add SonosPlayer %s: %s", player, err)
83 except Exception as err:
84 self.logger.warning(
85 "Failed to add SonosPlayer %s: %s",
86 player,
87 err,
88 exc_info=err if self.logger.isEnabledFor(10) else None,
89 )
90
91 allow_network_scan = self.config.get_value(CONF_NETWORK_SCAN)
92 if not (household_id := self.config.get_value(CONF_HOUSEHOLD_ID)):
93 household_id = "Sonos"
94
95 def do_discover() -> None:
96 """Run discovery and add players in executor thread."""
97 self._discovery_running = True
98 try:
99 self.logger.debug("Sonos discovery started...")
100 discovered_devices: set[SoCo] = (
101 discover(
102 timeout=30, household_id=household_id, allow_network_scan=allow_network_scan
103 )
104 or set()
105 )
106
107 # process new players
108 for soco in discovered_devices:
109 try:
110 asyncio.run_coroutine_threadsafe(
111 self._setup_player(soco), self.mass.loop
112 ).result()
113 except RequestException as err:
114 # player is offline
115 self.logger.debug("Failed to add SonosPlayer %s: %s", soco, err)
116 except Exception as err:
117 self.logger.warning(
118 "Failed to add SonosPlayer %s: %s",
119 soco,
120 err,
121 exc_info=err if self.logger.isEnabledFor(10) else None,
122 )
123 finally:
124 self._discovery_running = False
125
126 await asyncio.to_thread(do_discover)
127
128 def reschedule() -> None:
129 self._discovery_reschedule_timer = None
130 self.mass.create_task(self.discover_players())
131
132 # reschedule self once finished
133 self._discovery_reschedule_timer = self.mass.loop.call_later(1800, reschedule)
134
135 async def _setup_player(self, soco: SoCo) -> None:
136 """Set up a discovered Sonos player."""
137 player_id = soco.uid
138
139 if existing := cast("SonosPlayer", self.mass.players.get_player(player_id=player_id)):
140 if existing.soco.ip_address != soco.ip_address:
141 existing.update_ip(soco.ip_address)
142 return
143 if not soco.is_visible:
144 return
145 enabled = self.mass.config.get_raw_player_config_value(player_id, "enabled", True)
146 if not enabled:
147 self.logger.debug("Ignoring disabled player: %s", player_id)
148 return
149 try:
150 # Ensure speaker info is available during setup
151 if not soco.speaker_info:
152 soco.get_speaker_info(True, timeout=7)
153 sonos_player = SonosPlayer(self, soco)
154 if not soco.fixed_volume:
155 sonos_player._attr_supported_features = {
156 *sonos_player._attr_supported_features,
157 PlayerFeature.VOLUME_SET,
158 }
159
160 # Register with Music Assistant
161 await sonos_player.setup()
162
163 except Exception as err:
164 self.logger.error("Error setting up Sonos player %s: %s", player_id, err)
165