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