1 """Support to interface with Sonos players."""
3 from __future__
import annotations
6 from functools
import partial
10 from soco
import SoCo, alarms
11 from soco.core
import (
17 from soco.data_structures
import DidlFavorite, DidlMusicTrack
18 from soco.ms_data_structures
import MusicServiceItem
19 from sonos_websocket.exception
import SonosWebsocketError
20 import voluptuous
as vol
25 ATTR_MEDIA_ALBUM_NAME,
28 ATTR_MEDIA_CONTENT_ID,
32 MediaPlayerDeviceClass,
35 MediaPlayerEntityFeature,
39 async_process_play_media_url,
52 from .
import UnjoinData, media_browser
55 DOMAIN
as SONOS_DOMAIN,
61 SONOS_CREATE_MEDIA_PLAYER,
64 SONOS_STATE_TRANSITIONING,
68 from .entity
import SonosEntity
69 from .helpers
import soco_error
70 from .speaker
import SonosMedia, SonosSpeaker
72 _LOGGER = logging.getLogger(__name__)
74 LONG_SERVICE_TIMEOUT = 30.0
75 UNJOIN_SERVICE_TIMEOUT = 0.1
79 RepeatMode.OFF:
False,
81 RepeatMode.ONE:
"ONE",
84 SONOS_TO_REPEAT = {meaning: mode
for mode, meaning
in REPEAT_TO_SONOS.items()}
86 UPNP_ERRORS_TO_IGNORE = [
"701",
"711",
"712"]
87 ANNOUNCE_NOT_SUPPORTED_ERRORS: list[str] = [
"globalError"]
89 SERVICE_SNAPSHOT =
"snapshot"
90 SERVICE_RESTORE =
"restore"
91 SERVICE_SET_TIMER =
"set_sleep_timer"
92 SERVICE_CLEAR_TIMER =
"clear_sleep_timer"
93 SERVICE_UPDATE_ALARM =
"update_alarm"
94 SERVICE_PLAY_QUEUE =
"play_queue"
95 SERVICE_REMOVE_FROM_QUEUE =
"remove_from_queue"
96 SERVICE_GET_QUEUE =
"get_queue"
98 ATTR_SLEEP_TIME =
"sleep_time"
99 ATTR_ALARM_ID =
"alarm_id"
100 ATTR_VOLUME =
"volume"
101 ATTR_ENABLED =
"enabled"
102 ATTR_INCLUDE_LINKED_ZONES =
"include_linked_zones"
103 ATTR_MASTER =
"master"
104 ATTR_WITH_GROUP =
"with_group"
105 ATTR_QUEUE_POSITION =
"queue_position"
110 config_entry: ConfigEntry,
111 async_add_entities: AddEntitiesCallback,
113 """Set up Sonos from a config entry."""
114 platform = entity_platform.async_get_current_platform()
117 def async_create_entities(speaker: SonosSpeaker) ->
None:
118 """Handle device discovery and create entities."""
119 _LOGGER.debug(
"Creating media_player on %s", speaker.zone_name)
122 @service.verify_domain_control(hass, SONOS_DOMAIN)
123 async
def async_service_handle(service_call: ServiceCall) ->
None:
124 """Handle dispatched services."""
125 assert platform
is not None
126 entities = await platform.async_extract_from_service(service_call)
132 for entity
in entities:
133 assert isinstance(entity, SonosMediaPlayerEntity)
134 speakers.append(entity.speaker)
136 if service_call.service == SERVICE_SNAPSHOT:
137 await SonosSpeaker.snapshot_multi(
138 hass, speakers, service_call.data[ATTR_WITH_GROUP]
140 elif service_call.service == SERVICE_RESTORE:
141 await SonosSpeaker.restore_multi(
142 hass, speakers, service_call.data[ATTR_WITH_GROUP]
145 config_entry.async_on_unload(
149 join_unjoin_schema = cv.make_entity_service_schema(
150 {vol.Optional(ATTR_WITH_GROUP, default=
True): cv.boolean}
153 hass.services.async_register(
154 SONOS_DOMAIN, SERVICE_SNAPSHOT, async_service_handle, join_unjoin_schema
157 hass.services.async_register(
158 SONOS_DOMAIN, SERVICE_RESTORE, async_service_handle, join_unjoin_schema
161 platform.async_register_entity_service(
164 vol.Required(ATTR_SLEEP_TIME): vol.All(
165 vol.Coerce(int), vol.Range(min=0, max=86399)
171 platform.async_register_entity_service(
172 SERVICE_CLEAR_TIMER,
None,
"clear_sleep_timer"
175 platform.async_register_entity_service(
176 SERVICE_UPDATE_ALARM,
178 vol.Required(ATTR_ALARM_ID): cv.positive_int,
179 vol.Optional(ATTR_TIME): cv.time,
180 vol.Optional(ATTR_VOLUME): cv.small_float,
181 vol.Optional(ATTR_ENABLED): cv.boolean,
182 vol.Optional(ATTR_INCLUDE_LINKED_ZONES): cv.boolean,
187 platform.async_register_entity_service(
189 {vol.Optional(ATTR_QUEUE_POSITION): cv.positive_int},
193 platform.async_register_entity_service(
194 SERVICE_REMOVE_FROM_QUEUE,
195 {vol.Optional(ATTR_QUEUE_POSITION): cv.positive_int},
199 platform.async_register_entity_service(
203 supports_response=SupportsResponse.ONLY,
208 """Representation of a Sonos entity."""
211 _attr_supported_features = (
212 MediaPlayerEntityFeature.BROWSE_MEDIA
213 | MediaPlayerEntityFeature.CLEAR_PLAYLIST
214 | MediaPlayerEntityFeature.GROUPING
215 | MediaPlayerEntityFeature.MEDIA_ANNOUNCE
216 | MediaPlayerEntityFeature.MEDIA_ENQUEUE
217 | MediaPlayerEntityFeature.NEXT_TRACK
218 | MediaPlayerEntityFeature.PAUSE
219 | MediaPlayerEntityFeature.PLAY
220 | MediaPlayerEntityFeature.PLAY_MEDIA
221 | MediaPlayerEntityFeature.PREVIOUS_TRACK
222 | MediaPlayerEntityFeature.REPEAT_SET
223 | MediaPlayerEntityFeature.SEEK
224 | MediaPlayerEntityFeature.SELECT_SOURCE
225 | MediaPlayerEntityFeature.SHUFFLE_SET
226 | MediaPlayerEntityFeature.STOP
227 | MediaPlayerEntityFeature.VOLUME_MUTE
228 | MediaPlayerEntityFeature.VOLUME_SET
230 _attr_media_content_type = MediaType.MUSIC
231 _attr_device_class = MediaPlayerDeviceClass.SPEAKER
234 """Initialize the media player entity."""
239 """Handle common setup when added to hass."""
251 """Write media state if the provided UID is coordinator of this speaker."""
257 """Return if the media_player is available."""
261 and self.
mediamedia.playback_status
is not None
266 """Return the current coordinator SonosSpeaker."""
271 """List of entity_ids which are currently grouped together."""
272 return self.
speakerspeaker.sonos_group_entities
275 """Return a hash of self."""
279 def state(self) -> MediaPlayerState:
280 """Return the state of the entity."""
281 if self.
mediamedia.playback_status
in (
288 if self.
mediamedia.title
is None:
289 return MediaPlayerState.IDLE
290 return MediaPlayerState.PAUSED
291 if self.
mediamedia.playback_status
in (
293 SONOS_STATE_TRANSITIONING,
295 return MediaPlayerState.PLAYING
296 return MediaPlayerState.IDLE
299 """Retrieve latest state by polling."""
301 self.
hasshass.data[DATA_SONOS].favorites[self.
speakerspeaker.household_id].async_poll()
303 await self.
hasshass.async_add_executor_job(self.
_update_update)
306 """Retrieve latest state by polling."""
307 self.
speakerspeaker.update_groups()
308 self.
speakerspeaker.update_volume()
309 if self.
speakerspeaker.is_coordinator:
310 self.
mediamedia.poll_media()
314 """Volume level of the media player (0..1)."""
315 return self.
speakerspeaker.volume
and self.
speakerspeaker.volume / 100
319 """Return true if volume is muted."""
320 return self.
speakerspeaker.muted
324 """Shuffling state."""
325 return PLAY_MODES[self.
mediamedia.play_mode][0]
329 """Return current repeat mode."""
330 sonos_repeat = PLAY_MODES[self.
mediamedia.play_mode][1]
331 return SONOS_TO_REPEAT[sonos_repeat]
335 """Return the SonosMedia object from the coordinator speaker."""
340 """Content id of current playing media."""
341 return self.
mediamedia.uri
345 """Duration of current playing media in seconds."""
346 return int(self.
mediamedia.duration)
if self.
mediamedia.duration
else None
350 """Position of current playing media in seconds."""
351 return self.
mediamedia.position
355 """When was the position of the current playing media valid."""
356 return self.
mediamedia.position_updated_at
360 """Image url of current playing media."""
361 return self.
mediamedia.image_url
or None
365 """Channel currently playing."""
366 return self.
mediamedia.channel
or None
370 """Title of playlist currently playing."""
371 return self.
mediamedia.playlist_name
375 """Artist of current playing media, music track only."""
376 return self.
mediamedia.artist
or None
380 """Album name of current playing media, music track only."""
381 return self.
mediamedia.album_name
or None
385 """Title of current playing media."""
386 return self.
mediamedia.title
or None
390 """Name of the current input source."""
391 return self.
mediamedia.source_name
or None
395 """Volume up media player."""
396 self.
socosoco.volume += VOLUME_INCREMENT
400 """Volume down media player."""
401 self.
socosoco.volume -= VOLUME_INCREMENT
405 """Set volume level, range 0..1."""
406 self.
socosoco.volume =
int(volume * 100)
408 @soco_error(UPNP_ERRORS_TO_IGNORE)
410 """Enable/Disable shuffle mode."""
411 sonos_shuffle = shuffle
412 sonos_repeat = PLAY_MODES[self.
mediamedia.play_mode][1]
413 self.
coordinatorcoordinator.soco.play_mode = PLAY_MODE_BY_MEANING[
414 (sonos_shuffle, sonos_repeat)
417 @soco_error(UPNP_ERRORS_TO_IGNORE)
419 """Set repeat mode."""
420 sonos_shuffle = PLAY_MODES[self.
mediamedia.play_mode][0]
421 sonos_repeat = REPEAT_TO_SONOS[repeat]
422 self.
coordinatorcoordinator.soco.play_mode = PLAY_MODE_BY_MEANING[
423 (sonos_shuffle, sonos_repeat)
428 """Mute (true) or unmute (false) media player."""
429 self.
socosoco.mute = mute
433 """Select input source."""
435 if source == SOURCE_LINEIN:
436 soco.switch_to_line_in()
439 if source == SOURCE_TV:
446 """Play a favorite by name."""
447 fav = [fav
for fav
in self.
speakerspeaker.favorites
if fav.title == name]
451 translation_domain=SONOS_DOMAIN,
452 translation_key=
"invalid_favorite",
453 translation_placeholders={
462 """Play a favorite."""
463 uri = favorite.reference.get_uri()
465 if soco.music_source_from_uri(uri)
in [
469 soco.play_uri(uri, title=favorite.title, timeout=LONG_SERVICE_TIMEOUT)
472 soco.add_to_queue(favorite.reference, timeout=LONG_SERVICE_TIMEOUT)
473 soco.play_from_queue(0)
477 """List of available input sources."""
478 model = self.
coordinatorcoordinator.model_name.split()[-1].upper()
479 if model
in MODELS_LINEIN_ONLY:
480 return [SOURCE_LINEIN]
481 if model
in MODELS_TV_ONLY:
483 if model
in MODELS_LINEIN_AND_TV:
484 return [SOURCE_LINEIN, SOURCE_TV]
487 @soco_error(UPNP_ERRORS_TO_IGNORE)
489 """Send play command."""
492 @soco_error(UPNP_ERRORS_TO_IGNORE)
494 """Send stop command."""
497 @soco_error(UPNP_ERRORS_TO_IGNORE)
499 """Send pause command."""
502 @soco_error(UPNP_ERRORS_TO_IGNORE)
504 """Send next track command."""
507 @soco_error(UPNP_ERRORS_TO_IGNORE)
509 """Send next track command."""
512 @soco_error(UPNP_ERRORS_TO_IGNORE)
514 """Send seek command."""
515 self.
coordinatorcoordinator.soco.seek(
str(datetime.timedelta(seconds=
int(position))))
519 """Clear players playlist."""
523 self, media_type: MediaType | str, media_id: str, **kwargs: Any
525 """Send the play_media command to the media player.
527 If media_id is a Plex payload, attempt Plex->Sonos playback.
529 If media_id is an Apple Music, Deezer, Sonos, or Tidal share link,
530 attempt playback using the respective service.
532 If media_type is "playlist", media_id should be a Sonos
533 Playlist name. Otherwise, media_id should be a URI.
537 if media_source.is_media_source_id(media_id):
538 is_radio = media_id.startswith(
"media-source://radio_browser/")
539 media_type = MediaType.MUSIC
540 media = await media_source.async_resolve_media(
545 if kwargs.get(ATTR_MEDIA_ANNOUNCE):
546 volume = kwargs.get(
"extra", {}).
get(
"volume")
547 _LOGGER.debug(
"Playing %s using websocket audioclip", media_id)
549 assert self.
speakerspeaker.websocket
550 response, _ = await self.
speakerspeaker.websocket.play_clip(
554 except SonosWebsocketError
as exc:
556 f
"Error when calling Sonos websocket: {exc}"
558 if response.get(
"success"):
560 if response.get(
"type")
in ANNOUNCE_NOT_SUPPORTED_ERRORS:
564 "Speaker %s does not support announce, media_id %s response %s",
571 translation_domain=SONOS_DOMAIN,
572 translation_key=
"announce_media_error",
573 translation_placeholders={
574 "media_id": media_id,
575 "response": response,
579 if spotify.is_spotify_media_type(media_type):
580 media_type = spotify.resolve_spotify_media_type(media_type)
581 media_id = spotify.spotify_uri_from_media_browser_url(media_id)
583 await self.
hasshass.async_add_executor_job(
584 partial(self.
_play_media_play_media, media_type, media_id, is_radio, **kwargs)
589 self, media_type: MediaType | str, media_id: str, is_radio: bool, **kwargs: Any
591 """Wrap sync calls to async_play_media."""
592 _LOGGER.debug(
"_play_media media_type %s media_id %s", media_type, media_id)
593 enqueue = kwargs.get(ATTR_MEDIA_ENQUEUE, MediaPlayerEnqueue.REPLACE)
595 if media_type ==
"favorite_item_id":
596 favorite = self.
speakerspeaker.favorites.lookup_by_item_id(media_id)
598 raise ValueError(f
"Missing favorite for media_id: {media_id}")
603 if media_id
and media_id.startswith(PLEX_URI_SCHEME):
604 plex_plugin = self.
speakerspeaker.plex_plugin
606 self.
hasshass, media_type, media_id, supports_playqueues=
False
610 if enqueue == MediaPlayerEnqueue.ADD:
611 plex_plugin.add_to_queue(result.media, timeout=LONG_SERVICE_TIMEOUT)
613 MediaPlayerEnqueue.NEXT,
614 MediaPlayerEnqueue.PLAY,
616 pos = (self.
mediamedia.queue_position
or 0) + 1
617 new_pos = plex_plugin.add_to_queue(
618 result.media, position=pos, timeout=LONG_SERVICE_TIMEOUT
620 if enqueue == MediaPlayerEnqueue.PLAY:
621 soco.play_from_queue(new_pos - 1)
622 elif enqueue == MediaPlayerEnqueue.REPLACE:
624 plex_plugin.add_to_queue(result.media, timeout=LONG_SERVICE_TIMEOUT)
625 soco.play_from_queue(0)
628 share_link = self.
coordinatorcoordinator.share_link
629 if share_link.is_share_link(media_id):
630 if enqueue == MediaPlayerEnqueue.ADD:
631 share_link.add_share_link_to_queue(
632 media_id, timeout=LONG_SERVICE_TIMEOUT
635 MediaPlayerEnqueue.NEXT,
636 MediaPlayerEnqueue.PLAY,
638 pos = (self.
mediamedia.queue_position
or 0) + 1
639 new_pos = share_link.add_share_link_to_queue(
640 media_id, position=pos, timeout=LONG_SERVICE_TIMEOUT
642 if enqueue == MediaPlayerEnqueue.PLAY:
643 soco.play_from_queue(new_pos - 1)
644 elif enqueue == MediaPlayerEnqueue.REPLACE:
646 share_link.add_share_link_to_queue(
647 media_id, timeout=LONG_SERVICE_TIMEOUT
649 soco.play_from_queue(0)
650 elif media_type
in {MediaType.MUSIC, MediaType.TRACK}:
654 if enqueue == MediaPlayerEnqueue.ADD:
655 soco.add_uri_to_queue(media_id, timeout=LONG_SERVICE_TIMEOUT)
657 MediaPlayerEnqueue.NEXT,
658 MediaPlayerEnqueue.PLAY,
660 pos = (self.
mediamedia.queue_position
or 0) + 1
661 new_pos = soco.add_uri_to_queue(
662 media_id, position=pos, timeout=LONG_SERVICE_TIMEOUT
664 if enqueue == MediaPlayerEnqueue.PLAY:
665 soco.play_from_queue(new_pos - 1)
666 elif enqueue == MediaPlayerEnqueue.REPLACE:
667 soco.play_uri(media_id, force_radio=is_radio)
668 elif media_type == MediaType.PLAYLIST:
669 if media_id.startswith(
"S:"):
670 playlist = media_browser.get_media(
671 self.
mediamedia.library, media_id, media_type
674 playlists = soco.get_sonos_playlists(complete_result=
True)
675 playlist = next((p
for p
in playlists
if p.title == media_id),
None)
678 translation_domain=SONOS_DOMAIN,
679 translation_key=
"invalid_sonos_playlist",
680 translation_placeholders={
685 soco.add_to_queue(playlist, timeout=LONG_SERVICE_TIMEOUT)
686 soco.play_from_queue(0)
687 elif media_type
in PLAYABLE_MEDIA_TYPES:
688 item = media_browser.get_media(self.
mediamedia.library, media_id, media_type)
691 translation_domain=SONOS_DOMAIN,
692 translation_key=
"invalid_media",
693 translation_placeholders={
694 "media_id": media_id,
700 translation_domain=SONOS_DOMAIN,
701 translation_key=
"invalid_content_type",
702 translation_placeholders={
703 "media_type": media_type,
708 self, soco: SoCo, item: MusicServiceItem, enqueue: MediaPlayerEnqueue
710 """Manage adding, replacing, playing items onto the sonos queue."""
712 "_play_media_queue item_id [%s] title [%s] enqueue [%s]",
717 if enqueue == MediaPlayerEnqueue.REPLACE:
720 if enqueue
in (MediaPlayerEnqueue.ADD, MediaPlayerEnqueue.REPLACE):
721 soco.add_to_queue(item, timeout=LONG_SERVICE_TIMEOUT)
722 if enqueue == MediaPlayerEnqueue.REPLACE:
723 soco.play_from_queue(0)
725 pos = (self.
mediamedia.queue_position
or 0) + 1
726 new_pos = soco.add_to_queue(
727 item, position=pos, timeout=LONG_SERVICE_TIMEOUT
729 if enqueue == MediaPlayerEnqueue.PLAY:
730 soco.play_from_queue(new_pos - 1)
734 """Set the timer on the player."""
735 self.
coordinatorcoordinator.soco.set_sleep_timer(sleep_time)
739 """Clear the timer on the player."""
740 self.
coordinatorcoordinator.soco.set_sleep_timer(
None)
746 time: datetime.datetime |
None =
None,
747 volume: float |
None =
None,
748 enabled: bool |
None =
None,
749 include_linked_zones: bool |
None =
None,
751 """Set the alarm clock on the player."""
752 alarm: alarms.Alarm |
None =
None
753 for one_alarm
in alarms.get_alarms(self.
coordinatorcoordinator.soco):
754 if one_alarm.alarm_id ==
str(alarm_id):
757 _LOGGER.warning(
"Did not find alarm with id %s", alarm_id)
760 alarm.start_time = time
761 if volume
is not None:
762 alarm.volume =
int(volume * 100)
763 if enabled
is not None:
764 alarm.enabled = enabled
765 if include_linked_zones
is not None:
766 alarm.include_linked_zones = include_linked_zones
771 """Start playing the queue."""
772 self.
socosoco.play_from_queue(queue_position)
776 """Remove item from the queue."""
777 self.
coordinatorcoordinator.soco.remove_from_queue(queue_position)
782 queue: list[DidlMusicTrack] = self.
coordinatorcoordinator.soco.get_queue(max_items=0)
785 ATTR_MEDIA_TITLE: getattr(track,
"title",
None),
786 ATTR_MEDIA_ALBUM_NAME: getattr(track,
"album",
None),
787 ATTR_MEDIA_ARTIST: getattr(track,
"creator",
None),
788 ATTR_MEDIA_CONTENT_ID: track.get_uri(),
795 """Return entity specific state attributes."""
796 attributes: dict[str, Any] = {}
798 if self.
mediamedia.queue_position
is not None:
799 attributes[ATTR_QUEUE_POSITION] = self.
mediamedia.queue_position
801 if self.
mediamedia.queue_size:
802 attributes[
"queue_size"] = self.
mediamedia.queue_size
811 media_content_type: MediaType | str,
812 media_content_id: str,
813 media_image_id: str |
None =
None,
814 ) -> tuple[bytes |
None, str |
None]:
815 """Fetch media browser image to serve via proxy."""
817 media_content_type
in {MediaType.ALBUM, MediaType.ARTIST}
820 item = await self.
hasshass.async_add_executor_job(
821 media_browser.get_media,
822 self.
mediamedia.library,
824 MEDIA_TYPES_TO_SONOS[media_content_type],
826 if image_url := getattr(item,
"album_art_uri",
None):
833 media_content_type: MediaType | str |
None =
None,
834 media_content_id: str |
None =
None,
836 """Implement the websocket media browsing helper."""
837 return await media_browser.async_browse_media(
847 """Join `group_members` as a player group with the current player."""
849 for entity_id
in group_members:
850 if speaker := self.
hasshass.data[DATA_SONOS].entity_id_mappings.get(entity_id):
851 speakers.append(speaker)
855 await SonosSpeaker.join_multi(self.
hasshass, self.
speakerspeaker, speakers)
858 """Remove this player from any group.
860 Coalesces all calls within UNJOIN_SERVICE_TIMEOUT to allow use of SonosSpeaker.unjoin_multi()
861 which optimizes the order in which speakers are removed from their groups.
862 Removing coordinators last better preserves playqueues on the speakers.
864 sonos_data = self.
hasshass.data[DATA_SONOS]
865 household_id = self.
speakerspeaker.household_id
867 async
def async_process_unjoin(now: datetime.datetime) ->
None:
868 """Process the unjoin with all remove requests within the coalescing period."""
869 unjoin_data = sonos_data.unjoin_data.pop(household_id)
871 "Processing unjoins for %s", [x.zone_name
for x
in unjoin_data.speakers]
873 await SonosSpeaker.unjoin_multi(self.
hasshass, unjoin_data.speakers)
874 unjoin_data.event.set()
876 if unjoin_data := sonos_data.unjoin_data.get(household_id):
877 unjoin_data.speakers.append(self.
speakerspeaker)
879 unjoin_data = sonos_data.unjoin_data[household_id] = UnjoinData(
884 _LOGGER.debug(
"Requesting unjoin for %s", self.
speakerspeaker.zone_name)
885 await unjoin_data.event.wait()
None async_write_ha_state(self)
None async_on_remove(self, CALLBACK_TYPE func)
web.Response get(self, web.Request request, str config_key)
PlexMediaSearchResult process_plex_payload(HomeAssistant hass, str content_type, str content_id, PlexServer|None default_plex_server=None, bool supports_playqueues=True)
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
CALLBACK_TYPE async_call_later(HomeAssistant hass, float|timedelta delay, HassJob[[datetime], Coroutine[Any, Any, None]|None]|Callable[[datetime], Coroutine[Any, Any, None]|None] action)