/
/
/
1"""HEOS Player Provider implementation."""
2
3from __future__ import annotations
4
5import asyncio
6import logging
7from contextlib import suppress
8from typing import TYPE_CHECKING, cast
9
10from music_assistant_models.config_entries import ConfigEntry
11from music_assistant_models.enums import ConfigEntryType
12from music_assistant_models.errors import SetupFailedError
13from music_assistant_models.player import PlayerSource
14from pyheos import Heos, HeosError, HeosOptions, MediaItem, PlayerUpdateResult, const
15from zeroconf import ServiceStateChange
16
17from music_assistant.constants import CONF_ENABLED, CONF_IP_ADDRESS, VERBOSE_LOG_LEVEL
18from music_assistant.helpers.util import get_primary_ip_address_from_zeroconf
19from music_assistant.models.player_provider import PlayerProvider
20from music_assistant.providers.heos.constants import HEOS_PASSIVE_SOURCES
21
22from .constants import (
23 CONF_TIMEOUT,
24 CONNECT_INITIAL_RETRY_DELAY,
25 CONNECT_MAX_ATTEMPTS,
26 CONNECT_RETRY_BACKOFF_FACTOR,
27 DEFAULT_TIMEOUT,
28)
29from .player import HeosPlayer
30
31if TYPE_CHECKING:
32 from music_assistant_models.config_entries import ProviderConfig
33 from music_assistant_models.enums import ProviderFeature
34 from music_assistant_models.provider import ProviderManifest
35 from zeroconf.asyncio import AsyncServiceInfo
36
37 from music_assistant.mass import MusicAssistant
38
39
40class HeosPlayerProvider(PlayerProvider):
41 """Player provided for Denon HEOS."""
42
43 _heos: Heos | None = None
44 _heos_queue: Heos | None = None
45 _player_discovery_running: bool = False
46 _controller_discovery_running: bool = False
47
48 def __init__(
49 self,
50 mass: MusicAssistant,
51 manifest: ProviderManifest,
52 config: ProviderConfig,
53 supported_features: set[ProviderFeature] | None = None,
54 ) -> None:
55 """Initialize the HEOS player provider."""
56 super().__init__(mass, manifest, config, supported_features)
57 self._music_source_list: list[PlayerSource] = []
58 self._input_source_list: list[MediaItem] = []
59
60 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
61 """Return Config entries to configure this provider."""
62 return (
63 ConfigEntry(
64 key=CONF_IP_ADDRESS,
65 type=ConfigEntryType.STRING,
66 required=False,
67 advanced=True,
68 requires_reload=True,
69 ),
70 ConfigEntry(
71 key=CONF_TIMEOUT,
72 type=ConfigEntryType.INTEGER,
73 default_value=DEFAULT_TIMEOUT,
74 required=False,
75 range=(10, 60),
76 requires_reload=True,
77 advanced=True,
78 ),
79 )
80
81 async def handle_async_init(self) -> None:
82 """Handle async initialization of the provider."""
83 if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
84 logging.getLogger("pyheos").setLevel(logging.DEBUG)
85 else:
86 logging.getLogger("pyheos").setLevel(self.logger.level + 10)
87
88 if ip_address := self.config.get_value(CONF_IP_ADDRESS):
89 # Manual IP path
90 ip_address = cast("str", ip_address)
91 try:
92 await self._setup_controllers(ip_address)
93 except SetupFailedError:
94 self.logger.error(
95 "Failed to set up HEOS controller at configured IP %s", ip_address
96 )
97 await self._disconnect_controllers()
98 raise
99
100 # Explicitly discover players now
101 await self.discover_players()
102
103 async def _setup_controllers(self, controller_ip: str, connect_preferred: bool = False) -> None:
104 """Set up the HEOS controller."""
105 self.logger.debug("Attempting HEOS controller setup on IP %s", controller_ip)
106
107 self._heos = Heos(
108 HeosOptions(
109 controller_ip,
110 timeout=cast("int", self.config.get_value(CONF_TIMEOUT)),
111 auto_reconnect=True,
112 auto_failover=True,
113 )
114 )
115 await self._connect_controller(controller_ip)
116
117 try:
118 self.logger.debug("HEOS controller connected, checking preferred setup")
119 system_info = await self._heos.get_system_info()
120 preferred_ips: list[str] | None = [
121 host.ip_address for host in system_info.preferred_hosts if host.ip_address
122 ]
123
124 if preferred_ips and controller_ip not in preferred_ips:
125 if connect_preferred:
126 self.logger.debug(
127 "Attempting to connect to preferred Host %s", preferred_ips[0]
128 )
129 await self._heos.disconnect()
130 # Set up controller with preferred host instead
131 return await self._setup_controllers(preferred_ips[0], connect_preferred=False)
132
133 # Just log a warning, it still works but might be less reliable
134 self.logger.warning("Configured IP %s is not a preferred HEOS host", controller_ip)
135 except HeosError as err:
136 self.logger.error("Failed to verify HEOS controller health: %s", err)
137 raise SetupFailedError("Failed to verify HEOS controller health") from err
138
139 try:
140 self._heos.add_on_controller_event(self._handle_controller_event)
141 self._heos.add_on_disconnected(self._on_heos_disconnected)
142 await self._populate_sources()
143 except HeosError as err:
144 self.logger.error("Unexpected error setting up HEOS controller: %s", err)
145 raise SetupFailedError("Unexpected error setting up HEOS controller") from err
146
147 # Set up up dedicated queue controller, queue commands can be slow and we don't want them to interfere with event processing on the main controller connection
148 try:
149 self._heos_queue = Heos(
150 HeosOptions(
151 controller_ip,
152 timeout=cast("int", self.config.get_value(CONF_TIMEOUT)),
153 auto_reconnect=True,
154 auto_failover=True,
155 events=False,
156 )
157 )
158 await self._heos_queue.connect()
159 except HeosError as err:
160 self.logger.error("Failed to set up HEOS queue controller: %s", err)
161 raise SetupFailedError("Failed to set up HEOS queue controller") from err
162
163 async def _connect_controller(self, controller_ip: str) -> None:
164 """Connect to the HEOS controller with a few retries for early mDNS announcements."""
165 assert self._heos is not None
166
167 for attempt in range(1, CONNECT_MAX_ATTEMPTS + 1):
168 try:
169 await self._heos.connect()
170 except HeosError as err:
171 if attempt == CONNECT_MAX_ATTEMPTS:
172 self.logger.error(
173 "Failed to connect to HEOS controller at %s after %d attempts: %s",
174 controller_ip,
175 CONNECT_MAX_ATTEMPTS,
176 err,
177 )
178 raise SetupFailedError("Failed to connect to HEOS controller") from err
179
180 retry_delay = CONNECT_INITIAL_RETRY_DELAY * (
181 CONNECT_RETRY_BACKOFF_FACTOR ** (attempt - 1)
182 )
183 self.logger.debug(
184 "HEOS controller connection attempt %d/%d failed for %s: %s. Retrying in %.1fs",
185 attempt,
186 CONNECT_MAX_ATTEMPTS,
187 controller_ip,
188 err,
189 retry_delay,
190 )
191 await self._heos.disconnect()
192 await asyncio.sleep(retry_delay)
193 else:
194 if attempt > 1:
195 self.logger.debug(
196 "Connected to HEOS controller at %s on attempt %d/%d",
197 controller_ip,
198 attempt,
199 CONNECT_MAX_ATTEMPTS,
200 )
201 return
202
203 async def _handle_controller_event(
204 self, event: str, result: PlayerUpdateResult | None = None
205 ) -> None:
206 self.logger.debug("Controller event received: %s", event)
207
208 if event == const.EVENT_GROUPS_CHANGED:
209 for player in self.mass.players.all_players(provider_filter=self.instance_id):
210 assert isinstance(player, HeosPlayer) # for type checking
211 await player.build_group_list()
212
213 if event == const.EVENT_PLAYERS_CHANGED:
214 if result is None:
215 return
216
217 await self.discover_players()
218
219 async def _populate_sources(self) -> None:
220 """Build source list based on data from controller."""
221 if not self._heos:
222 return
223 self._input_source_list = list(await self._heos.get_input_sources())
224
225 music_sources = await self._heos.get_music_sources()
226 for source_id, source in music_sources.items():
227 self._music_source_list.append(
228 PlayerSource(
229 id=str(source_id),
230 name=source.name,
231 passive=source_id in HEOS_PASSIVE_SOURCES or not source.available,
232 can_play_pause=True, # All sources support play/pause
233 can_next_previous=source_id == 1024, # TODO: properly check
234 )
235 )
236
237 @property
238 def music_source_list(self) -> list[PlayerSource]:
239 """Get mapped music source list from controller info."""
240 return self._music_source_list
241
242 @property
243 def input_source_list(self) -> list[MediaItem]:
244 """Get input list from controller info. This represents all inputs across all players."""
245 return self._input_source_list
246
247 async def unload(self, is_removed: bool = False) -> None:
248 """Handle unload/close of the provider."""
249 await self._disconnect_controllers()
250
251 for player in self.players:
252 self.logger.debug("Unloading player %s", player.name)
253 await self.mass.players.unregister(player.player_id)
254
255 async def _disconnect_controllers(self) -> None:
256 """Disconnect HEOS controller connections."""
257 if self._heos:
258 self._heos.dispatcher.disconnect_all() # Remove all event connections
259 with suppress(Exception):
260 await self._heos.disconnect()
261 self._heos = None
262
263 if self._heos_queue:
264 self._heos_queue.dispatcher.disconnect_all() # Remove all event connections
265 with suppress(Exception):
266 await self._heos_queue.disconnect()
267 self._heos_queue = None
268
269 async def _on_heos_disconnected(self) -> None:
270 """Mark all HEOS players unavailable when the controller loses connection."""
271 self.logger.warning("HEOS controller disconnected, marking players unavailable")
272 for player in self.mass.players.all_players(provider_filter=self.instance_id):
273 assert isinstance(player, HeosPlayer)
274 player.set_device_info()
275 player.update_state()
276
277 async def discover_players(self) -> None:
278 """Discover players for this provider."""
279 if self._controller_discovery_running or self._player_discovery_running or not self._heos:
280 return # discovery already running or not set up
281
282 try:
283 self._player_discovery_running = True
284 self.logger.debug("Discovering HEOS players")
285 devices = await self._heos.get_players()
286 for device in devices.values():
287 player_id = str(device.player_id)
288 if player := cast("HeosPlayer", self.mass.players.get_player(player_id)):
289 self.logger.debug(
290 "Updating existing HEOS player: %s (%s)", device.name, player_id
291 )
292 # Update properties such as name or availability
293 player.set_device_info()
294 player.update_state()
295 continue
296
297 player_enabled = self.mass.config.get_raw_player_config_value(
298 player_id, CONF_ENABLED, default=True
299 )
300 if not player_enabled:
301 self.logger.debug("Skipping disabled player: %s (%s)", device.name, player_id)
302 continue
303 self.logger.info("Discovered new HEOS player: %s (%s)", device.name, player_id)
304
305 heos_player = HeosPlayer(self, device)
306 await heos_player.setup()
307 finally:
308 self._player_discovery_running = False
309
310 async def on_mdns_service_state_change(
311 self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None
312 ) -> None:
313 """Discovery via mdns."""
314 if state_change == ServiceStateChange.Removed:
315 return
316
317 if not info:
318 return
319
320 if self._heos or self._controller_discovery_running:
321 self.logger.debug("Ignoring mDNS configuration because we're already set up")
322 # We're already set up or in the process of setting up
323 return
324
325 device_ip = get_primary_ip_address_from_zeroconf(info)
326 if not device_ip:
327 self.logger.debug("Ignoring incomplete mdns discovery for HEOS player: %s", name)
328 return
329
330 self.logger.debug("Discovered HEOS device %s on %s", name, device_ip)
331
332 self._controller_discovery_running = True
333 try:
334 await self._setup_controllers(device_ip, True)
335 except SetupFailedError:
336 self.logger.error(
337 "Failed to set up HEOS controller at %s discovered via mDNS", device_ip
338 )
339 await self._disconnect_controllers()
340 finally:
341 self._controller_discovery_running = False
342
343 await self.discover_players()
344