/
/
/
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 music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
20from music_assistant_models.enums import ConfigEntryType, PlaybackState, PlayerFeature
21from music_assistant_models.player import (
22 DeviceInfo,
23 PlayerMedia,
24 PlayerOption,
25 PlayerOptionEntry,
26 PlayerOptionType,
27 PlayerOptionValueType,
28 PlayerSoundMode,
29 PlayerSource,
30)
31from music_assistant_models.unique_list import UniqueList
32from propcache import under_cached_property as cached_property
33
34from music_assistant.models.player import Player
35from music_assistant.providers.musiccast.avt_helpers import (
36 avt_get_media_info,
37 avt_next,
38 avt_play,
39 avt_previous,
40 avt_set_url,
41 avt_stop,
42 search_xml,
43)
44from music_assistant.providers.musiccast.constants import (
45 CONF_PLAYER_HANDLE_SOURCE_DISABLED,
46 CONF_PLAYER_SWITCH_SOURCE_NON_NET,
47 CONF_PLAYER_TURN_OFF_ON_LEAVE,
48 MC_CAPABILITIES,
49 MC_CONTROL_SOURCE_IDS,
50 MC_NETUSB_SOURCE_IDS,
51 MC_PASSIVE_SOURCE_IDS,
52 MC_POLL_INTERVAL,
53 MC_SOUND_MODE_FRIENDLY_NAMES,
54 MC_SOURCE_MAIN_SYNC,
55 MC_SOURCE_MC_LINK,
56 PLAYER_CONFIG_ENTRIES,
57 PLAYER_ZONE_SPLITTER,
58)
59from music_assistant.providers.musiccast.musiccast import (
60 MusicCastPhysicalDevice,
61 MusicCastPlayerState,
62 MusicCastZoneDevice,
63)
64
65if TYPE_CHECKING:
66 from .provider import MusicCastProvider
67
68
69@dataclass(kw_only=True)
70class UpnpUpdateHelper:
71 """UpnpUpdateHelper.
72
73 See _update_player_attributes.
74 """
75
76 last_poll: float # time.time
77 controlled_by_mass: bool
78 current_uri: str | None
79
80
81class MusicCastPlayer(Player):
82 """MusicCastPlayer in Music Assistant."""
83
84 def __init__(
85 self,
86 provider: "MusicCastProvider",
87 player_id: str,
88 physical_device: MusicCastPhysicalDevice,
89 zone_device: MusicCastZoneDevice,
90 ) -> None:
91 """Init MC Player.
92
93 Keep reference to physical and zone device.
94 """
95 super().__init__(provider, player_id)
96 self.physical_device = physical_device
97 self.zone_device = zone_device
98
99 # make this a property and update during normal state updates?
100 # refers to being controlled by upnp.
101 self.update_lock = asyncio.Lock()
102 self.upnp_update_helper: UpnpUpdateHelper | None = None
103
104 async def setup(self) -> None:
105 """Set up player in Music Assistant."""
106 self.set_static_attributes()
107
108 def set_static_attributes(self) -> None:
109 """Set static properties."""
110 self._attr_supported_features = {
111 PlayerFeature.VOLUME_SET,
112 PlayerFeature.VOLUME_MUTE,
113 PlayerFeature.PAUSE, # for non MA control, see pause method
114 PlayerFeature.POWER,
115 PlayerFeature.SELECT_SOURCE,
116 PlayerFeature.SET_MEMBERS,
117 PlayerFeature.NEXT_PREVIOUS,
118 PlayerFeature.ENQUEUE,
119 PlayerFeature.GAPLESS_PLAYBACK,
120 PlayerFeature.SELECT_SOUND_MODE,
121 PlayerFeature.OPTIONS,
122 }
123
124 self._attr_device_info = DeviceInfo(
125 manufacturer="Yamaha Corporation",
126 model=self.physical_device.device.data.model_name or "unknown model",
127 software_version=(self.physical_device.device.data.system_version or "unknown version"),
128 )
129
130 # polling
131 self._attr_needs_poll = True
132 self._attr_poll_interval = MC_POLL_INTERVAL
133
134 # default MC name
135 if self.zone_device.zone_data is not None:
136 self._attr_name = self.zone_device.zone_data.name
137
138 # group
139 self._attr_can_group_with = {self.provider.instance_id}
140
141 self._attr_available = True
142
143 # SOURCES
144 for source_id, source_name in self.zone_device.source_mapping.items():
145 control = source_id in MC_CONTROL_SOURCE_IDS
146 passive = source_id in MC_PASSIVE_SOURCE_IDS
147 self._attr_source_list.append(
148 PlayerSource(
149 id=source_id,
150 name=source_name,
151 passive=passive,
152 can_play_pause=control,
153 can_seek=False,
154 can_next_previous=control,
155 )
156 )
157
158 # SOUND MODES
159 for source_id in self.zone_device.sound_mode_list:
160 friendly_name = MC_SOUND_MODE_FRIENDLY_NAMES.get(source_id) or " ".join(
161 [x.capitalize() for x in source_id.split("_")]
162 )
163 self._attr_sound_mode_list.append(
164 PlayerSoundMode(id=source_id, name=friendly_name, passive=False)
165 )
166
167 async def set_dynamic_attributes(self) -> None:
168 """Update Player attributes."""
169 # ruff: noqa: PLR0915
170 self._attr_available = True
171
172 zone_data = self.zone_device.zone_data
173 if zone_data is None:
174 return
175
176 self._attr_powered = zone_data.power == "on"
177
178 # NOTE: aiomusiccast does not type hint the volume variables, and they may
179 # be none, and not only integers
180 _current_volume = cast("int | None", zone_data.current_volume)
181 _max_volume = cast("int | None", zone_data.max_volume)
182 _min_volume = cast("int | None", zone_data.min_volume)
183 if _current_volume is None:
184 self._attr_volume_level = None
185 else:
186 _min_volume = 0 if _min_volume is None else _min_volume
187 _max_volume = 100 if _max_volume is None else _max_volume
188 if _min_volume == _max_volume:
189 _max_volume += 1
190 self._attr_volume_level = int(_current_volume / (_max_volume - _min_volume) * 100)
191 self._attr_volume_muted = zone_data.mute
192
193 # STATE
194
195 match self.zone_device.state:
196 case MusicCastPlayerState.PAUSED:
197 self._attr_playback_state = PlaybackState.PAUSED
198 case MusicCastPlayerState.PLAYING:
199 self._attr_playback_state = PlaybackState.PLAYING
200 case MusicCastPlayerState.IDLE | MusicCastPlayerState.OFF:
201 self._attr_playback_state = PlaybackState.IDLE
202 self._attr_elapsed_time = self.zone_device.media_position
203 if self.zone_device.media_position_updated_at is not None:
204 self._attr_elapsed_time_last_updated = (
205 self.zone_device.media_position_updated_at.timestamp()
206 )
207 else:
208 self._attr_elapsed_time_last_updated = None
209
210 # UPDATE UPNP HELPER
211 now = time.time()
212 if self.upnp_update_helper is None or now - self.upnp_update_helper.last_poll > 5:
213 # Let's not do this too often
214 # Note: The devices always return the last UPnP xmls, even if
215 # currently another source/ playback method is used
216 try:
217 _xml_media_info = await avt_get_media_info(
218 self.mass.http_session, self.physical_device
219 )
220 except ClientError:
221 # this is regularly called, we can ignore a failing update
222 self.logger.debug("Acquiring media info failed, trying again in 5s.")
223 if self.upnp_update_helper is not None:
224 self.upnp_update_helper.last_poll = now
225 return
226 _player_current_url = search_xml(_xml_media_info, "CurrentURI")
227
228 # controlled by mass is only True, if we are directly controlled
229 # i.e. we are not a group member.
230 # the device's source id is server, if controlled by upnp, but also, if the internal
231 # dlna function of the device are used. As a fallback, we then
232 # use the item's title. This can only fail, if our current and next item
233 # has the same name as the external.
234 controlled_by_mass = False
235 if _player_current_url is not None:
236 controlled_by_mass = (
237 self.player_id in _player_current_url
238 and self.mass.streams.base_url in _player_current_url
239 and self.zone_device.source_id == "server"
240 )
241
242 self.upnp_update_helper = UpnpUpdateHelper(
243 last_poll=now,
244 controlled_by_mass=controlled_by_mass,
245 current_uri=_player_current_url,
246 )
247
248 # UPDATE PLAYBACK INFORMATION
249 # Note to self:
250 # player._current_media tells queue controller what is playing
251 # and player.set_current_media is the helper function
252 # do not access the queue controller to gain playback information here
253 if (
254 self.upnp_update_helper.current_uri is not None
255 and self.upnp_update_helper.controlled_by_mass
256 ):
257 self.set_current_media(uri=self.upnp_update_helper.current_uri, clear_all=True)
258 elif self.zone_device.is_client:
259 _server = self.zone_device.group_server
260 _server_id = self._get_player_id_from_zone_device(_server)
261 _server_player = cast("MusicCastPlayer | None", self.mass.players.get(_server_id))
262 _server_update_helper: None | UpnpUpdateHelper = None
263 if _server_player is not None:
264 _server_update_helper = _server_player.upnp_update_helper
265 if (
266 _server_update_helper is not None
267 and _server_update_helper.current_uri is not None
268 and _server_update_helper.controlled_by_mass
269 ):
270 self.set_current_media(uri=_server_update_helper.current_uri, clear_all=True)
271 else:
272 self.set_current_media(
273 uri=f"{_server_id}_{_server.source_id}",
274 title=_server.media_title,
275 artist=_server.media_artist,
276 album=_server.media_album_name,
277 image_url=_server.media_image_url,
278 )
279 else:
280 self.set_current_media(
281 uri=f"{self.player_id}_{self.zone_device.source_id}",
282 title=self.zone_device.media_title,
283 artist=self.zone_device.media_artist,
284 album=self.zone_device.media_album_name,
285 image_url=self.zone_device.media_image_url,
286 )
287
288 # SOURCE
289 self._attr_active_source = self.player_id
290 if not self.zone_device.is_client and not self.upnp_update_helper.controlled_by_mass:
291 self._attr_active_source = self.zone_device.source_id
292 elif self.zone_device.is_client:
293 _server = self.zone_device.group_server
294 _server_id = self._get_player_id_from_zone_device(_server)
295 _server_player = cast("MusicCastPlayer | None", self.mass.players.get(_server_id))
296 if _server_player is not None and _server_player.upnp_update_helper is not None:
297 self._attr_active_source = (
298 self.zone_device.source_id
299 if not _server_player.upnp_update_helper.controlled_by_mass
300 else None
301 )
302
303 # SOUND MODE
304 self._attr_active_sound_mode = self.zone_device.sound_mode_id
305
306 # GROUPING
307 # A zone cannot be synced to another zone or main of the same device.
308 # Additionally, a zone can only be synced, if main is currently not using any netusb
309 # function.
310 # For a Zone which will be synced to main, grouping emits a "main_sync" instead
311 # of a mc link. The other way round, we log a warning.
312 if len(self.zone_device.musiccast_group) == 1:
313 if self.zone_device.musiccast_group[0] == self.zone_device:
314 # we are in a group with ourselves.
315 self._attr_group_members.clear()
316
317 elif not self.zone_device.is_client and not self.zone_device.is_server:
318 self._attr_group_members.clear()
319
320 elif self.zone_device.is_client:
321 _synced_to_id = self._get_player_id_from_zone_device(self.zone_device.group_server)
322 self._attr_group_members.clear()
323
324 elif self.zone_device.is_server:
325 self._attr_group_members = [
326 self._get_player_id_from_zone_device(x) for x in self.zone_device.musiccast_group
327 ]
328
329 # PLAYER OPTIONS
330 # see https://github.com/vigonotion/aiomusiccast/blob/main/aiomusiccast/capabilities.py
331 # capability can be any instance of OptionSetter, BinarySetter, NumberSetter, NumberSensor,
332 # BinarySensor, TextSensor
333 # the type hint of the lib's zone_data.capabilities is wrong (_not_ list[str])
334 self._attr_options = []
335 for capability in cast(
336 "list[MC_CAPABILITIES]",
337 zone_data.capabilities,
338 ):
339 if isinstance(capability, MCBinarySensor):
340 self._attr_options.append(
341 PlayerOption(
342 key=capability.id,
343 name=capability.name,
344 type=PlayerOptionType.BOOLEAN,
345 read_only=True,
346 value=capability.current,
347 )
348 )
349 elif isinstance(capability, MCBinarySetter):
350 self._attr_options.append(
351 PlayerOption(
352 key=capability.id,
353 name=capability.name,
354 type=PlayerOptionType.BOOLEAN,
355 value=capability.current,
356 read_only=False,
357 )
358 )
359 elif isinstance(capability, MCNumberSensor):
360 self._attr_options.append(
361 PlayerOption(
362 key=capability.id,
363 name=capability.name,
364 type=PlayerOptionType.INTEGER,
365 value=capability.current,
366 read_only=True,
367 )
368 )
369 elif isinstance(capability, MCNumberSetter):
370 self._attr_options.append(
371 PlayerOption(
372 key=capability.id,
373 name=capability.name,
374 type=PlayerOptionType.INTEGER,
375 value=capability.current,
376 read_only=False,
377 min_value=capability.value_range.minimum,
378 max_value=capability.value_range.maximum,
379 step=capability.value_range.step,
380 )
381 )
382 elif isinstance(capability, MCTextSensor):
383 self._attr_options.append(
384 PlayerOption(
385 key=capability.id,
386 name=capability.name,
387 type=PlayerOptionType.STRING,
388 value=capability.current,
389 read_only=True,
390 )
391 )
392 elif isinstance(capability, MCOptionSetter):
393 options = []
394 for option_key, option_name in capability.options.items():
395 options.append(
396 PlayerOptionEntry(
397 key=str(option_key), # aiomusiccast allows str and int.
398 name=option_name,
399 value=str(option_key),
400 type=PlayerOptionType.STRING,
401 )
402 )
403 self._attr_options.append(
404 PlayerOption(
405 key=capability.id,
406 name=capability.name,
407 type=PlayerOptionType.STRING,
408 value=str(capability.current),
409 read_only=False,
410 options=UniqueList(options),
411 )
412 )
413
414 self.update_state()
415
416 @cached_property
417 def synced_to(self) -> str | None:
418 """
419 Return the id of the player this player is synced to (sync leader).
420
421 If this player is not synced to another player (or is the sync leader itself),
422 this should return None.
423 If it is part of a (permanent) group, this should also return None.
424 """
425 if self.zone_device.is_network_client:
426 server_id = self._get_player_id_from_zone_device(self.zone_device.group_server)
427 return server_id if server_id != self.player_id else None
428 return None
429
430 async def _cmd_run(self, fun: Callable[..., Coroutine[Any, Any, None]], *args: Any) -> None:
431 """Help function for all player cmds."""
432 try:
433 await fun(*args)
434 except MusicCastConnectionException:
435 # should go to provider here.
436 await self._set_player_unavailable()
437 except MusicCastGroupException:
438 # can happen, user shall try again.
439 ...
440
441 async def _handle_zone_grouping(self, zone_player: MusicCastZoneDevice) -> None:
442 """Handle zone grouping.
443
444 If a device has multiple zones, only a single zone can be net controlled.
445 If another zone wants to join the group, the current net zone has to switch
446 its input to a non-net one and optionally turn off.
447
448 This methods targets another zone of this players physical device!
449 """
450 # this is not this player's id
451 player_id = self._get_player_id_from_zone_device(zone_player)
452 assert player_id is not None # for TYPE_CHECKING
453
454 # skip zone handling if disabled.
455 if bool(
456 await self.mass.config.get_player_config_value(
457 player_id, CONF_PLAYER_HANDLE_SOURCE_DISABLED
458 )
459 ):
460 return
461
462 _source = str(
463 await self.mass.config.get_player_config_value(
464 player_id, CONF_PLAYER_SWITCH_SOURCE_NON_NET
465 )
466 )
467 # verify that this source actually exists and is non net
468 _allowed_sources = self._get_allowed_sources_zone_switch(zone_player)
469 mass_player = self.mass.players.get(player_id)
470 if mass_player is None:
471 # Do not assert here, should the player not yet exist
472 return
473 if _source not in _allowed_sources:
474 msg = (
475 "The switch source you specified for "
476 f"{mass_player.display_name or mass_player.name}"
477 " is not allowed. "
478 f"The source must be any of: {', '.join(sorted(_allowed_sources))} "
479 "Will use the first available source."
480 )
481 self.logger.error(msg)
482 _source = _allowed_sources.pop()
483
484 await mass_player.select_source(_source)
485 _turn_off = bool(
486 await self.mass.config.get_player_config_value(player_id, CONF_PLAYER_TURN_OFF_ON_LEAVE)
487 )
488 if _turn_off:
489 await asyncio.sleep(2)
490 await mass_player.power(powered=False)
491
492 def _get_player_id_from_zone_device(self, zone_player: MusicCastZoneDevice) -> str:
493 device_id = zone_player.physical_device.device.data.device_id
494 assert device_id is not None
495 return f"{device_id}{PLAYER_ZONE_SPLITTER}{zone_player.zone_name}"
496
497 def _get_allowed_sources_zone_switch(self, zone_player: MusicCastZoneDevice) -> set[str]:
498 """Return non net sources for a zone player."""
499 assert zone_player.zone_data is not None, "zone data missing"
500 _input_sources: set[str] = set(zone_player.zone_data.input_list)
501 _net_sources = set(MC_NETUSB_SOURCE_IDS)
502 _net_sources.add(MC_SOURCE_MC_LINK) # mc grouping source
503 _net_sources.add(MC_SOURCE_MAIN_SYNC) # main zone sync
504 return _input_sources.difference(_net_sources)
505
506 async def _set_player_unavailable(self) -> None:
507 """Set this player and associated zone players unavailable.
508
509 Only called from a main zone player.
510 """
511 assert self.zone_device.zone_name == "main", "Call only from main player!"
512 self.logger.debug("Player %s became unavailable.", self.display_name)
513
514 if TYPE_CHECKING:
515 assert isinstance(self.provider, MusicCastProvider)
516
517 # disable polling
518 self.physical_device.remove()
519
520 async with self.update_lock:
521 self._attr_available = False
522 self.update_state()
523
524 # set other zone unavailable
525 for zone_device in self.zone_device.other_zones:
526 if zone_device_player := self.mass.players.get(
527 self._get_player_id_from_zone_device(zone_device)
528 ):
529 assert isinstance(zone_device_player, MusicCastPlayer) # for type checking
530 async with zone_device_player.update_lock:
531 zone_device_player._attr_available = False
532 zone_device_player.update_state()
533
534 async def poll(self) -> None:
535 """Poll player."""
536 if self.update_lock.locked():
537 # udp updates come in roughly every second when playing, so discard
538 return
539 if self.zone_device.zone_name != "main":
540 # we only poll main, which polls the whole device
541 return
542 async with self.update_lock:
543 # explicit polling on main
544 try:
545 await self.physical_device.fetch()
546 except (MusicCastConnectionException, MusicCastGroupException):
547 await self._set_player_unavailable()
548 return
549 except ClientError:
550 return
551 await self.set_dynamic_attributes()
552
553 def _non_async_udp_callback(self, physical_device: MusicCastPhysicalDevice) -> None:
554 """Call on UDP updates."""
555 self.mass.loop.create_task(self._async_udp_callback())
556
557 async def _async_udp_callback(self) -> None:
558 async with self.update_lock:
559 await self.set_dynamic_attributes()
560
561 async def power(self, powered: bool) -> None:
562 """Power command."""
563 if powered:
564 await self._cmd_run(self.zone_device.turn_on)
565 else:
566 await self._cmd_run(self.zone_device.turn_off)
567
568 async def volume_set(self, volume_level: int) -> None:
569 """Volume set command."""
570 await self._cmd_run(self.zone_device.volume_set, volume_level)
571
572 async def volume_mute(self, muted: bool) -> None:
573 """Volume mute command."""
574 await self._cmd_run(self.zone_device.volume_mute, muted)
575
576 async def play(self) -> None:
577 """Play command."""
578 if self.upnp_update_helper is not None and self.upnp_update_helper.controlled_by_mass:
579 await avt_play(self.mass.http_session, self.physical_device)
580 else:
581 await self._cmd_run(self.zone_device.play)
582
583 async def stop(self) -> None:
584 """Stop command."""
585 if self.upnp_update_helper is not None and self.upnp_update_helper.controlled_by_mass:
586 await avt_stop(self.mass.http_session, self.physical_device)
587 else:
588 await self._cmd_run(self.zone_device.stop)
589
590 async def pause(self) -> None:
591 """Pause command."""
592 if self.upnp_update_helper is not None and self.upnp_update_helper.controlled_by_mass:
593 # if we are controlled by MA, i.e. upnp, send a stop, since
594 # pause appears to be unreliable/ not working
595 await avt_stop(self.mass.http_session, self.physical_device)
596 else:
597 await self._cmd_run(self.zone_device.pause)
598
599 async def next_track(self) -> None:
600 """Next command."""
601 if self.upnp_update_helper is not None and self.upnp_update_helper.controlled_by_mass:
602 await avt_next(self.mass.http_session, self.physical_device)
603 else:
604 await self._cmd_run(self.zone_device.next_track)
605
606 async def previous_track(self) -> None:
607 """Previous command."""
608 if self.upnp_update_helper is not None and self.upnp_update_helper.controlled_by_mass:
609 await avt_previous(self.mass.http_session, self.physical_device)
610 else:
611 await self._cmd_run(self.zone_device.previous_track)
612
613 async def play_media(self, media: PlayerMedia) -> None:
614 """Play media command."""
615 if len(self.physical_device.zone_devices) > 1:
616 # zone handling
617 # only a single zone may have netusb capability
618 for zone_name, dev in self.physical_device.zone_devices.items():
619 if zone_name == self.zone_device.zone_name:
620 continue
621 if dev.is_netusb:
622 await self._handle_zone_grouping(dev)
623 async with self.update_lock:
624 # just in case
625 if self.zone_device.source_id != "server":
626 await self.select_source("server")
627 await avt_set_url(self.mass.http_session, self.physical_device, player_media=media)
628 await avt_play(self.mass.http_session, self.physical_device)
629
630 self.upnp_update_helper = UpnpUpdateHelper(
631 last_poll=time.time(),
632 controlled_by_mass=True,
633 current_uri=media.uri,
634 )
635
636 async def enqueue_next_media(self, media: PlayerMedia) -> None:
637 """Enqueue next command."""
638 await avt_set_url(
639 self.mass.http_session,
640 self.physical_device,
641 player_media=media,
642 enqueue=True,
643 )
644
645 async def select_source(self, source: str) -> None:
646 """Select source command."""
647 await self._cmd_run(self.zone_device.select_source, source)
648
649 async def select_sound_mode(self, sound_mode: str) -> None:
650 """Select sound Mode Command."""
651 await self._cmd_run(self.zone_device.select_sound_mode, sound_mode)
652
653 async def set_option(self, option_key: str, option_value: PlayerOptionValueType) -> None:
654 """Set player option."""
655 if self.zone_device.zone_data is None:
656 return
657 for capability in cast(
658 "list[MC_CAPABILITIES]",
659 self.zone_device.zone_data.capabilities,
660 ):
661 if str(capability.id) != option_key:
662 continue
663 if not isinstance(capability, MCBinarySetter | MCNumberSetter | MCOptionSetter):
664 self.logger.error(f"Option {capability.name} is read only!")
665 return
666 if isinstance(capability, MCBinarySetter):
667 await capability.set(bool(option_value))
668 elif isinstance(capability, MCNumberSetter):
669 min_value = capability.value_range.minimum
670 max_value = capability.value_range.maximum
671 if not min_value <= int(option_value) <= max_value:
672 self.logger.error(
673 f"Option {capability.name} has numeric range of"
674 f"{min_value} <= value <= {max_value}"
675 )
676 return
677 await capability.set(int(option_value))
678 elif isinstance(capability, MCOptionSetter):
679 assert isinstance(option_value, str | int) # for type checking
680 _option_value = option_value # we may have an int in aiomusiccast as key
681 with suppress(ValueError):
682 _option_value = int(_option_value)
683 if _option_value not in capability.options:
684 self.logger.error(f"Option {_option_value} is not allowed for {option_key}")
685 return
686 await capability.set(_option_value)
687 break
688
689 async def ungroup(self) -> None:
690 """Ungroup command."""
691 if self.zone_device.zone_name.startswith("zone"):
692 # We are are zone.
693 # We do not leave an MC group, but just change our source.
694 await self._handle_zone_grouping(self.zone_device)
695 return
696 await self._cmd_run(self.zone_device.unjoin_player)
697
698 async def set_members(
699 self,
700 player_ids_to_add: list[str] | None = None,
701 player_ids_to_remove: list[str] | None = None,
702 ) -> None:
703 """Set multiple members.
704
705 This function is called on the server.
706 """
707 # Removing players
708 if player_ids_to_remove:
709 for player_id in player_ids_to_remove:
710 if player := self.mass.players.get(player_id):
711 assert isinstance(player, MusicCastPlayer) # for type checking
712 await player.ungroup()
713
714 # Adding players
715 if not player_ids_to_add:
716 return
717 children: set[str] = set() # set[ma_player_id]
718 children_zones: list[str] = [] # list[ma_player_id]
719 player_ids_to_add = [] if player_ids_to_add is None else player_ids_to_add
720 for child_id in player_ids_to_add:
721 if child_player := self.mass.players.get(child_id):
722 assert isinstance(child_player, MusicCastPlayer) # for type checking
723 _other_zone_mc: MusicCastZoneDevice | None = None
724 for x in child_player.zone_device.other_zones:
725 if x.is_netusb:
726 _other_zone_mc = x
727 if _other_zone_mc and _other_zone_mc != child_player.zone_device:
728 # of the same device, we use main_sync as input
729 if _other_zone_mc.zone_name == "main":
730 children_zones.append(child_id)
731 else:
732 self.logger.warning(
733 "It is impossible to join as a normal zone to another zone of the same "
734 "device. Only joining to main is possible. Please refer to the docs."
735 )
736 else:
737 children.add(child_id)
738
739 for child_id in children_zones:
740 child_player = self.mass.players.get(child_id)
741 if TYPE_CHECKING:
742 child_player = cast("MusicCastPlayer", child_player)
743 if child_player.zone_device.state == MusicCastPlayerState.OFF:
744 await child_player.power(powered=True)
745 await child_player.select_source(MC_SOURCE_MAIN_SYNC)
746 if not children:
747 return
748
749 child_player_zone_devices: list[MusicCastZoneDevice] = []
750 for child_id in children:
751 child_player = self.mass.players.get(child_id)
752 if TYPE_CHECKING:
753 child_player = cast("MusicCastPlayer", child_player)
754 child_player_zone_devices.append(child_player.zone_device)
755
756 await self._cmd_run(self.zone_device.join_players, child_player_zone_devices)
757
758 async def get_config_entries(
759 self,
760 action: str | None = None,
761 values: dict[str, ConfigValueType] | None = None,
762 ) -> list[ConfigEntry]:
763 """Get player config entries."""
764 base_entries = await super().get_config_entries(action=action, values=values)
765
766 zone_entries: list[ConfigEntry] = []
767 if len(self.physical_device.zone_devices) > 1:
768 source_options: list[ConfigValueOption] = []
769 allowed_sources = self._get_allowed_sources_zone_switch(self.zone_device)
770 for (
771 source_id,
772 source_name,
773 ) in self.zone_device.source_mapping.items():
774 if source_id in allowed_sources:
775 source_options.append(ConfigValueOption(title=source_name, value=source_id))
776 if len(source_options) == 0:
777 # this should never happen
778 self.logger.error(
779 "The player %s has multiple zones, but lacks a non-net source to switch to."
780 " Please report this on github or discord.",
781 self.display_name or self.name,
782 )
783 zone_entries = []
784 else:
785 zone_entries = [
786 ConfigEntry(
787 key=CONF_PLAYER_HANDLE_SOURCE_DISABLED,
788 type=ConfigEntryType.BOOLEAN,
789 label="Disable zone handling completely.",
790 default_value=False,
791 description="This disables zone handling completely. Other options "
792 "will be ignored. Enable should you encounter playback issues while "
793 "e.g. playing to main. You can also hide the player from the UI "
794 "by taking advantage of 'Hide the player in the user interface' "
795 "dropdown.",
796 ),
797 ConfigEntry(
798 key=CONF_PLAYER_SWITCH_SOURCE_NON_NET,
799 label="Switch to this non-net source when leaving a group.",
800 type=ConfigEntryType.STRING,
801 options=source_options,
802 default_value=source_options[0].value,
803 description="The zone will switch to this source when leaving a group."
804 " It must be an input which doesn't require network connectivity.",
805 ),
806 ConfigEntry(
807 key=CONF_PLAYER_TURN_OFF_ON_LEAVE,
808 type=ConfigEntryType.BOOLEAN,
809 label="Turn off the zone when it leaves a group.",
810 default_value=False,
811 description="Turn off the zone when it leaves a group.",
812 ),
813 ]
814
815 return base_entries + zone_entries + PLAYER_CONFIG_ENTRIES
816