/
/
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 await self._populate_sources()
142 except HeosError as err:
143 self.logger.error("Unexpected error setting up HEOS controller: %s", err)
144 raise SetupFailedError("Unexpected error setting up HEOS controller") from err
145
146 # 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
147 try:
148 self._heos_queue = Heos(
149 HeosOptions(
150 controller_ip,
151 timeout=cast("int", self.config.get_value(CONF_TIMEOUT)),
152 auto_reconnect=True,
153 auto_failover=True,
154 events=False,
155 )
156 )
157 await self._heos_queue.connect()
158 except HeosError as err:
159 self.logger.error("Failed to set up HEOS queue controller: %s", err)
160 raise SetupFailedError("Failed to set up HEOS queue controller") from err
161
162 async def _connect_controller(self, controller_ip: str) -> None:
163 """Connect to the HEOS controller with a few retries for early mDNS announcements."""
164 assert self._heos is not None
165
166 for attempt in range(1, CONNECT_MAX_ATTEMPTS + 1):
167 try:
168 await self._heos.connect()
169 except HeosError as err:
170 if attempt == CONNECT_MAX_ATTEMPTS:
171 self.logger.error(
172 "Failed to connect to HEOS controller at %s after %d attempts: %s",
173 controller_ip,
174 CONNECT_MAX_ATTEMPTS,
175 err,
176 )
177 raise SetupFailedError("Failed to connect to HEOS controller") from err
178
179 retry_delay = CONNECT_INITIAL_RETRY_DELAY * (
180 CONNECT_RETRY_BACKOFF_FACTOR ** (attempt - 1)
181 )
182 self.logger.debug(
183 "HEOS controller connection attempt %d/%d failed for %s: %s. Retrying in %.1fs",
184 attempt,
185 CONNECT_MAX_ATTEMPTS,
186 controller_ip,
187 err,
188 retry_delay,
189 )
190 await self._heos.disconnect()
191 await asyncio.sleep(retry_delay)
192 else:
193 if attempt > 1:
194 self.logger.debug(
195 "Connected to HEOS controller at %s on attempt %d/%d",
196 controller_ip,
197 attempt,
198 CONNECT_MAX_ATTEMPTS,
199 )
200 return
201
202 async def _handle_controller_event(
203 self, event: str, result: PlayerUpdateResult | None = None
204 ) -> None:
205 self.logger.debug("Controller event received: %s", event)
206
207 if event == const.EVENT_GROUPS_CHANGED:
208 for player in self.mass.players.all_players(provider_filter=self.instance_id):
209 assert isinstance(player, HeosPlayer) # for type checking
210 await player.build_group_list()
211
212 if event == const.EVENT_PLAYERS_CHANGED:
213 if result is None:
214 return
215
216 await self.discover_players()
217
218 async def _populate_sources(self) -> None:
219 """Build source list based on data from controller."""
220 if not self._heos:
221 return
222 self._input_source_list = list(await self._heos.get_input_sources())
223
224 music_sources = await self._heos.get_music_sources()
225 for source_id, source in music_sources.items():
226 self._music_source_list.append(
227 PlayerSource(
228 id=str(source_id),
229 name=source.name,
230 passive=source_id in HEOS_PASSIVE_SOURCES or not source.available,
231 can_play_pause=True, # All sources support play/pause
232 can_next_previous=source_id == 1024, # TODO: properly check
233 )
234 )
235
236 @property
237 def music_source_list(self) -> list[PlayerSource]:
238 """Get mapped music source list from controller info."""
239 return self._music_source_list
240
241 @property
242 def input_source_list(self) -> list[MediaItem]:
243 """Get input list from controller info. This represents all inputs across all players."""
244 return self._input_source_list
245
246 async def unload(self, is_removed: bool = False) -> None:
247 """Handle unload/close of the provider."""
248 await self._disconnect_controllers()
249
250 for player in self.players:
251 self.logger.debug("Unloading player %s", player.name)
252 await self.mass.players.unregister(player.player_id)
253
254 async def _disconnect_controllers(self) -> None:
255 """Disconnect HEOS controller connections."""
256 if self._heos:
257 self._heos.dispatcher.disconnect_all() # Remove all event connections
258 with suppress(Exception):
259 await self._heos.disconnect()
260 self._heos = None
261
262 if self._heos_queue:
263 self._heos_queue.dispatcher.disconnect_all() # Remove all event connections
264 with suppress(Exception):
265 await self._heos_queue.disconnect()
266 self._heos_queue = None
267
268 async def discover_players(self) -> None:
269 """Discover players for this provider."""
270 if self._controller_discovery_running or self._player_discovery_running or not self._heos:
271 return # discovery already running or not set up
272
273 try:
274 self._player_discovery_running = True
275 self.logger.debug("Discovering HEOS players")
276 devices = await self._heos.get_players()
277 for device in devices.values():
278 player_id = str(device.player_id)
279 if player := cast("HeosPlayer", self.mass.players.get_player(player_id)):
280 self.logger.debug(
281 "Updating existing HEOS player: %s (%s)", device.name, player_id
282 )
283 # Update properties such as name or availability
284 player.set_device_info()
285 player.update_state()
286 continue
287
288 player_enabled = self.mass.config.get_raw_player_config_value(
289 player_id, CONF_ENABLED, default=True
290 )
291 if not player_enabled:
292 self.logger.debug("Skipping disabled player: %s (%s)", device.name, player_id)
293 continue
294 self.logger.info("Discovered new HEOS player: %s (%s)", device.name, player_id)
295
296 heos_player = HeosPlayer(self, device)
297 await heos_player.setup()
298 finally:
299 self._player_discovery_running = False
300
301 async def on_mdns_service_state_change(
302 self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None
303 ) -> None:
304 """Discovery via mdns."""
305 if state_change == ServiceStateChange.Removed:
306 return
307
308 if not info:
309 return
310
311 if self._heos or self._controller_discovery_running:
312 self.logger.debug("Ignoring mDNS configuration because we're already set up")
313 # We're already set up or in the process of setting up
314 return
315
316 device_ip = get_primary_ip_address_from_zeroconf(info)
317 if not device_ip:
318 self.logger.debug("Ignoring incomplete mdns discovery for HEOS player: %s", name)
319 return
320
321 self.logger.debug("Discovered HEOS device %s on %s", name, device_ip)
322
323 self._controller_discovery_running = True
324 try:
325 await self._setup_controllers(device_ip, True)
326 except SetupFailedError:
327 self.logger.error(
328 "Failed to set up HEOS controller at %s discovered via mDNS", device_ip
329 )
330 await self._disconnect_controllers()
331 finally:
332 self._controller_discovery_running = False
333
334 await self.discover_players()
335