music-assistant-server

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