/
/
/
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