/
/
/
1"""MusicCastPlayer."""
2
3import asyncio
4import time
5from collections.abc import Callable, Coroutine
6from contextlib import suppress
7from dataclasses import dataclass
8from typing import TYPE_CHECKING, Any, cast
9
10from aiohttp.client_exceptions import ClientError
11from aiomusiccast.capabilities import BinarySensor as MCBinarySensor
12from aiomusiccast.capabilities import BinarySetter as MCBinarySetter
13from aiomusiccast.capabilities import NumberSensor as MCNumberSensor
14from aiomusiccast.capabilities import NumberSetter as MCNumberSetter
15from aiomusiccast.capabilities import OptionSetter as MCOptionSetter
16from aiomusiccast.capabilities import TextSensor as MCTextSensor
17from aiomusiccast.exceptions import MusicCastGroupException
18from aiomusiccast.pyamaha import MusicCastConnectionException
19from aiomusiccast.pyamaha import System as MCSystem
20from mashumaro import DataClassDictMixin
21from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
22from music_assistant_models.enums import (
23 ConfigEntryType,
24 IdentifierType,
25 PlaybackState,
26 PlayerFeature,
27)
28from music_assistant_models.player import (
29 DeviceInfo,
30 PlayerMedia,
31 PlayerOption,
32 PlayerOptionEntry,
33 PlayerOptionType,
34 PlayerOptionValueType,
35 PlayerSoundMode,
36 PlayerSource,
37)
38from music_assistant_models.unique_list import UniqueList
39
40from music_assistant.helpers.util import is_valid_mac_address
41from music_assistant.models.player import Player
42from music_assistant.providers.musiccast.avt_helpers import (
43 avt_get_media_info,
44 avt_next,
45 avt_play,
46 avt_previous,
47 avt_set_url,
48 avt_stop,
49 search_xml,
50)
51from music_assistant.providers.musiccast.constants import (
52 CONF_PLAYER_AUTO_ADVANCE,
53 CONF_PLAYER_HANDLE_SOURCE_DISABLED,
54 CONF_PLAYER_SWITCH_SOURCE_NON_NET,
55 CONF_PLAYER_TURN_OFF_ON_LEAVE,
56 MC_CAPABILITIES,
57 MC_CONTROL_SOURCE_IDS,
58 MC_NETUSB_SOURCE_IDS,
59 MC_PASSIVE_SOURCE_IDS,
60 MC_POLL_INTERVAL,
61 MC_SOUND_MODE_FRIENDLY_NAMES,
62 MC_SOURCE_MAIN_SYNC,
63 MC_SOURCE_MC_LINK,
64 PLAYER_CONFIG_ENTRIES,
65 PLAYER_ZONE_SPLITTER,
66)
67from music_assistant.providers.musiccast.musiccast import (
68 MusicCastPhysicalDevice,
69 MusicCastPlayerState,
70 MusicCastZoneDevice,
71)
72
73if TYPE_CHECKING:
74 from .provider import MusicCastProvider
75
76
77def get_player_option_translation_key(mc_key: str) -> str:
78 """
79 Get translation key for player option.
80
81 MC key has format like 'zone_ENHANCER' or 'zone_TONE_CONTROL_bass'
82 """
83 mc_key = mc_key.lower().replace("zone_", "")
84 if mc_key == "tone_control_bass":
85 return "bass"
86 if mc_key == "tone_control_treble":
87 return "treble"
88 if mc_key == "surr_decoder_type":
89 return "surround_decoder_type"
90 return mc_key
91
92
93@dataclass
94class MusicCastMacAddresses(DataClassDictMixin):
95 """
96 MusicCastMacAddresses.
97
98 The MAC addresses lack the colons.
99 """
100
101 wired_lan: str | None = None
102 wireless_lan: str | None = None
103 wireless_direct: str | None = None
104
105
106@dataclass
107class MusicCastNetworkStatus(DataClassDictMixin):
108 """Helper class to parse the relevant information from aiomusiccast."""
109
110 connection: str | None = None
111 ip_address: str | None = None
112 mac_address: MusicCastMacAddresses | None = None
113
114
115@dataclass(kw_only=True)
116class UpnpUpdateHelper:
117 """
118 UpnpUpdateHelper.
119
120 See _update_player_attributes.
121 """
122
123 last_poll: float # time.time
124 controlled_by_mass: bool
125 current_uri: str | None
126
127
128class MusicCastPlayer(Player):
129 """MusicCastPlayer in Music Assistant."""
130
131 def __init__(
132 self,
133 provider: MusicCastProvider,
134 player_id: str,
135 physical_device: MusicCastPhysicalDevice,
136 zone_device: MusicCastZoneDevice,
137 ) -> None:
138 """
139 Init MC Player.
140
141 Keep reference to physical and zone device.
142 """
143 super().__init__(provider, player_id)
144 self.physical_device = physical_device
145 self.zone_device = zone_device
146
147 # make this a property and update during normal state updates?
148 # refers to being controlled by upnp.
149 self.update_lock = asyncio.Lock()
150 self.upnp_update_helper: UpnpUpdateHelper | None = None
151 # last netusb_track value, used to detect device-driven gapless transitions
152 self._last_netusb_track: str | None = None
153 # used to detect when the device dropped to idle mid-queue without
154 # honouring the queued NextURI (Yamaha gapless can fail this way)
155 self._last_playback_state: PlaybackState | None = None
156 self._last_playing_elapsed_time: float = 0.0
157
158 async def setup(self) -> None:
159 """Set up player in Music Assistant."""
160 await self.set_static_attributes()
161 await self.set_dynamic_attributes(update_state=False)
162
163 async def set_static_attributes(self) -> None:
164 """Set static properties."""
165 self._attr_supported_features = {
166 PlayerFeature.PLAY_MEDIA,
167 PlayerFeature.VOLUME_SET,
168 PlayerFeature.VOLUME_MUTE,
169 PlayerFeature.POWER,
170 PlayerFeature.SELECT_SOURCE,
171 PlayerFeature.NEXT_PREVIOUS,
172 PlayerFeature.ENQUEUE,
173 PlayerFeature.GAPLESS_PLAYBACK,
174 PlayerFeature.SELECT_SOUND_MODE,
175 PlayerFeature.OPTIONS,
176 }
177
178 self._attr_device_info = DeviceInfo(
179 manufacturer="Yamaha Corporation",
180 model=self.physical_device.device.data.model_name or "unknown model",
181 software_version=(self.physical_device.device.data.system_version or "unknown version"),
182 )
183
184 if "zone" not in self.player_id:
185 # we do not add mac/ ip information to zone players to prevent false player merging
186 network_status = await self.physical_device.device.device.request_json(
187 MCSystem.get_network_status()
188 )
189 network_info = MusicCastNetworkStatus.from_dict(network_status)
190 mac_address: str | None = None
191
192 if network_info.connection is not None and network_info.mac_address is not None:
193 mac_address = getattr(network_info.mac_address, network_info.connection, None)
194
195 if device_ip := self.physical_device.device.device.ip:
196 self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, device_ip)
197
198 if mac_address is not None:
199 mac = ":".join(mac_address[i : i + 2].upper() for i in range(0, 12, 2))
200 self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac)
201
202 if device_id := self.physical_device.device.data.device_id:
203 self._attr_device_info.add_identifier(IdentifierType.UUID, device_id)
204 # device_id is the MAC address (12 hex chars), format as XX:XX:XX:XX:XX:XX
205 if len(device_id) == 12 and mac_address is None:
206 # fallback to device id for mac
207 mac = ":".join(device_id[i : i + 2].upper() for i in range(0, 12, 2))
208 # Only add MAC address if it's valid (not 00:00:00:00:00:00)
209 if is_valid_mac_address(mac):
210 self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac)
211
212 # polling
213 self._attr_needs_poll = True
214 self._attr_poll_interval = MC_POLL_INTERVAL
215
216 # default MC name
217 if self.zone_device.zone_data is not None:
218 self._attr_name = self.zone_device.zone_data.name
219
220 # group
221 self._attr_can_group_with = {self.provider.instance_id}
222
223 self._attr_available = True
224
225 # SOURCES
226 for source_id, source_name in self.zone_device.source_mapping.items():
227 control = source_id in MC_CONTROL_SOURCE_IDS
228 passive = source_id in MC_PASSIVE_SOURCE_IDS
229 self._attr_source_list.append(
230 PlayerSource(
231 id=source_id,
232 name=source_name,
233 passive=passive,
234 can_play_pause=control,
235 can_seek=False,
236 can_next_previous=control,
237 )
238 )
239
240 # SOUND MODES
241 for source_id in self.zone_device.sound_mode_list:
242 friendly_name = MC_SOUND_MODE_FRIENDLY_NAMES.get(source_id) or " ".join(
243 [x.capitalize() for x in source_id.split("_")]
244 )
245 self._attr_sound_mode_list.append(
246 PlayerSoundMode(id=source_id, name=friendly_name, passive=False)
247 )
248
249 async def set_dynamic_attributes(self, update_state: bool = True) -> None:
250 """Update Player attributes."""
251 # ruff: noqa: PLR0915
252 self._attr_available = True
253
254 zone_data = self.zone_device.zone_data
255 if zone_data is None:
256 return
257
258 self._attr_powered = zone_data.power == "on"
259
260 # NOTE: aiomusiccast does not type hint the volume variables, and they may
261 # be none, and not only integers
262 _current_volume = cast("int | None", zone_data.current_volume)
263 _max_volume = cast("int | None", zone_data.max_volume)
264 _min_volume = cast("int | None", zone_data.min_volume)
265 if _current_volume is None:
266 self._attr_volume_level = None
267 else:
268 _min_volume = 0 if _min_volume is None else _min_volume
269 _max_volume = 100 if _max_volume is None else _max_volume
270 if _min_volume == _max_volume:
271 _max_volume += 1
272 self._attr_volume_level = int(_current_volume / (_max_volume - _min_volume) * 100)
273 self._attr_volume_muted = zone_data.mute
274
275 # STATE
276
277 self._attr_elapsed_time = None
278 match self.zone_device.state:
279 case MusicCastPlayerState.PAUSED:
280 self._attr_playback_state = PlaybackState.PAUSED
281 case MusicCastPlayerState.PLAYING:
282 self._attr_playback_state = PlaybackState.PLAYING
283 if self.zone_device.media_position_updated_at is not None:
284 self._attr_elapsed_time = self.zone_device.media_position
285 self._attr_elapsed_time_last_updated = (
286 self.zone_device.media_position_updated_at.timestamp()
287 )
288 case MusicCastPlayerState.IDLE | MusicCastPlayerState.OFF:
289 self._attr_playback_state = PlaybackState.IDLE
290
291 # UPDATE UPNP HELPER
292 now = time.time()
293 _current_netusb_track = (
294 self.physical_device.device.data.netusb_track if self.zone_device.is_netusb else None
295 )
296 _netusb_track_changed = (
297 self._last_netusb_track is not None
298 and _current_netusb_track is not None
299 and _current_netusb_track != self._last_netusb_track
300 )
301 self._last_netusb_track = _current_netusb_track
302 _upnp_cache_age = (
303 None if self.upnp_update_helper is None else now - self.upnp_update_helper.last_poll
304 )
305 # invalidate the cache on a netusb_track change so a gapless transition
306 # reflects in current_uri without waiting for the regular 5s refresh
307 _upnp_cache_hit = (
308 _upnp_cache_age is not None and _upnp_cache_age <= 5 and not _netusb_track_changed
309 )
310 _prev_current_uri = (
311 self.upnp_update_helper.current_uri if self.upnp_update_helper is not None else None
312 )
313 if not _upnp_cache_hit:
314 # Let's not do this too often
315 # Note: The devices always return the last UPnP xmls, even if
316 # currently another source/ playback method is used
317 try:
318 _xml_media_info = await avt_get_media_info(
319 self.mass.http_session, self.physical_device
320 )
321 except ClientError:
322 # this is regularly called, we can ignore a failing update
323 self.logger.debug("Acquiring media info failed, trying again in 5s.")
324 if self.upnp_update_helper is not None:
325 self.upnp_update_helper.last_poll = now
326 return
327 _player_current_url = search_xml(_xml_media_info, "CurrentURI")
328
329 # controlled by mass is only True, if we are directly controlled
330 # i.e. we are not a group member.
331 # the device's source id is server, if controlled by upnp, but also, if the internal
332 # dlna function of the device are used. As a fallback, we then
333 # use the item's title. This can only fail, if our current and next item
334 # has the same name as the external.
335 controlled_by_mass = False
336 if _player_current_url is not None:
337 controlled_by_mass = (
338 self.player_id in _player_current_url
339 and self.mass.streams.base_url in _player_current_url
340 and self.zone_device.source_id == "server"
341 )
342
343 self.upnp_update_helper = UpnpUpdateHelper(
344 last_poll=now,
345 controlled_by_mass=controlled_by_mass,
346 current_uri=_player_current_url,
347 )
348
349 # either freshly assigned above or a cache hit (which implies it was set before)
350 assert self.upnp_update_helper is not None
351
352 # UPDATE PLAYBACK INFORMATION
353 # Note to self:
354 # player._current_media tells queue controller what is playing
355 # and player.set_current_media is the helper function
356 # do not access the queue controller to gain playback information here
357 self._attr_supported_features.add(PlayerFeature.PAUSE) # we support pause...
358 if (
359 self.upnp_update_helper.current_uri is not None
360 and self.upnp_update_helper.controlled_by_mass
361 ):
362 self._attr_supported_features.discard(
363 PlayerFeature.PAUSE
364 ) # ...unless we are controlled by MA
365 self.set_current_media(uri=self.upnp_update_helper.current_uri, clear_all=True)
366 elif self.zone_device.is_client:
367 _server = self.zone_device.group_server
368 _server_id = self._get_player_id_from_zone_device(_server)
369 _server_player = cast(
370 "MusicCastPlayer | None", self.mass.players.get_player(_server_id)
371 )
372 _server_update_helper: None | UpnpUpdateHelper = None
373 if _server_player is not None:
374 _server_update_helper = _server_player.upnp_update_helper
375 if (
376 _server_update_helper is not None
377 and _server_update_helper.current_uri is not None
378 and _server_update_helper.controlled_by_mass
379 ):
380 self.set_current_media(uri=_server_update_helper.current_uri, clear_all=True)
381 else:
382 self.set_current_media(
383 uri=f"{_server_id}_{_server.source_id}",
384 title=_server.media_title,
385 artist=_server.media_artist,
386 album=_server.media_album_name,
387 image_url=_server.media_image_url,
388 )
389 else:
390 self.set_current_media(
391 uri=f"{self.player_id}_{self.zone_device.source_id}",
392 title=self.zone_device.media_title,
393 artist=self.zone_device.media_artist,
394 album=self.zone_device.media_album_name,
395 image_url=self.zone_device.media_image_url,
396 )
397
398 # SOURCE
399 self._attr_active_source = self.player_id
400 if not self.zone_device.is_client and not self.upnp_update_helper.controlled_by_mass:
401 self._attr_active_source = self.zone_device.source_id
402 elif self.zone_device.is_client:
403 _server = self.zone_device.group_server
404 _server_id = self._get_player_id_from_zone_device(_server)
405 _server_player = cast(
406 "MusicCastPlayer | None", self.mass.players.get_player(_server_id)
407 )
408 if _server_player is not None and _server_player.upnp_update_helper is not None:
409 self._attr_active_source = (
410 self.zone_device.source_id
411 if not _server_player.upnp_update_helper.controlled_by_mass
412 else None
413 )
414
415 # SOUND MODE
416 self._attr_active_sound_mode = self.zone_device.sound_mode_id
417
418 # GROUPING
419 # A zone cannot be synced to another zone or main of the same device.
420 # Additionally, a zone can only be synced, if main is currently not using any netusb
421 # function.
422 # For a Zone which will be synced to main, grouping emits a "main_sync" instead
423 # of a mc link. The other way round, we log a warning.
424 if len(self.zone_device.musiccast_group) == 1:
425 if self.zone_device.musiccast_group[0] == self.zone_device:
426 # we are in a group with ourselves.
427 self._attr_group_members.clear()
428
429 elif not self.zone_device.is_client and not self.zone_device.is_server:
430 self._attr_group_members.clear()
431
432 elif self.zone_device.is_client:
433 _synced_to_id = self._get_player_id_from_zone_device(self.zone_device.group_server)
434 self._attr_group_members.clear()
435
436 elif self.zone_device.is_server:
437 self._attr_group_members = [
438 self._get_player_id_from_zone_device(x) for x in self.zone_device.musiccast_group
439 ]
440
441 # disallow set members (i.e. a zone to become a group leader) if it is currently grouped to the main zone
442 if self.zone_device.source_id == MC_SOURCE_MAIN_SYNC:
443 self._attr_supported_features.discard(PlayerFeature.SET_MEMBERS)
444 else:
445 self._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
446
447 # PLAYER OPTIONS
448 # see https://github.com/vigonotion/aiomusiccast/blob/main/aiomusiccast/capabilities.py
449 # capability can be any instance of OptionSetter, BinarySetter, NumberSetter, NumberSensor,
450 # BinarySensor, TextSensor
451 # the type hint of the lib's zone_data.capabilities is wrong (_not_ list[str])
452 self._attr_options = []
453 for capability in cast(
454 "list[MC_CAPABILITIES]",
455 zone_data.capabilities,
456 ):
457 if isinstance(capability, MCBinarySensor):
458 self._attr_options.append(
459 PlayerOption(
460 key=capability.id,
461 translation_key=get_player_option_translation_key(capability.id),
462 name=capability.name,
463 type=PlayerOptionType.BOOLEAN,
464 read_only=True,
465 value=capability.current,
466 )
467 )
468 elif isinstance(capability, MCBinarySetter):
469 self._attr_options.append(
470 PlayerOption(
471 key=capability.id,
472 translation_key=get_player_option_translation_key(capability.id),
473 name=capability.name,
474 type=PlayerOptionType.BOOLEAN,
475 value=capability.current,
476 read_only=False,
477 )
478 )
479 elif isinstance(capability, MCNumberSensor):
480 self._attr_options.append(
481 PlayerOption(
482 key=capability.id,
483 translation_key=get_player_option_translation_key(capability.id),
484 name=capability.name,
485 type=PlayerOptionType.INTEGER,
486 value=capability.current,
487 read_only=True,
488 )
489 )
490 elif isinstance(capability, MCNumberSetter):
491 self._attr_options.append(
492 PlayerOption(
493 key=capability.id,
494 translation_key=get_player_option_translation_key(capability.id),
495 name=capability.name,
496 type=PlayerOptionType.INTEGER,
497 value=capability.current,
498 read_only=False,
499 min_value=capability.value_range.minimum,
500 max_value=capability.value_range.maximum,
501 step=capability.value_range.step,
502 )
503 )
504 elif isinstance(capability, MCTextSensor):
505 self._attr_options.append(
506 PlayerOption(
507 key=capability.id,
508 translation_key=get_player_option_translation_key(capability.id),
509 name=capability.name,
510 type=PlayerOptionType.STRING,
511 value=capability.current,
512 read_only=True,
513 )
514 )
515 elif isinstance(capability, MCOptionSetter):
516 options = []
517 for option_key, option_name in capability.options.items():
518 options.append(
519 PlayerOptionEntry(
520 key=str(option_key), # aiomusiccast allows str and int.
521 name=option_name,
522 value=str(option_key),
523 type=PlayerOptionType.STRING,
524 )
525 )
526 self._attr_options.append(
527 PlayerOption(
528 key=capability.id,
529 translation_key=get_player_option_translation_key(capability.id),
530 name=capability.name,
531 type=PlayerOptionType.STRING,
532 value=str(capability.current),
533 read_only=False,
534 options=UniqueList(options),
535 )
536 )
537
538 if update_state:
539 self.update_state()
540
541 # state.current_media is queue-derived, so a current_uri change alone does not
542 # produce a state diff. Nudge the queue directly so it re-parses the new URI.
543 if (
544 update_state
545 and self.upnp_update_helper.controlled_by_mass
546 and self.upnp_update_helper.current_uri != _prev_current_uri
547 ):
548 self.mass.player_queues.on_player_update(self, {})
549
550 self._maybe_advance_on_track_end()
551
552 def _maybe_advance_on_track_end(self) -> None:
553 """Schedule a queue advance if the device went idle at end of track."""
554 # The device sometimes drops the queued NextURI and stops instead of
555 # transitioning. Recover by calling next() on the queue. Gated by a
556 # per-player config so users who don't want the safety net can opt out.
557 _prev_state = self._last_playback_state
558 self._last_playback_state = self._attr_playback_state
559 if self._attr_playback_state == PlaybackState.PLAYING:
560 if self._attr_elapsed_time is not None:
561 self._last_playing_elapsed_time = self._attr_elapsed_time
562 return
563 if (
564 _prev_state != PlaybackState.PLAYING
565 or self._attr_playback_state != PlaybackState.IDLE
566 or self.upnp_update_helper is None
567 or not self.upnp_update_helper.controlled_by_mass
568 ):
569 return
570 if not bool(
571 self.mass.config.get_raw_player_config_value(
572 self.player_id, CONF_PLAYER_AUTO_ADVANCE, default=True
573 )
574 ):
575 return
576 queue = self.mass.player_queues.get(self.player_id)
577 if queue is None or queue.current_item is None or queue.next_item is None:
578 return
579 _duration = queue.current_item.duration or 0
580 # only act within 4 s of track duration to minimise hijacking a user stop
581 if not _duration or self._last_playing_elapsed_time < _duration - 4:
582 return
583 self.mass.call_later(
584 3,
585 self._advance_queue_after_idle,
586 queue.current_item.queue_item_id,
587 task_id=f"musiccast_advance_after_idle_{self.player_id}",
588 )
589
590 async def _advance_queue_after_idle(self, expected_current_item_id: str) -> None:
591 """Advance the queue if the player is still idle on the same item."""
592 if self._attr_playback_state != PlaybackState.IDLE:
593 return
594 queue = self.mass.player_queues.get(self.player_id)
595 if queue is None or not queue.active:
596 return
597 if (
598 queue.current_item is None
599 or queue.current_item.queue_item_id != expected_current_item_id
600 ):
601 return
602 if queue.next_item is None:
603 return
604 self.logger.debug("Advancing queue to next item after end-of-track idle")
605 await self.mass.player_queues.next(self.player_id)
606
607 @property
608 def synced_to(self) -> str | None:
609 """
610 Return the id of the player this player is synced to (sync leader).
611
612 If this player is not synced to another player (or is the sync leader itself),
613 this should return None.
614 If it is part of a (permanent) group, this should also return None.
615 """
616 if self.zone_device.is_network_client:
617 server_id = self._get_player_id_from_zone_device(self.zone_device.group_server)
618 return server_id if server_id != self.player_id else None
619 return None
620
621 async def _cmd_run(self, fun: Callable[..., Coroutine[Any, Any, None]], *args: Any) -> None:
622 """Help function for all player cmds."""
623 try:
624 await fun(*args)
625 except MusicCastConnectionException:
626 # should go to provider here.
627 await self._set_player_unavailable()
628 except MusicCastGroupException:
629 # can happen, user shall try again.
630 ...
631
632 async def _handle_zone_grouping(self, zone_player: MusicCastZoneDevice) -> None:
633 """
634 Handle zone grouping.
635
636 If a device has multiple zones, only a single zone can be net controlled.
637 If another zone wants to join the group, the current net zone has to switch
638 its input to a non-net one and optionally turn off.
639
640 This methods targets another zone of this players physical device!
641 """
642 # this is not this player's id
643 player_id = self._get_player_id_from_zone_device(zone_player)
644 assert player_id is not None # for TYPE_CHECKING
645
646 mass_player = self.mass.players.get_player(player_id)
647 if mass_player is None:
648 # Do not assert here, should the player not yet exist
649 return
650
651 # skip zone handling if player is disabled globally
652 if not mass_player.enabled:
653 self.logger.debug("Ignoring zone handling for disabled player %s.", player_id)
654 return
655
656 # skip zone handling if disabled via setting
657 if mass_player.get_config_value(CONF_PLAYER_HANDLE_SOURCE_DISABLED):
658 self.logger.debug("Ignoring zone handling for player %s.", player_id)
659 return
660
661 self.logger.debug("Handling zone for player %s.", player_id)
662
663 _source = mass_player.get_config_value(CONF_PLAYER_SWITCH_SOURCE_NON_NET, return_type=str)
664 # verify that this source actually exists and is non net
665 _allowed_sources = self._get_allowed_sources_zone_switch(zone_player)
666 if _source not in _allowed_sources:
667 msg = (
668 "The switch source you specified for "
669 f"{mass_player.display_name or mass_player.name}"
670 " is not allowed. "
671 f"The source must be any of: {', '.join(sorted(_allowed_sources))} "
672 "Will use the first available source."
673 )
674 self.logger.error(msg)
675 _source = _allowed_sources.pop()
676
677 await mass_player.select_source(_source)
678 _turn_off = mass_player.get_config_value(CONF_PLAYER_TURN_OFF_ON_LEAVE, return_type=bool)
679 if _turn_off:
680 await asyncio.sleep(2)
681 await mass_player.power(powered=False)
682
683 def _get_player_id_from_zone_device(self, zone_player: MusicCastZoneDevice) -> str:
684 device_id = zone_player.physical_device.device.data.device_id
685 assert device_id is not None
686 return f"{device_id}{PLAYER_ZONE_SPLITTER}{zone_player.zone_name}"
687
688 def _get_allowed_sources_zone_switch(self, zone_player: MusicCastZoneDevice) -> set[str]:
689 """Return non net sources for a zone player."""
690 assert zone_player.zone_data is not None, "zone data missing"
691 _input_sources: set[str] = set(zone_player.zone_data.input_list)
692 _net_sources = set(MC_NETUSB_SOURCE_IDS)
693 _net_sources.add(MC_SOURCE_MC_LINK) # mc grouping source
694 _net_sources.add(MC_SOURCE_MAIN_SYNC) # main zone sync
695 return _input_sources.difference(_net_sources)
696
697 async def _set_player_unavailable(self) -> None:
698 """Set this player and associated zone players unavailable."""
699 self.logger.debug("Player %s became unavailable.", self.display_name)
700
701 if TYPE_CHECKING:
702 assert isinstance(self.provider, MusicCastProvider)
703
704 # UDP polling is stopped but the physical device stays registered so
705 # the next poll can recover it.
706 self.physical_device.disable_polling()
707
708 # no update_lock: _cmd_run can call this while play_media already holds it
709 self._attr_available = False
710 self.update_state()
711
712 for zone_device in self.zone_device.other_zones:
713 if zone_device_player := self.mass.players.get_player(
714 self._get_player_id_from_zone_device(zone_device)
715 ):
716 assert isinstance(zone_device_player, MusicCastPlayer) # for type checking
717 zone_device_player._attr_available = False
718 zone_device_player.update_state()
719
720 async def _set_player_available(self) -> None:
721 """Re-enable UDP polling after recovery."""
722 assert self.zone_device.zone_name == "main", "Call only from main player!"
723 self.logger.debug("Player %s became available again.", self.display_name)
724 if self.physical_device.device.device.transport is None:
725 await self.physical_device.enable_polling()
726
727 async def poll(self) -> None:
728 """Poll player."""
729 if self.zone_device.zone_name != "main":
730 # we only poll main, which polls the whole device
731 return
732 async with self.update_lock:
733 _was_unavailable = not self._attr_available
734 try:
735 await self.physical_device.fetch()
736 except MusicCastConnectionException, MusicCastGroupException:
737 await self._set_player_unavailable()
738 return
739 except ClientError:
740 return
741 if _was_unavailable:
742 await self._set_player_available()
743 await self.set_dynamic_attributes()
744 # fetch() above covers every zone; push it to the other zone players too
745 for zone_device in self.zone_device.other_zones:
746 if zone_device_player := self.mass.players.get_player(
747 self._get_player_id_from_zone_device(zone_device)
748 ):
749 assert isinstance(zone_device_player, MusicCastPlayer) # for type checking
750 async with zone_device_player.update_lock:
751 await zone_device_player.set_dynamic_attributes()
752
753 def _non_async_udp_callback(self, physical_device: MusicCastPhysicalDevice) -> None:
754 """Call on UDP updates."""
755 self.mass.loop.create_task(self._async_udp_callback())
756
757 async def _async_udp_callback(self) -> None:
758 async with self.update_lock:
759 await self.set_dynamic_attributes()
760
761 async def power(self, powered: bool) -> None:
762 """Power command."""
763 if powered:
764 await self._cmd_run(self.zone_device.turn_on)
765 else:
766 await self._cmd_run(self.zone_device.turn_off)
767
768 async def volume_set(self, volume_level: int) -> None:
769 """Volume set command."""
770 await self._cmd_run(self.zone_device.volume_set, volume_level)
771
772 async def volume_mute(self, muted: bool) -> None:
773 """Volume mute command."""
774 await self._cmd_run(self.zone_device.volume_mute, muted)
775
776 async def play(self) -> None:
777 """Play command."""
778 if self.upnp_update_helper is not None and self.upnp_update_helper.controlled_by_mass:
779 await avt_play(self.mass.http_session, self.physical_device)
780 else:
781 await self._cmd_run(self.zone_device.play)
782
783 async def stop(self) -> None:
784 """Stop command."""
785 if self.upnp_update_helper is not None and self.upnp_update_helper.controlled_by_mass:
786 await avt_stop(self.mass.http_session, self.physical_device)
787 else:
788 await self._cmd_run(self.zone_device.stop)
789
790 async def pause(self) -> None:
791 """Pause command."""
792 if self.upnp_update_helper is not None and self.upnp_update_helper.controlled_by_mass:
793 # if we are controlled by MA, i.e. upnp, send a stop, since
794 # pause appears to be unreliable/ not working
795 await avt_stop(self.mass.http_session, self.physical_device)
796 else:
797 await self._cmd_run(self.zone_device.pause)
798
799 async def next_track(self) -> None:
800 """Next command."""
801 if self.upnp_update_helper is not None and self.upnp_update_helper.controlled_by_mass:
802 await avt_next(self.mass.http_session, self.physical_device)
803 else:
804 await self._cmd_run(self.zone_device.next_track)
805
806 async def previous_track(self) -> None:
807 """Previous command."""
808 if self.upnp_update_helper is not None and self.upnp_update_helper.controlled_by_mass:
809 await avt_previous(self.mass.http_session, self.physical_device)
810 else:
811 await self._cmd_run(self.zone_device.previous_track)
812
813 async def play_media(self, media: PlayerMedia) -> None:
814 """Play media command."""
815 _zone_handling_attempted = False
816 if len(self.physical_device.zone_devices) > 1:
817 # zone handling
818 # only a single zone may have netusb capability
819 for zone_name, dev in self.physical_device.zone_devices.items():
820 if zone_name == self.zone_device.zone_name:
821 continue
822 # skip powered-off zones: their remembered source can match netusb_input
823 # without actually consuming the resource, and switching can affect main
824 if dev.is_netusb and dev.zone_data is not None and dev.zone_data.power == "on":
825 await self._handle_zone_grouping(dev)
826 _zone_handling_attempted = True
827 async with self.update_lock:
828 # re-assert "server" when zone handling ran or the cached source is stale;
829 # autoplay_disabled stops the device resuming the input's last queue
830 if _zone_handling_attempted or self.zone_device.source_id != "server":
831 await self._cmd_run(self.zone_device.select_source, "server", "autoplay_disabled")
832 media.uri = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
833 # clear any pending AVT state to avoid wedging on rapid play_media
834 await avt_stop(self.mass.http_session, self.physical_device)
835 await avt_set_url(self.mass.http_session, self.physical_device, player_media=media)
836 await avt_play(self.mass.http_session, self.physical_device)
837
838 self.upnp_update_helper = UpnpUpdateHelper(
839 last_poll=time.time(),
840 controlled_by_mass=True,
841 current_uri=media.uri,
842 )
843
844 async def enqueue_next_media(self, media: PlayerMedia) -> None:
845 """Enqueue next command."""
846 media.uri = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
847 await avt_set_url(
848 self.mass.http_session,
849 self.physical_device,
850 player_media=media,
851 enqueue=True,
852 )
853
854 async def select_source(self, source: str) -> None:
855 """Select source command."""
856 await self._cmd_run(self.zone_device.select_source, source)
857
858 async def select_sound_mode(self, sound_mode: str) -> None:
859 """Select sound Mode Command."""
860 await self._cmd_run(self.zone_device.select_sound_mode, sound_mode)
861
862 async def set_option(self, option_key: str, option_value: PlayerOptionValueType) -> None:
863 """Set player option."""
864 if self.zone_device.zone_data is None:
865 return
866 for capability in cast(
867 "list[MC_CAPABILITIES]",
868 self.zone_device.zone_data.capabilities,
869 ):
870 if str(capability.id) != option_key:
871 continue
872 if not isinstance(capability, MCBinarySetter | MCNumberSetter | MCOptionSetter):
873 self.logger.error(f"Option {capability.name} is read only!")
874 return
875 if isinstance(capability, MCBinarySetter):
876 await capability.set(bool(option_value))
877 elif isinstance(capability, MCNumberSetter):
878 min_value = capability.value_range.minimum
879 max_value = capability.value_range.maximum
880 if not min_value <= int(option_value) <= max_value:
881 self.logger.error(
882 f"Option {capability.name} has numeric range of"
883 f"{min_value} <= value <= {max_value}"
884 )
885 return
886 await capability.set(int(option_value))
887 elif isinstance(capability, MCOptionSetter):
888 assert isinstance(option_value, str | int) # for type checking
889 _option_value = option_value # we may have an int in aiomusiccast as key
890 with suppress(ValueError):
891 _option_value = int(_option_value)
892 if _option_value not in capability.options:
893 self.logger.error(f"Option {_option_value} is not allowed for {option_key}")
894 return
895 await capability.set(_option_value)
896 break
897
898 async def ungroup(self) -> None:
899 """Ungroup command."""
900 if self.zone_device.zone_name.startswith("zone"):
901 # We are are zone.
902 # We do not leave an MC group, but just change our source.
903 await self._handle_zone_grouping(self.zone_device)
904 return
905 await self._cmd_run(self.zone_device.unjoin_player)
906
907 async def set_members(
908 self,
909 player_ids_to_add: list[str] | None = None,
910 player_ids_to_remove: list[str] | None = None,
911 ) -> None:
912 """
913 Set multiple members.
914
915 This function is called on the server.
916 """
917 # Removing players
918 if player_ids_to_remove:
919 for player_id in player_ids_to_remove:
920 if player := self.mass.players.get_player(player_id):
921 assert isinstance(player, MusicCastPlayer) # for type checking
922 await player.ungroup()
923
924 # Adding players
925 if not player_ids_to_add:
926 return
927 children: set[str] = set() # set[ma_player_id]
928 children_zones: list[str] = [] # list[ma_player_id]
929 player_ids_to_add = [] if player_ids_to_add is None else player_ids_to_add
930 for child_id in player_ids_to_add:
931 child_player = self.mass.players.get_player(child_id)
932 if child_player is None:
933 continue
934 assert isinstance(child_player, MusicCastPlayer) # for type checking
935
936 # find a sibling zone on the child's device currently using netusb;
937 # skip disabled zones (user opted out of MA managing them)
938 _other_zone_mc: MusicCastZoneDevice | None = None
939 for x in child_player.zone_device.other_zones:
940 if not x.is_netusb:
941 continue
942 _other_player_id = self._get_player_id_from_zone_device(x)
943 _other_player = self.mass.players.get_player(_other_player_id)
944 if _other_player is None or not _other_player.enabled:
945 continue
946 _other_zone_mc = x
947 # only one zone can hold netusb at a time
948 break
949
950 # no conflicting sibling -> standard client join
951 if _other_zone_mc is None:
952 children.add(child_id)
953 continue
954
955 # child is a non-main zone of a device whose main is the netusb consumer;
956 # join the group via main_sync so the child follows main locally
957 if child_player.zone_device.zone_name != "main" and _other_zone_mc.zone_name == "main":
958 children_zones.append(child_id)
959 continue
960
961 # child is main but a sibling holds netusb; free the sibling so main
962 # can become the netusb client, then join normally
963 if child_player.zone_device.zone_name == "main":
964 await child_player._handle_zone_grouping(_other_zone_mc)
965 children.add(child_id)
966 continue
967
968 # non-main child while another non-main sibling holds netusb is unsupported
969 self.logger.warning(
970 "It is impossible to join as a normal zone to another zone of the same "
971 "device. Only joining to main is possible. Please refer to the docs."
972 )
973
974 for child_id in children_zones:
975 child_player = self.mass.players.get_player(child_id)
976 if TYPE_CHECKING:
977 child_player = cast("MusicCastPlayer", child_player)
978 if child_player.zone_device.state == MusicCastPlayerState.OFF:
979 await child_player.power(powered=True)
980 await child_player.select_source(MC_SOURCE_MAIN_SYNC)
981 if not children:
982 return
983
984 child_player_zone_devices: list[MusicCastZoneDevice] = []
985 for child_id in children:
986 child_player = self.mass.players.get_player(child_id)
987 if TYPE_CHECKING:
988 child_player = cast("MusicCastPlayer", child_player)
989 child_player_zone_devices.append(child_player.zone_device)
990
991 await self._cmd_run(self.zone_device.join_players, child_player_zone_devices)
992
993 async def get_config_entries(self) -> list[ConfigEntry]:
994 """Get player config entries."""
995 base_entries = await super().get_config_entries()
996
997 zone_entries: list[ConfigEntry] = []
998 if len(self.physical_device.zone_devices) > 1:
999 source_options: list[ConfigValueOption] = []
1000 allowed_sources = self._get_allowed_sources_zone_switch(self.zone_device)
1001 for (
1002 source_id,
1003 source_name,
1004 ) in self.zone_device.source_mapping.items():
1005 if source_id in allowed_sources:
1006 source_options.append(ConfigValueOption(source_id, title=source_name))
1007 if len(source_options) == 0:
1008 # this should never happen
1009 self.logger.error(
1010 "The player %s has multiple zones, but lacks a non-net source to switch to."
1011 " Please report this on github or discord.",
1012 self.display_name or self.name,
1013 )
1014 zone_entries = []
1015 else:
1016 zone_entries = [
1017 ConfigEntry(
1018 key=CONF_PLAYER_HANDLE_SOURCE_DISABLED,
1019 type=ConfigEntryType.BOOLEAN,
1020 default_value=False,
1021 ),
1022 ConfigEntry(
1023 key=CONF_PLAYER_SWITCH_SOURCE_NON_NET,
1024 type=ConfigEntryType.STRING,
1025 options=source_options,
1026 default_value=source_options[0].value,
1027 ),
1028 ConfigEntry(
1029 key=CONF_PLAYER_TURN_OFF_ON_LEAVE,
1030 type=ConfigEntryType.BOOLEAN,
1031 default_value=False,
1032 ),
1033 ]
1034
1035 auto_advance_entry = ConfigEntry(
1036 key=CONF_PLAYER_AUTO_ADVANCE,
1037 type=ConfigEntryType.BOOLEAN,
1038 default_value=True,
1039 )
1040
1041 return base_entries + zone_entries + [auto_advance_entry] + PLAYER_CONFIG_ENTRIES
1042