diff --git a/pyheos/__init__.py b/pyheos/__init__.py index e86ed5c..6b3f223 100644 --- a/pyheos/__init__.py +++ b/pyheos/__init__.py @@ -9,27 +9,69 @@ DisconnectType, Dispatcher, EventCallbackType, + GroupEventCallbackType, PlayerEventCallbackType, SendType, TargetType, ) -from .error import CommandError, CommandFailedError, HeosError +from .error import ( + CommandAuthenticationError, + CommandError, + CommandFailedError, + HeosError, +) from .group import HeosGroup -from .heos import Heos, HeosOptions +from .heos import Heos from .media import ( + AlbumMetadata, BrowseResult, + ImageMetadata, Media, MediaItem, MediaMusicSource, + QueueItem, + RetreiveMetadataResult, + ServiceOption, +) +from .options import HeosOptions +from .player import ( + CONTROLS_ALL, + CONTROLS_FORWARD_ONLY, + CONTROLS_PLAY_STOP, + HeosNowPlayingMedia, + HeosPlayer, + PlayerUpdateResult, + PlayMode, ) -from .player import HeosNowPlayingMedia, HeosPlayer, PlayMode +from .search import MultiSearchResult, SearchCriteria, SearchResult, SearchStatistic from .system import HeosHost, HeosSystem +from .types import ( + AddCriteriaType, + ConnectionState, + ControlType, + LineOutLevelType, + MediaType, + NetworkType, + PlayState, + RepeatType, + SignalHeosEvent, + SignalType, + VolumeControlType, +) __all__ = [ + "AddCriteriaType", + "AlbumMetadata", "BrowseResult", "CallbackType", + "ControlType", + "CommandAuthenticationError", "CommandError", "CommandFailedError", + "CONTROLS_ALL", + "CONTROLS_FORWARD_ONLY", + "CONTROLS_PLAY_STOP", + "ConnectionState", "ConnectType", "const", "ControllerEventCallbackType", @@ -37,19 +79,37 @@ "DisconnectType", "Dispatcher", "EventCallbackType", + "GroupEventCallbackType", "Heos", "HeosError", "HeosGroup", "HeosHost", + "HeosNowPlayingMedia", "HeosOptions", "HeosPlayer", - "HeosNowPlayingMedia", "HeosSystem", + "ImageMetadata", + "LineOutLevelType", "Media", "MediaItem", "MediaMusicSource", - "PlayerEventCallbackType", + "MediaType", + "MultiSearchResult", + "NetworkType", "PlayMode", + "PlayState", + "PlayerEventCallbackType", + "PlayerUpdateResult", + "QueueItem", + "RepeatType", + "RetreiveMetadataResult", + "SearchCriteria", + "SearchResult", + "SearchStatistic", "SendType", + "ServiceOption", + "SignalHeosEvent", + "SignalType", "TargetType", + "VolumeControlType", ] diff --git a/pyheos/command/__init__.py b/pyheos/command/__init__.py index 4d16548..9442f0c 100644 --- a/pyheos/command/__init__.py +++ b/pyheos/command/__init__.py @@ -1,51 +1,187 @@ """Define the HEOS command module.""" -from typing import Final +import logging +from enum import ReprEnum +from typing import Any, Final, TypeVar + +REPORT_ISSUE_TEXT: Final = ( + "Please report this issue at https://github.com/andrewsayre/pyheos/issues" +) + +ATTR_ADD_CRITERIA_ID: Final = "aid" +ATTR_ALBUM: Final = "album" +ATTR_ALBUM_ID: Final = "album_id" +ATTR_ARTIST: Final = "artist" +ATTR_AVAILABLE: Final = "available" +ATTR_COMMAND: Final = "command" +ATTR_CONTAINER: Final = "container" +ATTR_CONTAINER_ID: Final = "cid" +ATTR_CONTROL: Final = "control" +ATTR_COUNT: Final = "count" +ATTR_CURRENT_POSITION: Final = "cur_pos" +ATTR_DESTINATION_QUEUE_ID: Final = "dqid" +ATTR_DURATION: Final = "duration" +ATTR_ENABLE: Final = "enable" +ATTR_ERROR: Final = "error" +ATTR_ERROR_ID: Final = "eid" +ATTR_ERROR_NUMBER: Final = "errno" +ATTR_GROUP_ID: Final = "gid" +ATTR_HEOS: Final = "heos" +ATTR_ID: Final = "id" +ATTR_IMAGE_URL: Final = "image_url" +ATTR_IMAGES: Final = "images" +ATTR_INPUT: Final = "input" +ATTR_IP_ADDRESS: Final = "ip" +ATTR_LEVEL: Final = "level" +ATTR_LINE_OUT: Final = "lineout" +ATTR_MEDIA_ID: Final = "mid" +ATTR_MESSAGE: Final = "message" +ATTR_MODEL: Final = "model" +ATTR_MUTE: Final = "mute" +ATTR_NAME: Final = "name" +ATTR_NETWORK: Final = "network" +ATTR_OPTION_ID: Final = "option" +ATTR_OPTIONS: Final = "options" +ATTR_PASSWORD: Final = "pw" +ATTR_PAYLOAD: Final = "payload" +ATTR_PLAYABLE: Final = "playable" +ATTR_PLAYER_ID: Final = "pid" +ATTR_PLAYERS: Final = "players" +ATTR_PRESET: Final = "preset" +ATTR_QUEUE_ID: Final = "qid" +ATTR_RANGE: Final = "range" +ATTR_REFRESH: Final = "refresh" +ATTR_REPEAT: Final = "repeat" +ATTR_RESULT: Final = "result" +ATTR_RETURNED: Final = "returned" +ATTR_ROLE: Final = "role" +ATTR_SEARCH: Final = "search" +ATTR_SEARCH_CRITERIA_ID: Final = "scid" +ATTR_SERIAL: Final = "serial" +ATTR_SERVICE_USER_NAME: Final = "service_username" +ATTR_SHUFFLE: Final = "shuffle" +ATTR_SIGNED_IN: Final = "signed_in" +ATTR_SIGNED_OUT: Final = "signed_out" +ATTR_SONG: Final = "song" +ATTR_SOURCE_ID: Final = "sid" +ATTR_SOURCE_PLAYER_ID: Final = "spid" +ATTR_SOURCE_QUEUE_ID: Final = "sqid" +ATTR_STATE: Final = "state" +ATTR_STATS: Final = "stats" +ATTR_STATION: Final = "station" +ATTR_STEP: Final = "step" +ATTR_SYSTEM_ERROR_NUMBER: Final = "syserrno" +ATTR_TEXT: Final = "text" +ATTR_TYPE: Final = "type" +ATTR_UPDATE: Final = "update" +ATTR_URL: Final = "url" +ATTR_USER_NAME: Final = "un" +ATTR_VERSION: Final = "version" +ATTR_WIDTH: Final = "width" +ATTR_WILDCARD: Final = "wildcard" + +VALUE_ON: Final = "on" +VALUE_OFF: Final = "off" +VALUE_TRUE: Final = "true" +VALUE_FALSE: Final = "false" +VALUE_YES: Final = "yes" +VALUE_NO: Final = "no" +VALUE_SUCCESS: Final = "success" +VALUE_LEADER: Final = "leader" +VALUE_MEMBER: Final = "member" +VALUE_UPDATE_EXIST: Final = "update_exist" # Browse commands -COMMAND_BROWSE_GET_SOURCES: Final = "browse/get_music_sources" +COMMAND_BROWSE_ADD_TO_QUEUE: Final = "browse/add_to_queue" COMMAND_BROWSE_BROWSE: Final = "browse/browse" +COMMAND_BROWSE_DELETE__PLAYLIST: Final = "browse/delete_playlist" +COMMAND_BROWSE_GET_SEARCH_CRITERIA: Final = "browse/get_search_criteria" +COMMAND_BROWSE_GET_SOURCE_INFO: Final = "browse/get_source_info" +COMMAND_BROWSE_GET_SOURCES: Final = "browse/get_music_sources" +COMMAND_BROWSE_MULTI_SEARCH: Final = "browse/multi_search" COMMAND_BROWSE_PLAY_INPUT: Final = "browse/play_input" COMMAND_BROWSE_PLAY_PRESET: Final = "browse/play_preset" COMMAND_BROWSE_PLAY_STREAM: Final = "browse/play_stream" -COMMAND_BROWSE_ADD_TO_QUEUE: Final = "browse/add_to_queue" +COMMAND_BROWSE_RENAME_PLAYLIST: Final = "browse/rename_playlist" +COMMAND_BROWSE_RETRIEVE_METADATA: Final = "browse/retrieve_metadata" +COMMAND_BROWSE_SEARCH: Final = "browse/search" +COMMAND_BROWSE_SET_SERVICE_OPTION: Final = "browse/set_service_option" # Player commands -COMMAND_GET_PLAYERS: Final = "player/get_players" -COMMAND_GET_PLAY_STATE: Final = "player/get_play_state" -COMMAND_SET_PLAY_STATE: Final = "player/set_play_state" -COMMAND_GET_NOW_PLAYING_MEDIA: Final = "player/get_now_playing_media" -COMMAND_GET_VOLUME: Final = "player/get_volume" -COMMAND_SET_VOLUME: Final = "player/set_volume" +COMMAND_CHECK_UPDATE: Final = "player/check_update" +COMMAND_CLEAR_QUEUE: Final = "player/clear_queue" COMMAND_GET_MUTE: Final = "player/get_mute" -COMMAND_SET_MUTE: Final = "player/set_mute" -COMMAND_VOLUME_UP: Final = "player/volume_up" -COMMAND_VOLUME_DOWN: Final = "player/volume_down" -COMMAND_TOGGLE_MUTE: Final = "player/toggle_mute" +COMMAND_GET_NOW_PLAYING_MEDIA: Final = "player/get_now_playing_media" COMMAND_GET_PLAY_MODE: Final = "player/get_play_mode" -COMMAND_SET_PLAY_MODE: Final = "player/set_play_mode" -COMMAND_CLEAR_QUEUE: Final = "player/clear_queue" +COMMAND_GET_PLAY_STATE: Final = "player/get_play_state" +COMMAND_GET_PLAYER_INFO: Final = "player/get_player_info" +COMMAND_GET_PLAYERS: Final = "player/get_players" +COMMAND_GET_QUEUE: Final = "player/get_queue" +COMMAND_GET_QUICK_SELECTS: Final = "player/get_quickselects" +COMMAND_GET_VOLUME: Final = "player/get_volume" +COMMAND_MOVE_QUEUE_ITEM: Final = "player/move_queue_item" COMMAND_PLAY_NEXT: Final = "player/play_next" COMMAND_PLAY_PREVIOUS: Final = "player/play_previous" +COMMAND_PLAY_QUEUE: Final = "player/play_queue" COMMAND_PLAY_QUICK_SELECT: Final = "player/play_quickselect" +COMMAND_REMOVE_FROM_QUEUE: Final = "player/remove_from_queue" +COMMAND_SAVE_QUEUE: Final = "player/save_queue" +COMMAND_SET_MUTE: Final = "player/set_mute" +COMMAND_SET_PLAY_MODE: Final = "player/set_play_mode" +COMMAND_SET_PLAY_STATE: Final = "player/set_play_state" COMMAND_SET_QUICK_SELECT: Final = "player/set_quickselect" -COMMAND_GET_QUICK_SELECTS: Final = "player/get_quickselects" +COMMAND_SET_VOLUME: Final = "player/set_volume" +COMMAND_TOGGLE_MUTE: Final = "player/toggle_mute" +COMMAND_VOLUME_DOWN: Final = "player/volume_down" +COMMAND_VOLUME_UP: Final = "player/volume_up" # Group commands -COMMAND_GET_GROUPS: Final = "group/get_groups" -COMMAND_SET_GROUP: Final = "group/set_group" -COMMAND_GET_GROUP_VOLUME: Final = "group/get_volume" -COMMAND_SET_GROUP_VOLUME: Final = "group/set_volume" +COMMAND_GET_GROUP_INFO: Final = "group/get_group_info" COMMAND_GET_GROUP_MUTE: Final = "group/get_mute" -COMMAND_SET_GROUP_MUTE: Final = "group/set_mute" +COMMAND_GET_GROUP_VOLUME: Final = "group/get_volume" +COMMAND_GET_GROUPS: Final = "group/get_groups" COMMAND_GROUP_TOGGLE_MUTE: Final = "group/toggle_mute" -COMMAND_GROUP_VOLUME_UP: Final = "group/volume_up" COMMAND_GROUP_VOLUME_DOWN: Final = "group/volume_down" +COMMAND_GROUP_VOLUME_UP: Final = "group/volume_up" +COMMAND_SET_GROUP: Final = "group/set_group" +COMMAND_SET_GROUP_MUTE: Final = "group/set_mute" +COMMAND_SET_GROUP_VOLUME: Final = "group/set_volume" # System commands -COMMAND_REGISTER_FOR_CHANGE_EVENTS: Final = "system/register_for_change_events" -COMMAND_HEART_BEAT: Final = "system/heart_beat" COMMAND_ACCOUNT_CHECK: Final = "system/check_account" +COMMAND_HEART_BEAT: Final = "system/heart_beat" +COMMAND_REGISTER_FOR_CHANGE_EVENTS: Final = "system/register_for_change_events" COMMAND_REBOOT: Final = "system/reboot" COMMAND_SIGN_IN: Final = "system/sign_in" COMMAND_SIGN_OUT: Final = "system/sign_out" + +_LOGGER: Final = logging.getLogger(__name__) + +TEnum = TypeVar("TEnum", bound=ReprEnum) + + +def optional_int(value: str | None) -> int | None: + if value is not None: + return int(value) + return None + + +def parse_enum( + key: str, data: dict[str, Any], enum_type: type[TEnum], default: TEnum +) -> TEnum: + """Parse an enum value from the provided data. This is a safe operation that will return the default value if the key is missing or the value is not recognized.""" + value = data.get(key) + if value is None: + return default + try: + return enum_type(value) + except ValueError: + _LOGGER.warning( + "Unrecognized '%s' value: '%s', using default value: '%s'. Full data: %s. %s", + key, + value, + default, + data, + REPORT_ISSUE_TEXT, + ) + return default diff --git a/pyheos/command/browse.py b/pyheos/command/browse.py index 4a2bf37..92538ec 100644 --- a/pyheos/command/browse.py +++ b/pyheos/command/browse.py @@ -3,154 +3,712 @@ This module creates HEOS browse commands. -Commands not currently implemented: - 4.4.2 Get Source Info - 4.4.5 Get Source Search Criteria - 4.4.6 Search - 4.4.14 Rename HEOS Playlist - 4.4.15 Delete HEOS Playlist - 4.4.17 Retrieve Album Metadata - 4.4.19 Set service option - 4.4.20 Universal Search (Multi-Search) - - +Not implemented (commands do not exist/obsolete): + 4.4.13 Get HEOS Playlists: Refer to Browse Sources and Browse Source Containers + 4.4.16 Get HEOS History: Refer to Browse Sources and Browse Source Containers + 4.4.18 Get Service Options for now playing screen: OBSOLETE """ -from typing import Any +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, cast -from pyheos import command, const +from pyheos import command as c +from pyheos.command.connection import ConnectionMixin +from pyheos.const import ( + MUSIC_SOURCE_AUX_INPUT, + MUSIC_SOURCE_FAVORITES, + MUSIC_SOURCE_PLAYLISTS, + SEARCHED_TRACKS, + SERVICE_OPTION_ADD_ALBUM_TO_LIBRARY, + SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, + SERVICE_OPTION_ADD_STATION_TO_LIBRARY, + SERVICE_OPTION_ADD_TO_FAVORITES, + SERVICE_OPTION_ADD_TRACK_TO_LIBRARY, + SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + SERVICE_OPTION_REMOVE_ALBUM_FROM_LIBRARY, + SERVICE_OPTION_REMOVE_FROM_FAVORITES, + SERVICE_OPTION_REMOVE_PLAYLIST_FROM_LIBRARY, + SERVICE_OPTION_REMOVE_STATION_FROM_LIBRARY, + SERVICE_OPTION_REMOVE_TRACK_FROM_LIBRARY, + SERVICE_OPTION_THUMBS_DOWN, + SERVICE_OPTION_THUMBS_UP, + VALID_INPUTS, +) +from pyheos.media import ( + BrowseResult, + MediaItem, + MediaMusicSource, + RetreiveMetadataResult, +) from pyheos.message import HeosCommand +from pyheos.search import MultiSearchResult, SearchCriteria, SearchResult +from pyheos.types import AddCriteriaType, MediaType + +if TYPE_CHECKING: + from pyheos.heos import Heos + + +class BrowseCommands(ConnectionMixin): + """A mixin to provide access to the browse commands.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Init a new instance of the BrowseMixin.""" + super(BrowseCommands, self).__init__(*args, **kwargs) + + self._music_sources: dict[int, MediaMusicSource] = {} + self._music_sources_loaded = False + + @property + def music_sources(self) -> dict[int, MediaMusicSource]: + """Get available music sources.""" + return self._music_sources + + async def get_music_sources( + self, refresh: bool = False + ) -> dict[int, MediaMusicSource]: + """ + Get available music sources. + + References: + 4.4.1 Get Music Sources + """ + if not self._music_sources_loaded or refresh: + params = {} + if refresh: + params[c.ATTR_REFRESH] = c.VALUE_ON + message = await self._connection.command( + HeosCommand(c.COMMAND_BROWSE_GET_SOURCES, params) + ) + self._music_sources.clear() + for data in cast(Sequence[dict], message.payload): + source = MediaMusicSource.from_data(data, cast("Heos", self)) + self._music_sources[source.source_id] = source + self._music_sources_loaded = True + return self._music_sources + + async def get_music_source_info( + self, + source_id: int | None = None, + music_source: MediaMusicSource | None = None, + *, + refresh: bool = False, + ) -> MediaMusicSource: + """ + Get information about a specific music source. + + References: + 4.4.2 Get Source Info + """ + if source_id is None and music_source is None: + raise ValueError("Either source_id or music_source must be provided") + if source_id is not None and music_source is not None: + raise ValueError("Only one of source_id or music_source should be provided") + # if only source_id provided, try getting from loaded + if music_source is None: + assert source_id is not None + music_source = self._music_sources.get(source_id) + else: + source_id = music_source.source_id -class BrowseCommands: - """Define functions for creating browse commands.""" + if music_source is None or refresh: + # Get the latest information + result = await self._connection.command( + HeosCommand( + c.COMMAND_BROWSE_GET_SOURCE_INFO, {c.ATTR_SOURCE_ID: source_id} + ) + ) + payload = cast(dict[str, Any], result.payload) + if music_source is None: + music_source = MediaMusicSource.from_data(payload, cast("Heos", self)) + else: + music_source._update_from_data(payload) + return music_source - @staticmethod - def browse( + async def browse( + self, source_id: int, container_id: str | None = None, range_start: int | None = None, range_end: int | None = None, - ) -> HeosCommand: - """Create a HEOS command to browse the provided source. + ) -> BrowseResult: + """Browse the contents of the specified source or container. References: 4.4.3 Browse Source 4.4.4 Browse Source Containers 4.4.13 Get HEOS Playlists 4.4.16 Get HEOS History + + Args: + source_id: The identifier of the source to browse. + container_id: The identifier of the container to browse. If not provided, the root of the source will be expanded. + range_start: The index of the first item to return. Both range_start and range_end must be provided to return a range of items. + range_end: The index of the last item to return. Both range_start and range_end must be provided to return a range of items. + Returns: + A BrowseResult instance containing the items in the source or container. """ - params: dict[str, Any] = {const.ATTR_SOURCE_ID: source_id} + params: dict[str, Any] = {c.ATTR_SOURCE_ID: source_id} if container_id: - params[const.ATTR_CONTAINER_ID] = container_id + params[c.ATTR_CONTAINER_ID] = container_id if isinstance(range_start, int) and isinstance(range_end, int): - params[const.ATTR_RANGE] = f"{range_start},{range_end}" - return HeosCommand(command.COMMAND_BROWSE_BROWSE, params) + params[c.ATTR_RANGE] = f"{range_start},{range_end}" + message = await self._connection.command( + HeosCommand(c.COMMAND_BROWSE_BROWSE, params) + ) + return BrowseResult._from_message(message, cast("Heos", self)) - @staticmethod - def get_music_sources(refresh: bool = False) -> HeosCommand: + async def browse_media( + self, + media: MediaItem | MediaMusicSource, + range_start: int | None = None, + range_end: int | None = None, + ) -> BrowseResult: + """Browse the contents of the specified media item. + + References: + 4.4.3 Browse Source + 4.4.4 Browse Source Containers + 4.4.13 Get HEOS Playlists + 4.4.16 Get HEOS History + + Args: + media: The media item to browse, must be of type MediaItem or MediaMusicSource. + range_start: The index of the first item to return. Both range_start and range_end must be provided to return a range of items. + range_end: The index of the last item to return. Both range_start and range_end must be provided to return a range of items. + Returns: + A BrowseResult instance containing the items in the media item. """ - Create a HEOS command to get the music sources. + if isinstance(media, MediaMusicSource): + if not media.available: + raise ValueError("Source is not available to browse") + return await self.browse(media.source_id) + else: + if not media.browsable: + raise ValueError("Only media sources and containers can be browsed") + return await self.browse( + media.source_id, media.container_id, range_start, range_end + ) + + async def get_search_criteria(self, source_id: int) -> list[SearchCriteria]: + """ + Create a HEOS command to get the search criteria. References: - 4.4.1 Get Music Sources + 4.4.5 Get Search Criteria """ - params = {} - if refresh: - params[const.ATTR_REFRESH] = const.VALUE_ON - return HeosCommand(command.COMMAND_BROWSE_GET_SOURCES, params) + result = await self._connection.command( + HeosCommand( + c.COMMAND_BROWSE_GET_SEARCH_CRITERIA, + {c.ATTR_SOURCE_ID: source_id}, + ) + ) + payload = cast(list[dict[str, str]], result.payload) + return [SearchCriteria._from_data(data) for data in payload] - @staticmethod - def play_station( - player_id: int, + async def search( + self, source_id: int, - container_id: str | None, - media_id: str, - ) -> HeosCommand: + search: str, + criteria_id: int, + range_start: int | None = None, + range_end: int | None = None, + ) -> SearchResult: """ - Create a HEOS command to play a station. + Create a HEOS command to search for media. References: - 4.4.7 Play Station + 4.4.6 Search""" + if search == "": + raise ValueError("'search' parameter must not be empty") + if len(search) > 128: + raise ValueError( + "'search' parameter must be less than or equal to 128 characters" + ) + params = { + c.ATTR_SOURCE_ID: source_id, + c.ATTR_SEARCH: search, + c.ATTR_SEARCH_CRITERIA_ID: criteria_id, + } + if isinstance(range_start, int) and isinstance(range_end, int): + params[c.ATTR_RANGE] = f"{range_start},{range_end}" + result = await self._connection.command( + HeosCommand(c.COMMAND_BROWSE_SEARCH, params) + ) + return SearchResult._from_message(result, cast("Heos", self)) - Note: Parameters 'cid' and 'name' do not appear to be required in testing, however send 'cid' if provided. + async def play_input_source( + self, player_id: int, input_name: str, source_player_id: int | None = None + ) -> None: + """ + Play the specified input source on the specified player. + + References: + 4.4.9 Play Input Source + + Args: + player_id: The identifier of the player to play the input source. + input: The input source to play. + source_player_id: The identifier of the player that has the input source, if different than the player_id. """ params = { - const.ATTR_PLAYER_ID: player_id, - const.ATTR_SOURCE_ID: source_id, - const.ATTR_MEDIA_ID: media_id, + c.ATTR_PLAYER_ID: player_id, + c.ATTR_INPUT: input_name, } - if container_id is not None: - params[const.ATTR_CONTAINER_ID] = container_id - return HeosCommand(command.COMMAND_BROWSE_PLAY_STREAM, params) + if source_player_id is not None: + params[c.ATTR_SOURCE_PLAYER_ID] = source_player_id + await self._connection.command(HeosCommand(c.COMMAND_BROWSE_PLAY_INPUT, params)) - @staticmethod - def play_preset_station(player_id: int, preset: int) -> HeosCommand: + async def play_station( + self, player_id: int, source_id: int, container_id: str | None, media_id: str + ) -> None: """ - Create a HEOS command to play a preset station. + Play the specified station on the specified player. References: - 4.4.8 Play Preset Station + 4.4.7 Play Station + + Args: + player_id: The identifier of the player to play the station. + source_id: The identifier of the source containing the station. + container_id: The identifier of the container containing the station. + media_id: The identifier of the station to play. """ - if preset < 1: - raise ValueError(f"Invalid preset: {preset}") - return HeosCommand( - command.COMMAND_BROWSE_PLAY_PRESET, - {const.ATTR_PLAYER_ID: player_id, const.ATTR_PRESET: preset}, + params = { + c.ATTR_PLAYER_ID: player_id, + c.ATTR_SOURCE_ID: source_id, + c.ATTR_MEDIA_ID: media_id, + } + if container_id is not None: + params[c.ATTR_CONTAINER_ID] = container_id + await self._connection.command( + HeosCommand(c.COMMAND_BROWSE_PLAY_STREAM, params) ) - @staticmethod - def play_input_source( - player_id: int, input_name: str, source_player_id: int | None = None - ) -> HeosCommand: + async def play_preset_station(self, player_id: int, index: int) -> None: """ - Create a HEOS command to play the specified input source. + Play the preset station on the specified player (favorite) References: - 4.4.9 Play Input Source + 4.4.8 Play Preset Station + + Args: + player_id: The identifier of the player to play the preset station. + index: The index of the preset station to play. """ - params = { - const.ATTR_PLAYER_ID: player_id, - const.ATTR_INPUT: input_name, - } - if source_player_id is not None: - params[const.ATTR_SOURCE_PLAYER_ID] = source_player_id - return HeosCommand(command.COMMAND_BROWSE_PLAY_INPUT, params) + if index < 1: + raise ValueError(f"Invalid preset: {index}") + await self._connection.command( + HeosCommand( + c.COMMAND_BROWSE_PLAY_PRESET, + {c.ATTR_PLAYER_ID: player_id, c.ATTR_PRESET: index}, + ) + ) - @staticmethod - def play_url(player_id: int, url: str) -> HeosCommand: + async def play_url(self, player_id: int, url: str) -> None: """ - Create a HEOS command to play the specified URL. + Play the specified URL on the specified player. References: 4.4.10 Play URL + + Args: + player_id: The identifier of the player to play the URL. + url: The URL to play. """ - return HeosCommand( - command.COMMAND_BROWSE_PLAY_STREAM, - {const.ATTR_PLAYER_ID: player_id, const.ATTR_URL: url}, + await self._connection.command( + HeosCommand( + c.COMMAND_BROWSE_PLAY_STREAM, + {c.ATTR_PLAYER_ID: player_id, c.ATTR_URL: url}, + ) ) - @staticmethod - def add_to_queue( + async def add_to_queue( + self, player_id: int, source_id: int, container_id: str, media_id: str | None = None, - add_criteria: const.AddCriteriaType = const.AddCriteriaType.PLAY_NOW, - ) -> HeosCommand: + add_criteria: AddCriteriaType = AddCriteriaType.PLAY_NOW, + ) -> None: """ - Create a HEOS command to add the specified media to the queue. + Add the specified media item to the queue of the specified player. References: 4.4.11 Add Container to Queue with Options 4.4.12 Add Track to Queue with Options + + Args: + player_id: The identifier of the player to add the media item. + source_id: The identifier of the source containing the media item. + container_id: The identifier of the container containing the media item. + media_id: The identifier of the media item to add. Required for MediaType.Song. + add_criteria: Determines how tracks are added to the queue. The default is AddCriteriaType.PLAY_NOW. """ params = { - const.ATTR_PLAYER_ID: player_id, - const.ATTR_SOURCE_ID: source_id, - const.ATTR_CONTAINER_ID: container_id, - const.ATTR_ADD_CRITERIA_ID: add_criteria, + c.ATTR_PLAYER_ID: player_id, + c.ATTR_SOURCE_ID: source_id, + c.ATTR_CONTAINER_ID: container_id, + c.ATTR_ADD_CRITERIA_ID: add_criteria, } if media_id is not None: - params[const.ATTR_MEDIA_ID] = media_id - return HeosCommand(command.COMMAND_BROWSE_ADD_TO_QUEUE, params) + params[c.ATTR_MEDIA_ID] = media_id + await self._connection.command( + HeosCommand(c.COMMAND_BROWSE_ADD_TO_QUEUE, params) + ) + + async def add_search_to_queue( + self, + player_id: int, + source_id: int, + search: str, + criteria_container_id: str = SEARCHED_TRACKS, + add_criteria: AddCriteriaType = AddCriteriaType.PLAY_NOW, + ) -> None: + """Add searched tracks to the queue of the specified player. + + References: + 4.4.11 Add Container to Queue with Options + + Args: + player_id: The identifier of the player to add the search results. + source_id: The identifier of the source to search. + search: The search string. + criteria_container_id: the criteria container id prefix. + add_criteria: Determines how tracks are added to the queue. The default is AddCriteriaType.PLAY_NOW. + """ + await self.add_to_queue( + player_id=player_id, + source_id=source_id, + container_id=f"{criteria_container_id}{search}", + add_criteria=add_criteria, + ) + + async def rename_playlist( + self, source_id: int, container_id: str, new_name: str + ) -> None: + """ + Rename a HEOS playlist. + + References: + 4.4.14 Rename HEOS Playlist + """ + if new_name == "": + raise ValueError("'new_name' parameter must not be empty") + if len(new_name) > 128: + raise ValueError( + "'new_name' parameter must be less than or equal to 128 characters" + ) + await self._connection.command( + HeosCommand( + c.COMMAND_BROWSE_RENAME_PLAYLIST, + { + c.ATTR_SOURCE_ID: source_id, + c.ATTR_CONTAINER_ID: container_id, + c.ATTR_NAME: new_name, + }, + ) + ) + + async def delete_playlist(self, source_id: int, container_id: str) -> None: + """ + Create a HEOS command to delete a playlist. + + References: + 4.4.15 Delete HEOS Playlist""" + + await self._connection.command( + HeosCommand( + c.COMMAND_BROWSE_DELETE__PLAYLIST, + { + c.ATTR_SOURCE_ID: source_id, + c.ATTR_CONTAINER_ID: container_id, + }, + ) + ) + + async def retrieve_metadata( + self, source_it: int, container_id: str + ) -> RetreiveMetadataResult: + """ + Create a HEOS command to retrieve metadata. Only supported by Rhapsody/Napster music sources. + + References: + 4.4.17 Retrieve Metadata + """ + result = await self._connection.command( + HeosCommand( + c.COMMAND_BROWSE_RETRIEVE_METADATA, + { + c.ATTR_SOURCE_ID: source_it, + c.ATTR_CONTAINER_ID: container_id, + }, + ) + ) + return RetreiveMetadataResult._from_message(result) + + async def set_service_option( + this, + option_id: int, + source_id: int | None = None, + container_id: str | None = None, + media_id: str | None = None, + player_id: int | None = None, + name: str | None = None, + criteria_id: int | None = None, + range_start: int | None = None, + range_end: int | None = None, + ) -> None: + """ + Create a HEOS command to set a service option. + + References: + 4.4.19 Set Service Option + """ + params: dict[str, Any] = {c.ATTR_OPTION_ID: option_id} + disallowed_params = {} + + if option_id in ( + SERVICE_OPTION_ADD_TRACK_TO_LIBRARY, + SERVICE_OPTION_ADD_STATION_TO_LIBRARY, + SERVICE_OPTION_REMOVE_TRACK_FROM_LIBRARY, + SERVICE_OPTION_REMOVE_STATION_FROM_LIBRARY, + ): + if source_id is None or media_id is None: + raise ValueError( + f"source_id and media_id parameters are required for service option_id {option_id}" + ) + disallowed_params = { + "container_id": container_id, + "player_id": player_id, + "name": name, + "criteria_id": criteria_id, + "range_start": range_start, + "range_end": range_end, + } + params[c.ATTR_SOURCE_ID] = source_id + params[c.ATTR_MEDIA_ID] = media_id + elif option_id in ( + SERVICE_OPTION_ADD_ALBUM_TO_LIBRARY, + SERVICE_OPTION_REMOVE_ALBUM_FROM_LIBRARY, + SERVICE_OPTION_REMOVE_PLAYLIST_FROM_LIBRARY, + ): + if source_id is None or container_id is None: + raise ValueError( + f"source_id and container_id parameters are required for service option_id {option_id}" + ) + disallowed_params = { + "media_id": media_id, + "player_id": player_id, + "name": name, + "criteria_id": criteria_id, + "range_start": range_start, + "range_end": range_end, + } + params[c.ATTR_SOURCE_ID] = source_id + params[c.ATTR_CONTAINER_ID] = container_id + elif option_id == SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY: + if source_id is None or container_id is None or name is None: + raise ValueError( + f"source_id, container_id, and name parameters are required for service option_id {option_id}" + ) + disallowed_params = { + "media_id": media_id, + "player_id": player_id, + "criteria_id": criteria_id, + "range_start": range_start, + "range_end": range_end, + } + params[c.ATTR_SOURCE_ID] = source_id + params[c.ATTR_CONTAINER_ID] = container_id + params[c.ATTR_NAME] = name + elif option_id in ( + SERVICE_OPTION_THUMBS_UP, + SERVICE_OPTION_THUMBS_DOWN, + ): + if source_id is None or player_id is None: + raise ValueError( + f"source_id and player_id parameters are required for service option_id {option_id}" + ) + disallowed_params = { + "media_id": media_id, + "container_id": container_id, + "name": name, + "criteria_id": criteria_id, + "range_start": range_start, + "range_end": range_end, + } + params[c.ATTR_SOURCE_ID] = source_id + params[c.ATTR_PLAYER_ID] = player_id + elif option_id == SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA: + if source_id is None or name is None or criteria_id is None: + raise ValueError( + f"source_id, name, and criteria_id parameters are required for service option_id {option_id}" + ) + disallowed_params = { + "media_id": media_id, + "container_id": container_id, + "player_id": player_id, + } + params[c.ATTR_SOURCE_ID] = source_id + params[c.ATTR_SEARCH_CRITERIA_ID] = criteria_id + params[c.ATTR_NAME] = name + if isinstance(range_start, int) and isinstance(range_end, int): + params[c.ATTR_RANGE] = f"{range_start},{range_end}" + elif option_id == SERVICE_OPTION_ADD_TO_FAVORITES: + if not bool(player_id) ^ ( + source_id is not None and media_id is not None and name is not None + ): + raise ValueError( + f"Either parameters player_id OR source_id, media_id, and name are required for service option_id {option_id}" + ) + if player_id is not None: + if source_id is not None or media_id is not None or name is not None: + raise ValueError( + f"source_id, media_id, and name parameters are not allowed when using player_id for service option_id {option_id}" + ) + params[c.ATTR_PLAYER_ID] = player_id + else: + params[c.ATTR_SOURCE_ID] = source_id + params[c.ATTR_MEDIA_ID] = media_id + params[c.ATTR_NAME] = name + disallowed_params = { + "container_id": container_id, + "criteria_id": criteria_id, + "range_start": range_start, + "range_end": range_end, + } + elif option_id == SERVICE_OPTION_REMOVE_FROM_FAVORITES: + if media_id is None: + raise ValueError( + f"media_id parameter is required for service option_id {option_id}" + ) + params[c.ATTR_MEDIA_ID] = media_id + disallowed_params = { + "source_id": source_id, + "player_id": player_id, + "container_id": container_id, + "name": name, + "criteria_id": criteria_id, + "range_start": range_start, + "range_end": range_end, + } + else: + raise ValueError(f"Unknown option_id: {option_id}") + + # Raise if any disallowed parameters are provided + if any(param is not None for param in disallowed_params.values()): + raise ValueError( + f"{', '.join(disallowed_params.keys())} parameters are not allowed for service option_id {option_id}" + ) + + await this._connection.command( + HeosCommand(c.COMMAND_BROWSE_SET_SERVICE_OPTION, params) + ) + + async def play_media( + self, + player_id: int, + media: MediaItem, + add_criteria: AddCriteriaType = AddCriteriaType.PLAY_NOW, + ) -> None: + """ + Play the specified media item on the specified player. + + Args: + player_id: The identifier of the player to play the media item. + media: The media item to play. + add_criteria: Determines how containers or tracks are added to the queue. The default is AddCriteriaType.PLAY_NOW. + """ + if not media.playable: + raise ValueError(f"Media '{media}' is not playable") + + if media.media_id in VALID_INPUTS: + await self.play_input_source(player_id, media.media_id, media.source_id) + elif media.type == MediaType.STATION: + if media.media_id is None: + raise ValueError(f"'Media '{media}' cannot have a None media_id") + await self.play_station( + player_id=player_id, + source_id=media.source_id, + container_id=media.container_id, + media_id=media.media_id, + ) + else: + # Handles both songs and containers + if media.container_id is None: + raise ValueError(f"Media '{media}' cannot have a None container_id") + await self.add_to_queue( + player_id=player_id, + source_id=media.source_id, + container_id=media.container_id, + media_id=media.media_id, + add_criteria=add_criteria, + ) + + async def get_input_sources(self) -> Sequence[MediaItem]: + """ + Get available input sources. + + This will browse all aux input sources and return a list of all available input sources. + + Returns: + A sequence of MediaItem instances representing the available input sources across all aux input sources. + """ + result = await self.browse(MUSIC_SOURCE_AUX_INPUT) + input_sources: list[MediaItem] = [] + for item in result.items: + source_browse_result = await item.browse() + input_sources.extend(source_browse_result.items) + + return input_sources + + async def get_favorites(self) -> dict[int, MediaItem]: + """ + Get available favorites. + + This will browse the favorites music source and return a dictionary of all available favorites. + + Returns: + A dictionary with keys representing the index (1-based) of the favorite and the value being the MediaItem instance. + """ + result = await self.browse(MUSIC_SOURCE_FAVORITES) + return {index + 1: source for index, source in enumerate(result.items)} + + async def get_playlists(self) -> Sequence[MediaItem]: + """ + Get available playlists. + + This will browse the playlists music source and return a list of all available playlists. + + Returns: + A sequence of MediaItem instances representing the available playlists. + """ + result = await self.browse(MUSIC_SOURCE_PLAYLISTS) + return result.items + + async def multi_search( + self, + search: str, + source_ids: list[int] | None = None, + criteria_ids: list[int] | None = None, + ) -> MultiSearchResult: + """ + Create a HEOS command to perform a multi-search. + + References: + 4.4.20 Multi Search + """ + if len(search) > 128: + raise ValueError( + "'search' parameter must be less than or equal to 128 characters" + ) + params = {c.ATTR_SEARCH: search} + if source_ids is not None: + params[c.ATTR_SOURCE_ID] = ",".join(map(str, source_ids)) + if criteria_ids is not None: + params[c.ATTR_SEARCH_CRITERIA_ID] = ",".join(map(str, criteria_ids)) + result = await self._connection.command( + HeosCommand(c.COMMAND_BROWSE_MULTI_SEARCH, params) + ) + return MultiSearchResult._from_message(result, cast("Heos", self)) diff --git a/pyheos/command/connection.py b/pyheos/command/connection.py new file mode 100644 index 0000000..e78bda7 --- /dev/null +++ b/pyheos/command/connection.py @@ -0,0 +1,27 @@ +"""Define the connection mixin module.""" + +from pyheos.connection import AutoReconnectingConnection +from pyheos.options import HeosOptions +from pyheos.types import ConnectionState + + +class ConnectionMixin: + "A mixin to provide access to the connection." + + def __init__(self, options: HeosOptions) -> None: + """Init a new instance of the ConnectionMixin.""" + self._options = options + self._connection = AutoReconnectingConnection( + options.host, + timeout=options.timeout, + reconnect=options.auto_reconnect, + reconnect_delay=options.auto_reconnect_delay, + reconnect_max_attempts=options.auto_reconnect_max_attempts, + heart_beat=options.heart_beat, + heart_beat_interval=options.heart_beat_interval, + ) + + @property + def connection_state(self) -> ConnectionState: + """Get the state of the connection.""" + return self._connection.state diff --git a/pyheos/command/group.py b/pyheos/command/group.py index 1896e44..bfb4b71 100644 --- a/pyheos/command/group.py +++ b/pyheos/command/group.py @@ -2,121 +2,238 @@ Define the group command module. This module creates HEOS group commands. - -Commands not currently implemented: - 4.3.2 Get Group Info - """ +import asyncio from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, cast -from pyheos import command, const +from pyheos import command as c +from pyheos.command.connection import ConnectionMixin +from pyheos.const import DEFAULT_STEP +from pyheos.group import HeosGroup from pyheos.message import HeosCommand +if TYPE_CHECKING: + from pyheos.heos import Heos + + +class GroupCommands(ConnectionMixin): + """A mixin to provide access to the group commands.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Init a new instance of the BrowseMixin.""" + super(GroupCommands, self).__init__(*args, **kwargs) + self._groups: dict[int, HeosGroup] = {} + self._groups_loaded = False -class GroupCommands: - """Define functions for creating group commands.""" + @property + def groups(self) -> dict[int, HeosGroup]: + """Get the loaded groups.""" + return self._groups - @staticmethod - def get_groups() -> HeosCommand: - """Create a get groups command. + async def get_groups(self, *, refresh: bool = False) -> dict[int, HeosGroup]: + """Get available groups. References: 4.3.1 Get Groups""" - return HeosCommand(command.COMMAND_GET_GROUPS) + if not self._groups_loaded or refresh: + groups = {} + result = await self._connection.command(HeosCommand(c.COMMAND_GET_GROUPS)) + payload = cast(Sequence[dict], result.payload) + for data in payload: + group = HeosGroup._from_data(data, cast("Heos", self)) + groups[group.group_id] = group + self._groups = groups + # Update all statuses + await asyncio.gather( + *[ + group.refresh(refresh_base_info=False) + for group in self._groups.values() + ] + ) + self._groups_loaded = True + return self._groups + + async def get_group_info( + self, + group_id: int | None = None, + group: HeosGroup | None = None, + *, + refresh: bool = False, + ) -> HeosGroup: + """Get information about a group. + + Only one of group_id or group should be provided. + + Args: + group_id: The identifier of the group to get information about. Only one of group_id or group should be provided. + group: The HeosGroup instance to update with the latest information. Only one of group_id or group should be provided. + refresh: Set to True to force a refresh of the group information. - @staticmethod - def set_group(player_ids: Sequence[int]) -> HeosCommand: + References: + 4.3.2 Get Group Info""" + if group_id is None and group is None: + raise ValueError("Either group_id or group must be provided") + if group_id is not None and group is not None: + raise ValueError("Only one of group_id or group should be provided") + + # if only group_id provided, try getting from loaded + if group is None: + assert group_id is not None + group = self._groups.get(group_id) + else: + group_id = group.group_id + + if group is None or refresh: + # Get the latest information + result = await self._connection.command( + HeosCommand(c.COMMAND_GET_GROUP_INFO, {c.ATTR_GROUP_ID: group_id}) + ) + payload = cast(dict[str, Any], result.payload) + if group is None: + group = HeosGroup._from_data(payload, cast("Heos", self)) + else: + group._update_from_data(payload) + await group.refresh(refresh_base_info=False) + return group + + async def set_group(self, player_ids: Sequence[int]) -> None: """Create, modify, or ungroup players. + Args: + player_ids: The list of player identifiers to group or ungroup. The first player is the group leader. + References: 4.3.3 Set Group""" - return HeosCommand( - command.COMMAND_SET_GROUP, - {const.ATTR_PLAYER_ID: ",".join(map(str, player_ids))}, + await self._connection.command( + HeosCommand( + c.COMMAND_SET_GROUP, + {c.ATTR_PLAYER_ID: ",".join(map(str, player_ids))}, + ) ) - @staticmethod - def get_group_volume(group_id: int) -> HeosCommand: + async def create_group( + self, leader_player_id: int, member_player_ids: Sequence[int] + ) -> None: + """Create a HEOS group. + + Args: + leader_player_id: The player_id of the lead player in the group. + member_player_ids: The player_ids of the group members. + + References: + 4.3.3 Set Group""" + player_ids = [leader_player_id] + player_ids.extend(member_player_ids) + await self.set_group(player_ids) + + async def remove_group(self, group_id: int) -> None: + """Ungroup the specified group. + + Args: + group_id: The identifier of the group to ungroup. Must be the lead player. + + References: + 4.3.3 Set Group + """ + await self.set_group([group_id]) + + async def update_group( + self, group_id: int, member_player_ids: Sequence[int] + ) -> None: + """Update the membership of a group. + + Args: + group_id: The identifier of the group to update (same as the lead player_id) + member_player_ids: The new player_ids of the group members. + """ + await self.create_group(group_id, member_player_ids) + + async def get_group_volume(self, group_id: int) -> int: """ Get the volume of a group. References: 4.3.4 Get Group Volume """ - return HeosCommand( - command.COMMAND_GET_GROUP_VOLUME, {const.ATTR_GROUP_ID: group_id} + result = await self._connection.command( + HeosCommand(c.COMMAND_GET_GROUP_VOLUME, {c.ATTR_GROUP_ID: group_id}) ) + return result.get_message_value_int(c.ATTR_LEVEL) - @staticmethod - def set_group_volume(group_id: int, level: int) -> HeosCommand: + async def set_group_volume(self, group_id: int, level: int) -> None: """Set the volume of the group. References: 4.3.5 Set Group Volume""" if level < 0 or level > 100: raise ValueError("'level' must be in the range 0-100") - return HeosCommand( - command.COMMAND_SET_GROUP_VOLUME, - {const.ATTR_GROUP_ID: group_id, const.ATTR_LEVEL: level}, + await self._connection.command( + HeosCommand( + c.COMMAND_SET_GROUP_VOLUME, + {c.ATTR_GROUP_ID: group_id, c.ATTR_LEVEL: level}, + ) ) - @staticmethod - def group_volume_up(group_id: int, step: int) -> HeosCommand: + async def group_volume_up(self, group_id: int, step: int = DEFAULT_STEP) -> None: """Increase the volume level. References: 4.3.6 Group Volume Up""" if step < 1 or step > 10: raise ValueError("'step' must be in the range 1-10") - return HeosCommand( - command.COMMAND_GROUP_VOLUME_UP, - {const.ATTR_GROUP_ID: group_id, const.ATTR_STEP: step}, + await self._connection.command( + HeosCommand( + c.COMMAND_GROUP_VOLUME_UP, + {c.ATTR_GROUP_ID: group_id, c.ATTR_STEP: step}, + ) ) - @staticmethod - def group_volume_down(group_id: int, step: int) -> HeosCommand: + async def group_volume_down(self, group_id: int, step: int = DEFAULT_STEP) -> None: """Increase the volume level. References: 4.2.7 Group Volume Down""" if step < 1 or step > 10: raise ValueError("'step' must be in the range 1-10") - return HeosCommand( - command.COMMAND_GROUP_VOLUME_DOWN, - {const.ATTR_GROUP_ID: group_id, const.ATTR_STEP: step}, + await self._connection.command( + HeosCommand( + c.COMMAND_GROUP_VOLUME_DOWN, + {c.ATTR_GROUP_ID: group_id, c.ATTR_STEP: step}, + ) ) - @staticmethod - def get_group_mute(group_id: int) -> HeosCommand: + async def get_group_mute(self, group_id: int) -> bool: """Get the mute status of the group. References: 4.3.8 Get Group Mute""" - return HeosCommand( - command.COMMAND_GET_GROUP_MUTE, {const.ATTR_GROUP_ID: group_id} + result = await self._connection.command( + HeosCommand(c.COMMAND_GET_GROUP_MUTE, {c.ATTR_GROUP_ID: group_id}) ) + return result.get_message_value(c.ATTR_STATE) == c.VALUE_ON - @staticmethod - def group_set_mute(group_id: int, state: bool) -> HeosCommand: + async def group_set_mute(self, group_id: int, state: bool) -> None: """Set the mute state of the group. References: 4.3.9 Set Group Mute""" - return HeosCommand( - command.COMMAND_SET_GROUP_MUTE, - { - const.ATTR_GROUP_ID: group_id, - const.ATTR_STATE: const.VALUE_ON if state else const.VALUE_OFF, - }, + await self._connection.command( + HeosCommand( + c.COMMAND_SET_GROUP_MUTE, + { + c.ATTR_GROUP_ID: group_id, + c.ATTR_STATE: c.VALUE_ON if state else c.VALUE_OFF, + }, + ) ) - @staticmethod - def group_toggle_mute(group_id: int) -> HeosCommand: + async def group_toggle_mute(self, group_id: int) -> None: """Toggle the mute state. References: 4.3.10 Toggle Group Mute""" - return HeosCommand( - command.COMMAND_GROUP_TOGGLE_MUTE, {const.ATTR_GROUP_ID: group_id} + await self._connection.command( + HeosCommand(c.COMMAND_GROUP_TOGGLE_MUTE, {c.ATTR_GROUP_ID: group_id}) ) diff --git a/pyheos/command/player.py b/pyheos/command/player.py index b854e3d..badc74e 100644 --- a/pyheos/command/player.py +++ b/pyheos/command/player.py @@ -2,234 +2,483 @@ Define the player command module. This module creates HEOS player commands. - -Commands not currently implemented: - 4.2.2 Get Player Info - 4.2.15 Get Queue - 4.2.16 Play Queue Item - 4.2.17 Remove Item(s) from Queue - 4.2.18 Save Queue as Playlist - 4.2.20 Move Queue - 4.2.26 Check for Firmware Update - """ -from pyheos import command, const +import asyncio +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, cast + +from pyheos import command as c +from pyheos import const +from pyheos.command.connection import ConnectionMixin +from pyheos.media import QueueItem from pyheos.message import HeosCommand +from pyheos.player import ( + HeosNowPlayingMedia, + HeosPlayer, + PlayerUpdateResult, + PlayMode, + PlayState, +) +from pyheos.types import RepeatType +if TYPE_CHECKING: + from pyheos.heos import Heos -class PlayerCommands: - """Define functions for creating player commands.""" - @staticmethod - def get_players() -> HeosCommand: - """ - Get players. +class PlayerCommands(ConnectionMixin): + """A mixin to provide access to the player commands.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Init a new instance of the BrowseMixin.""" + super(PlayerCommands, self).__init__(*args, **kwargs) + + self._players: dict[int, HeosPlayer] = {} + self._players_loaded = False + + @property + def players(self) -> dict[int, HeosPlayer]: + """Get the loaded players.""" + return self._players + + async def get_players(self, *, refresh: bool = False) -> dict[int, HeosPlayer]: + """Get available players. References: - 4.2.1 Get Players - """ - return HeosCommand(command.COMMAND_GET_PLAYERS) + 4.2.1 Get Players""" + # get players and pull initial state + if not self._players_loaded or refresh: + await self.load_players() + return self._players + + async def get_player_info( + self, + player_id: int | None = None, + player: HeosPlayer | None = None, + *, + refresh: bool = False, + ) -> HeosPlayer: + """Get information about a player. + + Only one of player_id or player should be provided. + + Args: + palyer_id: The identifier of the group to get information about. Only one of player_id or player should be provided. + player: The HeosPlayer instance to update with the latest information. Only one of player_id or player should be provided. + refresh: Set to True to force a refresh of the group information. + Returns: + A HeosPlayer instance containing the player information. - @staticmethod - def get_play_state(player_id: int) -> HeosCommand: + References: + 4.2.2 Get Player Info""" + if player_id is None and player is None: + raise ValueError("Either player_id or player must be provided") + if player_id is not None and player is not None: + raise ValueError("Only one of player_id or player should be provided") + + # if only palyer_id provided, try getting from loaded + if player is None: + assert player_id is not None + player = self._players.get(player_id) + else: + player_id = player.player_id + + if player is None or refresh: + # Get the latest information + result = await self._connection.command( + HeosCommand(c.COMMAND_GET_PLAYER_INFO, {c.ATTR_PLAYER_ID: player_id}) + ) + + payload = cast(dict[str, Any], result.payload) + if player is None: + player = HeosPlayer._from_data(payload, cast("Heos", self)) + else: + player._update_from_data(payload) + await player.refresh(refresh_base_info=False) + return player + + async def load_players(self) -> PlayerUpdateResult: + """Refresh the players.""" + result = PlayerUpdateResult() + + players: dict[int, HeosPlayer] = {} + response = await self._connection.command(HeosCommand(c.COMMAND_GET_PLAYERS)) + payload = cast(Sequence[dict], response.payload) + existing = list(self._players.values()) + for player_data in payload: + player_id = int(player_data[c.ATTR_PLAYER_ID]) + name = player_data[c.ATTR_NAME] + version = player_data[c.ATTR_VERSION] + serial = player_data.get(c.ATTR_SERIAL) + # Try matching by serial (if available), then try matching by player_id + # and fallback to matching name when firmware version is different + player = next( + ( + player + for player in existing + if (player.serial == serial and serial is not None) + or player.player_id == player_id + or (player.name == name and player.version != version) + ), + None, + ) + if player: + # Found existing, update + if player.player_id != player_id: + result.updated_player_ids[player.player_id] = player_id + player._update_from_data(player_data) + player.available = True + players[player_id] = player + existing.remove(player) + else: + # New player + player = HeosPlayer._from_data(player_data, cast("Heos", self)) + result.added_player_ids.append(player_id) + players[player_id] = player + # For any item remaining in existing, mark unavailalbe, add to updated + for player in existing: + result.removed_player_ids.append(player.player_id) + player.available = False + players[player.player_id] = player + + # Pull data for available players + await asyncio.gather( + *[ + player.refresh(refresh_base_info=False) + for player in players.values() + if player.available + ] + ) + self._players = players + self._players_loaded = True + return result + + async def player_get_play_state(self, player_id: int) -> PlayState: """Get the state of the player. References: 4.2.3 Get Play State""" - return HeosCommand( - command.COMMAND_GET_PLAY_STATE, {const.ATTR_PLAYER_ID: player_id} + response = await self._connection.command( + HeosCommand(c.COMMAND_GET_PLAY_STATE, {c.ATTR_PLAYER_ID: player_id}) ) + return PlayState(response.get_message_value(c.ATTR_STATE)) - @staticmethod - def set_play_state(player_id: int, state: const.PlayState) -> HeosCommand: + async def player_set_play_state(self, player_id: int, state: PlayState) -> None: """Set the state of the player. References: 4.2.4 Set Play State""" - return HeosCommand( - command.COMMAND_SET_PLAY_STATE, - {const.ATTR_PLAYER_ID: player_id, const.ATTR_STATE: state}, + await self._connection.command( + HeosCommand( + c.COMMAND_SET_PLAY_STATE, + {c.ATTR_PLAYER_ID: player_id, c.ATTR_STATE: state}, + ) ) - @staticmethod - def get_now_playing_media(player_id: int) -> HeosCommand: + async def get_now_playing_media( + self, player_id: int, update: HeosNowPlayingMedia | None = None + ) -> HeosNowPlayingMedia: """Get the now playing media information. + Args: + player_id: The identifier of the player to get the now playing media. + update: The current now playing media information to update. If not provided, a new instance will be created. + + Returns: + A HeosNowPlayingMedia instance containing the now playing media information. + References: 4.2.5 Get Now Playing Media""" - return HeosCommand( - command.COMMAND_GET_NOW_PLAYING_MEDIA, {const.ATTR_PLAYER_ID: player_id} + result = await self._connection.command( + HeosCommand(c.COMMAND_GET_NOW_PLAYING_MEDIA, {c.ATTR_PLAYER_ID: player_id}) ) + instance = update or HeosNowPlayingMedia() + instance._update_from_message(result) + return instance - @staticmethod - def get_volume(player_id: int) -> HeosCommand: + async def player_get_volume(self, player_id: int) -> int: """Get the volume level of the player. References: 4.2.6 Get Volume""" - return HeosCommand( - command.COMMAND_GET_VOLUME, {const.ATTR_PLAYER_ID: player_id} + result = await self._connection.command( + HeosCommand(c.COMMAND_GET_VOLUME, {c.ATTR_PLAYER_ID: player_id}) ) + return result.get_message_value_int(c.ATTR_LEVEL) - @staticmethod - def set_volume(player_id: int, level: int) -> HeosCommand: + async def player_set_volume(self, player_id: int, level: int) -> None: """Set the volume of the player. References: 4.2.7 Set Volume""" if level < 0 or level > 100: raise ValueError("'level' must be in the range 0-100") - return HeosCommand( - command.COMMAND_SET_VOLUME, - {const.ATTR_PLAYER_ID: player_id, const.ATTR_LEVEL: level}, + await self._connection.command( + HeosCommand( + c.COMMAND_SET_VOLUME, + {c.ATTR_PLAYER_ID: player_id, c.ATTR_LEVEL: level}, + ) ) - @staticmethod - def volume_up(player_id: int, step: int = const.DEFAULT_STEP) -> HeosCommand: + async def player_volume_up( + self, player_id: int, step: int = const.DEFAULT_STEP + ) -> None: """Increase the volume level. References: 4.2.8 Volume Up""" if step < 1 or step > 10: raise ValueError("'step' must be in the range 1-10") - return HeosCommand( - command.COMMAND_VOLUME_UP, - {const.ATTR_PLAYER_ID: player_id, const.ATTR_STEP: step}, + await self._connection.command( + HeosCommand( + c.COMMAND_VOLUME_UP, + {c.ATTR_PLAYER_ID: player_id, c.ATTR_STEP: step}, + ) ) - @staticmethod - def volume_down(player_id: int, step: int = const.DEFAULT_STEP) -> HeosCommand: + async def player_volume_down( + self, player_id: int, step: int = const.DEFAULT_STEP + ) -> None: """Increase the volume level. References: 4.2.9 Volume Down""" if step < 1 or step > 10: raise ValueError("'step' must be in the range 1-10") - return HeosCommand( - command.COMMAND_VOLUME_DOWN, - {const.ATTR_PLAYER_ID: player_id, const.ATTR_STEP: step}, + await self._connection.command( + HeosCommand( + c.COMMAND_VOLUME_DOWN, + {c.ATTR_PLAYER_ID: player_id, c.ATTR_STEP: step}, + ) ) - @staticmethod - def get_mute(player_id: int) -> HeosCommand: + async def player_get_mute(self, player_id: int) -> bool: """Get the mute state of the player. References: 4.2.10 Get Mute""" - return HeosCommand(command.COMMAND_GET_MUTE, {const.ATTR_PLAYER_ID: player_id}) + result = await self._connection.command( + HeosCommand(c.COMMAND_GET_MUTE, {c.ATTR_PLAYER_ID: player_id}) + ) + return result.get_message_value(c.ATTR_STATE) == c.VALUE_ON - @staticmethod - def set_mute(player_id: int, state: bool) -> HeosCommand: + async def player_set_mute(self, player_id: int, state: bool) -> None: """Set the mute state of the player. References: 4.2.11 Set Mute""" - return HeosCommand( - command.COMMAND_SET_MUTE, - { - const.ATTR_PLAYER_ID: player_id, - const.ATTR_STATE: const.VALUE_ON if state else const.VALUE_OFF, - }, + await self._connection.command( + HeosCommand( + c.COMMAND_SET_MUTE, + { + c.ATTR_PLAYER_ID: player_id, + c.ATTR_STATE: c.VALUE_ON if state else c.VALUE_OFF, + }, + ) ) - @staticmethod - def toggle_mute(player_id: int) -> HeosCommand: + async def player_toggle_mute(self, player_id: int) -> None: """Toggle the mute state. References: 4.2.12 Toggle Mute""" - return HeosCommand( - command.COMMAND_TOGGLE_MUTE, {const.ATTR_PLAYER_ID: player_id} + await self._connection.command( + HeosCommand(c.COMMAND_TOGGLE_MUTE, {c.ATTR_PLAYER_ID: player_id}) ) - @staticmethod - def get_play_mode(player_id: int) -> HeosCommand: - """Get the current play mode. + async def player_get_play_mode(self, player_id: int) -> PlayMode: + """Get the play mode of the player. References: 4.2.13 Get Play Mode""" - return HeosCommand( - command.COMMAND_GET_PLAY_MODE, {const.ATTR_PLAYER_ID: player_id} + result = await self._connection.command( + HeosCommand(c.COMMAND_GET_PLAY_MODE, {c.ATTR_PLAYER_ID: player_id}) ) + return PlayMode._from_data(result) - @staticmethod - def set_play_mode( - player_id: int, repeat: const.RepeatType, shuffle: bool - ) -> HeosCommand: - """Set the current play mode. + async def player_set_play_mode( + self, player_id: int, repeat: RepeatType, shuffle: bool + ) -> None: + """Set the play mode of the player. References: 4.2.14 Set Play Mode""" - return HeosCommand( - command.COMMAND_SET_PLAY_MODE, - { - const.ATTR_PLAYER_ID: player_id, - const.ATTR_REPEAT: repeat, - const.ATTR_SHUFFLE: const.VALUE_ON if shuffle else const.VALUE_OFF, - }, + await self._connection.command( + HeosCommand( + c.COMMAND_SET_PLAY_MODE, + { + c.ATTR_PLAYER_ID: player_id, + c.ATTR_REPEAT: repeat, + c.ATTR_SHUFFLE: c.VALUE_ON if shuffle else c.VALUE_OFF, + }, + ) + ) + + async def player_get_queue( + self, + player_id: int, + range_start: int | None = None, + range_end: int | None = None, + ) -> list[QueueItem]: + """Get the queue for the current player. + + References: + 4.2.15 Get Queue + """ + params: dict[str, Any] = {c.ATTR_PLAYER_ID: player_id} + if isinstance(range_start, int) and isinstance(range_end, int): + params[c.ATTR_RANGE] = f"{range_start},{range_end}" + result = await self._connection.command( + HeosCommand(c.COMMAND_GET_QUEUE, params) + ) + payload = cast(list[dict[str, str]], result.payload) + return [QueueItem.from_data(data) for data in payload] + + async def player_play_queue(self, player_id: int, queue_id: int) -> None: + """Play a queue item. + + References: + 4.2.16 Play Queue Item""" + await self._connection.command( + HeosCommand( + c.COMMAND_PLAY_QUEUE, + {c.ATTR_PLAYER_ID: player_id, c.ATTR_QUEUE_ID: queue_id}, + ) + ) + + async def player_remove_from_queue( + self, player_id: int, queue_ids: list[int] + ) -> None: + """Remove an item from the queue. + + References: + 4.2.17 Remove Item(s) from Queue""" + await self._connection.command( + HeosCommand( + c.COMMAND_REMOVE_FROM_QUEUE, + { + c.ATTR_PLAYER_ID: player_id, + c.ATTR_QUEUE_ID: ",".join(map(str, queue_ids)), + }, + ) + ) + + async def player_save_queue(self, player_id: int, name: str) -> None: + """Save the queue as a playlist. + + References: + 4.2.18 Save Queue as Playlist""" + if len(name) > 128: + raise ValueError("'name' must be less than or equal to 128 characters") + await self._connection.command( + HeosCommand( + c.COMMAND_SAVE_QUEUE, + {c.ATTR_PLAYER_ID: player_id, c.ATTR_NAME: name}, + ) ) - @staticmethod - def clear_queue(player_id: int) -> HeosCommand: + async def player_clear_queue(self, player_id: int) -> None: """Clear the queue. References: 4.2.19 Clear Queue""" - return HeosCommand( - command.COMMAND_CLEAR_QUEUE, {const.ATTR_PLAYER_ID: player_id} + await self._connection.command( + HeosCommand(c.COMMAND_CLEAR_QUEUE, {c.ATTR_PLAYER_ID: player_id}) ) - @staticmethod - def play_next(player_id: int) -> HeosCommand: + async def player_move_queue_item( + self, player_id: int, source_queue_ids: list[int], destination_queue_id: int + ) -> None: + """Move one or more items in the queue. + + References: + 4.2.20 Move Queue""" + await self._connection.command( + HeosCommand( + c.COMMAND_MOVE_QUEUE_ITEM, + { + c.ATTR_PLAYER_ID: player_id, + c.ATTR_SOURCE_QUEUE_ID: ",".join(map(str, source_queue_ids)), + c.ATTR_DESTINATION_QUEUE_ID: destination_queue_id, + }, + ) + ) + + async def player_play_next(self, player_id: int) -> None: """Play next. References: 4.2.21 Play Next""" - return HeosCommand(command.COMMAND_PLAY_NEXT, {const.ATTR_PLAYER_ID: player_id}) + await self._connection.command( + HeosCommand(c.COMMAND_PLAY_NEXT, {c.ATTR_PLAYER_ID: player_id}) + ) - @staticmethod - def play_previous(player_id: int) -> HeosCommand: + async def player_play_previous(self, player_id: int) -> None: """Play next. References: 4.2.22 Play Previous""" - return HeosCommand( - command.COMMAND_PLAY_PREVIOUS, {const.ATTR_PLAYER_ID: player_id} + await self._connection.command( + HeosCommand(c.COMMAND_PLAY_PREVIOUS, {c.ATTR_PLAYER_ID: player_id}) ) - @staticmethod - def set_quick_select(player_id: int, quick_select_id: int) -> HeosCommand: + async def player_set_quick_select( + self, player_id: int, quick_select_id: int + ) -> None: """Play a quick select. References: 4.2.23 Set QuickSelect""" if quick_select_id < 1 or quick_select_id > 6: raise ValueError("'quick_select_id' must be in the range 1-6") - return HeosCommand( - command.COMMAND_SET_QUICK_SELECT, - {const.ATTR_PLAYER_ID: player_id, const.ATTR_ID: quick_select_id}, + await self._connection.command( + HeosCommand( + c.COMMAND_SET_QUICK_SELECT, + {c.ATTR_PLAYER_ID: player_id, c.ATTR_ID: quick_select_id}, + ) ) - @staticmethod - def play_quick_select(player_id: int, quick_select_id: int) -> HeosCommand: + async def player_play_quick_select( + self, player_id: int, quick_select_id: int + ) -> None: """Play a quick select. References: 4.2.24 Play QuickSelect""" if quick_select_id < 1 or quick_select_id > 6: raise ValueError("'quick_select_id' must be in the range 1-6") - return HeosCommand( - command.COMMAND_PLAY_QUICK_SELECT, - {const.ATTR_PLAYER_ID: player_id, const.ATTR_ID: quick_select_id}, + await self._connection.command( + HeosCommand( + c.COMMAND_PLAY_QUICK_SELECT, + {c.ATTR_PLAYER_ID: player_id, c.ATTR_ID: quick_select_id}, + ) ) - @staticmethod - def get_quick_selects(player_id: int) -> HeosCommand: + async def player_get_quick_selects(self, player_id: int) -> dict[int, str]: """Get quick selects. References: 4.2.25 Get QuickSelects""" - return HeosCommand( - command.COMMAND_GET_QUICK_SELECTS, {const.ATTR_PLAYER_ID: player_id} + result = await self._connection.command( + HeosCommand(c.COMMAND_GET_QUICK_SELECTS, {c.ATTR_PLAYER_ID: player_id}) + ) + return { + int(data[c.ATTR_ID]): data[c.ATTR_NAME] + for data in cast(list[dict], result.payload) + } + + async def player_check_update(self, player_id: int) -> bool: + """Check for a firmware update. + + Args: + player_id: The identifier of the player to check for a firmware update. + Returns: + True if an update is available, otherwise False. + + References: + 4.2.26 Check for Firmware Update""" + result = await self._connection.command( + HeosCommand(c.COMMAND_CHECK_UPDATE, {c.ATTR_PLAYER_ID: player_id}) ) + payload = cast(dict[str, Any], result.payload) + return bool(payload[c.ATTR_UPDATE] == c.VALUE_UPDATE_EXIST) diff --git a/pyheos/command/system.py b/pyheos/command/system.py index 1a60b98..dbf4123 100644 --- a/pyheos/command/system.py +++ b/pyheos/command/system.py @@ -9,63 +9,130 @@ This command will not be implemented in the library. """ -from pyheos import command, const +from collections.abc import Sequence +from typing import Any, cast + +from pyheos import command as c +from pyheos.command.connection import ConnectionMixin +from pyheos.credentials import Credentials from pyheos.message import HeosCommand +from pyheos.system import HeosHost, HeosSystem + + +class SystemCommands(ConnectionMixin): + """A mixin to provide access to the system commands.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Init a new instance of the BrowseMixin.""" + super(SystemCommands, self).__init__(*args, **kwargs) + + self._current_credentials = self._options.credentials + self._signed_in_username: str | None = None + + @property + def is_signed_in(self) -> bool: + """Return True if the HEOS accuont is signed in.""" + return bool(self._signed_in_username) + @property + def signed_in_username(self) -> str | None: + """Return the signed-in username.""" + return self._signed_in_username -class SystemCommands: - """Define functions for creating system commands.""" + @property + def current_credentials(self) -> Credentials | None: + """Return the current credential, if any set.""" + return self._current_credentials - @staticmethod - def register_for_change_events(enable: bool) -> HeosCommand: + @current_credentials.setter + def current_credentials(self, credentials: Credentials | None) -> None: + """Update the current credential.""" + self._current_credentials = credentials + + async def register_for_change_events(self, enable: bool) -> None: """Register for change events. References: 4.1.1 Register for Change Events""" - return HeosCommand( - command.COMMAND_REGISTER_FOR_CHANGE_EVENTS, - {const.ATTR_ENABLE: const.VALUE_ON if enable else const.VALUE_OFF}, + await self._connection.command( + HeosCommand( + c.COMMAND_REGISTER_FOR_CHANGE_EVENTS, + {c.ATTR_ENABLE: c.VALUE_ON if enable else c.VALUE_OFF}, + ) ) - @staticmethod - def check_account() -> HeosCommand: - """Create a check account command. + async def check_account(self) -> str | None: + """Return the logged in username. References: 4.1.2 HEOS Account Check""" - return HeosCommand(command.COMMAND_ACCOUNT_CHECK) - - @staticmethod - def sign_in(username: str, password: str) -> HeosCommand: - """Create a sign in command. + result = await self._connection.command(HeosCommand(c.COMMAND_ACCOUNT_CHECK)) + if c.ATTR_SIGNED_IN in result.message: + self._signed_in_username = result.get_message_value(c.ATTR_USER_NAME) + else: + self._signed_in_username = None + return self._signed_in_username + + async def sign_in( + self, username: str, password: str, *, update_credential: bool = True + ) -> str: + """Sign in to the HEOS account using the provided credential and return the user name. + + Args: + username: The username of the HEOS account. + password: The password of the HEOS account. + update_credential: Set to True to update the stored credential if login is successful, False to keep the current credential. The default is True. If the credential is updated, it will be used to signed in automatically upon reconnection. + + Returns: + The username of the signed in account. References: 4.1.3 HEOS Account Sign In""" - return HeosCommand( - command.COMMAND_SIGN_IN, - {const.ATTR_USER_NAME: username, const.ATTR_PASSWORD: password}, + result = await self._connection.command( + HeosCommand( + c.COMMAND_SIGN_IN, + {c.ATTR_USER_NAME: username, c.ATTR_PASSWORD: password}, + ) ) + self._signed_in_username = result.get_message_value(c.ATTR_USER_NAME) + if update_credential: + self.current_credentials = Credentials(username, password) + return self._signed_in_username + + async def sign_out(self, *, update_credential: bool = True) -> None: + """Sign out of the HEOS account. - @staticmethod - def sign_out() -> HeosCommand: - """Create a sign out command. + Args: + update_credential: Set to True to clear the stored credential, False to keep it. The default is True. If the credential is cleared, the account will not be signed in automatically upon reconnection. References: 4.1.4 HEOS Account Sign Out""" - return HeosCommand(command.COMMAND_SIGN_OUT) + await self._connection.command(HeosCommand(c.COMMAND_SIGN_OUT)) + self._signed_in_username = None + if update_credential: + self.current_credentials = None - @staticmethod - def heart_beat() -> HeosCommand: - """Create a heart beat command. + async def heart_beat(self) -> None: + """Send a heart beat message to the HEOS device. References: 4.1.5 HEOS System Heart Beat""" - return HeosCommand(command.COMMAND_HEART_BEAT) + await self._connection.command(HeosCommand(c.COMMAND_HEART_BEAT)) - @staticmethod - def reboot() -> HeosCommand: - """Create a reboot command. + async def reboot(self) -> None: + """Reboot the HEOS device. References: 4.1.6 HEOS Speaker Reboot""" - return HeosCommand(command.COMMAND_REBOOT) + await self._connection.command(HeosCommand(c.COMMAND_REBOOT)) + + async def get_system_info(self) -> HeosSystem: + """Get information about the HEOS system. + + References: + 4.2.1 Get Players""" + response = await self._connection.command(HeosCommand(c.COMMAND_GET_PLAYERS)) + payload = cast(Sequence[dict], response.payload) + hosts = list([HeosHost._from_data(item) for item in payload]) + host = next(host for host in hosts if host.ip_address == self._options.host) + return HeosSystem(self._signed_in_username, host, hosts) diff --git a/pyheos/connection.py b/pyheos/connection.py index 9b2c13f..032b236 100644 --- a/pyheos/connection.py +++ b/pyheos/connection.py @@ -6,21 +6,16 @@ from datetime import datetime, timedelta from typing import TYPE_CHECKING, Final -from pyheos.command import COMMAND_REBOOT -from pyheos.command.system import SystemCommands +from pyheos.command import COMMAND_HEART_BEAT, COMMAND_REBOOT from pyheos.message import HeosCommand, HeosMessage +from pyheos.types import ConnectionState -from .const import ( - STATE_CONNECTED, - STATE_DISCONNECTED, - STATE_RECONNECTING, -) -from .error import CommandError, CommandFailedError, HeosError, format_error_message +from .error import CommandError, CommandFailedError, HeosError CLI_PORT: Final = 1255 SEPARATOR: Final = "\r\n" SEPARATOR_BYTES: Final = SEPARATOR.encode() - +MAX_RECONNECT_DELAY = 600 _LOGGER: Final = logging.getLogger(__name__) @@ -36,7 +31,7 @@ def __init__(self, host: str, *, timeout: float) -> None: """Init a new instance of the ConnectionBase.""" self._host: str = host self._timeout: float = timeout - self._state = STATE_DISCONNECTED + self._state: ConnectionState = ConnectionState.DISCONNECTED self._writer: asyncio.StreamWriter | None = None self._pending_command_event = ResponseEvent() self._running_tasks: set[asyncio.Task] = set() @@ -51,7 +46,7 @@ def __init__(self, host: str, *, timeout: float) -> None: ] = [] @property - def state(self) -> str: + def state(self) -> ConnectionState: """Get the current state of the connection.""" return self._state @@ -121,12 +116,17 @@ async def _reset(self) -> None: # Reset other parameters self._pending_command_event.clear() self._last_activity = datetime.now() - self._state = STATE_DISCONNECTED + self._state = ConnectionState.DISCONNECTED async def _disconnect_from_error(self, error: Exception) -> None: """Disconnect and reset as an of an error.""" await self._reset() - _LOGGER.debug(f"Disconnected from {self._host} due to error: {error}") + _LOGGER.debug( + "Disconnected from %s due to error: %s: %s", + self._host, + type(error).__name__, + error, + ) await self._on_disconnected(due_to_error=True) async def _read_handler(self, reader: asyncio.StreamReader) -> None: @@ -145,16 +145,16 @@ async def _read_handler(self, reader: asyncio.StreamReader) -> None: else: self._last_activity = datetime.now() await self._handle_message( - HeosMessage.from_raw_message(binary_result.decode()) + HeosMessage._from_raw_message(binary_result.decode()) ) async def _handle_message(self, message: HeosMessage) -> None: """Handle a message received from the HEOS device.""" if message.is_under_process: - _LOGGER.debug(f"Command under process '{message.command}': '{message}'") + _LOGGER.debug("Command under process '%s'", message.command) return if message.is_event: - _LOGGER.debug(f"Event received: '{message.command}': '{message}'") + _LOGGER.debug("Event received: '%s': '%s'", message.command, message) self._register_task(self._on_event(message)) return @@ -167,10 +167,8 @@ async def command(self, command: HeosCommand) -> HeosMessage: # Encapsulate the core logic so that we can wrap it in a lock. async def _command_impl() -> HeosMessage: """Implementation of the command.""" - if self._state is not STATE_CONNECTED: - _LOGGER.debug( - f"Command failed '{command.uri_masked}': Not connected to device" - ) + if self._state is not ConnectionState.CONNECTED: + _LOGGER.debug("Command failed '%s': Not connected to device", command) raise CommandError(command.command, "Not connected to device") if TYPE_CHECKING: assert self._writer is not None @@ -182,15 +180,18 @@ async def _command_impl() -> HeosMessage: except (ConnectionError, OSError, AttributeError) as error: # Occurs when the connection is broken. Run in the background to ensure connection is reset. self._register_task(self._disconnect_from_error(error)) - message = format_error_message(error) - _LOGGER.debug(f"Command failed '{command.uri_masked}': {message}") - raise CommandError(command.command, message) from error + _LOGGER.debug( + "Command failed '%s': %s: %s", command, type(error).__name__, error + ) + raise CommandError( + command.command, f"Command failed: {error}" + ) from error else: self._last_activity = datetime.now() # If the command is a reboot, we won't get a response. if command.command == COMMAND_REBOOT: - _LOGGER.debug(f"Command executed '{command.uri_masked}': No response") + _LOGGER.debug("Command executed '%s': No response", command) return HeosMessage(COMMAND_REBOOT) # Wait for the response with a timeout @@ -200,10 +201,8 @@ async def _command_impl() -> HeosMessage: ) except asyncio.TimeoutError as error: # Occurs when the command times out - _LOGGER.debug(f"Command timed out '{command.uri_masked}'") - raise CommandError( - command.command, format_error_message(error) - ) from error + _LOGGER.debug("Command timed out '%s'", command) + raise CommandError(command.command, "Command timed out") from error finally: self._pending_command_event.clear() @@ -212,45 +211,56 @@ async def _command_impl() -> HeosMessage: # Check the result if not response.result: - _LOGGER.debug(f"Command failed '{command.uri_masked}': '{response}'") - raise CommandFailedError.from_message(response) + _LOGGER.debug("Command failed '%s': '%s'", command, response) + raise CommandFailedError._from_message(response) - _LOGGER.debug(f"Command executed '{command.uri_masked}': '{response}'") + _LOGGER.debug("Command executed '%s': '%s'", command, response) return response # Run the within the lock + command_error: CommandFailedError | None = None await self._command_lock.acquire() try: return await _command_impl() except CommandFailedError as error: - await self._on_command_error(error) + command_error = error raise # Re-raise to send the error to the caller. finally: self._command_lock.release() + if command_error: + await self._on_command_error(command_error) async def connect(self) -> None: """Connect to the HEOS device.""" - if self._state is STATE_CONNECTED: + if self._state is ConnectionState.CONNECTED: return # Open the connection to the host try: reader, self._writer = await asyncio.wait_for( asyncio.open_connection(self._host, CLI_PORT), self._timeout ) - except (OSError, ConnectionError, asyncio.TimeoutError) as err: - raise HeosError(format_error_message(err)) from err + except asyncio.TimeoutError as err: + _LOGGER.debug("Failed to connect to %s: Connection timed out", self._host) + raise HeosError("Connection timed out") from err + except (OSError, ConnectionError) as err: + _LOGGER.debug( + "Failed to connect to %s: %s: %s", self._host, type(err).__name__, err + ) + raise HeosError( + f"Unable to connect to {self._host}: {type(err).__name__}: {err}" + ) from err # Start read handler self._register_task(self._read_handler(reader)) self._last_activity = datetime.now() - self._state = STATE_CONNECTED - _LOGGER.debug(f"Connected to {self._host}") + self._state = ConnectionState.CONNECTED + _LOGGER.debug("Connected to %s", self._host) await self._on_connected() async def disconnect(self) -> None: """Disconnect from the HEOS device.""" await self._reset() - _LOGGER.debug(f"Disconnected from {self._host}") + _LOGGER.debug("Disconnected from %s", self._host) await self._on_disconnected() @@ -288,11 +298,11 @@ async def _heart_beat_handler(self) -> None: This effectively tests that the connection to the device is still alive. If the heart beat fails or times out, the existing command processing logic will reset the state of the connection. """ - while self._state == STATE_CONNECTED: + while self._state == ConnectionState.CONNECTED: last_acitvity_delta = datetime.now() - self._last_activity if last_acitvity_delta >= self._heart_beat_interval_delta: try: - await self.command(SystemCommands.heart_beat()) + await self.command(HeosCommand(COMMAND_HEART_BEAT)) except (CommandError, asyncio.TimeoutError): # Exit the task, as the connection will be reset/closed. return @@ -301,20 +311,25 @@ async def _heart_beat_handler(self) -> None: async def _attempt_reconnect(self) -> None: """Attempt to reconnect after disconnection from error.""" + self._state = ConnectionState.RECONNECTING attempts = 0 unlimited_attempts = self._reconnect_max_attempts == 0 - self._state = STATE_RECONNECTING + delay = min(self._reconnect_delay, MAX_RECONNECT_DELAY) while (attempts < self._reconnect_max_attempts) or unlimited_attempts: try: - await asyncio.sleep(self._reconnect_delay) - await self.connect() - except HeosError as err: - attempts += 1 _LOGGER.debug( - f"Failed reconnect attempt {attempts} to {self._host}: {err}" + "Waiting %s seconds before attempting to reconnect", delay + ) + await asyncio.sleep(delay) + _LOGGER.debug( + "Attempting reconnect #%s to %s", (attempts + 1), self._host ) + await self.connect() + except HeosError: + attempts += 1 + delay = min(delay * 2, MAX_RECONNECT_DELAY) else: - return + return # This never actually hits as the task is cancelled when the connection is established, but it's here for completeness. async def _on_connected(self) -> None: """Handle when the connection is established.""" diff --git a/pyheos/const.py b/pyheos/const.py index 60616ed..04ff0a0 100644 --- a/pyheos/const.py +++ b/pyheos/const.py @@ -1,6 +1,9 @@ -"""Define consts for the pyheos package.""" +"""Define consts for the pyheos package. + +This module only contains constants needed to interact with the library (that are exported). Constants only +used internally are located in the modules where they are used. +""" -from enum import IntEnum, StrEnum from typing import Final DEFAULT_TIMEOUT: Final = 10.0 @@ -9,128 +12,38 @@ DEFAULT_HEART_BEAT: Final = 10.0 DEFAULT_STEP: Final = 5 -ATTR_ADD_CRITERIA_ID: Final = "aid" -ATTR_ALBUM_ID: Final = "album_id" -ATTR_ALBUM: Final = "album" -ATTR_ARTIST: Final = "artist" -ATTR_AVAILABLE: Final = "available" -ATTR_COMMAND: Final = "command" -ATTR_CONTAINER: Final = "container" -ATTR_CONTAINER_ID: Final = "cid" -ATTR_COUNT: Final = "count" -ATTR_CURRENT_POSITION: Final = "cur_pos" -ATTR_DURATION: Final = "duration" -ATTR_ENABLE: Final = "enable" -ATTR_ERROR: Final = "error" -ATTR_ERROR_ID: Final = "eid" -ATTR_GROUP_ID: Final = "gid" -ATTR_HEOS: Final = "heos" -ATTR_ID: Final = "id" -ATTR_IMAGE_URL: Final = "image_url" -ATTR_INPUT: Final = "input" -ATTR_IP_ADDRESS: Final = "ip" -ATTR_LEVEL: Final = "level" -ATTR_LINE_OUT: Final = "lineout" -ATTR_MEDIA_ID: Final = "mid" -ATTR_MESSAGE: Final = "message" -ATTR_MODEL: Final = "model" -ATTR_MUTE: Final = "mute" -ATTR_NAME: Final = "name" -ATTR_NETWORK: Final = "network" -ATTR_PASSWORD: Final = "pw" -ATTR_PAYLOAD: Final = "payload" -ATTR_PLAYABLE: Final = "playable" -ATTR_PLAYER_ID: Final = "pid" -ATTR_PLAYERS: Final = "players" -ATTR_PRESET: Final = "preset" -ATTR_QUEUE_ID: Final = "qid" -ATTR_RANGE: Final = "range" -ATTR_REFRESH: Final = "refresh" -ATTR_REPEAT: Final = "repeat" -ATTR_RESULT: Final = "result" -ATTR_RETURNED: Final = "returned" -ATTR_ROLE: Final = "role" -ATTR_SERIAL: Final = "serial" -ATTR_SERVICE_USER_NAME: Final = "service_username" -ATTR_SHUFFLE: Final = "shuffle" -ATTR_SONG: Final = "song" -ATTR_SOURCE_ID: Final = "sid" -ATTR_SOURCE_PLAYER_ID: Final = "spid" -ATTR_SIGNED_OUT: Final = "signed_out" -ATTR_SIGNED_IN: Final = "signed_in" -ATTR_STATE: Final = "state" -ATTR_STATION: Final = "station" -ATTR_STEP: Final = "step" -ATTR_SYSTEM_ERROR_NUMBER: Final = "syserrno" -ATTR_TEXT: Final = "text" -ATTR_TYPE: Final = "type" -ATTR_URL: Final = "url" -ATTR_USER_NAME: Final = "un" -ATTR_VERSION: Final = "version" - -VALUE_ON: Final = "on" -VALUE_OFF: Final = "off" -VALUE_TRUE: Final = "true" -VALUE_FALSE: Final = "false" -VALUE_YES: Final = "yes" -VALUE_NO: Final = "no" -VALUE_SUCCESS: Final = "success" -VALUE_LEADER: Final = "leader" -VALUE_MEMBER: Final = "member" - +# Command error codes (keep discrete values as we do not control the list) +ERROR_UNREGONIZED_COMMAND: Final = 1 +ERROR_INVALID_ID: Final = 2 +ERROR_WRONG_ARGUMENTS: Final = 3 +ERROR_DATA_NOT_AVAILABLE: Final = 4 +ERROR_RESOURCE_NOT_AVAILABLE: Final = 5 ERROR_INVALID_CREDNETIALS: Final = 6 +ERROR_COMMAND_NOT_EXECUTED: Final = 7 ERROR_USER_NOT_LOGGED_IN: Final = 8 +ERROR_PARAMETER_OUT_OF_RANGE: Final = 9 ERROR_USER_NOT_FOUND: Final = 10 +ERROR_INTERNAL: Final = 11 ERROR_SYSTEM_ERROR: Final = 12 - +ERROR_PROCESSING_PREVIOUS_COMMAND: Final = 13 +ERROR_MEDIA_CANNOT_BE_PLAYED: Final = 14 +ERROR_OPTION_NOTP_SUPPORTED: Final = 15 +ERROR_TOO_MANY_COMMANDS_IN_QUEUE: Final = 16 +ERROR_SKIP_LIMIT_REACHED: Final = 17 + +# Document system error codes (keep discrete values as we do not control the list) +SYSTEM_ERROR_REMOTE_SERVICE_ERROR: Final = -9 +SYSTEM_ERROR_SERVICE_NOT_REGISTERED: Final = -1061 SYSTEM_ERROR_USER_NOT_LOGGED_IN: Final = -1063 SYSTEM_ERROR_USER_NOT_FOUND: Final = -1056 +SYSTEM_ERROR_CONTENT_AUTHENTICATION_ERROR: Final = -1201 +SYSTEM_ERROR_CONTENT_AUTHORIZATION_ERROR: Final = -1232 +SYSTEM_ERROR_ACCOUNT_PARAMETERS_INVALID: Final = -1239 -STATE_CONNECTED: Final = "connected" -STATE_DISCONNECTED: Final = "disconnected" -STATE_RECONNECTING: Final = "reconnecting" - -NETWORK_TYPE_WIRED: Final = "wired" -NETWORK_TYPE_WIFI: Final = "wifi" -NETWORK_TYPE_UNKNOWN: Final = "unknown" - -DATA_NEW: Final = "new" -DATA_MAPPED_IDS: Final = "mapped_ids" - - -class PlayState(StrEnum): - """Define the play states.""" - - PLAY = "play" - PAUSE = "pause" - STOP = "stop" - - -class RepeatType(StrEnum): - """Define the repeat types.""" - - ON_ALL = "on_all" - ON_ONE = "on_one" - OFF = "off" - - -class MediaType(StrEnum): - """Define the media types.""" +# Search Crtieria Container IDs (keep discrete values as we do not control the list) +SEARCHED_TRACKS: Final = "SEARCHED_TRACKS-" - ALBUM = "album" - ARTIST = "artist" - CONTAINER = "container" - DLNA_SERVER = "dlna_server" - GENRE = "genre" - HEOS_SERVER = "heos_server" - HEOS_SERVICE = "heos_service" - MUSIC_SERVICE = "music_service" - PLAYLIST = "playlist" - SONG = "song" - STATION = "station" - - -# Music Sources +# Music Sources (keep discrete values as we do not control the list) MUSIC_SOURCE_CONNECT: Final = 0 # TIDAL Connect // possibly Spotify Connect as well (?) MUSIC_SOURCE_PANDORA: Final = 1 MUSIC_SOURCE_RHAPSODY: Final = 2 @@ -153,67 +66,7 @@ class MediaType(StrEnum): MUSIC_SOURCE_AUX_INPUT: Final = 1027 MUSIC_SOURCE_FAVORITES: Final = 1028 -# Supported controls -CONTROL_PLAY: Final = "play" -CONTROL_PAUSE: Final = "pause" -CONTROL_STOP: Final = "stop" -CONTROL_PLAY_NEXT: Final = "play_next" -CONTROL_PLAY_PREVIOUS: Final = "play_previous" - -CONTROLS_ALL: Final = [ - CONTROL_PLAY, - CONTROL_PAUSE, - CONTROL_STOP, - CONTROL_PLAY_NEXT, - CONTROL_PLAY_PREVIOUS, -] -CONTROLS_FORWARD_ONLY: Final = [ - CONTROL_PLAY, - CONTROL_PAUSE, - CONTROL_STOP, - CONTROL_PLAY_NEXT, -] -CONTROL_PLAY_STOP: Final = [CONTROL_PLAY, CONTROL_STOP] - -SOURCE_CONTROLS: Final = { - MUSIC_SOURCE_CONNECT: {MediaType.STATION: CONTROLS_ALL}, - MUSIC_SOURCE_PANDORA: {MediaType.STATION: CONTROLS_FORWARD_ONLY}, - MUSIC_SOURCE_RHAPSODY: { - MediaType.SONG: CONTROLS_ALL, - MediaType.STATION: CONTROLS_FORWARD_ONLY, - }, - MUSIC_SOURCE_TUNEIN: { - MediaType.SONG: CONTROLS_ALL, - MediaType.STATION: CONTROL_PLAY_STOP, - }, - MUSIC_SOURCE_SPOTIFY: { - MediaType.SONG: CONTROLS_ALL, - MediaType.STATION: CONTROLS_FORWARD_ONLY, - }, - MUSIC_SOURCE_DEEZER: { - MediaType.SONG: CONTROLS_ALL, - MediaType.STATION: CONTROLS_FORWARD_ONLY, - }, - MUSIC_SOURCE_NAPSTER: { - MediaType.SONG: CONTROLS_ALL, - MediaType.STATION: CONTROLS_FORWARD_ONLY, - }, - MUSIC_SOURCE_IHEARTRADIO: { - MediaType.SONG: CONTROLS_ALL, - MediaType.STATION: CONTROL_PLAY_STOP, - }, - MUSIC_SOURCE_SIRIUSXM: {MediaType.STATION: CONTROL_PLAY_STOP}, - MUSIC_SOURCE_SOUNDCLOUD: {MediaType.SONG: CONTROLS_ALL}, - MUSIC_SOURCE_TIDAL: {MediaType.SONG: CONTROLS_ALL}, - MUSIC_SOURCE_AMAZON: { - MediaType.SONG: CONTROLS_ALL, - MediaType.STATION: CONTROLS_ALL, - }, - MUSIC_SOURCE_AUX_INPUT: {MediaType.STATION: CONTROL_PLAY_STOP}, -} - - -# Inputs +# Inputs (keep discrete values as we do not control the list) INPUT_ANALOG_IN_1: Final = "inputs/analog_in_1" INPUT_ANALOG_IN_2: Final = "inputs/analog_in_2" INPUT_AUX_8K: Final = "inputs/aux_8k" @@ -341,29 +194,22 @@ class MediaType(StrEnum): INPUT_USB_AC, ) - -class AddCriteriaType(IntEnum): - """Define the add to queue options.""" - - PLAY_NOW = 1 - PLAY_NEXT = 2 - ADD_TO_END = 3 - REPLACE_AND_PLAY = 4 - - -# Signals -SIGNAL_PLAYER_EVENT: Final = "player_event" -SIGNAL_GROUP_EVENT: Final = "group_event" -SIGNAL_CONTROLLER_EVENT: Final = "controller_event" -SIGNAL_HEOS_EVENT: Final = "heos_event" -EVENT_CONNECTED: Final = "connected" -EVENT_DISCONNECTED: Final = "disconnected" -EVENT_USER_CREDENTIALS_INVALID: Final = "user credentials invalid" - -BASE_URI: Final = "heos://" - - -# Events +# Service options (keep discrete values as we do not control the list) +SERVICE_OPTION_ADD_TRACK_TO_LIBRARY: Final = 1 +SERVICE_OPTION_ADD_ALBUM_TO_LIBRARY: Final = 2 +SERVICE_OPTION_ADD_STATION_TO_LIBRARY: Final = 3 +SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY: Final = 4 +SERVICE_OPTION_REMOVE_TRACK_FROM_LIBRARY: Final = 5 +SERVICE_OPTION_REMOVE_ALBUM_FROM_LIBRARY: Final = 6 +SERVICE_OPTION_REMOVE_STATION_FROM_LIBRARY: Final = 7 +SERVICE_OPTION_REMOVE_PLAYLIST_FROM_LIBRARY: Final = 8 +SERVICE_OPTION_THUMBS_UP: Final = 11 +SERVICE_OPTION_THUMBS_DOWN: Final = 12 +SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA: Final = 13 +SERVICE_OPTION_ADD_TO_FAVORITES: Final = 19 +SERVICE_OPTION_REMOVE_FROM_FAVORITES: Final = 20 + +# HEOS Events (keep discrete values as we do not control the list) EVENT_PLAYER_STATE_CHANGED: Final = "event/player_state_changed" EVENT_PLAYER_NOW_PLAYING_CHANGED: Final = "event/player_now_playing_changed" EVENT_PLAYER_NOW_PLAYING_PROGRESS: Final = "event/player_now_playing_progress" diff --git a/pyheos/dispatch.py b/pyheos/dispatch.py index 272cb3e..326a645 100644 --- a/pyheos/dispatch.py +++ b/pyheos/dispatch.py @@ -5,7 +5,7 @@ import logging from collections import defaultdict from collections.abc import Callable, Sequence -from typing import Any, Final +from typing import Any, Final, TypeVar _LOGGER: Final = logging.getLogger(__name__) @@ -14,10 +14,15 @@ ConnectType = Callable[[str, TargetType], DisconnectType] SendType = Callable[..., Sequence[asyncio.Future]] -EventCallbackType = Callable[[str], Any] +TEvent = TypeVar("TEvent", bound=str) +TPlayerId = TypeVar("TPlayerId", bound=int) +TGroupId = TypeVar("TGroupId", bound=int) + CallbackType = Callable[[], Any] -ControllerEventCallbackType = Callable[[str, Any], Any] -PlayerEventCallbackType = Callable[[int, str], Any] +EventCallbackType = Callable[[TEvent], Any] +ControllerEventCallbackType = Callable[[TEvent, Any], Any] +PlayerEventCallbackType = Callable[[TPlayerId, TEvent], Any] +GroupEventCallbackType = Callable[[TGroupId, TEvent], Any] def _is_coroutine_function(func: TargetType) -> bool: diff --git a/pyheos/error.py b/pyheos/error.py index 3914837..5fe1127 100644 --- a/pyheos/error.py +++ b/pyheos/error.py @@ -1,39 +1,25 @@ """Define the error module for HEOS.""" -import asyncio -from functools import cached_property -from typing import Final - -from pyheos import const +from pyheos.command import ATTR_ERROR_ID, ATTR_SYSTEM_ERROR_NUMBER, ATTR_TEXT +from pyheos.const import ( + ERROR_INVALID_CREDNETIALS, + ERROR_SYSTEM_ERROR, + ERROR_USER_NOT_FOUND, + ERROR_USER_NOT_LOGGED_IN, + SYSTEM_ERROR_USER_NOT_FOUND, + SYSTEM_ERROR_USER_NOT_LOGGED_IN, +) from pyheos.message import HeosMessage -DEFAULT_ERROR_MESSAGES: Final[dict[type[Exception], str]] = { - asyncio.TimeoutError: "Command timed out", - ConnectionError: "Connection error", - BrokenPipeError: "Broken pipe", - ConnectionAbortedError: "Connection aborted", - ConnectionRefusedError: "Connection refused", - ConnectionResetError: "Connection reset", - OSError: "OS I/O error", -} - - -def format_error_message(error: Exception) -> str: - """Format the error message based on a base error.""" - error_message: str = str(error) - if not error_message: - error_message = DEFAULT_ERROR_MESSAGES.get(type(error), type(error).__name__) - return error_message - class HeosError(Exception): - """Define an error from the heos library.""" + """Define an error from the HEOS library.""" pass class CommandError(HeosError): - """Define an error command response.""" + """Define an error for when a HEOS command send fails.""" def __init__(self, command: str, message: str): """Create a new instance of the error.""" @@ -47,7 +33,7 @@ def command(self) -> str: class CommandFailedError(CommandError): - """Define an error when a HEOS command fails.""" + """Define an error for when a HEOS command is sent, but a failure response is returned.""" def __init__( self, @@ -63,18 +49,39 @@ def __init__( self._system_error_number = system_error_number super().__init__(command, f"{text} ({error_id})") + @staticmethod + def __is_authentication_error( + error_id: int, system_error_number: int | None + ) -> bool: + """Return True if the error is related to authentication, otherwise False.""" + if error_id == ERROR_SYSTEM_ERROR: + return system_error_number in ( + SYSTEM_ERROR_USER_NOT_LOGGED_IN, + SYSTEM_ERROR_USER_NOT_FOUND, + ) + return error_id in ( + ERROR_INVALID_CREDNETIALS, + ERROR_USER_NOT_LOGGED_IN, + ERROR_USER_NOT_FOUND, + ) + @classmethod - def from_message(cls, message: HeosMessage) -> "CommandFailedError": + def _from_message(cls, message: HeosMessage) -> "CommandFailedError": """Create a new instance of the error from a message.""" - error_text = message.get_message_value(const.ATTR_TEXT) + error_text = message.get_message_value(ATTR_TEXT) system_error_number = None - error_id = message.get_message_value_int(const.ATTR_ERROR_ID) - if error_id == const.ERROR_SYSTEM_ERROR: + error_id = message.get_message_value_int(ATTR_ERROR_ID) + if error_id == ERROR_SYSTEM_ERROR: system_error_number = message.get_message_value_int( - const.ATTR_SYSTEM_ERROR_NUMBER + ATTR_SYSTEM_ERROR_NUMBER ) error_text += f" {system_error_number}" + if CommandFailedError.__is_authentication_error(error_id, system_error_number): + return CommandAuthenticationError( + message.command, error_text, error_id, system_error_number + ) + return CommandFailedError( message.command, error_text, error_id, system_error_number ) @@ -94,16 +101,8 @@ def system_error_number(self) -> int | None: """Return the system error number if available.""" return self._system_error_number - @cached_property - def is_credential_error(self) -> bool: - """Return True if the error is related to authentication, otherwise False.""" - if self.error_id == const.ERROR_SYSTEM_ERROR: - return self.system_error_number in ( - const.SYSTEM_ERROR_USER_NOT_LOGGED_IN, - const.SYSTEM_ERROR_USER_NOT_FOUND, - ) - return self._error_id in ( - const.ERROR_INVALID_CREDNETIALS, - const.ERROR_USER_NOT_LOGGED_IN, - const.ERROR_USER_NOT_FOUND, - ) + +class CommandAuthenticationError(CommandFailedError): + """Define an error for when a command succeeds, but an authentication error is returned.""" + + pass diff --git a/pyheos/group.py b/pyheos/group.py index e1524ec..cdd3e04 100644 --- a/pyheos/group.py +++ b/pyheos/group.py @@ -5,9 +5,12 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Optional +from pyheos.const import DEFAULT_STEP, EVENT_GROUP_VOLUME_CHANGED +from pyheos.dispatch import DisconnectType, EventCallbackType, callback_wrapper from pyheos.message import HeosMessage +from pyheos.types import SignalType -from . import const +from . import command as c if TYPE_CHECKING: from pyheos.heos import Heos @@ -25,47 +28,83 @@ class HeosGroup: is_muted: bool = False heos: Optional["Heos"] = field(repr=False, hash=False, compare=False, default=None) - @classmethod - def from_data( - cls, + @staticmethod + def _from_data( data: dict[str, Any], heos: Optional["Heos"] = None, ) -> "HeosGroup": """Create a new instance from the provided data.""" player_id: int | None = None player_ids: list[int] = [] - for group_player in data[const.ATTR_PLAYERS]: - # Find the loaded player - member_player_id = int(group_player[const.ATTR_PLAYER_ID]) - if group_player[const.ATTR_ROLE] == const.VALUE_LEADER: - player_id = member_player_id - else: - player_ids.append(member_player_id) - if player_id is None: - raise ValueError("No leader found in group data") - return cls( - name=data[const.ATTR_NAME], - group_id=int(data[const.ATTR_GROUP_ID]), + player_id, player_ids = HeosGroup.__get_ids(data[c.ATTR_PLAYERS]) + return HeosGroup( + name=data[c.ATTR_NAME], + group_id=int(data[c.ATTR_GROUP_ID]), lead_player_id=player_id, member_player_ids=player_ids, heos=heos, ) - async def on_event(self, event: HeosMessage) -> bool: + @staticmethod + def __get_ids(players: list[dict[str, Any]]) -> tuple[int, list[int]]: + """Get the leader and members from the player data.""" + lead_player_id: int | None = None + member_player_ids: list[int] = [] + for member_player in players: + # Find the loaded player + member_player_id = int(member_player[c.ATTR_PLAYER_ID]) + if member_player[c.ATTR_ROLE] == c.VALUE_LEADER: + lead_player_id = member_player_id + else: + member_player_ids.append(member_player_id) + if lead_player_id is None: + raise ValueError("No leader found in group data") + return lead_player_id, member_player_ids + + def _update_from_data(self, data: dict[str, Any]) -> None: + """Update the group with the provided data.""" + self.name = data[c.ATTR_NAME] + self.group_id = int(data[c.ATTR_GROUP_ID]) + self.lead_player_id, self.member_player_ids = self.__get_ids( + data[c.ATTR_PLAYERS] + ) + + async def _on_event(self, event: HeosMessage) -> bool: """Handle a group update event.""" if not ( - event.command == const.EVENT_GROUP_VOLUME_CHANGED - and event.get_message_value_int(const.ATTR_GROUP_ID) == self.group_id + event.command == EVENT_GROUP_VOLUME_CHANGED + and event.get_message_value_int(c.ATTR_GROUP_ID) == self.group_id ): return False - self.volume = event.get_message_value_int(const.ATTR_LEVEL) - self.is_muted = event.get_message_value(const.ATTR_MUTE) == const.VALUE_ON + self.volume = event.get_message_value_int(c.ATTR_LEVEL) + self.is_muted = event.get_message_value(c.ATTR_MUTE) == c.VALUE_ON return True - async def refresh(self) -> None: - """Pull current state.""" + def add_on_group_event(self, callback: EventCallbackType) -> DisconnectType: + """Connect a callback to be invoked when an event occurs for this group. + + Args: + callback: The callback to be invoked. + Returns: + A function that disconnects the callback.""" + assert self.heos, "Heos instance not set" + # Use lambda to yield player_id since the value can change + return self.heos.dispatcher.connect( + SignalType.GROUP_EVENT, + callback_wrapper(callback, {0: lambda: self.group_id}), + ) + + async def refresh(self, *, refresh_base_info: bool = True) -> None: + """Pulls the current volume and mute state of the group. + + Args: + refresh_base_info: When True, the base information of the group, including the name and members, will also be pulled. Defaults is False. + """ assert self.heos, "Heos instance not set" - await asyncio.gather(self.refresh_volume(), self.refresh_mute()) + if refresh_base_info: + await self.heos.get_group_info(group=self, refresh=True) + else: + await asyncio.gather(self.refresh_volume(), self.refresh_mute()) async def refresh_volume(self) -> None: """Pull the latest volume.""" @@ -82,12 +121,12 @@ async def set_volume(self, level: int) -> None: assert self.heos, "Heos instance not set" await self.heos.set_group_volume(self.group_id, level) - async def volume_up(self, step: int = const.DEFAULT_STEP) -> None: + async def volume_up(self, step: int = DEFAULT_STEP) -> None: """Raise the volume.""" assert self.heos, "Heos instance not set" await self.heos.group_volume_up(self.group_id, step) - async def volume_down(self, step: int = const.DEFAULT_STEP) -> None: + async def volume_down(self, step: int = DEFAULT_STEP) -> None: """Raise the volume.""" assert self.heos, "Heos instance not set" await self.heos.group_volume_down(self.group_id, step) diff --git a/pyheos/heos.py b/pyheos/heos.py index e5af9fb..8ba60f0 100644 --- a/pyheos/heos.py +++ b/pyheos/heos.py @@ -1,17 +1,13 @@ """Define the heos manager module.""" -import asyncio import logging -from collections.abc import Sequence -from dataclasses import dataclass, field -from typing import Any, Final, cast +from typing import Any, Final from pyheos.command import COMMAND_SIGN_IN from pyheos.command.browse import BrowseCommands from pyheos.command.group import GroupCommands from pyheos.command.player import PlayerCommands from pyheos.command.system import SystemCommands -from pyheos.credentials import Credentials from pyheos.dispatch import ( CallbackType, ControllerEventCallbackType, @@ -19,848 +15,25 @@ EventCallbackType, callback_wrapper, ) -from pyheos.error import CommandError, CommandFailedError -from pyheos.media import ( - BrowseResult, - MediaItem, - MediaMusicSource, -) +from pyheos.error import CommandAuthenticationError, CommandFailedError from pyheos.message import HeosMessage -from pyheos.system import HeosHost, HeosSystem +from pyheos.options import HeosOptions +from pyheos.player import PlayerUpdateResult +from pyheos.system import HeosSystem +from . import command as c from . import const -from .connection import AutoReconnectingConnection from .dispatch import Dispatcher -from .group import HeosGroup -from .player import HeosNowPlayingMedia, HeosPlayer, PlayMode +from .types import ( + ConnectionState, + SignalHeosEvent, + SignalType, +) _LOGGER: Final = logging.getLogger(__name__) -@dataclass(frozen=True) -class HeosOptions: - """ - The HeosOptions encapsulates options for connecting to a Heos System. - - Args: - host: A host name or IP address of a HEOS-capable device. - timeout: The timeout in seconds for opening a connectoin and issuing commands to the device. - events: Set to True to enable event updates, False to disable. The default is True. - heart_beat: Set to True to enable heart beat messages, False to disable. Used in conjunction with heart_beat_delay. The default is True. - heart_beat_interval: The interval in seconds between heart beat messages. Used in conjunction with heart_beat. - all_progress_events: Set to True to receive media progress events, False to only receive media changed events. The default is True. - dispatcher: The dispatcher instance to use for event callbacks. If not provided, an internally created instance will be used. - auto_reconnect: Set to True to automatically reconnect if the connection is lost. The default is False. Used in conjunction with auto_reconnect_delay. - auto_reconnect_delay: The delay in seconds before attempting to reconnect. The default is 10 seconds. Used in conjunction with auto_reconnect. - credentials: credentials to use to automatically sign-in to the HEOS account upon successful connection. If not provided, the account will not be signed in. - """ - - host: str - timeout: float = field(default=const.DEFAULT_TIMEOUT, kw_only=True) - events: bool = field(default=True, kw_only=True) - all_progress_events: bool = field(default=True, kw_only=True) - dispatcher: Dispatcher | None = field(default=None, kw_only=True) - auto_reconnect: bool = field(default=False, kw_only=True) - auto_reconnect_delay: float = field( - default=const.DEFAULT_RECONNECT_DELAY, kw_only=True - ) - auto_reconnect_max_attempts: int = field( - default=const.DEFAULT_RECONNECT_ATTEMPTS, kw_only=True - ) - heart_beat: bool = field(default=True, kw_only=True) - heart_beat_interval: float = field(default=const.DEFAULT_HEART_BEAT, kw_only=True) - credentials: Credentials | None = field(default=None, kw_only=True) - - -class ConnectionMixin: - "A mixin to provide access to the connection." - - def __init__(self, options: HeosOptions) -> None: - """Init a new instance of the ConnectionMixin.""" - self._options = options - self._connection = AutoReconnectingConnection( - options.host, - timeout=options.timeout, - reconnect=options.auto_reconnect, - reconnect_delay=options.auto_reconnect_delay, - reconnect_max_attempts=options.auto_reconnect_max_attempts, - heart_beat=options.heart_beat, - heart_beat_interval=options.heart_beat_interval, - ) - - @property - def connection_state(self) -> str: - """Get the state of the connection.""" - return self._connection.state - - -class SystemMixin(ConnectionMixin): - """A mixin to provide access to the system commands.""" - - def __init__(self, *args: Any, **kwargs: Any) -> None: - """Init a new instance of the BrowseMixin.""" - super(SystemMixin, self).__init__(*args, **kwargs) - - self._current_credentials = self._options.credentials - self._signed_in_username: str | None = None - - @property - def is_signed_in(self) -> bool: - """Return True if the HEOS accuont is signed in.""" - return bool(self._signed_in_username) - - @property - def signed_in_username(self) -> str | None: - """Return the signed-in username.""" - return self._signed_in_username - - @property - def current_credentials(self) -> Credentials | None: - """Return the current credential, if any set.""" - return self._current_credentials - - async def register_for_change_events(self, enable: bool) -> None: - """Register for change events. - - References: - 4.1.1 Register for Change Events""" - await self._connection.command( - SystemCommands.register_for_change_events(enable) - ) - - async def check_account(self) -> str | None: - """Return the logged in username. - - References: - 4.1.2 HEOS Account Check""" - result = await self._connection.command(SystemCommands.check_account()) - if const.ATTR_SIGNED_IN in result.message: - self._signed_in_username = result.get_message_value(const.ATTR_USER_NAME) - else: - self._signed_in_username = None - return self._signed_in_username - - async def sign_in( - self, username: str, password: str, *, update_credential: bool = True - ) -> str: - """Sign in to the HEOS account using the provided credential and return the user name. - - Args: - username: The username of the HEOS account. - password: The password of the HEOS account. - update_credential: Set to True to update the stored credential if login is successful, False to keep the current credential. The default is True. If the credential is updated, it will be used to signed in automatically upon reconnection. - - Returns: - The username of the signed in account. - - References: - 4.1.3 HEOS Account Sign In""" - result = await self._connection.command( - SystemCommands.sign_in(username, password) - ) - self._signed_in_username = result.get_message_value(const.ATTR_USER_NAME) - if update_credential: - self._current_credentials = Credentials(username, password) - return self._signed_in_username - - async def sign_out(self, *, update_credential: bool = True) -> None: - """Sign out of the HEOS account. - - Args: - update_credential: Set to True to clear the stored credential, False to keep it. The default is True. If the credential is cleared, the account will not be signed in automatically upon reconnection. - - References: - 4.1.4 HEOS Account Sign Out""" - await self._connection.command(SystemCommands.sign_out()) - self._signed_in_username = None - if update_credential: - self._current_credentials = None - - async def heart_beat(self) -> None: - """Send a heart beat message to the HEOS device. - - References: - 4.1.5 HEOS System Heart Beat""" - await self._connection.command(SystemCommands.heart_beat()) - - async def reboot(self) -> None: - """Reboot the HEOS device. - - References: - 4.1.6 HEOS Speaker Reboot""" - await self._connection.command(SystemCommands.reboot()) - - async def get_system_info(self) -> HeosSystem: - """Get information about the HEOS system. - - References: - 4.2.1 Get Players""" - response = await self._connection.command(PlayerCommands.get_players()) - payload = cast(Sequence[dict], response.payload) - hosts = list([HeosHost.from_data(item) for item in payload]) - host = next(host for host in hosts if host.ip_address == self._options.host) - return HeosSystem(self._signed_in_username, host, hosts) - - -class BrowseMixin(ConnectionMixin): - """A mixin to provide access to the browse commands.""" - - def __init__(self, *args: Any, **kwargs: Any) -> None: - """Init a new instance of the BrowseMixin.""" - super(BrowseMixin, self).__init__(*args, **kwargs) - - self._music_sources: dict[int, MediaMusicSource] = {} - self._music_sources_loaded = False - - @property - def music_sources(self) -> dict[int, MediaMusicSource]: - """Get available music sources.""" - return self._music_sources - - async def get_music_sources( - self, refresh: bool = False - ) -> dict[int, MediaMusicSource]: - """ - Get available music sources. - - References: - 4.4.1 Get Music Sources - """ - if not self._music_sources_loaded or refresh: - message = await self._connection.command( - BrowseCommands.get_music_sources(refresh) - ) - self._music_sources.clear() - for data in cast(Sequence[dict], message.payload): - source = MediaMusicSource.from_data(data, cast("Heos", self)) - self._music_sources[source.source_id] = source - self._music_sources_loaded = True - return self._music_sources - - async def browse( - self, - source_id: int, - container_id: str | None = None, - range_start: int | None = None, - range_end: int | None = None, - ) -> BrowseResult: - """Browse the contents of the specified source or container. - - References: - 4.4.3 Browse Source - 4.4.4 Browse Source Containers - 4.4.13 Get HEOS Playlists - 4.4.16 Get HEOS History - - Args: - source_id: The identifier of the source to browse. - container_id: The identifier of the container to browse. If not provided, the root of the source will be expanded. - range_start: The index of the first item to return. Both range_start and range_end must be provided to return a range of items. - range_end: The index of the last item to return. Both range_start and range_end must be provided to return a range of items. - Returns: - A BrowseResult instance containing the items in the source or container. - """ - message = await self._connection.command( - BrowseCommands.browse(source_id, container_id, range_start, range_end) - ) - return BrowseResult.from_data(message, cast("Heos", self)) - - async def browse_media( - self, - media: MediaItem | MediaMusicSource, - range_start: int | None = None, - range_end: int | None = None, - ) -> BrowseResult: - """Browse the contents of the specified media item. - - References: - 4.4.3 Browse Source - 4.4.4 Browse Source Containers - 4.4.13 Get HEOS Playlists - 4.4.16 Get HEOS History - - Args: - media: The media item to browse, must be of type MediaItem or MediaMusicSource. - range_start: The index of the first item to return. Both range_start and range_end must be provided to return a range of items. - range_end: The index of the last item to return. Both range_start and range_end must be provided to return a range of items. - Returns: - A BrowseResult instance containing the items in the media item. - """ - if isinstance(media, MediaMusicSource): - if not media.available: - raise ValueError("Source is not available to browse") - return await self.browse(media.source_id) - else: - if not media.browsable: - raise ValueError("Only media sources and containers can be browsed") - return await self.browse( - media.source_id, media.container_id, range_start, range_end - ) - - async def play_input_source( - self, player_id: int, input: str, source_player_id: int | None = None - ) -> None: - """ - Play the specified input source on the specified player. - - References: - 4.4.9 Play Input Source - - Args: - player_id: The identifier of the player to play the input source. - input: The input source to play. - source_player_id: The identifier of the player that has the input source, if different than the player_id. - """ - await self._connection.command( - BrowseCommands.play_input_source(player_id, input, source_player_id) - ) - - async def play_station( - self, player_id: int, source_id: int, container_id: str | None, media_id: str - ) -> None: - """ - Play the specified station on the specified player. - - References: - 4.4.7 Play Station - - Args: - player_id: The identifier of the player to play the station. - source_id: The identifier of the source containing the station. - container_id: The identifier of the container containing the station. - media_id: The identifier of the station to play. - """ - await self._connection.command( - BrowseCommands.play_station(player_id, source_id, container_id, media_id) - ) - - async def play_preset_station(self, player_id: int, index: int) -> None: - """ - Play the preset station on the specified player (favorite) - - References: - 4.4.8 Play Preset Station - - Args: - player_id: The identifier of the player to play the preset station. - index: The index of the preset station to play. - """ - await self._connection.command( - BrowseCommands.play_preset_station(player_id, index) - ) - - async def play_url(self, player_id: int, url: str) -> None: - """ - Play the specified URL on the specified player. - - References: - 4.4.10 Play URL - - Args: - player_id: The identifier of the player to play the URL. - url: The URL to play. - """ - await self._connection.command(BrowseCommands.play_url(player_id, url)) - - async def add_to_queue( - self, - player_id: int, - source_id: int, - container_id: str, - media_id: str | None = None, - add_criteria: const.AddCriteriaType = const.AddCriteriaType.PLAY_NOW, - ) -> None: - """ - Add the specified media item to the queue of the specified player. - - References: - 4.4.11 Add Container to Queue with Options - 4.4.12 Add Track to Queue with Options - - Args: - player_id: The identifier of the player to add the media item. - source_id: The identifier of the source containing the media item. - container_id: The identifier of the container containing the media item. - media_id: The identifier of the media item to add. Required for MediaType.Song. - add_criteria: Determines how tracks are added to the queue. The default is AddCriteriaType.PLAY_NOW. - """ - await self._connection.command( - BrowseCommands.add_to_queue( - player_id=player_id, - source_id=source_id, - container_id=container_id, - media_id=media_id, - add_criteria=add_criteria, - ) - ) - - async def play_media( - self, - player_id: int, - media: MediaItem, - add_criteria: const.AddCriteriaType = const.AddCriteriaType.PLAY_NOW, - ) -> None: - """ - Play the specified media item on the specified player. - - Args: - player_id: The identifier of the player to play the media item. - media: The media item to play. - add_criteria: Determines how containers or tracks are added to the queue. The default is AddCriteriaType.PLAY_NOW. - """ - if not media.playable: - raise ValueError(f"Media '{media}' is not playable") - - if media.media_id in const.VALID_INPUTS: - await self.play_input_source(player_id, media.media_id, media.source_id) - elif media.type == const.MediaType.STATION: - if media.media_id is None: - raise ValueError(f"'Media '{media}' cannot have a None media_id") - await self.play_station( - player_id=player_id, - source_id=media.source_id, - container_id=media.container_id, - media_id=media.media_id, - ) - else: - # Handles both songs and containers - if media.container_id is None: - raise ValueError(f"Media '{media}' cannot have a None container_id") - await self.add_to_queue( - player_id=player_id, - source_id=media.source_id, - container_id=media.container_id, - media_id=media.media_id, - add_criteria=add_criteria, - ) - - async def get_input_sources(self) -> Sequence[MediaItem]: - """ - Get available input sources. - - This will browse all aux input sources and return a list of all available input sources. - - Returns: - A sequence of MediaItem instances representing the available input sources across all aux input sources. - """ - result = await self.browse(const.MUSIC_SOURCE_AUX_INPUT) - input_sources: list[MediaItem] = [] - for item in result.items: - source_browse_result = await item.browse() - input_sources.extend(source_browse_result.items) - - return input_sources - - async def get_favorites(self) -> dict[int, MediaItem]: - """ - Get available favorites. - - This will browse the favorites music source and return a dictionary of all available favorites. - - Returns: - A dictionary with keys representing the index (1-based) of the favorite and the value being the MediaItem instance. - """ - result = await self.browse(const.MUSIC_SOURCE_FAVORITES) - return {index + 1: source for index, source in enumerate(result.items)} - - async def get_playlists(self) -> Sequence[MediaItem]: - """ - Get available playlists. - - This will browse the playlists music source and return a list of all available playlists. - - Returns: - A sequence of MediaItem instances representing the available playlists. - """ - result = await self.browse(const.MUSIC_SOURCE_PLAYLISTS) - return result.items - - -class PlayerMixin(ConnectionMixin): - """A mixin to provide access to the player commands.""" - - def __init__(self, *args: Any, **kwargs: Any) -> None: - """Init a new instance of the BrowseMixin.""" - super(PlayerMixin, self).__init__(*args, **kwargs) - - self._players: dict[int, HeosPlayer] = {} - self._players_loaded = False - - @property - def players(self) -> dict[int, HeosPlayer]: - """Get the loaded players.""" - return self._players - - async def get_players(self, *, refresh: bool = False) -> dict[int, HeosPlayer]: - """Get available players. - - References: - 4.2.1 Get Players""" - # get players and pull initial state - if not self._players_loaded or refresh: - await self.load_players() - return self._players - - async def load_players(self) -> dict[str, list | dict]: - """Refresh the players.""" - new_player_ids = [] - mapped_player_ids = {} - players = {} - response = await self._connection.command(PlayerCommands.get_players()) - payload = cast(Sequence[dict], response.payload) - existing = list(self._players.values()) - for player_data in payload: - player_id = player_data[const.ATTR_PLAYER_ID] - name = player_data[const.ATTR_NAME] - version = player_data[const.ATTR_VERSION] - # Try finding existing player by id or match name when firmware - # version is different because IDs change after a firmware upgrade - player = next( - ( - player - for player in existing - if player.player_id == player_id - or (player.name == name and player.version != version) - ), - None, - ) - if player: - # Existing player matched - update - if player.player_id != player_id: - mapped_player_ids[player_id] = player.player_id - player.update_from_data(player_data) - player.available = True - players[player_id] = player - existing.remove(player) - else: - # New player - player = HeosPlayer.from_data(player_data, cast("Heos", self)) - new_player_ids.append(player_id) - players[player_id] = player - # For any item remaining in existing, mark unavailalbe, add to updated - for player in existing: - player.available = False - players[player.player_id] = player - - # Update all statuses - await asyncio.gather( - *[player.refresh() for player in players.values() if player.available] - ) - self._players = players - self._players_loaded = True - return { - const.DATA_NEW: new_player_ids, - const.DATA_MAPPED_IDS: mapped_player_ids, - } - - async def player_get_play_state(self, player_id: int) -> const.PlayState: - """Get the state of the player. - - References: - 4.2.3 Get Play State""" - response = await self._connection.command( - PlayerCommands.get_play_state(player_id) - ) - return const.PlayState(response.get_message_value(const.ATTR_STATE)) - - async def player_set_play_state( - self, player_id: int, state: const.PlayState - ) -> None: - """Set the state of the player. - - References: - 4.2.4 Set Play State""" - await self._connection.command(PlayerCommands.set_play_state(player_id, state)) - - async def get_now_playing_media( - self, player_id: int, update: HeosNowPlayingMedia | None = None - ) -> HeosNowPlayingMedia: - """Get the now playing media information. - - Args: - player_id: The identifier of the player to get the now playing media. - update: The current now playing media information to update. If not provided, a new instance will be created. - - Returns: - A HeosNowPlayingMedia instance containing the now playing media information. - - References: - 4.2.5 Get Now Playing Media""" - result = await self._connection.command( - PlayerCommands.get_now_playing_media(player_id) - ) - instance = update or HeosNowPlayingMedia() - instance.update_from_message(result) - return instance - - async def player_get_volume(self, player_id: int) -> int: - """Get the volume level of the player. - - References: - 4.2.6 Get Volume""" - result = await self._connection.command(PlayerCommands.get_volume(player_id)) - return result.get_message_value_int(const.ATTR_LEVEL) - - async def player_set_volume(self, player_id: int, level: int) -> None: - """Set the volume of the player. - - References: - 4.2.7 Set Volume""" - await self._connection.command(PlayerCommands.set_volume(player_id, level)) - - async def player_volume_up( - self, player_id: int, step: int = const.DEFAULT_STEP - ) -> None: - """Increase the volume level. - - References: - 4.2.8 Volume Up""" - await self._connection.command(PlayerCommands.volume_up(player_id, step)) - - async def player_volume_down( - self, player_id: int, step: int = const.DEFAULT_STEP - ) -> None: - """Increase the volume level. - - References: - 4.2.9 Volume Down""" - await self._connection.command(PlayerCommands.volume_down(player_id, step)) - - async def player_get_mute(self, player_id: int) -> bool: - """Get the mute state of the player. - - References: - 4.2.10 Get Mute""" - result = await self._connection.command(PlayerCommands.get_mute(player_id)) - return result.get_message_value(const.ATTR_STATE) == const.VALUE_ON - - async def player_set_mute(self, player_id: int, state: bool) -> None: - """Set the mute state of the player. - - References: - 4.2.11 Set Mute""" - await self._connection.command(PlayerCommands.set_mute(player_id, state)) - - async def player_toggle_mute(self, player_id: int) -> None: - """Toggle the mute state. - - References: - 4.2.12 Toggle Mute""" - await self._connection.command(PlayerCommands.toggle_mute(player_id)) - - async def player_get_play_mode(self, player_id: int) -> PlayMode: - """Get the play mode of the player. - - References: - 4.2.13 Get Play Mode""" - result = await self._connection.command(PlayerCommands.get_play_mode(player_id)) - return PlayMode.from_data(result) - - async def player_set_play_mode( - self, player_id: int, repeat: const.RepeatType, shuffle: bool - ) -> None: - """Set the play mode of the player. - - References: - 4.2.14 Set Play Mode""" - await self._connection.command( - PlayerCommands.set_play_mode(player_id, repeat, shuffle) - ) - - async def player_clear_queue(self, player_id: int) -> None: - """Clear the queue. - - References: - 4.2.19 Clear Queue""" - await self._connection.command(PlayerCommands.clear_queue(player_id)) - - async def player_play_next(self, player_id: int) -> None: - """Play next. - - References: - 4.2.21 Play Next""" - await self._connection.command(PlayerCommands.play_next(player_id)) - - async def player_play_previous(self, player_id: int) -> None: - """Play next. - - References: - 4.2.22 Play Previous""" - await self._connection.command(PlayerCommands.play_previous(player_id)) - - async def player_set_quick_select( - self, player_id: int, quick_select_id: int - ) -> None: - """Play a quick select. - - References: - 4.2.23 Set QuickSelect""" - await self._connection.command( - PlayerCommands.set_quick_select(player_id, quick_select_id) - ) - - async def player_play_quick_select( - self, player_id: int, quick_select_id: int - ) -> None: - """Play a quick select. - - References: - 4.2.24 Play QuickSelect""" - await self._connection.command( - PlayerCommands.play_quick_select(player_id, quick_select_id) - ) - - async def get_player_quick_selects(self, player_id: int) -> dict[int, str]: - """Get quick selects. - - References: - 4.2.25 Get QuickSelects""" - result = await self._connection.command( - PlayerCommands.get_quick_selects(player_id) - ) - return { - int(data[const.ATTR_ID]): data[const.ATTR_NAME] - for data in cast(list[dict], result.payload) - } - - -class GroupMixin(PlayerMixin): - """A mixin to provide access to the group commands.""" - - def __init__(self, *args: Any, **kwargs: Any) -> None: - """Init a new instance of the BrowseMixin.""" - super(GroupMixin, self).__init__(*args, **kwargs) - self._groups: dict[int, HeosGroup] = {} - self._groups_loaded = False - - @property - def groups(self) -> dict[int, HeosGroup]: - """Get the loaded groups.""" - return self._groups - - async def get_groups(self, *, refresh: bool = False) -> dict[int, HeosGroup]: - """Get available groups.""" - if not self._groups_loaded or refresh: - groups = {} - result = await self._connection.command(GroupCommands.get_groups()) - payload = cast(Sequence[dict], result.payload) - for data in payload: - group = HeosGroup.from_data(data, cast("Heos", self)) - groups[group.group_id] = group - self._groups = groups - # Update all statuses - await asyncio.gather(*[group.refresh() for group in self._groups.values()]) - self._groups_loaded = True - return self._groups - - async def set_group(self, player_ids: Sequence[int]) -> None: - """Create, modify, or ungroup players. - - Args: - player_ids: The list of player identifiers to group or ungroup. The first player is the group leader. - - References: - 4.3.3 Set Group""" - await self._connection.command(GroupCommands.set_group(player_ids)) - - async def create_group( - self, leader_player_id: int, member_player_ids: Sequence[int] - ) -> None: - """Create a HEOS group. - - Args: - leader_player_id: The player_id of the lead player in the group. - member_player_ids: The player_ids of the group members. - - References: - 4.3.3 Set Group""" - player_ids = [leader_player_id] - player_ids.extend(member_player_ids) - await self.set_group(player_ids) - - async def remove_group(self, group_id: int) -> None: - """Ungroup the specified group. - - Args: - group_id: The identifier of the group to ungroup. Must be the lead player. - - References: - 4.3.3 Set Group - """ - await self.set_group([group_id]) - - async def update_group( - self, group_id: int, member_player_ids: Sequence[int] - ) -> None: - """Update the membership of a group. - - Args: - group_id: The identifier of the group to update (same as the lead player_id) - member_player_ids: The new player_ids of the group members. - """ - await self.create_group(group_id, member_player_ids) - - async def get_group_volume(self, group_id: int) -> int: - """ - Get the volume of a group. - - References: - 4.3.4 Get Group Volume - """ - result = await self._connection.command( - GroupCommands.get_group_volume(group_id) - ) - return result.get_message_value_int(const.ATTR_LEVEL) - - async def set_group_volume(self, group_id: int, level: int) -> None: - """Set the volume of the group. - - References: - 4.3.5 Set Group Volume""" - await self._connection.command(GroupCommands.set_group_volume(group_id, level)) - - async def group_volume_up( - self, group_id: int, step: int = const.DEFAULT_STEP - ) -> None: - """Increase the volume level. - - References: - 4.3.6 Group Volume Up""" - await self._connection.command(GroupCommands.group_volume_up(group_id, step)) - - async def group_volume_down( - self, group_id: int, step: int = const.DEFAULT_STEP - ) -> None: - """Increase the volume level. - - References: - 4.2.7 Group Volume Down""" - await self._connection.command(GroupCommands.group_volume_down(group_id, step)) - - async def get_group_mute(self, group_id: int) -> bool: - """Get the mute status of the group. - - References: - 4.3.8 Get Group Mute""" - result = await self._connection.command(GroupCommands.get_group_mute(group_id)) - return result.get_message_value(const.ATTR_STATE) == const.VALUE_ON - - async def group_set_mute(self, group_id: int, state: bool) -> None: - """Set the mute state of the group. - - References: - 4.3.9 Set Group Mute""" - await self._connection.command(GroupCommands.group_set_mute(group_id, state)) - - async def group_toggle_mute(self, group_id: int) -> None: - """Toggle the mute state. - - References: - 4.3.10 Toggle Group Mute""" - await self._connection.command(GroupCommands.group_toggle_mute(group_id)) - - -class Heos(SystemMixin, BrowseMixin, GroupMixin, PlayerMixin): +class Heos(SystemCommands, BrowseCommands, GroupCommands, PlayerCommands): """The Heos class provides access to the CLI API.""" @classmethod @@ -927,7 +100,7 @@ def add_on_controller_event( callback: The callback to receive the controller events. Returns: A function that disconnects the callback.""" - return self._dispatcher.connect(const.SIGNAL_CONTROLLER_EVENT, callback) + return self._dispatcher.connect(SignalType.CONTROLLER_EVENT, callback) def add_on_heos_event(self, callback: EventCallbackType) -> DisconnectType: """Connect a callback to receive HEOS events. @@ -936,7 +109,7 @@ def add_on_heos_event(self, callback: EventCallbackType) -> DisconnectType: callback: The callback to receive the HEOS events. The callback should accept a single string argument which will contain the event name. Returns: A function that disconnects the callback.""" - return self._dispatcher.connect(const.SIGNAL_HEOS_EVENT, callback) + return self._dispatcher.connect(SignalType.HEOS_EVENT, callback) def add_on_connected(self, callback: CallbackType) -> DisconnectType: """Connect a callback to be invoked when connected. @@ -946,7 +119,7 @@ def add_on_connected(self, callback: CallbackType) -> DisconnectType: Returns: A function that disconnects the callback.""" return self.add_on_heos_event( - callback_wrapper(callback, {0: const.EVENT_CONNECTED}), + callback_wrapper(callback, {0: SignalHeosEvent.CONNECTED}), ) def add_on_disconnected(self, callback: CallbackType) -> DisconnectType: @@ -957,7 +130,7 @@ def add_on_disconnected(self, callback: CallbackType) -> DisconnectType: Returns: A function that disconnects the callback.""" return self.add_on_heos_event( - callback_wrapper(callback, {0: const.EVENT_DISCONNECTED}), + callback_wrapper(callback, {0: SignalHeosEvent.DISCONNECTED}), ) def add_on_user_credentials_invalid(self, callback: CallbackType) -> DisconnectType: @@ -968,32 +141,32 @@ def add_on_user_credentials_invalid(self, callback: CallbackType) -> DisconnectT Returns: A function that disconnects the callback.""" return self.add_on_heos_event( - callback_wrapper(callback, {0: const.EVENT_USER_CREDENTIALS_INVALID}), + callback_wrapper(callback, {0: SignalHeosEvent.USER_CREDENTIALS_INVALID}), ) async def _on_connected(self) -> None: """Handle when connected, which may occur more than once.""" - assert self._connection.state == const.STATE_CONNECTED + assert self._connection.state == ConnectionState.CONNECTED await self._dispatcher.wait_send( - const.SIGNAL_HEOS_EVENT, const.EVENT_CONNECTED, return_exceptions=True + SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED, return_exceptions=True ) - if self._current_credentials: + if self.current_credentials: # Sign-in to the account if provided try: await self.sign_in( - self._current_credentials.username, - self._current_credentials.password, + self.current_credentials.username, + self.current_credentials.password, ) - except CommandError as err: - self._signed_in_username = None + except CommandAuthenticationError as err: _LOGGER.debug( "Failed to sign-in to HEOS Account after connection: %s", err ) + self.current_credentials = None await self._dispatcher.wait_send( - const.SIGNAL_HEOS_EVENT, - const.EVENT_USER_CREDENTIALS_INVALID, + SignalType.HEOS_EVENT, + SignalHeosEvent.USER_CREDENTIALS_INVALID, return_exceptions=True, ) else: @@ -1008,26 +181,32 @@ async def _on_connected(self) -> None: async def _on_disconnected(self, from_error: bool) -> None: """Handle when disconnected, which may occur more than once.""" - assert self._connection.state == const.STATE_DISCONNECTED + assert self._connection.state == ConnectionState.DISCONNECTED # Mark loaded players unavailable for player in self.players.values(): player.available = False await self._dispatcher.wait_send( - const.SIGNAL_HEOS_EVENT, const.EVENT_DISCONNECTED, return_exceptions=True + SignalType.HEOS_EVENT, SignalHeosEvent.DISCONNECTED, return_exceptions=True ) async def _on_command_error(self, error: CommandFailedError) -> None: """Handle when a command error occurs.""" - if error.is_credential_error and error.command != COMMAND_SIGN_IN: - self._signed_in_username = None - _LOGGER.debug( - "HEOS Account credentials are no longer valid: %s", - error.error_text, - exc_info=error, - ) + if ( + isinstance(error, CommandAuthenticationError) + and error.command != COMMAND_SIGN_IN + ): + # If we're managing credentials, clear them + if self.current_credentials is not None: + _LOGGER.debug( + "HEOS Account credentials are no longer valid: %s", + error.error_text, + exc_info=error, + ) + # Ensure a stale credential is cleared + await self.sign_out() await self._dispatcher.wait_send( - const.SIGNAL_HEOS_EVENT, - const.EVENT_USER_CREDENTIALS_INVALID, + SignalType.HEOS_EVENT, + SignalHeosEvent.USER_CREDENTIALS_INVALID, return_exceptions=True, ) @@ -1040,53 +219,54 @@ async def _on_event(self, event: HeosMessage) -> None: elif event.command in const.GROUP_EVENTS: await self._on_event_group(event) else: - _LOGGER.debug("Unrecognized event: %s", event) + _LOGGER.debug("Unrecognized event: %s", event.command) async def _on_event_heos(self, event: HeosMessage) -> None: """Process a HEOS system event.""" - result: dict[str, list | dict] | None = None + result: PlayerUpdateResult | None = None if event.command == const.EVENT_PLAYERS_CHANGED and self._players_loaded: result = await self.load_players() if event.command == const.EVENT_SOURCES_CHANGED and self._music_sources_loaded: await self.get_music_sources(refresh=True) elif event.command == const.EVENT_USER_CHANGED: - if const.ATTR_SIGNED_IN in event.message: - self._signed_in_username = event.get_message_value(const.ATTR_USER_NAME) + if c.ATTR_SIGNED_IN in event.message: + self._signed_in_username = event.get_message_value(c.ATTR_USER_NAME) else: self._signed_in_username = None elif event.command == const.EVENT_GROUPS_CHANGED and self._groups_loaded: await self.get_groups(refresh=True) await self._dispatcher.wait_send( - const.SIGNAL_CONTROLLER_EVENT, event.command, result, return_exceptions=True + SignalType.CONTROLLER_EVENT, event.command, result, return_exceptions=True ) - _LOGGER.debug("Event received: %s", event) async def _on_event_player(self, event: HeosMessage) -> None: """Process an event about a player.""" - player_id = event.get_message_value_int(const.ATTR_PLAYER_ID) + player_id = event.get_message_value_int(c.ATTR_PLAYER_ID) player = self.players.get(player_id) - if player and (await player.on_event(event, self._options.all_progress_events)): + if player and ( + await player._on_event(event, self._options.all_progress_events) + ): await self.dispatcher.wait_send( - const.SIGNAL_PLAYER_EVENT, + SignalType.PLAYER_EVENT, player_id, event.command, return_exceptions=True, ) - _LOGGER.debug("Event received for player %s: %s", player, event) + _LOGGER.debug("Event received for player %s: %s", player, event.command) async def _on_event_group(self, event: HeosMessage) -> None: """Process an event about a group.""" - group_id = event.get_message_value_int(const.ATTR_GROUP_ID) + group_id = event.get_message_value_int(c.ATTR_GROUP_ID) group = self.groups.get(group_id) - if group and await group.on_event(event): + if group and await group._on_event(event): await self.dispatcher.wait_send( - const.SIGNAL_GROUP_EVENT, + SignalType.GROUP_EVENT, group_id, event.command, return_exceptions=True, ) - _LOGGER.debug("Event received for group %s: %s", group_id, event) + _LOGGER.debug("Event received for group %s: %s", group_id, event.command) @property def dispatcher(self) -> Dispatcher: diff --git a/pyheos/media.py b/pyheos/media.py index 5ea4a3f..f243296 100644 --- a/pyheos/media.py +++ b/pyheos/media.py @@ -4,13 +4,40 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Optional, cast -from pyheos import const +from pyheos import command as c from pyheos.message import HeosMessage +from pyheos.types import AddCriteriaType, MediaType if TYPE_CHECKING: from . import Heos +@dataclass +class QueueItem: + """Define an item in the queue.""" + + queue_id: int + song: str + album: str + artist: str + image_url: str + media_id: str + album_id: str + + @classmethod + def from_data(cls, data: dict[str, str]) -> "QueueItem": + """Create a new instance from the provided data.""" + return cls( + queue_id=int(data[c.ATTR_QUEUE_ID]), + song=data[c.ATTR_SONG], + album=data[c.ATTR_ALBUM], + artist=data[c.ATTR_ARTIST], + image_url=data[c.ATTR_IMAGE_URL], + media_id=data[c.ATTR_MEDIA_ID], + album_id=data[c.ATTR_ALBUM_ID], + ) + + @dataclass(init=False) class Media: """ @@ -21,7 +48,7 @@ class Media: source_id: int name: str - type: const.MediaType + type: MediaType image_url: str = field(repr=False) heos: Optional["Heos"] = field(repr=False, hash=False, compare=False) @@ -45,15 +72,24 @@ def from_data( ) -> "MediaMusicSource": """Create a new instance from the provided data.""" return cls( - source_id=int(data[const.ATTR_SOURCE_ID]), - name=data[const.ATTR_NAME], - type=const.MediaType(data[const.ATTR_TYPE]), - image_url=data[const.ATTR_IMAGE_URL], - available=data[const.ATTR_AVAILABLE] == const.VALUE_TRUE, - service_username=data.get(const.ATTR_SERVICE_USER_NAME), + source_id=int(data[c.ATTR_SOURCE_ID]), + name=data[c.ATTR_NAME], + type=MediaType(data[c.ATTR_TYPE]), + image_url=data[c.ATTR_IMAGE_URL], + available=data[c.ATTR_AVAILABLE] == c.VALUE_TRUE, + service_username=data.get(c.ATTR_SERVICE_USER_NAME), heos=heos, ) + def _update_from_data(self, data: dict[str, Any]) -> None: + """Update the instance with new data.""" + self.source_id = int(data[c.ATTR_SOURCE_ID]) + self.name = data[c.ATTR_NAME] + self.type = MediaType(data[c.ATTR_TYPE]) + self.image_url = data[c.ATTR_IMAGE_URL] + self.available = data[c.ATTR_AVAILABLE] == c.VALUE_TRUE + self.service_username = data.get(c.ATTR_SERVICE_USER_NAME) + def clone(self) -> "MediaMusicSource": """Create a new instance from the current instance.""" return MediaMusicSource( @@ -66,6 +102,11 @@ def clone(self) -> "MediaMusicSource": heos=self.heos, ) + async def refresh(self) -> None: + """Refresh the instance with the latest data.""" + assert self.heos, "Heos instance not set" + await self.heos.get_music_source_info(music_source=self, refresh=True) + async def browse(self) -> "BrowseResult": """Browse the contents of this source. @@ -98,27 +139,26 @@ def from_data( """Create a new instance from the provided data.""" # Ensure we have a source_id - if const.ATTR_SOURCE_ID not in data and not source_id: + if c.ATTR_SOURCE_ID not in data and not source_id: raise ValueError("'source_id' is required when not present in 'data'") - new_source_id = int(data.get(const.ATTR_SOURCE_ID, source_id)) + new_source_id = int(data.get(c.ATTR_SOURCE_ID, source_id)) # Items is browsable if is a media source, or if it is a container new_browseable = ( - const.ATTR_SOURCE_ID in data - or data.get(const.ATTR_CONTAINER) == const.VALUE_YES + c.ATTR_SOURCE_ID in data or data.get(c.ATTR_CONTAINER) == c.VALUE_YES ) return cls( source_id=new_source_id, - container_id=data.get(const.ATTR_CONTAINER_ID, container_id), + container_id=data.get(c.ATTR_CONTAINER_ID, container_id), browsable=new_browseable, - name=data[const.ATTR_NAME], - type=const.MediaType(data[const.ATTR_TYPE]), - image_url=data[const.ATTR_IMAGE_URL], - playable=data.get(const.ATTR_PLAYABLE) == const.VALUE_YES, - media_id=data.get(const.ATTR_MEDIA_ID), - artist=data.get(const.ATTR_ARTIST), - album=data.get(const.ATTR_ALBUM), - album_id=data.get(const.ATTR_ALBUM_ID), + name=data[c.ATTR_NAME], + type=MediaType(data[c.ATTR_TYPE]), + image_url=data[c.ATTR_IMAGE_URL], + playable=data.get(c.ATTR_PLAYABLE) == c.VALUE_YES, + media_id=data.get(c.ATTR_MEDIA_ID), + artist=data.get(c.ATTR_ARTIST), + album=data.get(c.ATTR_ALBUM), + album_id=data.get(c.ATTR_ALBUM_ID), heos=heos, ) @@ -156,7 +196,7 @@ async def browse( async def play_media( self, player_id: int, - add_criteria: const.AddCriteriaType = const.AddCriteriaType.PLAY_NOW, + add_criteria: AddCriteriaType = AddCriteriaType.PLAY_NOW, ) -> None: """Play this media item on the specified player. @@ -167,6 +207,44 @@ async def play_media( await self.heos.play_media(player_id, self, add_criteria) +@dataclass +class ServiceOption: + """Define a service option.""" + + context: str + id: int + name: str + + @staticmethod + def _from_options( + data: list[dict[str, list[dict[str, Any]]]] | None, + ) -> list["ServiceOption"]: + """Create a list of instances from the provided data.""" + options: list[ServiceOption] = [] + if data is None: + return options + + # Unpack the options and flatten structure. Example payload: + # [{"play": [{"id": 19, "name": "Add to HEOS Favorites"}]}] + for context in data: + for context_key, context_options in context.items(): + options.extend( + [ + ServiceOption.__from_data(context_key, item) + for item in context_options + ] + ) + + return options + + @staticmethod + def __from_data(context: str, data: dict[str, str]) -> "ServiceOption": + """Create a new instance from the provided data.""" + return ServiceOption( + context=context, id=int(data[c.ATTR_ID]), name=data[c.ATTR_NAME] + ) + + @dataclass class BrowseResult: """Define the result of a browse operation.""" @@ -175,20 +253,21 @@ class BrowseResult: returned: int source_id: int items: Sequence[MediaItem] = field(repr=False, hash=False, compare=False) + options: Sequence[ServiceOption] = field(repr=False, hash=False, compare=False) container_id: str | None = None heos: Optional["Heos"] = field(repr=False, hash=False, compare=False, default=None) - @classmethod - def from_data( - cls, message: HeosMessage, heos: Optional["Heos"] = None + @staticmethod + def _from_message( + message: HeosMessage, heos: Optional["Heos"] = None ) -> "BrowseResult": """Create a new instance from the provided data.""" - source_id = message.get_message_value_int(const.ATTR_SOURCE_ID) - container_id = message.message.get(const.ATTR_CONTAINER_ID) + source_id = message.get_message_value_int(c.ATTR_SOURCE_ID) + container_id = message.message.get(c.ATTR_CONTAINER_ID) - return cls( - count=message.get_message_value_int(const.ATTR_COUNT), - returned=message.get_message_value_int(const.ATTR_RETURNED), + return BrowseResult( + count=message.get_message_value_int(c.ATTR_COUNT), + returned=message.get_message_value_int(c.ATTR_RETURNED), source_id=source_id, container_id=container_id, items=list( @@ -197,5 +276,66 @@ def from_data( for item in cast(Sequence[dict], message.payload) ] ), + options=ServiceOption._from_options(message.options), heos=heos, ) + + +@dataclass +class ImageMetadata: + """Define metadata for an image.""" + + image_url: str + width: int + + @staticmethod + def _from_data(data: dict[str, Any]) -> "ImageMetadata": + """Create a new instance from the provided data.""" + return ImageMetadata( + image_url=data[c.ATTR_IMAGE_URL], + width=int(data[c.ATTR_WIDTH]), + ) + + +@dataclass +class AlbumMetadata: + """Define metadata for an album.""" + + album_id: str + images: Sequence[ImageMetadata] = field(repr=False, hash=False, compare=False) + + @staticmethod + def _from_data(data: dict[str, Any]) -> "AlbumMetadata": + """Create a new instance from the provided data.""" + return AlbumMetadata( + album_id=data[c.ATTR_ALBUM_ID], + images=[ + ImageMetadata._from_data(cast(dict[str, Any], image)) + for image in data[c.ATTR_IMAGES] + ], + ) + + +@dataclass +class RetreiveMetadataResult: + "Define the result of a retrieve metadata operation." + + source_id: int + container_id: str + returned: int + count: int + metadata: Sequence[AlbumMetadata] = field(repr=False, hash=False, compare=False) + + @staticmethod + def _from_message(message: HeosMessage) -> "RetreiveMetadataResult": + "Create a new instance from the provided data." + return RetreiveMetadataResult( + source_id=message.get_message_value_int(c.ATTR_SOURCE_ID), + container_id=message.get_message_value(c.ATTR_CONTAINER_ID), + returned=message.get_message_value_int(c.ATTR_RETURNED), + count=message.get_message_value_int(c.ATTR_COUNT), + metadata=[ + AlbumMetadata._from_data(item) + for item in cast(Sequence[dict[str, Any]], message.payload) + ], + ) diff --git a/pyheos/message.py b/pyheos/message.py index 452d15c..6163229 100644 --- a/pyheos/message.py +++ b/pyheos/message.py @@ -6,10 +6,11 @@ from typing import Any, Final from urllib.parse import parse_qsl -from pyheos import const +from pyheos import command as c +BASE_URI: Final = "heos://" QUOTE_MAP: Final = {"&": "%26", "=": "%3D", "%": "%25"} -MASKED_PARAMS: Final = {const.ATTR_PASSWORD} +MASKED_PARAMS: Final = {c.ATTR_PASSWORD} MASK: Final = "********" @@ -20,12 +21,16 @@ class HeosCommand: command: str parameters: dict[str, Any] = field(default_factory=dict) - @property + def __repr__(self) -> str: + """Get a string representaton of the message.""" + return self.uri_masked + + @cached_property def uri(self) -> str: """Get the command as a URI string that can be sent to the controller.""" return self._get_uri(False) - @property + @cached_property def uri_masked(self) -> str: """Get the command as a URI string that has sensitive fields masked.""" return self._get_uri(True) @@ -33,26 +38,26 @@ def uri_masked(self) -> str: def _get_uri(self, mask: bool = False) -> str: """Get the command as a URI string.""" query_string = ( - f"?{HeosCommand._encode_query(self.parameters, mask=mask)}" + f"?{HeosCommand.__encode_query(self.parameters, mask=mask)}" if self.parameters else "" ) - return f"{const.BASE_URI}{self.command}{query_string}" + return f"{BASE_URI}{self.command}{query_string}" - @classmethod - def _quote(cls, value: Any) -> str: + @staticmethod + def __quote(value: Any) -> str: """Quote a string per the CLI specification.""" return "".join([QUOTE_MAP.get(char, char) for char in str(value)]) - @classmethod - def _encode_query(cls, items: dict[str, Any], *, mask: bool = False) -> str: + @staticmethod + def __encode_query(items: dict[str, Any], *, mask: bool = False) -> str: """Encode a dict to query string per CLI specifications.""" pairs = [] for key in sorted(items.keys()): value = MASK if mask and key in MASKED_PARAMS else items[key] - item = f"{key}={HeosCommand._quote(value)}" + item = f"{key}={HeosCommand.__quote(value)}" # Ensure 'url' goes last per CLI spec and is not quoted - if key == const.ATTR_URL: + if key == c.ATTR_URL: pairs.append(f"{key}={value}") else: pairs.insert(0, item) @@ -67,6 +72,7 @@ class HeosMessage: result: bool = True message: dict[str, str] = field(default_factory=dict) payload: dict[str, Any] | list[Any] | None = None + options: list[dict[str, list[dict[str, Any]]]] | None = None _raw_message: str | None = field( init=False, hash=False, repr=False, compare=False, default=None @@ -76,20 +82,19 @@ def __repr__(self) -> str: """Get a string representaton of the message.""" return self._raw_message or f"{self.command} {self.message}" - @classmethod - def from_raw_message(cls, raw_message: str) -> "HeosMessage": + @staticmethod + def _from_raw_message(raw_message: str) -> "HeosMessage": """Create a HeosMessage from a raw message.""" container = json.loads(raw_message) - heos = container[const.ATTR_HEOS] - instance = cls( - command=str(heos[const.ATTR_COMMAND]), - result=bool( - heos.get(const.ATTR_RESULT, const.VALUE_SUCCESS) == const.VALUE_SUCCESS - ), + heos = container[c.ATTR_HEOS] + instance = HeosMessage( + command=str(heos[c.ATTR_COMMAND]), + result=bool(heos.get(c.ATTR_RESULT, c.VALUE_SUCCESS) == c.VALUE_SUCCESS), message=dict( - parse_qsl(heos.get(const.ATTR_MESSAGE, ""), keep_blank_values=True) + parse_qsl(heos.get(c.ATTR_MESSAGE, ""), keep_blank_values=True) ), - payload=container.get(const.ATTR_PAYLOAD), + payload=container.get(c.ATTR_PAYLOAD), + options=container.get(c.ATTR_OPTIONS), ) instance._raw_message = raw_message return instance diff --git a/pyheos/options.py b/pyheos/options.py new file mode 100644 index 0000000..26ec39c --- /dev/null +++ b/pyheos/options.py @@ -0,0 +1,42 @@ +"""Define the options module.""" + +from dataclasses import dataclass, field + +from pyheos import const +from pyheos.credentials import Credentials +from pyheos.dispatch import Dispatcher + + +@dataclass(frozen=True) +class HeosOptions: + """ + The HeosOptions encapsulates options for connecting to a Heos System. + + Args: + host: A host name or IP address of a HEOS-capable device. + timeout: The timeout in seconds for opening a connectoin and issuing commands to the device. + events: Set to True to enable event updates, False to disable. The default is True. + heart_beat: Set to True to enable heart beat messages, False to disable. Used in conjunction with heart_beat_delay. The default is True. + heart_beat_interval: The interval in seconds between heart beat messages. Used in conjunction with heart_beat. + all_progress_events: Set to True to receive media progress events, False to only receive media changed events. The default is True. + dispatcher: The dispatcher instance to use for event callbacks. If not provided, an internally created instance will be used. + auto_reconnect: Set to True to automatically reconnect if the connection is lost. The default is False. Used in conjunction with auto_reconnect_delay. + auto_reconnect_delay: The delay in seconds before attempting to reconnect. The default is 10 seconds. Used in conjunction with auto_reconnect. + credentials: credentials to use to automatically sign-in to the HEOS account upon successful connection. If not provided, the account will not be signed in. + """ + + host: str + timeout: float = field(default=const.DEFAULT_TIMEOUT, kw_only=True) + events: bool = field(default=True, kw_only=True) + all_progress_events: bool = field(default=True, kw_only=True) + dispatcher: Dispatcher | None = field(default=None, kw_only=True) + auto_reconnect: bool = field(default=False, kw_only=True) + auto_reconnect_delay: float = field( + default=const.DEFAULT_RECONNECT_DELAY, kw_only=True + ) + auto_reconnect_max_attempts: int = field( + default=const.DEFAULT_RECONNECT_ATTEMPTS, kw_only=True + ) + heart_beat: bool = field(default=True, kw_only=True) + heart_beat_interval: float = field(default=const.DEFAULT_HEART_BEAT, kw_only=True) + credentials: Credentials | None = field(default=None, kw_only=True) diff --git a/pyheos/player.py b/pyheos/player.py index 96c53ba..2b5c5f0 100644 --- a/pyheos/player.py +++ b/pyheos/player.py @@ -4,17 +4,97 @@ from collections.abc import Sequence from dataclasses import dataclass, field from datetime import datetime -from typing import TYPE_CHECKING, Any, Optional, cast +from typing import TYPE_CHECKING, Any, Final, Optional, cast +from pyheos.command import optional_int, parse_enum from pyheos.dispatch import DisconnectType, EventCallbackType, callback_wrapper -from pyheos.media import MediaItem +from pyheos.media import MediaItem, QueueItem, ServiceOption from pyheos.message import HeosMessage - +from pyheos.types import ( + AddCriteriaType, + ControlType, + LineOutLevelType, + MediaType, + NetworkType, + PlayState, + RepeatType, + SignalType, + VolumeControlType, +) + +from . import command as c from . import const if TYPE_CHECKING: from .heos import Heos +CONTROLS_ALL: Final = [ + ControlType.PLAY, + ControlType.PAUSE, + ControlType.STOP, + ControlType.PLAY_NEXT, + ControlType.PLAY_PREVIOUS, +] +CONTROLS_FORWARD_ONLY: Final = [ + ControlType.PLAY, + ControlType.PAUSE, + ControlType.STOP, + ControlType.PLAY_NEXT, +] +CONTROLS_PLAY_STOP: Final = [ControlType.PLAY, ControlType.STOP] + +SOURCE_CONTROLS: Final = { + const.MUSIC_SOURCE_CONNECT: {MediaType.STATION: CONTROLS_ALL}, + const.MUSIC_SOURCE_PANDORA: {MediaType.STATION: CONTROLS_FORWARD_ONLY}, + const.MUSIC_SOURCE_RHAPSODY: { + MediaType.SONG: CONTROLS_ALL, + MediaType.STATION: CONTROLS_FORWARD_ONLY, + }, + const.MUSIC_SOURCE_TUNEIN: { + MediaType.SONG: CONTROLS_ALL, + MediaType.STATION: CONTROLS_PLAY_STOP, + }, + const.MUSIC_SOURCE_SPOTIFY: { + MediaType.SONG: CONTROLS_ALL, + MediaType.STATION: CONTROLS_FORWARD_ONLY, + }, + const.MUSIC_SOURCE_DEEZER: { + MediaType.SONG: CONTROLS_ALL, + MediaType.STATION: CONTROLS_FORWARD_ONLY, + }, + const.MUSIC_SOURCE_NAPSTER: { + MediaType.SONG: CONTROLS_ALL, + MediaType.STATION: CONTROLS_FORWARD_ONLY, + }, + const.MUSIC_SOURCE_IHEARTRADIO: { + MediaType.SONG: CONTROLS_ALL, + MediaType.STATION: CONTROLS_PLAY_STOP, + }, + const.MUSIC_SOURCE_SIRIUSXM: {MediaType.STATION: CONTROLS_PLAY_STOP}, + const.MUSIC_SOURCE_SOUNDCLOUD: {MediaType.SONG: CONTROLS_ALL}, + const.MUSIC_SOURCE_TIDAL: {MediaType.SONG: CONTROLS_ALL}, + const.MUSIC_SOURCE_AMAZON: { + MediaType.SONG: CONTROLS_ALL, + MediaType.STATION: CONTROLS_ALL, + }, + const.MUSIC_SOURCE_AUX_INPUT: {MediaType.STATION: CONTROLS_PLAY_STOP}, +} + + +@dataclass +class PlayerUpdateResult: + """Define the result of refreshing players. + + Args: + added_player_ids: The list of player identifiers that have been added. + removed_player_ids: The list of player identifiers that have been removed. + updated_player_ids: A dictionary that maps the previous player_id to the updated player_id + """ + + added_player_ids: list[int] = field(default_factory=list) + removed_player_ids: list[int] = field(default_factory=list) + updated_player_ids: dict[int, int] = field(default_factory=dict) + @dataclass class HeosNowPlayingMedia: @@ -33,61 +113,54 @@ class HeosNowPlayingMedia: current_position: int | None = None current_position_updated: datetime | None = None duration: int | None = None - supported_controls: Sequence[str] = field( - default_factory=lambda: const.CONTROLS_ALL, init=False + supported_controls: Sequence[ControlType] = field( + default_factory=lambda: CONTROLS_ALL, init=False + ) + options: Sequence[ServiceOption] = field( + repr=False, hash=False, compare=False, default_factory=list ) def __post_init__(self, *args: Any, **kwargs: Any) -> None: """Pst initialize the now playing media.""" self._update_supported_controls() - def update_from_message(self, message: HeosMessage) -> None: + def _update_from_message(self, message: HeosMessage) -> None: """Update the current instance from another instance.""" data = cast(dict[str, Any], message.payload) - self.type = data.get(const.ATTR_TYPE) - self.song = data.get(const.ATTR_SONG) - self.station = data.get(const.ATTR_STATION) - self.album = data.get(const.ATTR_ALBUM) - self.artist = data.get(const.ATTR_ARTIST) - self.image_url = data.get(const.ATTR_IMAGE_URL) - self.album_id = data.get(const.ATTR_ALBUM_ID) - self.media_id = data.get(const.ATTR_MEDIA_ID) - self.queue_id = self.get_optional_int(data.get(const.ATTR_QUEUE_ID)) - self.source_id = self.get_optional_int(data.get(const.ATTR_SOURCE_ID)) + self.type = data.get(c.ATTR_TYPE) + self.song = data.get(c.ATTR_SONG) + self.station = data.get(c.ATTR_STATION) + self.album = data.get(c.ATTR_ALBUM) + self.artist = data.get(c.ATTR_ARTIST) + self.image_url = data.get(c.ATTR_IMAGE_URL) + self.album_id = data.get(c.ATTR_ALBUM_ID) + self.media_id = data.get(c.ATTR_MEDIA_ID) + self.queue_id = optional_int(data.get(c.ATTR_QUEUE_ID)) + self.source_id = optional_int(data.get(c.ATTR_SOURCE_ID)) + self.options = ServiceOption._from_options(message.options) self._update_supported_controls() - self.clear_progress() - - @staticmethod - def get_optional_int(value: Any) -> int | None: - try: - return int(str(value)) - except (TypeError, ValueError): - return None + self._clear_progress() def _update_supported_controls(self) -> None: """Updates the supported controls based on the source and type.""" - new_supported_controls = ( - const.CONTROLS_ALL if self.source_id is not None else [] - ) + new_supported_controls = CONTROLS_ALL if self.source_id is not None else [] if self.source_id is not None and self.type is not None: - if controls := const.SOURCE_CONTROLS.get(self.source_id): + if controls := SOURCE_CONTROLS.get(self.source_id): new_supported_controls = controls.get( - const.MediaType(self.type), const.CONTROLS_ALL + MediaType(self.type), CONTROLS_ALL ) self.supported_controls = new_supported_controls - def on_event(self, event: HeosMessage, all_progress_events: bool) -> bool: + def _on_event(self, event: HeosMessage, all_progress_events: bool) -> bool: """Update the position/duration from an event.""" if all_progress_events or self.current_position is None: - self.current_position = event.get_message_value_int( - const.ATTR_CURRENT_POSITION - ) + self.current_position = event.get_message_value_int(c.ATTR_CURRENT_POSITION) self.current_position_updated = datetime.now() - self.duration = event.get_message_value_int(const.ATTR_DURATION) + self.duration = event.get_message_value_int(c.ATTR_DURATION) return True return False - def clear_progress(self) -> None: + def _clear_progress(self) -> None: """Clear the current position.""" self.current_position = None self.current_position_updated = None @@ -98,15 +171,15 @@ def clear_progress(self) -> None: class PlayMode: """Define the play mode options for a player.""" - repeat: const.RepeatType + repeat: RepeatType shuffle: bool - @classmethod - def from_data(cls, data: HeosMessage) -> "PlayMode": + @staticmethod + def _from_data(data: HeosMessage) -> "PlayMode": """Create a new instance from the provided data.""" - return cls( - repeat=const.RepeatType(data.get_message_value(const.ATTR_REPEAT)), - shuffle=data.get_message_value(const.ATTR_SHUFFLE) == const.VALUE_ON, + return PlayMode( + repeat=RepeatType(data.get_message_value(c.ATTR_REPEAT)), + shuffle=data.get_message_value(c.ATTR_SHUFFLE) == c.VALUE_ON, ) @@ -120,15 +193,16 @@ class HeosPlayer: serial: str | None = field(repr=False, hash=False, compare=False) version: str = field(repr=True, hash=False, compare=False) ip_address: str = field(repr=True, hash=False, compare=False) - network: str = field(repr=False, hash=False, compare=False) - line_out: int = field(repr=False, hash=False, compare=False) - state: const.PlayState | None = field( - repr=True, hash=False, compare=False, default=None + network: NetworkType = field(repr=False, hash=False, compare=False) + line_out: LineOutLevelType = field(repr=False, hash=False, compare=False) + control: VolumeControlType = field( + repr=False, hash=False, compare=False, default=VolumeControlType.UNKNOWN ) + state: PlayState | None = field(repr=True, hash=False, compare=False, default=None) volume: int = field(repr=False, hash=False, compare=False, default=0) is_muted: bool = field(repr=False, hash=False, compare=False, default=False) - repeat: const.RepeatType = field( - repr=False, hash=False, compare=False, default=const.RepeatType.OFF + repeat: RepeatType = field( + repr=False, hash=False, compare=False, default=RepeatType.OFF ) shuffle: bool = field(repr=False, hash=False, compare=False, default=False) playback_error: str | None = field( @@ -136,39 +210,54 @@ class HeosPlayer: ) now_playing_media: HeosNowPlayingMedia = field(default_factory=HeosNowPlayingMedia) available: bool = field(repr=False, hash=False, compare=False, default=True) + group_id: int | None = field(repr=False, hash=False, compare=False, default=None) heos: Optional["Heos"] = field(repr=False, hash=False, compare=False, default=None) - @classmethod - def from_data( - cls, + @staticmethod + def _from_data( data: dict[str, Any], heos: Optional["Heos"] = None, ) -> "HeosPlayer": """Create a new instance from the provided data.""" - return cls( - name=data[const.ATTR_NAME], - player_id=int(data[const.ATTR_PLAYER_ID]), - model=data[const.ATTR_MODEL], - serial=data.get(const.ATTR_SERIAL), - version=data[const.ATTR_VERSION], - ip_address=data[const.ATTR_IP_ADDRESS], - network=data[const.ATTR_NETWORK], - line_out=int(data[const.ATTR_LINE_OUT]), + + return HeosPlayer( + name=data[c.ATTR_NAME], + player_id=int(data[c.ATTR_PLAYER_ID]), + model=data[c.ATTR_MODEL], + serial=data.get(c.ATTR_SERIAL), + version=data[c.ATTR_VERSION], + ip_address=data[c.ATTR_IP_ADDRESS], + network=parse_enum(c.ATTR_NETWORK, data, NetworkType, NetworkType.UNKNOWN), + line_out=parse_enum( + c.ATTR_LINE_OUT, data, LineOutLevelType, LineOutLevelType.UNKNOWN + ), + control=parse_enum( + c.ATTR_CONTROL, data, VolumeControlType, VolumeControlType.UNKNOWN + ), + group_id=optional_int(data.get(c.ATTR_GROUP_ID)), heos=heos, ) - def update_from_data(self, data: dict[str, Any]) -> None: + def _update_from_data(self, data: dict[str, Any]) -> None: """Update the attributes from the supplied data.""" - self.name = data[const.ATTR_NAME] - self.player_id = int(data[const.ATTR_PLAYER_ID]) - self.model = data[const.ATTR_MODEL] - self.serial = data.get(const.ATTR_SERIAL) - self.version = data[const.ATTR_VERSION] - self.ip_address = data[const.ATTR_IP_ADDRESS] - self.network = data[const.ATTR_NETWORK] - self.line_out = int(data[const.ATTR_LINE_OUT]) - - async def on_event(self, event: HeosMessage, all_progress_events: bool) -> bool: + self.name = data[c.ATTR_NAME] + self.player_id = int(data[c.ATTR_PLAYER_ID]) + self.model = data[c.ATTR_MODEL] + self.serial = data.get(c.ATTR_SERIAL) + self.version = data[c.ATTR_VERSION] + self.ip_address = data[c.ATTR_IP_ADDRESS] + self.network = parse_enum( + c.ATTR_NETWORK, data, NetworkType, NetworkType.UNKNOWN + ) + self.line_out = parse_enum( + c.ATTR_LINE_OUT, data, LineOutLevelType, LineOutLevelType.UNKNOWN + ) + self.control = parse_enum( + c.ATTR_CONTROL, data, VolumeControlType, VolumeControlType.UNKNOWN + ) + self.group_id = optional_int(data.get(c.ATTR_GROUP_ID)) + + async def _on_event(self, event: HeosMessage, all_progress_events: bool) -> bool: """Updates the player based on the received HEOS event. This is an internal method invoked by the Heos class and is not intended for direct use. @@ -176,26 +265,26 @@ async def on_event(self, event: HeosMessage, all_progress_events: bool) -> bool: Returns: True if the player event changed state, other wise False.""" if event.command == const.EVENT_PLAYER_NOW_PLAYING_PROGRESS: - return self.now_playing_media.on_event(event, all_progress_events) + return self.now_playing_media._on_event(event, all_progress_events) if event.command == const.EVENT_PLAYER_STATE_CHANGED: - self.state = const.PlayState(event.get_message_value(const.ATTR_STATE)) - if self.state == const.PlayState.PLAY: - self.now_playing_media.clear_progress() + self.state = PlayState(event.get_message_value(c.ATTR_STATE)) + if self.state == PlayState.PLAY: + self.now_playing_media._clear_progress() elif event.command == const.EVENT_PLAYER_NOW_PLAYING_CHANGED: await self.refresh_now_playing_media() elif event.command == const.EVENT_PLAYER_VOLUME_CHANGED: - self.volume = event.get_message_value_int(const.ATTR_LEVEL) - self.is_muted = event.get_message_value(const.ATTR_MUTE) == const.VALUE_ON + self.volume = event.get_message_value_int(c.ATTR_LEVEL) + self.is_muted = event.get_message_value(c.ATTR_MUTE) == c.VALUE_ON elif event.command == const.EVENT_REPEAT_MODE_CHANGED: - self.repeat = const.RepeatType(event.get_message_value(const.ATTR_REPEAT)) + self.repeat = RepeatType(event.get_message_value(c.ATTR_REPEAT)) elif event.command == const.EVENT_SHUFFLE_MODE_CHANGED: - self.shuffle = event.get_message_value(const.ATTR_SHUFFLE) == const.VALUE_ON + self.shuffle = event.get_message_value(c.ATTR_SHUFFLE) == c.VALUE_ON elif event.command == const.EVENT_PLAYER_PLAYBACK_ERROR: - self.playback_error = event.get_message_value(const.ATTR_ERROR) + self.playback_error = event.get_message_value(c.ATTR_ERROR) return True def add_on_player_event(self, callback: EventCallbackType) -> DisconnectType: - """Connect a callback to be invoked when connected. + """Connect a callback to be invoked when an event occurs for this group. Args: callback: The callback to be invoked. @@ -204,20 +293,27 @@ def add_on_player_event(self, callback: EventCallbackType) -> DisconnectType: assert self.heos, "Heos instance not set" # Use lambda to yield player_id since the value can change return self.heos.dispatcher.connect( - const.SIGNAL_PLAYER_EVENT, + SignalType.PLAYER_EVENT, callback_wrapper(callback, {0: lambda: self.player_id}), ) - async def refresh(self) -> None: - """Pull current state.""" + async def refresh(self, *, refresh_base_info: bool = True) -> None: + """Pull current state. + + Args: + refresh_base_info: When True, the base information of the player, including the name, will also be pulled. Defaults is False. + """ assert self.heos, "Heos instance not set" - await asyncio.gather( - self.refresh_state(), - self.refresh_now_playing_media(), - self.refresh_volume(), - self.refresh_mute(), - self.refresh_play_mode(), - ) + if refresh_base_info: + await self.heos.get_player_info(player=self, refresh=True) + else: + await asyncio.gather( + self.refresh_state(), + self.refresh_now_playing_media(), + self.refresh_volume(), + self.refresh_mute(), + self.refresh_play_mode(), + ) async def refresh_state(self) -> None: """Refresh the now playing state.""" @@ -246,22 +342,22 @@ async def refresh_play_mode(self) -> None: self.repeat = play_mode.repeat self.shuffle = play_mode.shuffle - async def set_state(self, state: const.PlayState) -> None: + async def set_state(self, state: PlayState) -> None: """Set the state of the player.""" assert self.heos, "Heos instance not set" await self.heos.player_set_play_state(self.player_id, state) async def play(self) -> None: """Set the start to play.""" - await self.set_state(const.PlayState.PLAY) + await self.set_state(PlayState.PLAY) async def pause(self) -> None: """Set the start to pause.""" - await self.set_state(const.PlayState.PAUSE) + await self.set_state(PlayState.PAUSE) async def stop(self) -> None: """Set the start to stop.""" - await self.set_state(const.PlayState.STOP) + await self.set_state(PlayState.STOP) async def set_volume(self, level: int) -> None: """Set the volume level.""" @@ -296,16 +392,47 @@ async def toggle_mute(self) -> None: assert self.heos, "Heos instance not set" await self.heos.player_toggle_mute(self.player_id) - async def set_play_mode(self, repeat: const.RepeatType, shuffle: bool) -> None: + async def set_play_mode(self, repeat: RepeatType, shuffle: bool) -> None: """Set the play mode of the player.""" assert self.heos, "Heos instance not set" await self.heos.player_set_play_mode(self.player_id, repeat, shuffle) + async def get_queue( + self, range_start: int | None = None, range_end: int | None = None + ) -> list[QueueItem]: + """Get the queue of the player.""" + assert self.heos, "Heos instance not set" + return await self.heos.player_get_queue(self.player_id, range_start, range_end) + + async def play_queue(self, queue_id: int) -> None: + """Play the queue of the player.""" + assert self.heos, "Heos instance not set" + await self.heos.player_play_queue(self.player_id, queue_id) + + async def remove_from_queue(self, queue_ids: list[int]) -> None: + """Remove the specified queue items from the queue.""" + assert self.heos, "Heos instance not set" + await self.heos.player_remove_from_queue(self.player_id, queue_ids) + async def clear_queue(self) -> None: """Clear the queue of the player.""" assert self.heos, "Heos instance not set" await self.heos.player_clear_queue(self.player_id) + async def save_queue(self, name: str) -> None: + """Save the queue as a playlist.""" + assert self.heos, "Heos instance not set" + await self.heos.player_save_queue(self.player_id, name) + + async def move_queue_item( + self, source_queue_ids: list[int], destination_queue_id: int + ) -> None: + """Move one or more items in the queue.""" + assert self.heos, "Heos instance not set" + await self.heos.player_move_queue_item( + self.player_id, source_queue_ids, destination_queue_id + ) + async def play_next(self) -> None: """Clear the queue of the player.""" assert self.heos, "Heos instance not set" @@ -343,7 +470,7 @@ async def add_to_queue( source_id: int, container_id: str, media_id: str | None = None, - add_criteria: const.AddCriteriaType = const.AddCriteriaType.PLAY_NOW, + add_criteria: AddCriteriaType = AddCriteriaType.PLAY_NOW, ) -> None: """Add the specified source to the queue.""" assert self.heos, "Heos instance not set" @@ -351,10 +478,37 @@ async def add_to_queue( self.player_id, source_id, container_id, media_id, add_criteria ) + async def add_search_to_queue( + self, + source_id: int, + search: str, + criteria_container_id: str = const.SEARCHED_TRACKS, + add_criteria: AddCriteriaType = AddCriteriaType.PLAY_NOW, + ) -> None: + """Add searched tracks to the queue of the specified player. + + References: + 4.4.11 Add Container to Queue with Options + + Args: + source_id: The identifier of the source to search. + search: The search string. + criteria_container_id: the criteria container id prefix. + add_criteria: Determines how tracks are added to the queue. The default is AddCriteriaType.PLAY_NOW. + """ + assert self.heos, "Heos instance not set" + await self.heos.add_search_to_queue( + player_id=self.player_id, + source_id=source_id, + search=search, + criteria_container_id=criteria_container_id, + add_criteria=add_criteria, + ) + async def play_media( self, media: MediaItem, - add_criteria: const.AddCriteriaType = const.AddCriteriaType.PLAY_NOW, + add_criteria: AddCriteriaType = AddCriteriaType.PLAY_NOW, ) -> None: """Play the specified media. @@ -373,4 +527,12 @@ async def set_quick_select(self, quick_select_id: int) -> None: async def get_quick_selects(self) -> dict[int, str]: """Get a list of quick selects.""" assert self.heos, "Heos instance not set" - return await self.heos.get_player_quick_selects(self.player_id) + return await self.heos.player_get_quick_selects(self.player_id) + + async def check_update(self) -> bool: + """Check for a firmware update. + + Returns: + True if an update is available, otherwise False.""" + assert self.heos, "Heos instance not set" + return await self.heos.player_check_update(self.player_id) diff --git a/pyheos/search.py b/pyheos/search.py new file mode 100644 index 0000000..7bac43d --- /dev/null +++ b/pyheos/search.py @@ -0,0 +1,161 @@ +"""Define the search module.""" + +import re +from collections.abc import Sequence +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Final, Optional, cast + +from pyheos import command as c +from pyheos.media import MediaItem +from pyheos.message import HeosMessage + +if TYPE_CHECKING: + from pyheos.heos import Heos + +TUPLE_MATCHER: Final = re.compile(r"\(([0-9,-]+)\)") + + +@dataclass +class SearchCriteria: + """Define the search criteria for a music source.""" + + name: str + criteria_id: int + wildcard: bool + container_id: str | None = None + playable: bool = False + + @staticmethod + def _from_data(data: dict[str, str]) -> "SearchCriteria": + """Create a new instance from the provided data.""" + return SearchCriteria( + name=data[c.ATTR_NAME], + criteria_id=int(data[c.ATTR_SEARCH_CRITERIA_ID]), + wildcard=data[c.ATTR_WILDCARD] == c.VALUE_YES, + container_id=data.get(c.ATTR_CONTAINER_ID), + playable=data.get(c.ATTR_PLAYABLE) == c.VALUE_YES, + ) + + +@dataclass +class SearchResult: + """Define the search result.""" + + source_id: int + criteria_id: int + search: str + returned: int + count: int + items: Sequence[MediaItem] = field(repr=False, hash=False, compare=False) + heos: Optional["Heos"] = field(repr=False, hash=False, compare=False, default=None) + + @staticmethod + def _from_message(message: HeosMessage, heos: "Heos") -> "SearchResult": + """Create a new instance from a message.""" + source_id = message.get_message_value_int(c.ATTR_SOURCE_ID) + + return SearchResult( + heos=heos, + source_id=source_id, + criteria_id=message.get_message_value_int(c.ATTR_SEARCH_CRITERIA_ID), + search=message.get_message_value(c.ATTR_SEARCH), + returned=message.get_message_value_int(c.ATTR_RETURNED), + count=message.get_message_value_int(c.ATTR_COUNT), + items=list( + [ + MediaItem.from_data(item, source_id, None, heos) + for item in cast(Sequence[dict[str, str]], message.payload) + ] + ), + ) + + +@dataclass +class MultiSearchResult: + """Define the results of a multi-search.""" + + source_ids: Sequence[int] + criteria_ids: Sequence[int] + search: str + returned: int + count: int + items: Sequence[MediaItem] = field(repr=False, hash=False, compare=False) + statistics: Sequence["SearchStatistic"] = field( + repr=False, hash=False, compare=False + ) + errors: Sequence["SearchStatistic"] = field(repr=False, hash=False, compare=False) + heos: Optional["Heos"] = field(repr=False, hash=False, compare=False, default=None) + + @staticmethod + def _from_message(message: HeosMessage, heos: "Heos") -> "MultiSearchResult": + """Create a new instance from a message.""" + source_ids = message.get_message_value(c.ATTR_SOURCE_ID).split(",") + criteria_ids = message.get_message_value(c.ATTR_SEARCH_CRITERIA_ID).split(",") + statisics = SearchStatistic._from_string( + message.get_message_value(c.ATTR_STATS) + ) + items: list[MediaItem] = [] + # In order to determine the source_id of the result, we match up the index with how many items were returned for a given source + payload = cast(list[dict[str, str]], message.payload) + index = 0 + for stat in statisics: + assert stat.returned is not None + for _ in range(stat.returned): + items.append( + MediaItem.from_data(payload[index], stat.source_id, heos=heos) + ) + index += 1 + + return MultiSearchResult( + heos=heos, + source_ids=[int(source_id) for source_id in source_ids], + criteria_ids=[int(criteria_id) for criteria_id in criteria_ids], + search=message.get_message_value(c.ATTR_SEARCH), + returned=message.get_message_value_int(c.ATTR_RETURNED), + count=message.get_message_value_int(c.ATTR_COUNT), + items=items, + statistics=statisics, + errors=SearchStatistic._from_string( + message.get_message_value(c.ATTR_ERROR_NUMBER) + ), + ) + + +@dataclass +class SearchStatistic: + """Define the search statistics.""" + + source_id: int + criteria_id: int + returned: int | None = None + count: int | None = None + error_number: int | None = None + + @staticmethod + def _from_string(data: str) -> list["SearchStatistic"]: + """Create a new instance from the provided tuple.""" + # stats=(10,2,2,2),(10,1,0,0),(1,0,57,57),(10,3,15,15) + # errno=(13,0,2),(8,0,-1061) + stats: list[SearchStatistic] = [] + matches = TUPLE_MATCHER.findall(data) + for match in matches: + stats_tuple = match.split(",") + + if len(stats_tuple) == 3: + stats.append( + SearchStatistic( + source_id=int(stats_tuple[0]), + criteria_id=int(stats_tuple[1]), + error_number=int(stats_tuple[2]), + ) + ) + else: + stats.append( + SearchStatistic( + source_id=int(stats_tuple[0]), + criteria_id=int(stats_tuple[1]), + returned=int(stats_tuple[2]), + count=int(stats_tuple[3]), + ) + ) + return stats diff --git a/pyheos/system.py b/pyheos/system.py index 5585477..6db5d82 100644 --- a/pyheos/system.py +++ b/pyheos/system.py @@ -3,7 +3,8 @@ from dataclasses import dataclass from functools import cached_property -from pyheos import const +from pyheos import command as c +from pyheos.types import NetworkType @dataclass(frozen=True) @@ -18,10 +19,10 @@ class HeosHost: serial: str | None version: str ip_address: str - network: str + network: NetworkType - @classmethod - def from_data(cls, data: dict[str, str]) -> "HeosHost": + @staticmethod + def _from_data(data: dict[str, str]) -> "HeosHost": """Create a HeosHost object from a dictionary. Args: @@ -31,12 +32,12 @@ def from_data(cls, data: dict[str, str]) -> "HeosHost": HeosHost: The created HeosHost object. """ return HeosHost( - data[const.ATTR_NAME], - data[const.ATTR_MODEL], - data.get(const.ATTR_SERIAL), - data[const.ATTR_VERSION], - data[const.ATTR_IP_ADDRESS], - data[const.ATTR_NETWORK], + data[c.ATTR_NAME], + data[c.ATTR_MODEL], + data.get(c.ATTR_SERIAL), + data[c.ATTR_VERSION], + data[c.ATTR_IP_ADDRESS], + c.parse_enum(c.ATTR_NETWORK, data, NetworkType, NetworkType.UNKNOWN), ) @@ -59,9 +60,7 @@ def is_signed_in(self) -> bool: @cached_property def preferred_hosts(self) -> list[HeosHost]: """Return the preferred hosts.""" - return list( - [host for host in self.hosts if host.network == const.NETWORK_TYPE_WIRED] - ) + return list([host for host in self.hosts if host.network == NetworkType.WIRED]) @cached_property def connected_to_preferred_host(self) -> bool: diff --git a/pyheos/types.py b/pyheos/types.py new file mode 100644 index 0000000..787a7f7 --- /dev/null +++ b/pyheos/types.py @@ -0,0 +1,103 @@ +"""Define the types module for HEOS.""" + +from enum import IntEnum, StrEnum + + +class AddCriteriaType(IntEnum): + """Define the add to queue options.""" + + PLAY_NOW = 1 + PLAY_NEXT = 2 + ADD_TO_END = 3 + REPLACE_AND_PLAY = 4 + + +class ConnectionState(StrEnum): + """Define the possible connection states.""" + + CONNECTED = "connected" + DISCONNECTED = "disconnected" + RECONNECTING = "reconnecting" + + +class LineOutLevelType(IntEnum): + """Define the line out level types.""" + + UNKNOWN = 0 + VARIABLE = 1 + FIXED = 2 + + +class VolumeControlType(IntEnum): + "Define control types." + + UNKNOWN = 0 + NONE = 1 + IR = 2 + TRIGGER = 3 + NETWORK = 4 + + +class NetworkType(StrEnum): + """Define the network type.""" + + WIRED = "wired" + WIFI = "wifi" + UNKNOWN = "unknown" + + +class ControlType(StrEnum): + """Define the control types.""" + + PLAY = "play" + PAUSE = "pause" + STOP = "stop" + PLAY_NEXT = "play_next" + PLAY_PREVIOUS = "play_previous" + + +class MediaType(StrEnum): + """Define the media types.""" + + ALBUM = "album" + ARTIST = "artist" + CONTAINER = "container" + DLNA_SERVER = "dlna_server" + GENRE = "genre" + HEOS_SERVER = "heos_server" + HEOS_SERVICE = "heos_service" + MUSIC_SERVICE = "music_service" + PLAYLIST = "playlist" + SONG = "song" + STATION = "station" + + +class PlayState(StrEnum): + """Define the play states.""" + + PLAY = "play" + PAUSE = "pause" + STOP = "stop" + + +class SignalType(StrEnum): + """Define the signal names.""" + + PLAYER_EVENT = "player_event" + GROUP_EVENT = "group_event" + CONTROLLER_EVENT = "controller_event" + HEOS_EVENT = "heos_event" + + +class SignalHeosEvent(StrEnum): + CONNECTED = "connected" + DISCONNECTED = "disconnected" + USER_CREDENTIALS_INVALID = "usercredentials_invalid" + + +class RepeatType(StrEnum): + """Define the repeat types.""" + + ON_ALL = "on_all" + ON_ONE = "on_one" + OFF = "off" diff --git a/pyproject.toml b/pyproject.toml index 993454a..4e2690b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pyheos" -version = "0.9.0" +version = "1.0.0" description = "An async python library for controlling HEOS devices through the HEOS CLI Protocol" readme = "README.md" requires-python = ">=3.11" @@ -12,7 +12,7 @@ license = { text = "ASL 2.0" } authors = [{ name = "Andrew Sayre", email = "andrew@sayre.net" }] keywords = ["heos", "dennon", "maranz"] classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", @@ -106,6 +106,7 @@ disable = [ "consider-using-namedtuple-or-dataclass", "consider-using-assignment-expr", "possibly-used-before-assignment", + "duplicate-code", # Handled by ruff # Ref: @@ -364,3 +365,6 @@ exclude_lines = ["if TYPE_CHECKING:"] show_missing = true skip_empty = true sort = "Name" + +[tool.codespell] +skip = "./tests/fixtures/*" \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py index 3077e55..a98057b 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -9,12 +9,8 @@ from typing import Any, cast from urllib.parse import parse_qsl, quote_plus, urlencode, urlparse -from pyheos import Heos, const -from pyheos.command import ( - COMMAND_ACCOUNT_CHECK, - COMMAND_REBOOT, - COMMAND_REGISTER_FOR_CHANGE_EVENTS, -) +from pyheos import Heos +from pyheos import command as c from pyheos.connection import CLI_PORT, SEPARATOR, SEPARATOR_BYTES FILE_IO_POOL = ThreadPoolExecutor() @@ -68,7 +64,7 @@ def get_value(self, args: dict[str, Any]) -> Any: assert self.arg_name is not None arg_value = args[self.arg_name] if self.formatter == "on_off": - return const.VALUE_ON if arg_value else const.VALUE_OFF + return c.VALUE_ON if arg_value else c.VALUE_OFF return arg_value @@ -127,7 +123,7 @@ async def wrapped(*args: Any, **kwargs: Any) -> Any: for command in matched_commands: # Get the fixture command fixture_data = json.loads(await get_fixture(command.fixture)) - command_name = fixture_data[const.ATTR_HEOS][const.ATTR_COMMAND] + command_name = fixture_data[c.ATTR_HEOS][c.ATTR_COMMAND] # Resolve command arguments (they may contain a ArgumentValue) resolved_args = command.get_resolve_args(kwargs) @@ -214,13 +210,13 @@ def calls_player_commands( for player_id in player_ids: commands.extend( [ - CallCommand("player.get_play_state", {const.ATTR_PLAYER_ID: player_id}), + CallCommand("player.get_play_state", {c.ATTR_PLAYER_ID: player_id}), CallCommand( - "player.get_now_playing_media", {const.ATTR_PLAYER_ID: player_id} + "player.get_now_playing_media", {c.ATTR_PLAYER_ID: player_id} ), - CallCommand("player.get_volume", {const.ATTR_PLAYER_ID: player_id}), - CallCommand("player.get_mute", {const.ATTR_PLAYER_ID: player_id}), - CallCommand("player.get_play_mode", {const.ATTR_PLAYER_ID: player_id}), + CallCommand("player.get_volume", {c.ATTR_PLAYER_ID: player_id}), + CallCommand("player.get_mute", {c.ATTR_PLAYER_ID: player_id}), + CallCommand("player.get_play_mode", {c.ATTR_PLAYER_ID: player_id}), ] ) commands.extend(additional) @@ -230,8 +226,8 @@ def calls_player_commands( def calls_group_commands(*additional: CallCommand) -> Callable: commands = [ CallCommand("group.get_groups"), - CallCommand("group.get_volume", {const.ATTR_GROUP_ID: 1}), - CallCommand("group.get_mute", {const.ATTR_GROUP_ID: 1}), + CallCommand("group.get_volume", {c.ATTR_GROUP_ID: 1}), + CallCommand("group.get_mute", {c.ATTR_GROUP_ID: 1}), ] commands.extend(additional) return calls_commands(*commands) @@ -285,19 +281,24 @@ async def start(self) -> None: self._handle_connection, "127.0.0.1", CLI_PORT ) - self.register(COMMAND_ACCOUNT_CHECK, None, "system.check_account") + self.register(c.COMMAND_ACCOUNT_CHECK, None, "system.check_account") async def stop(self) -> None: """Stop the heos server.""" if not self._started: return self._started = False + + # Stop the server + assert self._server is not None + self._server.close() + + # Disconnect all connections for connection in self.connections: await connection.disconnect() self.connections.clear() - assert self._server is not None - self._server.close() + # Wait for server to close await self._server.wait_closed() async def write_event( @@ -393,15 +394,15 @@ async def _handle_connection( continue # Special processing for known/unknown commands - if command == COMMAND_REBOOT: + if command == c.COMMAND_REBOOT: # Simulate a reboot by shutting down the server await self.stop() await asyncio.sleep(0.3) await self.start() return - if command == COMMAND_REGISTER_FOR_CHANGE_EVENTS: - enable = str(query[const.ATTR_ENABLE]) - log.is_registered_for_events = enable == const.VALUE_ON + if command == c.COMMAND_REGISTER_FOR_CHANGE_EVENTS: + enable = str(query[c.ATTR_ENABLE]) + log.is_registered_for_events = enable == c.VALUE_ON response = (await get_fixture(fixture_name)).replace("{enable}", enable) else: response = ( @@ -465,9 +466,10 @@ async def get_response(self, query: dict) -> list[str]: async def _get_response(self, response: str, query: dict) -> str: response = await get_fixture(response) keys = { - const.ATTR_PLAYER_ID: "{player_id}", - const.ATTR_STATE: "{state}", - const.ATTR_LEVEL: "{level}", + c.ATTR_PLAYER_ID: "{player_id}", + c.ATTR_STATE: "{state}", + c.ATTR_LEVEL: "{level}", + c.ATTR_OPTIONS: "{options}", } for key, token in keys.items(): value = query.get(key) @@ -500,7 +502,10 @@ def __init__( async def disconnect(self) -> None: """Close the connection.""" self._writer.close() - await self._writer.wait_closed() + try: + await self._writer.wait_closed() + except ConnectionError: + pass async def write(self, payload: str) -> None: """Write the payload to the stream.""" diff --git a/tests/common.py b/tests/common.py index dea960f..81b0f0c 100644 --- a/tests/common.py +++ b/tests/common.py @@ -2,6 +2,7 @@ from pyheos import const from pyheos.media import MediaItem, MediaMusicSource +from pyheos.types import MediaType # Media Items @@ -11,7 +12,7 @@ class MediaItems: ALBUM = MediaItem( const.MUSIC_SOURCE_TIDAL, "After Hours", - const.MediaType.ALBUM, + MediaType.ALBUM, "http://resources.wimpmusic.com/images/bbe7f53c/44f0/41ba/873f/743e3091adde/160x160.jpg", None, True, @@ -26,7 +27,7 @@ class MediaItems: PLAYLIST = MediaItem( const.MUSIC_SOURCE_PLAYLISTS, "My Playlist", - const.MediaType.PLAYLIST, + MediaType.PLAYLIST, "", None, True, @@ -41,7 +42,7 @@ class MediaItems: INPUT = MediaItem( -263109739, "HEOS Drive - AUX In 1", - const.MediaType.STATION, + MediaType.STATION, "", None, True, @@ -56,7 +57,7 @@ class MediaItems: SONG = MediaItem( const.MUSIC_SOURCE_TIDAL, "Imaginary Parties", - const.MediaType.SONG, + MediaType.SONG, "http://resources.wimpmusic.com/images/7e7bacc1/3e75/4761/a822/9342239edfa0/640x640.jpg", None, True, @@ -71,7 +72,7 @@ class MediaItems: STATION = MediaItem( const.MUSIC_SOURCE_PANDORA, "Cooltime Kids (Children's) Radio", - const.MediaType.STATION, + MediaType.STATION, "https://content-images.p-cdn.com/images/9d/b9/b9/85/ef1146388a09ecb87153e168/_500W_500H.jpg", None, True, @@ -86,7 +87,7 @@ class MediaItems: DEVICE = MediaItem( -263109739, "HEOS Drive", - const.MediaType.HEOS_SERVICE, + MediaType.HEOS_SERVICE, "https://production.ws.skyegloup.com:443/media/images/service/logos/musicsource_logo_aux.png", None, False, @@ -100,7 +101,7 @@ class MediaMusicSources: FAVORITES = MediaMusicSource( const.MUSIC_SOURCE_FAVORITES, "Favorites", - const.MediaType.HEOS_SERVICE, + MediaType.HEOS_SERVICE, "https://production.ws.skyegloup.com:443/media/images/service/logos/musicsource_logo_favorites.png", None, True, @@ -109,7 +110,7 @@ class MediaMusicSources: PANDORA = MediaMusicSource( const.MUSIC_SOURCE_PANDORA, "Pandora", - const.MediaType.MUSIC_SERVICE, + MediaType.MUSIC_SERVICE, "https://production.ws.skyegloup.com:443/media/images/service/logos/pandora.png", None, False, @@ -118,7 +119,7 @@ class MediaMusicSources: TIDAL = MediaMusicSource( const.MUSIC_SOURCE_TIDAL, "Tidal", - const.MediaType.MUSIC_SERVICE, + MediaType.MUSIC_SERVICE, "https://production.ws.skyegloup.com:443/media/images/service/logos/tidal.png", None, True, diff --git a/tests/conftest.py b/tests/conftest.py index a6bd76c..921f2e1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,11 +6,11 @@ import pytest import pytest_asyncio -from pyheos import const from pyheos.group import HeosGroup from pyheos.heos import Heos, HeosOptions from pyheos.media import MediaItem, MediaMusicSource from pyheos.player import HeosPlayer +from pyheos.types import LineOutLevelType, NetworkType from tests.common import MediaItems, MediaMusicSources from . import MockHeos, MockHeosDevice @@ -141,8 +141,8 @@ async def player_fixture(heos: MockHeos) -> HeosPlayer: serial="B1A2C3K", version="1.493.180", ip_address="127.0.0.1", - network=const.NETWORK_TYPE_WIRED, - line_out=1, + network=NetworkType.WIRED, + line_out=LineOutLevelType.FIXED, heos=heos, ) @@ -157,8 +157,8 @@ async def player_front_porch_fixture(heos: MockHeos) -> HeosPlayer: serial=None, version="1.493.180", ip_address="127.0.0.2", - network=const.NETWORK_TYPE_WIFI, - line_out=1, + network=NetworkType.WIFI, + line_out=LineOutLevelType.FIXED, heos=heos, ) @@ -169,6 +169,6 @@ async def group_fixture(heos: MockHeos) -> HeosGroup: name="Back Patio + Front Porch", group_id=1, lead_player_id=1, - member_player_ids=[1, 2], + member_player_ids=[2], heos=heos, ) diff --git a/tests/fixtures/browse.add_to_queue_search.json b/tests/fixtures/browse.add_to_queue_search.json new file mode 100644 index 0000000..cd6e32e --- /dev/null +++ b/tests/fixtures/browse.add_to_queue_search.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/add_to_queue", "result": "success", "message": "pid={player_id}&sid=10&cid=SEARCHED_TRACKS-Tangerine Rays&aid=3"}} \ No newline at end of file diff --git a/tests/fixtures/browse.delete_playlist.json b/tests/fixtures/browse.delete_playlist.json new file mode 100644 index 0000000..8d928e1 --- /dev/null +++ b/tests/fixtures/browse.delete_playlist.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/delete_playlist", "result": "success", "message": "sid=1025&cid=763965"}} \ No newline at end of file diff --git a/tests/fixtures/browse.get_search_criteria.json b/tests/fixtures/browse.get_search_criteria.json new file mode 100644 index 0000000..be4b49e --- /dev/null +++ b/tests/fixtures/browse.get_search_criteria.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/get_search_criteria", "result": "success", "message": "sid=10"}, "payload": [{"name": "Artist", "scid": 1, "wildcard": "no"}, {"name": "Album", "scid": 2, "wildcard": "no"}, {"name": "Track", "scid": 3, "wildcard": "no", "cid": "SEARCHED_TRACKS-", "playable": "yes"}, {"name": "Playlist", "scid": 6, "wildcard": "no"}]} \ No newline at end of file diff --git a/tests/fixtures/browse.get_source_info.json b/tests/fixtures/browse.get_source_info.json new file mode 100644 index 0000000..c781ae7 --- /dev/null +++ b/tests/fixtures/browse.get_source_info.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/get_source_info", "result": "success", "message": ""}, "payload": {"name": "Pandora", "image_url": "https://production.ws.skyegloup.com:443/media/images/service/logos/pandora.png", "type": "music_service", "sid": 1, "available": "true", "service_username": "email@email.com"}} \ No newline at end of file diff --git a/tests/fixtures/browse.multi_search.json b/tests/fixtures/browse.multi_search.json new file mode 100644 index 0000000..cc1eaee --- /dev/null +++ b/tests/fixtures/browse.multi_search.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/multi_search", "result": "success", "message": "search=Tangerine Rays&sid=1,4,8,13,10&scid=0,1,2,3&returned=74&count=74&stats=(10,2,2,2),(10,1,0,0),(1,0,57,57),(10,3,15,15)&errno=(13,0,2),(8,0,-1061)"}, "payload": [{"container": "yes", "type": "album", "artist": "ZEDD", "cid": "LIBALBUM-401193042", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/42831a87/1001/4335/a832/fb8bdf6781db/160x160.jpg"}, {"container": "yes", "type": "album", "artist": "ZEDD", "cid": "LIBALBUM-401192835", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/f073e403/ee90/4d9e/80a4/731b57de9f55/160x160.jpg"}, {"container": "no", "mid": "CREATE_STATION-R66030", "type": "station", "playable": "yes", "name": "Tangerine Dream", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-R696162", "type": "station", "playable": "yes", "name": "X-Rays", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-R400422", "type": "station", "playable": "yes", "name": "Tangerine Kitty", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-R4004275", "type": "station", "playable": "yes", "name": "Ferrari Simmons %26 RaySean", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-R172591", "type": "station", "playable": "yes", "name": "Ray Stevens (Holiday)", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-R5180582", "type": "station", "playable": "yes", "name": "Dr. Hook %26 Ray Sawyer", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-R153854", "type": "station", "playable": "yes", "name": "X-Ray Spex", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S133862497", "type": "station", "playable": "yes", "name": "Tangerine Rays by Zedd, Bea Miller %26 ellis", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S142439068", "type": "station", "playable": "yes", "name": "Tangerine Rays by Zedd, Bea Miller %26 ellis", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S142439069", "type": "station", "playable": "yes", "name": "Tangerine Rays (Instrumental) by Zedd, Bea Miller %26 ellis", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S142439070", "type": "station", "playable": "yes", "name": "Tangerine Rays (Acapella) by Zedd, Bea Miller %26 ellis", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S136888654", "type": "station", "playable": "yes", "name": "Tangerine Rays (8-Bit Bea Miller, Ellis %26 Zedd Emulation) by 8-Bit Arcade", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S32964", "type": "station", "playable": "yes", "name": "Tangerine (Remaster) by Led Zeppelin", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S5786562", "type": "station", "playable": "yes", "name": "Tangerine Sky by Kottonmouth Kings", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S164056", "type": "station", "playable": "yes", "name": "Tangerine by Eliane Elias", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S5615318", "type": "station", "playable": "yes", "name": "Tangerine by Herb Alpert %26 The Tijuana Brass", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S46560612", "type": "station", "playable": "yes", "name": "TANGERINE DREAM by Snoh Aalegra", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S7989950", "type": "station", "playable": "yes", "name": "Sun Rays Like Stilts by Tommy Guerrero", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S571786", "type": "station", "playable": "yes", "name": "Tangerine (Remastered) by Les Brown", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S13186508", "type": "station", "playable": "yes", "name": "Sun Rays Vol. 2 by Chillout Lounge", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S1454325", "type": "station", "playable": "yes", "name": "Say Goodbye to the Tangerine Sky by Kottonmouth Kings", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S1926771", "type": "station", "playable": "yes", "name": "Rays of Light by 2002", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S28616466", "type": "station", "playable": "yes", "name": "Los Santos City Map by Tangerine Dream", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S48872068", "type": "station", "playable": "yes", "name": "Tangerine by Glass Animals", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S16481097", "type": "station", "playable": "yes", "name": "Speed Dragon by Tangerine Dream", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S1082443", "type": "station", "playable": "yes", "name": "Tangerine by Mary Louise Knutson", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S11825304", "type": "station", "playable": "yes", "name": "Identity Proven Matrix by Tangerine Dream", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S24154834", "type": "station", "playable": "yes", "name": "First Rays of Light by Celestial Alignment", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S16550589", "type": "station", "playable": "yes", "name": "Tangerine by Amane", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S1127632", "type": "station", "playable": "yes", "name": "Metaphor Part One by Tangerine Dream", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S18892466", "type": "station", "playable": "yes", "name": "Sequent 'C' (Remastered 2018) by Tangerine Dream", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S30194818", "type": "station", "playable": "yes", "name": "Tangerine (Live at Whittemore Center Arena) (Durham) (NH) (02.19.96) by Dave Matthews Band", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S15007209", "type": "station", "playable": "yes", "name": "The Sun Whose Rays (Live At Teatro La Fenice, Venice / 2006) by Keith Jarrett", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S11970982", "type": "station", "playable": "yes", "name": "Tangerine (feat. Big Sean) by Miley Cyrus", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S5930743", "type": "station", "playable": "yes", "name": "Morning Rays (Album Version) (feat. Richard Tee) by Tom Scott", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S13653697", "type": "station", "playable": "yes", "name": "Cosmic Rays by Charlie Parker Quartet", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S2082136", "type": "station", "playable": "yes", "name": "Tangerine by First Aid Kit", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S1898039", "type": "station", "playable": "yes", "name": "Seven Rays by 2002", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S28616469", "type": "station", "playable": "yes", "name": "Stratosfear 2019 by Tangerine Dream", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S30144", "type": "station", "playable": "yes", "name": "Your X-Rays Have Just Come Back From The Lab And We Think We Know What Your Problem Is by Jets To Brazil", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S40299096", "type": "station", "playable": "yes", "name": "Tangerine by Barii", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S1127647", "type": "station", "playable": "yes", "name": "Tangines On And Running by Tangerine Dream", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S38892893", "type": "station", "playable": "yes", "name": "Father And Son (Resurrection 2) by Tangerine Dream", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S28616468", "type": "station", "playable": "yes", "name": "Yellowstone Park 2019 by Tangerine Dream", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S8963484", "type": "station", "playable": "yes", "name": "X-Rays by Jackie Mason", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S6870549", "type": "station", "playable": "yes", "name": "Italian X Rays by Steve Miller Band", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S5811178", "type": "station", "playable": "yes", "name": "Tangerine (Remastered 2001) by Joe Pass", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S35125374", "type": "station", "playable": "yes", "name": "Tyger 2013 by Tangerine Dream", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S6442778", "type": "station", "playable": "yes", "name": "The First Rays of This Forever's Light by Matt Borghi", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S13653699", "type": "station", "playable": "yes", "name": "Cosmic Rays (Alternate Take) by Charlie Parker Quartet", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S36767691", "type": "station", "playable": "yes", "name": "Tangerine Sour by Emancipator %26 9 Theory", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S1098124", "type": "station", "playable": "yes", "name": "Moon Rays (2007 Digital Remaster/Rudy Van Gelder Edition) by Horace Silver Quintet", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S16550591", "type": "station", "playable": "yes", "name": "Tangerine by Amane", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S30475082", "type": "station", "playable": "yes", "name": "Tangerine by Lou Donaldson", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S17131911", "type": "station", "playable": "yes", "name": "Morgenstern, Pt. 4 by Schiller %26 Tangerine Dream", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S1609452", "type": "station", "playable": "yes", "name": "Ceremony of the Seven Rays by Deborah Van Dyke", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S9747275", "type": "station", "playable": "yes", "name": "Tangerine by Bob Brookmeyer %26 Stan Getz Quintet", "image_url": ""}, {"container": "no", "mid": "401192836", "type": "song", "artist": "ZEDD", "album": "Tangerine Rays", "album_id": "401192835", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/f073e403/ee90/4d9e/80a4/731b57de9f55/640x640.jpg"}, {"container": "no", "mid": "383020264", "type": "song", "artist": "ZEDD", "album": "Telos", "album_id": "383020262", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/28fe4513/7d90/4b62/b3df/2cb09ec35477/640x640.jpg"}, {"container": "no", "mid": "401192838", "type": "song", "artist": "ZEDD", "album": "Tangerine Rays", "album_id": "401192835", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/f073e403/ee90/4d9e/80a4/731b57de9f55/640x640.jpg"}, {"container": "no", "mid": "383020288", "type": "song", "artist": "ZEDD", "album": "Telos", "album_id": "383020285", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/36dc43cb/aa36/44cf/bb54/aca54d5991b6/640x640.jpg"}, {"container": "no", "mid": "383978029", "type": "song", "artist": "ZEDD", "album": "Telos", "album_id": "383978026", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/fec0bead/36dd/4357/8e3d/d0af125bb913/640x640.jpg"}, {"container": "no", "mid": "401193043", "type": "song", "artist": "ZEDD", "album": "Tangerine Rays", "album_id": "401193042", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/42831a87/1001/4335/a832/fb8bdf6781db/640x640.jpg"}, {"container": "no", "mid": "401192837", "type": "song", "artist": "ZEDD", "album": "Tangerine Rays", "album_id": "401192835", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/f073e403/ee90/4d9e/80a4/731b57de9f55/640x640.jpg"}, {"container": "no", "mid": "401193045", "type": "song", "artist": "ZEDD", "album": "Tangerine Rays", "album_id": "401193042", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/42831a87/1001/4335/a832/fb8bdf6781db/640x640.jpg"}, {"container": "no", "mid": "401193044", "type": "song", "artist": "ZEDD", "album": "Tangerine Rays", "album_id": "401193042", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/42831a87/1001/4335/a832/fb8bdf6781db/640x640.jpg"}, {"container": "no", "mid": "389676834", "type": "song", "artist": "Backing Business", "album": "Pristine Karaoke, Vol. 170", "album_id": "389676760", "playable": "yes", "name": "Tangerine Rays (Karaoke Version Originally Performed by Bea Miller, Ellis %26 Zedd)", "image_url": "http://resources.wimpmusic.com/images/4efa15cf/1a95/423a/8b05/f656281da36e/640x640.jpg"}, {"container": "no", "mid": "355503127", "type": "song", "artist": "Tangerine Jazz", "album": "Jazz for Improving Concentration in Spring", "album_id": "355503098", "playable": "yes", "name": "Study Alcove Gentle Rays", "image_url": "http://resources.wimpmusic.com/images/6214ae8c/36a7/4cfd/91c6/12165df564cc/640x640.jpg"}, {"container": "no", "mid": "376883749", "type": "song", "artist": "Tangerine Jazz", "album": "サマー・ブリーズとコーヒータイム", "album_id": "376883744", "playable": "yes", "name": "Gathering Summer Rays", "image_url": "http://resources.wimpmusic.com/images/7c7f3b86/24ea/459d/8640/a139dcd28f7e/640x640.jpg"}, {"container": "no", "mid": "377809865", "type": "song", "artist": "Tangerine Jazz", "album": "Summer Bossa Time at the Beachside", "album_id": "377809861", "playable": "yes", "name": "Gathering Summer Rays", "image_url": "http://resources.wimpmusic.com/images/3bbfc24b/9939/4a2d/b538/4f034f4cf305/640x640.jpg"}, {"container": "no", "mid": "355503109", "type": "song", "artist": "Tangerine Jazz", "album": "Jazz for Improving Concentration in Spring", "album_id": "355503098", "playable": "yes", "name": "First Rays Symmetry", "image_url": "http://resources.wimpmusic.com/images/6214ae8c/36a7/4cfd/91c6/12165df564cc/640x640.jpg"}, {"container": "no", "mid": "355149731", "type": "song", "artist": "Tangerine Jazz", "album": "Instrumental BGM Matching a Relaxed New Life", "album_id": "355149701", "playable": "yes", "name": "Morning Rays and Fresh Starts", "image_url": "http://resources.wimpmusic.com/images/fbfe5e8b/b775/4d97/9053/8f0ac7daf4fd/640x640.jpg"}]} diff --git a/tests/fixtures/browse.rename_playlist.json b/tests/fixtures/browse.rename_playlist.json new file mode 100644 index 0000000..372e20a --- /dev/null +++ b/tests/fixtures/browse.rename_playlist.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/rename_playlist", "result": "success", "message": "sid=1025&cid=171566&name=New Name"}} \ No newline at end of file diff --git a/tests/fixtures/browse.retrieve_metadata.json b/tests/fixtures/browse.retrieve_metadata.json new file mode 100644 index 0000000..ffb9a64 --- /dev/null +++ b/tests/fixtures/browse.retrieve_metadata.json @@ -0,0 +1,22 @@ +{ + "heos": { + "command": "browse/retrieve_metadata", + "result": "success", + "message": "sid=6&cid=123456&returned=1&count=1" + }, + "payload": [ + { + "album_id": "7890", + "images": [ + { + "image_url": "http://resources.wimpmusic.com/images/fbfe5e8b/b775/4d97/9053/8f0ac7daf4fd/640x640.jpg", + "width": 640 + }, + { + "image_url": "http://resources.wimpmusic.com/images/fbfe5e8b/b775/4d97/9053/8f0ac7daf4fd/320x320.jpg", + "width": 320 + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/browse.search.json b/tests/fixtures/browse.search.json new file mode 100644 index 0000000..e7a4cc9 --- /dev/null +++ b/tests/fixtures/browse.search.json @@ -0,0 +1,2 @@ + +{"heos": {"command": "browse/search", "result": "success", "message": "sid=10&scid=3&search=Tangerine Rays&returned=15&count=15"}, "payload": [{"container": "no", "mid": "401192836", "type": "song", "artist": "ZEDD", "album": "Tangerine Rays", "album_id": "401192835", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/f073e403/ee90/4d9e/80a4/731b57de9f55/640x640.jpg"}, {"container": "no", "mid": "383020264", "type": "song", "artist": "ZEDD", "album": "Telos", "album_id": "383020262", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/28fe4513/7d90/4b62/b3df/2cb09ec35477/640x640.jpg"}, {"container": "no", "mid": "401192838", "type": "song", "artist": "ZEDD", "album": "Tangerine Rays", "album_id": "401192835", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/f073e403/ee90/4d9e/80a4/731b57de9f55/640x640.jpg"}, {"container": "no", "mid": "383020288", "type": "song", "artist": "ZEDD", "album": "Telos", "album_id": "383020285", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/36dc43cb/aa36/44cf/bb54/aca54d5991b6/640x640.jpg"}, {"container": "no", "mid": "383978029", "type": "song", "artist": "ZEDD", "album": "Telos", "album_id": "383978026", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/fec0bead/36dd/4357/8e3d/d0af125bb913/640x640.jpg"}, {"container": "no", "mid": "401193043", "type": "song", "artist": "ZEDD", "album": "Tangerine Rays", "album_id": "401193042", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/42831a87/1001/4335/a832/fb8bdf6781db/640x640.jpg"}, {"container": "no", "mid": "401192837", "type": "song", "artist": "ZEDD", "album": "Tangerine Rays", "album_id": "401192835", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/f073e403/ee90/4d9e/80a4/731b57de9f55/640x640.jpg"}, {"container": "no", "mid": "401193045", "type": "song", "artist": "ZEDD", "album": "Tangerine Rays", "album_id": "401193042", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/42831a87/1001/4335/a832/fb8bdf6781db/640x640.jpg"}, {"container": "no", "mid": "401193044", "type": "song", "artist": "ZEDD", "album": "Tangerine Rays", "album_id": "401193042", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/42831a87/1001/4335/a832/fb8bdf6781db/640x640.jpg"}, {"container": "no", "mid": "389676834", "type": "song", "artist": "Backing Business", "album": "Pristine Karaoke, Vol. 170", "album_id": "389676760", "playable": "yes", "name": "Tangerine Rays (Karaoke Version Originally Performed by Bea Miller, Ellis %26 Zedd)", "image_url": "http://resources.wimpmusic.com/images/4efa15cf/1a95/423a/8b05/f656281da36e/640x640.jpg"}, {"container": "no", "mid": "355503127", "type": "song", "artist": "Tangerine Jazz", "album": "Jazz for Improving Concentration in Spring", "album_id": "355503098", "playable": "yes", "name": "Study Alcove Gentle Rays", "image_url": "http://resources.wimpmusic.com/images/6214ae8c/36a7/4cfd/91c6/12165df564cc/640x640.jpg"}, {"container": "no", "mid": "376883749", "type": "song", "artist": "Tangerine Jazz", "album": "サマー・ブリーズとコーヒータイム", "album_id": "376883744", "playable": "yes", "name": "Gathering Summer Rays", "image_url": "http://resources.wimpmusic.com/images/7c7f3b86/24ea/459d/8640/a139dcd28f7e/640x640.jpg"}, {"container": "no", "mid": "377809865", "type": "song", "artist": "Tangerine Jazz", "album": "Summer Bossa Time at the Beachside", "album_id": "377809861", "playable": "yes", "name": "Gathering Summer Rays", "image_url": "http://resources.wimpmusic.com/images/3bbfc24b/9939/4a2d/b538/4f034f4cf305/640x640.jpg"}, {"container": "no", "mid": "355503109", "type": "song", "artist": "Tangerine Jazz", "album": "Jazz for Improving Concentration in Spring", "album_id": "355503098", "playable": "yes", "name": "First Rays Symmetry", "image_url": "http://resources.wimpmusic.com/images/6214ae8c/36a7/4cfd/91c6/12165df564cc/640x640.jpg"}, {"container": "no", "mid": "355149731", "type": "song", "artist": "Tangerine Jazz", "album": "Instrumental BGM Matching a Relaxed New Life", "album_id": "355149701", "playable": "yes", "name": "Morning Rays and Fresh Starts", "image_url": "http://resources.wimpmusic.com/images/fbfe5e8b/b775/4d97/9053/8f0ac7daf4fd/640x640.jpg"}]} \ No newline at end of file diff --git a/tests/fixtures/browse.set_service_option_add_favorite.json b/tests/fixtures/browse.set_service_option_add_favorite.json new file mode 100644 index 0000000..5233802 --- /dev/null +++ b/tests/fixtures/browse.set_service_option_add_favorite.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/set_service_option", "result": "success", "message": "option=19&pid={player_id}"}} \ No newline at end of file diff --git a/tests/fixtures/browse.set_service_option_add_favorite_browse.json b/tests/fixtures/browse.set_service_option_add_favorite_browse.json new file mode 100644 index 0000000..40b1a63 --- /dev/null +++ b/tests/fixtures/browse.set_service_option_add_favorite_browse.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/set_service_option", "result": "success", "message": "option=19&sid=1&mid=123456&name=Test Radio"}} \ No newline at end of file diff --git a/tests/fixtures/browse.set_service_option_add_playlist.json b/tests/fixtures/browse.set_service_option_add_playlist.json new file mode 100644 index 0000000..d8c125f --- /dev/null +++ b/tests/fixtures/browse.set_service_option_add_playlist.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/set_service_option", "result": "success", "message": "option={option}&sid=1&cid=1234&name=Test Playlist"}} \ No newline at end of file diff --git a/tests/fixtures/browse.set_service_option_album_remove_playlist.json b/tests/fixtures/browse.set_service_option_album_remove_playlist.json new file mode 100644 index 0000000..42fad96 --- /dev/null +++ b/tests/fixtures/browse.set_service_option_album_remove_playlist.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/set_service_option", "result": "success", "message": "option={option}&sid=1&cid=1234"}} \ No newline at end of file diff --git a/tests/fixtures/browse.set_service_option_new_station.json b/tests/fixtures/browse.set_service_option_new_station.json new file mode 100644 index 0000000..d805e2b --- /dev/null +++ b/tests/fixtures/browse.set_service_option_new_station.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/set_service_option", "result": "success", "message": "option={option}&sid=1&scid=1234&name=Test&range=0,14"}} \ No newline at end of file diff --git a/tests/fixtures/browse.set_service_option_remove_favorite.json b/tests/fixtures/browse.set_service_option_remove_favorite.json new file mode 100644 index 0000000..dee2ffb --- /dev/null +++ b/tests/fixtures/browse.set_service_option_remove_favorite.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/set_service_option", "result": "success", "message": "option=20&mid=4277097921440801039"}} \ No newline at end of file diff --git a/tests/fixtures/browse.set_service_option_thumbs_up_down.json b/tests/fixtures/browse.set_service_option_thumbs_up_down.json new file mode 100644 index 0000000..6da4917 --- /dev/null +++ b/tests/fixtures/browse.set_service_option_thumbs_up_down.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/set_service_option", "result": "success", "message": "option={option}&sid=1&pid={player_id}"}} \ No newline at end of file diff --git a/tests/fixtures/browse.set_service_option_track_station.json b/tests/fixtures/browse.set_service_option_track_station.json new file mode 100644 index 0000000..5c0d79c --- /dev/null +++ b/tests/fixtures/browse.set_service_option_track_station.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/set_service_option", "result": "success", "message": "option={option}&sid=1&mid=1234"}} \ No newline at end of file diff --git a/tests/fixtures/group.get_group_info.json b/tests/fixtures/group.get_group_info.json new file mode 100644 index 0000000..685f39c --- /dev/null +++ b/tests/fixtures/group.get_group_info.json @@ -0,0 +1 @@ +{"heos": {"command": "group/get_group_info", "result": "success", "message": "gid=-263109739"}, "payload": {"name": "Zone 1 + Zone 2", "gid": -263109739, "players": [{"name": "Zone 2", "pid": 845195621, "role": "member"}, {"name": "Zone 1", "pid": -263109739, "role": "leader"}]}} \ No newline at end of file diff --git a/tests/fixtures/player.check_update.json b/tests/fixtures/player.check_update.json new file mode 100644 index 0000000..9e144c0 --- /dev/null +++ b/tests/fixtures/player.check_update.json @@ -0,0 +1 @@ +{"heos": {"command": "player/check_update", "result": "success", "message": "pid={player_id}"}, "payload": {"update": "update_exist"}} \ No newline at end of file diff --git a/tests/fixtures/player.get_now_playing_media_changed.json b/tests/fixtures/player.get_now_playing_media_changed.json index 3a1bc24..e3493a0 100644 --- a/tests/fixtures/player.get_now_playing_media_changed.json +++ b/tests/fixtures/player.get_now_playing_media_changed.json @@ -1 +1 @@ -{"heos": {"command": "player/get_now_playing_media", "result": "success", "message": "pid=1"}, "payload": {"type": "station", "song": "I've Been Waiting (feat. Fall Out Boy)", "station": "Today's Hits Radio", "album": "I've Been Waiting (Single) (Explicit)", "artist": "Lil Peep & ILoveMakonnen", "image_url": "http://media/url", "album_id": "1", "mid": "2PxuY99Qty", "qid": 1, "sid": 1}, "options": [{"play": [{"id": 11, "name": "Thumbs Up"}, {"id": 12, "name": "Thumbs Down"}, {"id": 19, "name": "Add to HEOS Favorites"}]}]} \ No newline at end of file +{"heos": {"command": "player/get_now_playing_media", "result": "success", "message": "pid=1"}, "payload": {"type": "station", "song": "I've Been Waiting (feat. Fall Out Boy)", "station": "Today's Hits Radio", "album": "I've Been Waiting (Single) (Explicit)", "artist": "Lil Peep & ILoveMakonnen", "image_url": "http://media/url", "album_id": "1", "mid": "2PxuY99Qty", "qid": 1, "sid": 1}, "options": [{"play": [{"id": 11, "name": "Thumbs Up"}, {"id": 12, "name": "Thumbs Down"}, {"id": 20, "name": "Remove from HEOS Favorites"}]}]} \ No newline at end of file diff --git a/tests/fixtures/player.get_player_info.json b/tests/fixtures/player.get_player_info.json new file mode 100644 index 0000000..6e7e43b --- /dev/null +++ b/tests/fixtures/player.get_player_info.json @@ -0,0 +1 @@ +{"heos": {"command": "player/get_player_info", "result": "success", "message": "pid=-263109739"}, "payload": {"name": "Zone 1", "pid": -263109739, "gid": -263109739, "model": "HEOS Drive", "version": "3.34.620", "ip": "127.0.0.1", "network": "wired", "lineout": 1, "serial": "123456789"}} \ No newline at end of file diff --git a/tests/fixtures/player.get_players.json b/tests/fixtures/player.get_players.json index a42737f..9d85716 100644 --- a/tests/fixtures/player.get_players.json +++ b/tests/fixtures/player.get_players.json @@ -11,11 +11,13 @@ "version": "1.493.180", "ip": "127.0.0.1", "network": "wired", - "lineout": 1, + "lineout": 2, + "control": 2, "serial": "B1A2C3K" }, { "name": "Front Porch", "pid": 2, + "gid": 2, "model": "HEOS Drive", "version": "1.493.180", "ip": "127.0.0.2", diff --git a/tests/fixtures/player.get_queue.json b/tests/fixtures/player.get_queue.json new file mode 100644 index 0000000..ef33e22 --- /dev/null +++ b/tests/fixtures/player.get_queue.json @@ -0,0 +1 @@ +{"heos": {"command": "player/get_queue", "result": "success", "message": "pid={player_id}&returned=11&count=11"}, "payload": [{"song": "Baby", "album": "22 Break", "artist": "Oh Wonder", "image_url": "http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg", "qid": 1, "mid": "199555606", "album_id": "199555605"}, {"song": "Down", "album": "22 Break", "artist": "Oh Wonder", "image_url": "http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg", "qid": 2, "mid": "199555607", "album_id": "199555605"}, {"song": "22 Break", "album": "22 Break", "artist": "Oh Wonder", "image_url": "http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg", "qid": 3, "mid": "199555608", "album_id": "199555605"}, {"song": "Free", "album": "22 Break", "artist": "Oh Wonder", "image_url": "http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg", "qid": 4, "mid": "199555609", "album_id": "199555605"}, {"song": "Don't Let The Neighbourhood Hear", "album": "22 Break", "artist": "Oh Wonder", "image_url": "http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg", "qid": 5, "mid": "199555610", "album_id": "199555605"}, {"song": "Dinner", "album": "22 Break", "artist": "Oh Wonder", "image_url": "http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg", "qid": 6, "mid": "199555611", "album_id": "199555605"}, {"song": "Rollercoaster Baby", "album": "22 Break", "artist": "Oh Wonder", "image_url": "http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg", "qid": 7, "mid": "199555612", "album_id": "199555605"}, {"song": "Love Me Now", "album": "22 Break", "artist": "Oh Wonder", "image_url": "http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg", "qid": 8, "mid": "199555613", "album_id": "199555605"}, {"song": "You > Me", "album": "22 Break", "artist": "Oh Wonder", "image_url": "http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg", "qid": 9, "mid": "199555614", "album_id": "199555605"}, {"song": "Kicking The Doors Down", "album": "22 Break", "artist": "Oh Wonder", "image_url": "http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg", "qid": 10, "mid": "199555615", "album_id": "199555605"}, {"song": "Twenty Fourteen", "album": "22 Break", "artist": "Oh Wonder", "image_url": "http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg", "qid": 11, "mid": "199555616", "album_id": "199555605"}]} \ No newline at end of file diff --git a/tests/fixtures/player.move_queue_item.json b/tests/fixtures/player.move_queue_item.json new file mode 100644 index 0000000..0919e5c --- /dev/null +++ b/tests/fixtures/player.move_queue_item.json @@ -0,0 +1 @@ +{"heos": {"command": "player/move_queue_item", "result": "success", "message": "pid={player_id}&sqid=2,3,4&dqid=1"}} \ No newline at end of file diff --git a/tests/fixtures/player.play_queue.json b/tests/fixtures/player.play_queue.json new file mode 100644 index 0000000..820f5a2 --- /dev/null +++ b/tests/fixtures/player.play_queue.json @@ -0,0 +1 @@ +{"heos": {"command": "player/play_queue", "result": "success", "message": "pid={player_id}&qid=1"}} \ No newline at end of file diff --git a/tests/fixtures/player.remove_from_queue.json b/tests/fixtures/player.remove_from_queue.json new file mode 100644 index 0000000..9a4b222 --- /dev/null +++ b/tests/fixtures/player.remove_from_queue.json @@ -0,0 +1 @@ +{"heos": {"command": "player/remove_from_queue", "result": "success", "message": "pid={player_id}&qid=10"}} \ No newline at end of file diff --git a/tests/fixtures/player.save_queue.json b/tests/fixtures/player.save_queue.json new file mode 100644 index 0000000..246c9b8 --- /dev/null +++ b/tests/fixtures/player.save_queue.json @@ -0,0 +1 @@ +{"heos": {"command": "player/save_queue", "result": "success", "message": "pid={player_id}&name=Test"}} \ No newline at end of file diff --git a/tests/test_group.py b/tests/test_group.py index c6de132..fe6503a 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -2,33 +2,40 @@ import pytest -from pyheos import const +from pyheos import command as c +from pyheos.const import EVENT_GROUP_VOLUME_CHANGED, EVENT_PLAYER_VOLUME_CHANGED from pyheos.group import HeosGroup from pyheos.heos import Heos from pyheos.message import HeosMessage -from tests import calls_command, value +from tests import CallCommand, calls_command, calls_commands, value def test_group_from_data_no_leader_raises() -> None: """Test creating a group from data with no leader.""" data = { - const.ATTR_NAME: "Test Group", - const.ATTR_GROUP_ID: "1", - const.ATTR_PLAYERS: [ - {const.ATTR_PLAYER_ID: "1", const.ATTR_ROLE: const.VALUE_MEMBER}, - {const.ATTR_PLAYER_ID: "2", const.ATTR_ROLE: const.VALUE_MEMBER}, + c.ATTR_NAME: "Test Group", + c.ATTR_GROUP_ID: "1", + c.ATTR_PLAYERS: [ + { + c.ATTR_PLAYER_ID: "1", + c.ATTR_ROLE: c.VALUE_MEMBER, + }, + { + c.ATTR_PLAYER_ID: "2", + c.ATTR_ROLE: c.VALUE_MEMBER, + }, ], } with pytest.raises(ValueError, match="No leader found in group data"): - HeosGroup.from_data(data, None) + HeosGroup._from_data(data, None) @pytest.mark.parametrize( ("command", "group_id", "result"), [ - (const.EVENT_GROUP_VOLUME_CHANGED, "1", True), - (const.EVENT_GROUP_VOLUME_CHANGED, "2", False), - (const.EVENT_PLAYER_VOLUME_CHANGED, "1", False), + (EVENT_GROUP_VOLUME_CHANGED, "1", True), + (EVENT_GROUP_VOLUME_CHANGED, "2", False), + (EVENT_PLAYER_VOLUME_CHANGED, "1", False), ], ) async def test_on_event_no_match_returns_false( @@ -38,12 +45,12 @@ async def test_on_event_no_match_returns_false( event = HeosMessage( command, message={ - const.ATTR_GROUP_ID: group_id, - const.ATTR_LEVEL: "10", - const.ATTR_MUTE: const.VALUE_ON, + c.ATTR_GROUP_ID: group_id, + c.ATTR_LEVEL: "10", + c.ATTR_MUTE: c.VALUE_ON, }, ) - assert result == await group.on_event(event) + assert result == await group._on_event(event) if result: assert group.volume == 10 assert group.is_muted @@ -52,7 +59,10 @@ async def test_on_event_no_match_returns_false( assert not group.is_muted -@calls_command("group.set_volume", {const.ATTR_LEVEL: "25", const.ATTR_GROUP_ID: "1"}) +@calls_command( + "group.set_volume", + {c.ATTR_LEVEL: "25", c.ATTR_GROUP_ID: "1"}, +) async def test_set_volume(group: HeosGroup) -> None: """Test the set_volume command.""" await group.set_volume(25) @@ -65,7 +75,10 @@ async def test_set_volume_invalid_raises(group: HeosGroup, volume: int) -> None: await group.set_volume(volume) -@calls_command("group.volume_down", {const.ATTR_STEP: "6", const.ATTR_GROUP_ID: "1"}) +@calls_command( + "group.volume_down", + {c.ATTR_STEP: "6", c.ATTR_GROUP_ID: "1"}, +) async def test_volume_down(group: HeosGroup) -> None: """Test the volume_down command.""" await group.volume_down(6) @@ -80,7 +93,7 @@ async def test_volume_down_invalid_raises( await group.volume_down(step) -@calls_command("group.volume_up", {const.ATTR_STEP: "6", const.ATTR_GROUP_ID: "1"}) +@calls_command("group.volume_up", {c.ATTR_STEP: "6", c.ATTR_GROUP_ID: "1"}) async def test_volume_up(group: HeosGroup) -> None: """Test the volume_up command.""" await group.volume_up(6) @@ -94,7 +107,11 @@ async def test_volume_up_invalid_raises(group: HeosGroup, step: int) -> None: @calls_command( - "group.set_mute", {const.ATTR_GROUP_ID: "1", const.ATTR_STATE: const.VALUE_ON} + "group.set_mute", + { + c.ATTR_GROUP_ID: "1", + c.ATTR_STATE: c.VALUE_ON, + }, ) async def test_mute(group: HeosGroup) -> None: """Test mute commands.""" @@ -105,8 +122,8 @@ async def test_mute(group: HeosGroup) -> None: @calls_command( "group.set_mute", { - const.ATTR_GROUP_ID: "1", - const.ATTR_STATE: value(arg_name="mute", formatter="on_off"), + c.ATTR_GROUP_ID: "1", + c.ATTR_STATE: value(arg_name="mute", formatter="on_off"), }, ) async def test_set_mute(group: HeosGroup, mute: bool) -> None: @@ -115,14 +132,51 @@ async def test_set_mute(group: HeosGroup, mute: bool) -> None: @calls_command( - "group.set_mute", {const.ATTR_GROUP_ID: "1", const.ATTR_STATE: const.VALUE_OFF} + "group.set_mute", + { + c.ATTR_GROUP_ID: "1", + c.ATTR_STATE: c.VALUE_OFF, + }, ) async def test_unmute(group: HeosGroup) -> None: """Test mute commands.""" await group.unmute() -@calls_command("group.toggle_mute", {const.ATTR_GROUP_ID: "1"}) +@calls_command("group.toggle_mute", {c.ATTR_GROUP_ID: "1"}) async def test_toggle_mute(group: HeosGroup) -> None: """Test toggle mute command.""" await group.toggle_mute() + + +@calls_commands( + CallCommand("group.get_group_info", {c.ATTR_GROUP_ID: 1}), + CallCommand("group.get_volume", {c.ATTR_GROUP_ID: -263109739}), + CallCommand("group.get_mute", {c.ATTR_GROUP_ID: -263109739}), +) +async def test_refresh(group: HeosGroup) -> None: + """Test refresh, including base, updates the correct information.""" + await group.refresh() + + assert group.name == "Zone 1 + Zone 2" + assert group.group_id == -263109739 + assert group.lead_player_id == -263109739 + assert group.member_player_ids == [845195621] + assert group.volume == 42 + assert not group.is_muted + + +@calls_commands( + CallCommand("group.get_volume", {c.ATTR_GROUP_ID: 1}), + CallCommand("group.get_mute", {c.ATTR_GROUP_ID: 1}), +) +async def test_refresh_no_base_update(group: HeosGroup) -> None: + """Test refresh updates the correct information.""" + await group.refresh(refresh_base_info=False) + + assert group.name == "Back Patio + Front Porch" + assert group.group_id == 1 + assert group.lead_player_id == 1 + assert group.member_player_ids == [2] + assert group.volume == 42 + assert not group.is_muted diff --git a/tests/test_heos.py b/tests/test_heos.py index 04c7080..3570f87 100644 --- a/tests/test_heos.py +++ b/tests/test_heos.py @@ -6,13 +6,52 @@ import pytest -from pyheos import command as commands -from pyheos import const +from pyheos import command as c +from pyheos.const import ( + EVENT_GROUP_VOLUME_CHANGED, + EVENT_GROUPS_CHANGED, + EVENT_PLAYER_NOW_PLAYING_CHANGED, + EVENT_PLAYER_NOW_PLAYING_PROGRESS, + EVENT_PLAYER_PLAYBACK_ERROR, + EVENT_PLAYER_QUEUE_CHANGED, + EVENT_PLAYER_STATE_CHANGED, + EVENT_PLAYER_VOLUME_CHANGED, + EVENT_PLAYERS_CHANGED, + EVENT_REPEAT_MODE_CHANGED, + EVENT_SHUFFLE_MODE_CHANGED, + EVENT_SOURCES_CHANGED, + EVENT_USER_CHANGED, + INPUT_CABLE_SAT, + MUSIC_SOURCE_AUX_INPUT, + MUSIC_SOURCE_FAVORITES, + MUSIC_SOURCE_PANDORA, + MUSIC_SOURCE_PLAYLISTS, + MUSIC_SOURCE_TUNEIN, +) from pyheos.credentials import Credentials from pyheos.dispatch import Dispatcher -from pyheos.error import CommandError, CommandFailedError, HeosError -from pyheos.heos import Heos, HeosOptions +from pyheos.error import ( + CommandAuthenticationError, + CommandError, + CommandFailedError, + HeosError, +) +from pyheos.group import HeosGroup +from pyheos.heos import Heos, HeosOptions, PlayerUpdateResult from pyheos.media import MediaItem, MediaMusicSource +from pyheos.player import CONTROLS_ALL, CONTROLS_FORWARD_ONLY, HeosPlayer +from pyheos.types import ( + AddCriteriaType, + ConnectionState, + LineOutLevelType, + MediaType, + NetworkType, + PlayState, + RepeatType, + SignalHeosEvent, + SignalType, + VolumeControlType, +) from tests.common import MediaItems from . import ( @@ -32,7 +71,7 @@ async def test_init() -> None: assert isinstance(heos.dispatcher, Dispatcher) assert len(heos.players) == 0 assert len(heos.music_sources) == 0 - assert heos.connection_state == const.STATE_DISCONNECTED + assert heos.connection_state == ConnectionState.DISCONNECTED @calls_command("player.get_players") @@ -49,14 +88,14 @@ async def test_validate_connection(mock_device: MockHeosDevice) -> None: assert system_info.hosts[0].ip_address == "127.0.0.1" assert system_info.hosts[0].model == "HEOS Drive" assert system_info.hosts[0].name == "Back Patio" - assert system_info.hosts[0].network == const.NETWORK_TYPE_WIRED + assert system_info.hosts[0].network == NetworkType.WIRED assert system_info.hosts[0].serial == "B1A2C3K" assert system_info.hosts[0].version == "1.493.180" assert system_info.hosts[1].ip_address == "127.0.0.2" assert system_info.hosts[1].model == "HEOS Drive" assert system_info.hosts[1].name == "Front Porch" - assert system_info.hosts[1].network == const.NETWORK_TYPE_WIFI + assert system_info.hosts[1].network == NetworkType.WIFI assert system_info.hosts[1].serial is None assert system_info.hosts[1].version == "1.493.180" @@ -68,10 +107,10 @@ async def test_connect(mock_device: MockHeosDevice) -> None: "127.0.0.1", timeout=0.1, auto_reconnect_delay=0.1, heart_beat=False ) ) - signal = connect_handler(heos, const.SIGNAL_HEOS_EVENT, const.EVENT_CONNECTED) + signal = connect_handler(heos, SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED) await heos.connect() assert signal.is_set() - assert heos.connection_state == const.STATE_CONNECTED + assert heos.connection_state == ConnectionState.CONNECTED assert len(mock_device.connections) == 1 connection = mock_device.connections[0] assert connection.is_registered_for_events @@ -91,7 +130,10 @@ async def test_connect_not_logged_in(mock_device: MockHeosDevice) -> None: @calls_command( "system.sign_in", - {const.ATTR_USER_NAME: "example@example.com", const.ATTR_PASSWORD: "example"}, + { + c.ATTR_USER_NAME: "example@example.com", + c.ATTR_PASSWORD: "example", + }, ) async def test_connect_with_credentials_logs_in(mock_device: MockHeosDevice) -> None: """Test heos signs-in when credentials provided.""" @@ -99,6 +141,7 @@ async def test_connect_with_credentials_logs_in(mock_device: MockHeosDevice) -> heos = await Heos.create_and_connect( "127.0.0.1", credentials=credentials, heart_beat=False ) + assert heos.current_credentials == credentials assert heos.is_signed_in assert heos.signed_in_username == "example@example.com" await heos.disconnect() @@ -106,7 +149,10 @@ async def test_connect_with_credentials_logs_in(mock_device: MockHeosDevice) -> @calls_command( "system.sign_in_failure", - {const.ATTR_USER_NAME: "example@example.com", const.ATTR_PASSWORD: "example"}, + { + c.ATTR_USER_NAME: "example@example.com", + c.ATTR_PASSWORD: "example", + }, ) async def test_connect_with_bad_credentials_dispatches_event( mock_device: MockHeosDevice, @@ -116,22 +162,50 @@ async def test_connect_with_bad_credentials_dispatches_event( heos = Heos(HeosOptions("127.0.0.1", credentials=credentials, heart_beat=False)) signal = connect_handler( - heos, const.SIGNAL_HEOS_EVENT, const.EVENT_USER_CREDENTIALS_INVALID + heos, SignalType.HEOS_EVENT, SignalHeosEvent.USER_CREDENTIALS_INVALID ) await heos.connect() assert signal.is_set() - + assert heos.current_credentials is None assert not heos.is_signed_in assert heos.signed_in_username is None await heos.disconnect() -@calls_command( - "browse.browse_fail_user_not_logged_in", - {const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_FAVORITES}, - add_command_under_process=True, +@calls_commands( + CallCommand( + "browse.browse_fail_user_not_logged_in", + {c.ATTR_SOURCE_ID: MUSIC_SOURCE_FAVORITES}, + add_command_under_process=True, + ), + CallCommand("system.sign_out"), +) +async def test_stale_credentials_cleared_afer_auth_error(heos: Heos) -> None: + """Test that a credential is cleared when an auth issue occurs later""" + credentials = Credentials("example@example.com", "example") + heos.current_credentials = credentials + + assert heos.is_signed_in + assert heos.signed_in_username == "example@example.com" + assert heos.current_credentials == credentials + + with pytest.raises(CommandAuthenticationError): + await heos.get_favorites() + + assert not heos.is_signed_in + assert heos.signed_in_username is None # type: ignore[unreachable] + assert heos.current_credentials is None + + +@calls_commands( + CallCommand( + "browse.browse_fail_user_not_logged_in", + {c.ATTR_SOURCE_ID: MUSIC_SOURCE_FAVORITES}, + add_command_under_process=True, + ), + CallCommand("system.sign_out"), ) async def test_command_credential_error_dispatches_event(heos: Heos) -> None: """Test command error with credential error dispatches event.""" @@ -139,10 +213,10 @@ async def test_command_credential_error_dispatches_event(heos: Heos) -> None: assert heos.signed_in_username is not None signal = connect_handler( - heos, const.SIGNAL_HEOS_EVENT, const.EVENT_USER_CREDENTIALS_INVALID + heos, SignalType.HEOS_EVENT, SignalHeosEvent.USER_CREDENTIALS_INVALID ) - with pytest.raises(CommandFailedError): + with pytest.raises(CommandAuthenticationError): await heos.get_favorites() assert signal.is_set() @@ -150,13 +224,46 @@ async def test_command_credential_error_dispatches_event(heos: Heos) -> None: assert heos.signed_in_username is None # type: ignore[unreachable] +@calls_commands( + CallCommand( + "browse.browse_fail_user_not_logged_in", + {c.ATTR_SOURCE_ID: MUSIC_SOURCE_FAVORITES}, + add_command_under_process=True, + ), + CallCommand("system.sign_out"), + CallCommand("browse.get_music_sources"), +) +async def test_command_credential_error_dispatches_event_call_other_command( + heos: Heos, +) -> None: + """Test calling another command during the credential error in the callback""" + assert heos.is_signed_in + assert heos.signed_in_username is not None + + callback_invoked = False + + async def callback() -> None: + nonlocal callback_invoked + callback_invoked = True + assert not heos.is_signed_in + assert heos.signed_in_username is None + sources = await heos.get_music_sources(True) + assert sources + + heos.add_on_user_credentials_invalid(callback) + + with pytest.raises(CommandAuthenticationError): + await heos.get_favorites() + assert callback_invoked + + @calls_command("system.heart_beat") async def test_background_heart_beat(mock_device: MockHeosDevice) -> None: """Test heart beat fires at interval.""" heos = await Heos.create_and_connect("127.0.0.1", heart_beat_interval=0.1) await asyncio.sleep(0.3) - mock_device.assert_command_called(commands.COMMAND_HEART_BEAT) + mock_device.assert_command_called(c.COMMAND_HEART_BEAT) await heos.disconnect() @@ -191,11 +298,11 @@ async def test_connect_timeout() -> None: async def test_connect_multiple_succeeds() -> None: """Test calling connect multiple times succeeds.""" heos = Heos(HeosOptions("127.0.0.1", timeout=0.1, heart_beat=False)) - signal = connect_handler(heos, const.SIGNAL_HEOS_EVENT, const.EVENT_CONNECTED) + signal = connect_handler(heos, SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED) try: await heos.connect() await signal.wait() - assert heos.connection_state == const.STATE_CONNECTED + assert heos.connection_state == ConnectionState.CONNECTED signal.clear() # Try calling again @@ -208,10 +315,10 @@ async def test_connect_multiple_succeeds() -> None: async def test_disconnect(mock_device: MockHeosDevice, heos: Heos) -> None: """Test disconnect updates state and fires signal.""" # Fixture automatically connects - signal = connect_handler(heos, const.SIGNAL_HEOS_EVENT, const.EVENT_DISCONNECTED) + signal = connect_handler(heos, SignalType.HEOS_EVENT, SignalHeosEvent.DISCONNECTED) await heos.disconnect() assert signal.is_set() - assert heos.connection_state == const.STATE_DISCONNECTED + assert heos.connection_state == ConnectionState.DISCONNECTED async def test_commands_fail_when_disconnected( @@ -220,11 +327,11 @@ async def test_commands_fail_when_disconnected( """Test calling commands fail when disconnected.""" # Fixture automatically connects await heos.disconnect() - assert heos.connection_state == const.STATE_DISCONNECTED + assert heos.connection_state == ConnectionState.DISCONNECTED with pytest.raises(CommandError, match="Not connected to device") as e_info: await heos.load_players() - assert e_info.value.command == commands.COMMAND_GET_PLAYERS + assert e_info.value.command == c.COMMAND_GET_PLAYERS assert ( "Command failed 'heos://player/get_players': Not connected to device" in caplog.text @@ -234,13 +341,13 @@ async def test_commands_fail_when_disconnected( async def test_connection_error(mock_device: MockHeosDevice, heos: Heos) -> None: """Test connection error during event results in disconnected.""" disconnect_signal = connect_handler( - heos, const.SIGNAL_HEOS_EVENT, const.EVENT_DISCONNECTED + heos, SignalType.HEOS_EVENT, SignalHeosEvent.DISCONNECTED ) # Assert transitions to disconnected and fires disconnect await mock_device.stop() await disconnect_signal.wait() - assert heos.connection_state == const.STATE_DISCONNECTED + assert heos.connection_state == ConnectionState.DISCONNECTED async def test_connection_error_during_command( @@ -248,7 +355,7 @@ async def test_connection_error_during_command( ) -> None: """Test connection error during command results in disconnected.""" disconnect_signal = connect_handler( - heos, const.SIGNAL_HEOS_EVENT, const.EVENT_DISCONNECTED + heos, SignalType.HEOS_EVENT, SignalHeosEvent.DISCONNECTED ) # Assert transitions to disconnected and fires disconnect @@ -259,7 +366,7 @@ async def test_connection_error_during_command( assert isinstance(e_info.value.__cause__, asyncio.TimeoutError) await disconnect_signal.wait() - assert heos.connection_state == const.STATE_DISCONNECTED + assert heos.connection_state == ConnectionState.DISCONNECTED async def test_reconnect_during_event(mock_device: MockHeosDevice) -> None: @@ -275,28 +382,29 @@ async def test_reconnect_during_event(mock_device: MockHeosDevice) -> None: ) connect_signal = connect_handler( - heos, const.SIGNAL_HEOS_EVENT, const.EVENT_CONNECTED + heos, SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED ) disconnect_signal = connect_handler( - heos, const.SIGNAL_HEOS_EVENT, const.EVENT_DISCONNECTED + heos, SignalType.HEOS_EVENT, SignalHeosEvent.DISCONNECTED ) # Assert open and fires connected await heos.connect() assert connect_signal.is_set() - assert heos.connection_state == const.STATE_CONNECTED + assert heos.connection_state == ConnectionState.CONNECTED connect_signal.clear() # Assert transitions to reconnecting and fires disconnect await mock_device.stop() await disconnect_signal.wait() - assert heos.connection_state == const.STATE_RECONNECTING + assert heos.connection_state == ConnectionState.RECONNECTING # type: ignore[comparison-overlap] # Assert reconnects once server is back up and fires connected - await asyncio.sleep(0.5) # Force reconnect timeout + # Force reconnect timeout + await asyncio.sleep(0.5) # type: ignore[unreachable] await mock_device.start() await connect_signal.wait() - assert heos.connection_state == const.STATE_CONNECTED + assert heos.connection_state == ConnectionState.CONNECTED await heos.disconnect() @@ -314,16 +422,16 @@ async def test_reconnect_during_command(mock_device: MockHeosDevice) -> None: ) connect_signal = connect_handler( - heos, const.SIGNAL_HEOS_EVENT, const.EVENT_CONNECTED + heos, SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED ) disconnect_signal = connect_handler( - heos, const.SIGNAL_HEOS_EVENT, const.EVENT_DISCONNECTED + heos, SignalType.HEOS_EVENT, SignalHeosEvent.DISCONNECTED ) # Assert open and fires connected await heos.connect() assert connect_signal.is_set() - assert heos.connection_state == const.STATE_CONNECTED + assert heos.connection_state == ConnectionState.CONNECTED connect_signal.clear() # Act @@ -335,7 +443,7 @@ async def test_reconnect_during_command(mock_device: MockHeosDevice) -> None: # Assert signals set await disconnect_signal.wait() await connect_signal.wait() - assert heos.connection_state == const.STATE_CONNECTED + assert heos.connection_state == ConnectionState.CONNECTED await heos.disconnect() @@ -353,28 +461,28 @@ async def test_reconnect_cancelled(mock_device: MockHeosDevice) -> None: ) connect_signal = connect_handler( - heos, const.SIGNAL_HEOS_EVENT, const.EVENT_CONNECTED + heos, SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED ) disconnect_signal = connect_handler( - heos, const.SIGNAL_HEOS_EVENT, const.EVENT_DISCONNECTED + heos, SignalType.HEOS_EVENT, SignalHeosEvent.DISCONNECTED ) # Assert open and fires connected await heos.connect() assert connect_signal.is_set() - assert heos.connection_state == const.STATE_CONNECTED + assert heos.connection_state == ConnectionState.CONNECTED connect_signal.clear() # Assert transitions to reconnecting and fires disconnect await mock_device.stop() await disconnect_signal.wait() - assert heos.connection_state == const.STATE_RECONNECTING + assert heos.connection_state == ConnectionState.RECONNECTING # type: ignore[comparison-overlap] - await asyncio.sleep(0.3) + await asyncio.sleep(0.3) # type: ignore[unreachable] # Assert calling disconnect sets state to disconnected await heos.disconnect() - assert heos.connection_state == const.STATE_DISCONNECTED + assert heos.connection_state == ConnectionState.DISCONNECTED @calls_player_commands() @@ -387,17 +495,85 @@ async def test_get_players(heos: Heos) -> None: assert player.player_id == 1 assert player.name == "Back Patio" assert player.ip_address == "127.0.0.1" - assert player.line_out == 1 + assert player.line_out == LineOutLevelType.FIXED + assert player.control == VolumeControlType.IR assert player.model == "HEOS Drive" - assert player.network == const.NETWORK_TYPE_WIRED - assert player.state == const.PlayState.STOP + assert player.network == NetworkType.WIRED + assert player.state == PlayState.STOP assert player.version == "1.493.180" assert player.volume == 36 assert not player.is_muted - assert player.repeat == const.RepeatType.OFF + assert player.repeat == RepeatType.OFF assert not player.shuffle assert player.available assert player.heos == heos + assert player.group_id is None + assert heos.players[2].group_id == 2 + + +@calls_commands( + CallCommand("player.get_player_info", {c.ATTR_PLAYER_ID: -263109739}), + CallCommand("player.get_play_state", {c.ATTR_PLAYER_ID: -263109739}), + CallCommand("player.get_now_playing_media", {c.ATTR_PLAYER_ID: -263109739}), + CallCommand("player.get_volume", {c.ATTR_PLAYER_ID: -263109739}), + CallCommand("player.get_mute", {c.ATTR_PLAYER_ID: -263109739}), + CallCommand("player.get_play_mode", {c.ATTR_PLAYER_ID: -263109739}), +) +async def test_get_player_info_by_id(heos: Heos) -> None: + """Test retrieving player info by player id.""" + player = await heos.get_player_info(-263109739) + assert player.name == "Zone 1" + assert player.player_id == -263109739 + + +@calls_player_commands() +async def test_get_player_info_by_id_already_loaded(heos: Heos) -> None: + """Test retrieving player info by player id for already loaded player does not update.""" + players = await heos.get_players() + original_player = players[1] + + player = await heos.get_player_info(1) + assert original_player == player + + +@calls_player_commands( + (1, 2), + CallCommand("player.get_player_info", {c.ATTR_PLAYER_ID: 1}), + CallCommand("player.get_play_state", {c.ATTR_PLAYER_ID: -263109739}), + CallCommand("player.get_now_playing_media", {c.ATTR_PLAYER_ID: -263109739}), + CallCommand("player.get_volume", {c.ATTR_PLAYER_ID: -263109739}), + CallCommand("player.get_mute", {c.ATTR_PLAYER_ID: -263109739}), + CallCommand("player.get_play_mode", {c.ATTR_PLAYER_ID: -263109739}), +) +async def test_get_player_info_by_id_already_loaded_refresh(heos: Heos) -> None: + """Test retrieving player info by player id for already loaded player updates.""" + players = await heos.get_players() + original_player = players[1] + + player = await heos.get_player_info(1, refresh=True) + assert original_player == player + assert player.name == "Zone 1" + assert player.player_id == -263109739 + + +@pytest.mark.parametrize( + ("player_id", "player", "error"), + [ + (None, None, "Either player_id or player must be provided"), + ( + 1, + object(), + "Only one of player_id or player should be provided", + ), + ], +) +async def test_get_player_info_invalid_parameters_raises( + player_id: int | None, player: HeosPlayer | None, error: str +) -> None: + """Test retrieving player info with invalid parameters raises.""" + heos = Heos(HeosOptions("127.0.0.1")) + with pytest.raises(ValueError, match=error): + await heos.get_player_info(player_id=player_id, player=player) @calls_player_commands() @@ -421,8 +597,13 @@ async def test_player_availability_matches_connection_state(heos: Heos) -> None: @calls_command("player.get_players_error") async def test_get_players_error(heos: Heos) -> None: """Test the get_players method load players.""" - with pytest.raises(CommandFailedError, match=re.escape("System error -519 (12)")): + with pytest.raises( + CommandFailedError, match=re.escape("System error -519 (12)") + ) as exc_info: await heos.get_players() + assert exc_info.value.error_id == 12 + assert exc_info.value.system_error_number == -519 + assert exc_info.value.error_text == "System error -519" @calls_player_commands() @@ -433,13 +614,13 @@ async def test_player_state_changed_event( # assert not playing await heos.get_players() player = heos.players[1] - assert player.state == const.PlayState.STOP + assert player.state == PlayState.STOP # Attach dispatch handler signal = asyncio.Event() async def handler(event: str) -> None: - assert event == const.EVENT_PLAYER_STATE_CHANGED + assert event == EVENT_PLAYER_STATE_CHANGED signal.set() player.add_on_player_event(handler) @@ -447,14 +628,14 @@ async def handler(event: str) -> None: # Write event through mock device await mock_device.write_event( "event.player_state_changed", - {"player_id": player.player_id, "state": const.PlayState.PLAY}, + {"player_id": player.player_id, "state": PlayState.PLAY}, ) # Wait until the signal await signal.wait() # Assert state changed - assert player.state == const.PlayState.PLAY # type: ignore[comparison-overlap] - assert heos.players[2].state == const.PlayState.STOP # type: ignore[unreachable] + assert player.state == PlayState.PLAY # type: ignore[comparison-overlap] + assert heos.players[2].state == PlayState.STOP # type: ignore[unreachable] @calls_player_commands() @@ -479,21 +660,26 @@ async def test_player_now_playing_changed_event( assert now_playing.media_id == "4256592506324148495" assert now_playing.queue_id == 1 assert now_playing.source_id == 13 - assert now_playing.supported_controls == const.CONTROLS_ALL + assert now_playing.supported_controls == CONTROLS_ALL + assert len(now_playing.options) == 3 + option = now_playing.options[2] + assert option.id == 19 + assert option.name == "Add to HEOS Favorites" + assert option.context == "play" # Attach dispatch handler signal = asyncio.Event() async def handler(player_id: int, event: str) -> None: assert player_id == player.player_id - assert event == const.EVENT_PLAYER_NOW_PLAYING_CHANGED + assert event == EVENT_PLAYER_NOW_PLAYING_CHANGED signal.set() - heos.dispatcher.connect(const.SIGNAL_PLAYER_EVENT, handler) + heos.dispatcher.connect(SignalType.PLAYER_EVENT, handler) # Write event through mock device command = mock_device.register( - commands.COMMAND_GET_NOW_PLAYING_MEDIA, + c.COMMAND_GET_NOW_PLAYING_MEDIA, None, "player.get_now_playing_media_changed", replace=True, @@ -519,7 +705,12 @@ async def handler(player_id: int, event: str) -> None: assert now_playing.current_position is None assert now_playing.current_position_updated is None assert now_playing.duration is None - assert now_playing.supported_controls == const.CONTROLS_FORWARD_ONLY + assert now_playing.supported_controls == CONTROLS_FORWARD_ONLY + assert len(now_playing.options) == 3 + option = now_playing.options[2] + assert option.id == 20 + assert option.name == "Remove from HEOS Favorites" + assert option.context == "play" @calls_player_commands() @@ -538,10 +729,10 @@ async def test_player_volume_changed_event( async def handler(player_id: int, event: str) -> None: assert player_id == player.player_id - assert event == const.EVENT_PLAYER_VOLUME_CHANGED + assert event == EVENT_PLAYER_VOLUME_CHANGED signal.set() - heos.dispatcher.connect(const.SIGNAL_PLAYER_EVENT, handler) + heos.dispatcher.connect(SignalType.PLAYER_EVENT, handler) # Write event through mock device await mock_device.write_event( @@ -549,7 +740,7 @@ async def handler(player_id: int, event: str) -> None: { "player_id": player.player_id, "level": 50.0, - "mute": const.VALUE_ON, + "mute": c.VALUE_ON, }, ) @@ -579,10 +770,10 @@ async def test_player_now_playing_progress_event( async def handler(player_id: int, event: str) -> None: assert player_id == player.player_id - assert event == const.EVENT_PLAYER_NOW_PLAYING_PROGRESS + assert event == EVENT_PLAYER_NOW_PLAYING_PROGRESS signal.set() - heos.dispatcher.connect(const.SIGNAL_PLAYER_EVENT, handler) + heos.dispatcher.connect(SignalType.PLAYER_EVENT, handler) # Write event through mock device await mock_device.write_event( @@ -625,7 +816,7 @@ async def handler(player_id: int, event: str) -> None: else: pytest.fail("Handler invoked more than once.") - heos.dispatcher.connect(const.SIGNAL_PLAYER_EVENT, handler) + heos.dispatcher.connect(SignalType.PLAYER_EVENT, handler) # raise it multiple times. await mock_device.write_event( @@ -657,17 +848,17 @@ async def test_repeat_mode_changed_event( # assert not playing await heos.get_players() player = heos.players[1] - assert player.repeat == const.RepeatType.OFF + assert player.repeat == RepeatType.OFF # Attach dispatch handler signal = asyncio.Event() async def handler(player_id: int, event: str) -> None: assert player_id == player.player_id - assert event == const.EVENT_REPEAT_MODE_CHANGED + assert event == EVENT_REPEAT_MODE_CHANGED signal.set() - heos.dispatcher.connect(const.SIGNAL_PLAYER_EVENT, handler) + heos.dispatcher.connect(SignalType.PLAYER_EVENT, handler) # Write event through mock device await mock_device.write_event("event.repeat_mode_changed") @@ -675,7 +866,7 @@ async def handler(player_id: int, event: str) -> None: # Wait until the signal is set await signal.wait() # Assert state changed - assert player.repeat == const.RepeatType.ON_ALL # type: ignore[comparison-overlap] + assert player.repeat == RepeatType.ON_ALL # type: ignore[comparison-overlap] @calls_player_commands() @@ -693,10 +884,10 @@ async def test_shuffle_mode_changed_event( async def handler(player_id: int, event: str) -> None: assert player_id == player.player_id - assert event == const.EVENT_SHUFFLE_MODE_CHANGED + assert event == EVENT_SHUFFLE_MODE_CHANGED signal.set() - heos.dispatcher.connect(const.SIGNAL_PLAYER_EVENT, handler) + heos.dispatcher.connect(SignalType.PLAYER_EVENT, handler) # Write event through mock device await mock_device.write_event("event.shuffle_mode_changed") @@ -716,16 +907,21 @@ async def test_players_changed_event(mock_device: MockHeosDevice, heos: Heos) -> # Attach dispatch handler signal = asyncio.Event() - async def handler(event: str, data: dict[str, Any]) -> None: - assert event == const.EVENT_PLAYERS_CHANGED - assert data == {const.DATA_NEW: [3], const.DATA_MAPPED_IDS: {}} + async def handler(event: str, result: PlayerUpdateResult) -> None: + assert event == EVENT_PLAYERS_CHANGED + assert result.added_player_ids == [3] + assert result.updated_player_ids == {} + assert result.removed_player_ids == [2] signal.set() - heos.dispatcher.connect(const.SIGNAL_CONTROLLER_EVENT, handler) + heos.dispatcher.connect(SignalType.CONTROLLER_EVENT, handler) # Write event through mock device command = mock_device.register( - commands.COMMAND_GET_PLAYERS, None, "player.get_players_changed", replace=True + c.COMMAND_GET_PLAYERS, + None, + "player.get_players_changed", + replace=True, ) await mock_device.write_event("event.players_changed") @@ -752,16 +948,18 @@ async def test_players_changed_event_new_ids( # Attach dispatch handler signal = asyncio.Event() - async def handler(event: str, data: dict[str, Any]) -> None: - assert event == const.EVENT_PLAYERS_CHANGED - assert data == {const.DATA_NEW: [], const.DATA_MAPPED_IDS: {101: 1, 102: 2}} + async def handler(event: str, result: PlayerUpdateResult) -> None: + assert event == EVENT_PLAYERS_CHANGED + assert result.added_player_ids == [] + assert result.updated_player_ids == {1: 101, 2: 102} + assert result.removed_player_ids == [] signal.set() - heos.dispatcher.connect(const.SIGNAL_CONTROLLER_EVENT, handler) + heos.dispatcher.connect(SignalType.CONTROLLER_EVENT, handler) # Write event through mock device command = mock_device.register( - commands.COMMAND_GET_PLAYERS, + c.COMMAND_GET_PLAYERS, None, "player.get_players_firmware_update", replace=True, @@ -786,15 +984,15 @@ async def test_sources_changed_event(mock_device: MockHeosDevice, heos: Heos) -> signal = asyncio.Event() async def handler(event: str, data: dict[str, Any]) -> None: - assert event == const.EVENT_SOURCES_CHANGED + assert event == EVENT_SOURCES_CHANGED signal.set() - heos.dispatcher.connect(const.SIGNAL_CONTROLLER_EVENT, handler) + heos.dispatcher.connect(SignalType.CONTROLLER_EVENT, handler) # Write event through mock device command = mock_device.register( - commands.COMMAND_BROWSE_GET_SOURCES, - {const.ATTR_REFRESH: const.VALUE_ON}, + c.COMMAND_BROWSE_GET_SOURCES, + {c.ATTR_REFRESH: c.VALUE_ON}, "browse.get_music_sources_changed", replace=True, ) @@ -803,7 +1001,7 @@ async def handler(event: str, data: dict[str, Any]) -> None: # Wait until the signal is set await signal.wait() command.assert_called() - assert heos.music_sources[const.MUSIC_SOURCE_TUNEIN].available + assert heos.music_sources[MUSIC_SOURCE_TUNEIN].available @calls_group_commands() @@ -814,14 +1012,14 @@ async def test_groups_changed_event(mock_device: MockHeosDevice, heos: Heos) -> signal = asyncio.Event() async def handler(event: str, data: dict[str, Any]) -> None: - assert event == const.EVENT_GROUPS_CHANGED + assert event == EVENT_GROUPS_CHANGED signal.set() - heos.dispatcher.connect(const.SIGNAL_CONTROLLER_EVENT, handler) + heos.dispatcher.connect(SignalType.CONTROLLER_EVENT, handler) # Write event through mock device command = mock_device.register( - commands.COMMAND_GET_GROUPS, None, "group.get_groups_changed", replace=True + c.COMMAND_GET_GROUPS, None, "group.get_groups_changed", replace=True ) await mock_device.write_event("event.groups_changed") @@ -841,10 +1039,10 @@ async def test_player_playback_error_event( async def handler(player_id: int, event: str) -> None: assert player_id == 1 - assert event == const.EVENT_PLAYER_PLAYBACK_ERROR + assert event == EVENT_PLAYER_PLAYBACK_ERROR signal.set() - heos.dispatcher.connect(const.SIGNAL_PLAYER_EVENT, handler) + heos.dispatcher.connect(SignalType.PLAYER_EVENT, handler) # Write event through mock device await mock_device.write_event("event.player_playback_error") @@ -864,10 +1062,10 @@ async def test_player_queue_changed_event( async def handler(player_id: int, event: str) -> None: assert player_id == 1 - assert event == const.EVENT_PLAYER_QUEUE_CHANGED + assert event == EVENT_PLAYER_QUEUE_CHANGED signal.set() - heos.dispatcher.connect(const.SIGNAL_PLAYER_EVENT, handler) + heos.dispatcher.connect(SignalType.PLAYER_EVENT, handler) # Write event through mock device await mock_device.write_event("event.player_queue_changed") @@ -888,12 +1086,11 @@ async def test_group_volume_changed_event( signal = asyncio.Event() - async def handler(group_id: int, event: str) -> None: - assert group_id == 1 - assert event == const.EVENT_GROUP_VOLUME_CHANGED + async def handler(event: str) -> None: + assert event == EVENT_GROUP_VOLUME_CHANGED signal.set() - heos.dispatcher.connect(const.SIGNAL_GROUP_EVENT, handler) + group.add_on_group_event(handler) # Write event through mock device await mock_device.write_event("event.group_volume_changed") @@ -909,10 +1106,10 @@ async def test_user_changed_event(mock_device: MockHeosDevice, heos: Heos) -> No signal = asyncio.Event() async def handler(event: str, data: dict[str, Any]) -> None: - assert event == const.EVENT_USER_CHANGED + assert event == EVENT_USER_CHANGED signal.set() - heos.dispatcher.connect(const.SIGNAL_CONTROLLER_EVENT, handler) + heos.dispatcher.connect(SignalType.CONTROLLER_EVENT, handler) # Test signed out event await mock_device.write_event("event.user_changed_signed_out") @@ -929,7 +1126,8 @@ async def handler(event: str, data: dict[str, Any]) -> None: @calls_command( - "browse.browse_favorites", {const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_FAVORITES} + "browse.browse_favorites", + {c.ATTR_SOURCE_ID: MUSIC_SOURCE_FAVORITES}, ) async def test_browse_media_music_source( heos: Heos, @@ -937,17 +1135,17 @@ async def test_browse_media_music_source( ) -> None: """Test browse with an unavailable MediaMusicSource raises.""" result = await heos.browse_media(media_music_source) - assert result.source_id == const.MUSIC_SOURCE_FAVORITES + assert result.source_id == MUSIC_SOURCE_FAVORITES assert result.returned == 3 assert result.count == 3 assert len(result.items) == 3 async def test_browse_media_music_source_unavailable_rasises( - heos: Heos, media_music_source_unavailable: MediaMusicSource, ) -> None: """Test browse with an unavailable MediaMusicSource raises.""" + heos = Heos(HeosOptions("127.0.0.1")) with pytest.raises(ValueError, match="Source is not available to browse"): await heos.browse_media(media_music_source_unavailable) @@ -955,9 +1153,9 @@ async def test_browse_media_music_source_unavailable_rasises( @calls_command( "browse.browse_album", { - const.ATTR_SOURCE_ID: MediaItems.ALBUM.source_id, - const.ATTR_CONTAINER_ID: MediaItems.ALBUM.container_id, - const.ATTR_RANGE: "0,13", + c.ATTR_SOURCE_ID: MediaItems.ALBUM.source_id, + c.ATTR_CONTAINER_ID: MediaItems.ALBUM.container_id, + c.ATTR_RANGE: "0,13", }, ) async def test_browse_media_item(heos: Heos, media_item_album: MediaItem) -> None: @@ -972,35 +1170,35 @@ async def test_browse_media_item(heos: Heos, media_item_album: MediaItem) -> Non async def test_browse_media_item_not_browsable_raises( - heos: Heos, media_item_song: MediaItem + media_item_song: MediaItem, ) -> None: """Test browse with an not browsable MediaItem raises.""" + heos = Heos(HeosOptions("127.0.0.1")) with pytest.raises( ValueError, match="Only media sources and containers can be browsed" ): await heos.browse_media(media_item_song) -async def test_play_media_unplayable_raises( - heos: Heos, media_item_album: MediaItem -) -> None: +async def test_play_media_unplayable_raises(media_item_album: MediaItem) -> None: """Test play media with unplayable source raises.""" + heos = Heos(HeosOptions("127.0.0.1")) media_item_album.playable = False with pytest.raises( ValueError, match=re.escape(f"Media '{media_item_album}' is not playable") ): - await heos.play_media(1, media_item_album, const.AddCriteriaType.PLAY_NOW) + await heos.play_media(1, media_item_album, AddCriteriaType.PLAY_NOW) @calls_command( "browse.add_to_queue_track", { - const.ATTR_PLAYER_ID: "1", - const.ATTR_SOURCE_ID: MediaItems.SONG.source_id, - const.ATTR_CONTAINER_ID: MediaItems.SONG.container_id, - const.ATTR_MEDIA_ID: MediaItems.SONG.media_id, - const.ATTR_ADD_CRITERIA_ID: const.AddCriteriaType.PLAY_NOW, + c.ATTR_PLAYER_ID: "1", + c.ATTR_SOURCE_ID: MediaItems.SONG.source_id, + c.ATTR_CONTAINER_ID: MediaItems.SONG.container_id, + c.ATTR_MEDIA_ID: MediaItems.SONG.media_id, + c.ATTR_ADD_CRITERIA_ID: AddCriteriaType.PLAY_NOW, }, ) async def test_play_media_song(heos: Heos, media_item_song: MediaItem) -> None: @@ -1009,9 +1207,10 @@ async def test_play_media_song(heos: Heos, media_item_song: MediaItem) -> None: async def test_play_media_song_missing_container_raises( - heos: Heos, media_item_song: MediaItem + media_item_song: MediaItem, ) -> None: """Test play song succeeseds.""" + heos = Heos(HeosOptions("127.0.0.1")) media_item_song.container_id = None with pytest.raises( @@ -1024,9 +1223,9 @@ async def test_play_media_song_missing_container_raises( @calls_command( "browse.play_input", { - const.ATTR_PLAYER_ID: 1, - const.ATTR_INPUT: MediaItems.INPUT.media_id, - const.ATTR_SOURCE_PLAYER_ID: MediaItems.INPUT.source_id, + c.ATTR_PLAYER_ID: 1, + c.ATTR_INPUT: MediaItems.INPUT.media_id, + c.ATTR_SOURCE_PLAYER_ID: MediaItems.INPUT.source_id, }, ) async def test_play_media_input(heos: Heos, media_item_input: MediaItem) -> None: @@ -1037,10 +1236,10 @@ async def test_play_media_input(heos: Heos, media_item_input: MediaItem) -> None @calls_command( "browse.play_stream_station", { - const.ATTR_PLAYER_ID: "1", - const.ATTR_SOURCE_ID: MediaItems.STATION.source_id, - const.ATTR_CONTAINER_ID: MediaItems.STATION.container_id, - const.ATTR_MEDIA_ID: MediaItems.STATION.media_id, + c.ATTR_PLAYER_ID: "1", + c.ATTR_SOURCE_ID: MediaItems.STATION.source_id, + c.ATTR_CONTAINER_ID: MediaItems.STATION.container_id, + c.ATTR_MEDIA_ID: MediaItems.STATION.media_id, }, ) async def test_play_media_station(heos: Heos, media_item_station: MediaItem) -> None: @@ -1049,9 +1248,10 @@ async def test_play_media_station(heos: Heos, media_item_station: MediaItem) -> async def test_play_media_station_missing_media_id_raises( - heos: Heos, media_item_station: MediaItem + media_item_station: MediaItem, ) -> None: """Test play song succeeseds.""" + heos = Heos(HeosOptions("127.0.0.1")) media_item_station.media_id = None with pytest.raises( @@ -1066,23 +1266,24 @@ async def test_get_music_sources(heos: Heos) -> None: """Test the heos connect method.""" sources = await heos.get_music_sources() assert len(sources) == 15 - pandora = sources[const.MUSIC_SOURCE_PANDORA] - assert pandora.source_id == const.MUSIC_SOURCE_PANDORA + pandora = sources[MUSIC_SOURCE_PANDORA] + assert pandora.source_id == MUSIC_SOURCE_PANDORA assert ( pandora.image_url == "https://production.ws.skyegloup.com:443/media/images/service/logos/pandora.png" ) - assert pandora.type == const.MediaType.MUSIC_SERVICE + assert pandora.type == MediaType.MUSIC_SERVICE assert pandora.available assert pandora.service_username == "test@test.com" @calls_commands( CallCommand( - "browse.browse_aux_input", {const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_AUX_INPUT} + "browse.browse_aux_input", + {c.ATTR_SOURCE_ID: MUSIC_SOURCE_AUX_INPUT}, ), - CallCommand("browse.browse_theater_receiver", {const.ATTR_SOURCE_ID: 546978854}), - CallCommand("browse.browse_heos_drive", {const.ATTR_SOURCE_ID: -263109739}), + CallCommand("browse.browse_theater_receiver", {c.ATTR_SOURCE_ID: 546978854}), + CallCommand("browse.browse_heos_drive", {c.ATTR_SOURCE_ID: -263109739}), ) async def test_get_input_sources(heos: Heos) -> None: """Test the get input sources method.""" @@ -1090,14 +1291,15 @@ async def test_get_input_sources(heos: Heos) -> None: assert len(sources) == 18 source = sources[0] assert source.playable - assert source.type == const.MediaType.STATION + assert source.type == MediaType.STATION assert source.name == "Theater Receiver - CBL/SAT" - assert source.media_id == const.INPUT_CABLE_SAT + assert source.media_id == INPUT_CABLE_SAT assert source.source_id == 546978854 @calls_command( - "browse.browse_favorites", {const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_FAVORITES} + "browse.browse_favorites", + {c.ATTR_SOURCE_ID: MUSIC_SOURCE_FAVORITES}, ) async def test_get_favorites(heos: Heos) -> None: """Test the get favorites method.""" @@ -1112,11 +1314,12 @@ async def test_get_favorites(heos: Heos) -> None: fav.image_url == "http://mediaserver-cont-ch1-1-v4v6.pandora.com/images/public/devicead/t/r/a/m/daartpralbumart_500W_500H.jpg" ) - assert fav.type == const.MediaType.STATION + assert fav.type == MediaType.STATION @calls_command( - "browse.browse_playlists", {const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_PLAYLISTS} + "browse.browse_playlists", + {c.ATTR_SOURCE_ID: MUSIC_SOURCE_PLAYLISTS}, ) async def test_get_playlists(heos: Heos) -> None: """Test the get playlists method.""" @@ -1127,13 +1330,16 @@ async def test_get_playlists(heos: Heos) -> None: assert playlist.container_id == "171566" assert playlist.name == "Rockin Songs" assert playlist.image_url == "" - assert playlist.type == const.MediaType.PLAYLIST - assert playlist.source_id == const.MUSIC_SOURCE_PLAYLISTS + assert playlist.type == MediaType.PLAYLIST + assert playlist.source_id == MUSIC_SOURCE_PLAYLISTS @calls_command( "system.sign_in", - {const.ATTR_USER_NAME: "example@example.com", const.ATTR_PASSWORD: "example"}, + { + c.ATTR_USER_NAME: "example@example.com", + c.ATTR_PASSWORD: "example", + }, ) async def test_sign_in_does_not_update_credentials(heos: Heos) -> None: """Test sign-in does not update existing credentials.""" @@ -1148,7 +1354,10 @@ async def test_sign_in_does_not_update_credentials(heos: Heos) -> None: CallCommand("system.sign_out"), CallCommand( "system.sign_in_failure", - {const.ATTR_USER_NAME: "example@example.com", const.ATTR_PASSWORD: "example"}, + { + c.ATTR_USER_NAME: "example@example.com", + c.ATTR_PASSWORD: "example", + }, ), ) async def test_sign_in_and_out(heos: Heos, caplog: pytest.LogCaptureFixture) -> None: @@ -1158,7 +1367,7 @@ async def test_sign_in_and_out(heos: Heos, caplog: pytest.LogCaptureFixture) -> assert heos.signed_in_username is None # Test sign-in failure - with pytest.raises(CommandFailedError, match="User not found"): + with pytest.raises(CommandAuthenticationError, match="User not found"): await heos.sign_in("example@example.com", "example") assert ( "Command failed 'heos://system/sign_in?un=example@example.com&pw=********':" @@ -1172,7 +1381,10 @@ async def test_sign_in_and_out(heos: Heos, caplog: pytest.LogCaptureFixture) -> CallCommand("system.sign_out"), CallCommand( "system.sign_in", - {const.ATTR_USER_NAME: "example@example.com", const.ATTR_PASSWORD: "example"}, + { + c.ATTR_USER_NAME: "example@example.com", + c.ATTR_PASSWORD: "example", + }, ), ) async def test_sign_in_updates_credential( @@ -1216,25 +1428,91 @@ async def test_get_groups(heos: Heos) -> None: assert not group.is_muted -@calls_command("group.set_group_create", {const.ATTR_PLAYER_ID: "1,2,3"}) +@calls_commands( + CallCommand("group.get_group_info", {c.ATTR_GROUP_ID: -263109739}), + CallCommand("group.get_volume", {c.ATTR_GROUP_ID: -263109739}), + CallCommand("group.get_mute", {c.ATTR_GROUP_ID: -263109739}), +) +async def test_get_group_info_by_id(heos: Heos) -> None: + """Test retrieving group info by group id.""" + group = await heos.get_group_info(-263109739) + assert group.name == "Zone 1 + Zone 2" + assert group.group_id == -263109739 + assert group.lead_player_id == -263109739 + assert group.member_player_ids == [845195621] + assert group.volume == 42 + assert not group.is_muted + + +@calls_group_commands() +async def test_get_group_info_by_id_already_loaded(heos: Heos) -> None: + """Test retrieving group info by group id for already loaded group does not update.""" + groups = await heos.get_groups() + original_group = groups[1] + + group = await heos.get_group_info(1) + assert original_group == group + + +@calls_group_commands( + CallCommand("group.get_group_info", {c.ATTR_GROUP_ID: 1}), + CallCommand("group.get_volume", {c.ATTR_GROUP_ID: -263109739}), + CallCommand("group.get_mute", {c.ATTR_GROUP_ID: -263109739}), +) +async def test_get_group_info_by_id_already_loaded_refresh(heos: Heos) -> None: + """Test retrieving group info by group id for already loaded group updates.""" + groups = await heos.get_groups() + original_group = groups[1] + + group = await heos.get_group_info(1, refresh=True) + assert original_group == group + assert group.name == "Zone 1 + Zone 2" + assert group.group_id == -263109739 + assert group.lead_player_id == -263109739 + assert group.member_player_ids == [845195621] + assert group.volume == 42 + assert not group.is_muted + + +@pytest.mark.parametrize( + ("group_id", "group", "error"), + [ + (None, None, "Either group_id or group must be provided"), + ( + 1, + HeosGroup("", 0, 0, []), + "Only one of group_id or group should be provided", + ), + ], +) +async def test_get_group_info_invalid_parameters_raises( + group_id: int | None, group: HeosGroup | None, error: str +) -> None: + """Test retrieving group info with invalid parameters raises.""" + heos = Heos(HeosOptions("127.0.0.1")) + with pytest.raises(ValueError, match=error): + await heos.get_group_info(group_id=group_id, group=group) + + +@calls_command("group.set_group_create", {c.ATTR_PLAYER_ID: "1,2,3"}) async def test_create_group(heos: Heos) -> None: """Test creating a group.""" await heos.create_group(1, [2, 3]) -@calls_command("group.set_group_remove", {const.ATTR_PLAYER_ID: 1}) +@calls_command("group.set_group_remove", {c.ATTR_PLAYER_ID: 1}) async def test_remove_group(heos: Heos) -> None: """Test removing a group.""" await heos.remove_group(1) -@calls_command("group.set_group_update", {const.ATTR_PLAYER_ID: "1,2"}) +@calls_command("group.set_group_update", {c.ATTR_PLAYER_ID: "1,2"}) async def test_update_group(heos: Heos) -> None: """Test removing a group.""" await heos.update_group(1, [2]) -@calls_command("player.get_now_playing_media", {const.ATTR_PLAYER_ID: 1}) +@calls_command("player.get_now_playing_media", {c.ATTR_PLAYER_ID: 1}) async def test_get_now_playing_media(heos: Heos) -> None: """Test removing a group.""" media = await heos.get_now_playing_media(1) @@ -1252,12 +1530,12 @@ async def test_get_now_playing_media(heos: Heos) -> None: assert media.media_id == "4256592506324148495" assert media.queue_id == 1 assert media.source_id == 13 - assert media.supported_controls == const.CONTROLS_ALL + assert media.supported_controls == CONTROLS_ALL @calls_command("system.heart_beat") async def test_heart_beat(heos: Heos) -> None: - """Test the heart beat command.""" + """Test the heart beat c.""" await heos.heart_beat() @@ -1270,24 +1548,24 @@ async def test_reboot() -> None: try: disconnect_signal = connect_handler( - heos, const.SIGNAL_HEOS_EVENT, const.EVENT_DISCONNECTED + heos, SignalType.HEOS_EVENT, SignalHeosEvent.DISCONNECTED ) connect_signal = connect_handler( - heos, const.SIGNAL_HEOS_EVENT, const.EVENT_CONNECTED + heos, SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED ) await heos.reboot() # wait for disconnect await disconnect_signal.wait() - assert heos.connection_state == const.STATE_RECONNECTING + assert heos.connection_state == ConnectionState.RECONNECTING # wait for reconnect await connect_signal.wait() - assert heos.connection_state == const.STATE_CONNECTED + assert heos.connection_state == ConnectionState.CONNECTED # type: ignore[comparison-overlap] finally: await heos.disconnect() - assert heos.connection_state == const.STATE_DISCONNECTED + assert heos.connection_state == ConnectionState.DISCONNECTED # type: ignore[unreachable] async def test_unrecognized_event_logs( diff --git a/tests/test_heos_browse.py b/tests/test_heos_browse.py new file mode 100644 index 0000000..1e62d3e --- /dev/null +++ b/tests/test_heos_browse.py @@ -0,0 +1,741 @@ +"""Tests for the browse mixin of the Heos module.""" + +from typing import Any + +import pytest + +from pyheos import command as c +from pyheos.const import ( + MUSIC_SOURCE_FAVORITES, + MUSIC_SOURCE_NAPSTER, + MUSIC_SOURCE_PANDORA, + MUSIC_SOURCE_PLAYLISTS, + MUSIC_SOURCE_TIDAL, + SERVICE_OPTION_ADD_ALBUM_TO_LIBRARY, + SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, + SERVICE_OPTION_ADD_STATION_TO_LIBRARY, + SERVICE_OPTION_ADD_TO_FAVORITES, + SERVICE_OPTION_ADD_TRACK_TO_LIBRARY, + SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + SERVICE_OPTION_REMOVE_ALBUM_FROM_LIBRARY, + SERVICE_OPTION_REMOVE_FROM_FAVORITES, + SERVICE_OPTION_REMOVE_PLAYLIST_FROM_LIBRARY, + SERVICE_OPTION_REMOVE_STATION_FROM_LIBRARY, + SERVICE_OPTION_REMOVE_TRACK_FROM_LIBRARY, + SERVICE_OPTION_THUMBS_DOWN, + SERVICE_OPTION_THUMBS_UP, +) +from pyheos.heos import Heos, HeosOptions +from pyheos.media import MediaMusicSource +from pyheos.types import MediaType +from tests import calls_command, value +from tests.common import MediaMusicSources + + +@calls_command("browse.get_source_info", {c.ATTR_SOURCE_ID: 123456}) +async def test_get_music_source_by_id(heos: Heos) -> None: + """Test retrieving music source by id.""" + source = await heos.get_music_source_info(123456) + assert source.source_id == 1 + assert source.name == "Pandora" + assert ( + source.image_url + == "https://production.ws.skyegloup.com:443/media/images/service/logos/pandora.png" + ) + assert source.type == MediaType.MUSIC_SERVICE + assert source.available + assert source.service_username == "email@email.com" + + +@calls_command("browse.get_music_sources") +async def test_get_music_source_info_by_id_already_loaded(heos: Heos) -> None: + """Test retrieving music source info by id for already loaded does not update.""" + sources = await heos.get_music_sources() + original_source = sources[MUSIC_SOURCE_FAVORITES] + retrived_source = await heos.get_music_source_info(original_source.source_id) + assert original_source == retrived_source + + +@calls_command( + "browse.get_source_info", + {c.ATTR_SOURCE_ID: MediaMusicSources.FAVORITES.source_id}, +) +async def test_get_music_source_info_by_id_already_loaded_refresh( + heos: Heos, media_music_source: MediaMusicSource +) -> None: + """Test retrieving player info by player id for already loaded player updates.""" + heos.music_sources[media_music_source.source_id] = media_music_source + media_music_source.available = False + retrived_source = await heos.get_music_source_info( + media_music_source.source_id, refresh=True + ) + assert media_music_source == retrived_source + assert media_music_source.available + + +@pytest.mark.parametrize( + ("source_id", "music_source", "error"), + [ + (None, None, "Either source_id or music_source must be provided"), + ( + 1, + object(), + "Only one of source_id or music_source should be provided", + ), + ], +) +async def test_get_music_source_info_invalid_parameters_raises( + source_id: int | None, music_source: MediaMusicSource | None, error: str +) -> None: + """Test retrieving player info with invalid parameters raises.""" + heos = Heos(HeosOptions("127.0.0.1")) + with pytest.raises(ValueError, match=error): + await heos.get_music_source_info(source_id=source_id, music_source=music_source) + + +@calls_command("browse.get_search_criteria", {c.ATTR_SOURCE_ID: MUSIC_SOURCE_TIDAL}) +async def test_get_search_criteria(heos: Heos) -> None: + """Test retrieving search criteria.""" + criteria = await heos.get_search_criteria(MUSIC_SOURCE_TIDAL) + assert len(criteria) == 4 + item = criteria[2] + assert item.name == "Track" + assert item.criteria_id == 3 + assert item.wildcard is False + assert item.container_id == "SEARCHED_TRACKS-" + assert item.playable is True + + +@calls_command( + "browse.search", + { + c.ATTR_SOURCE_ID: MUSIC_SOURCE_TIDAL, + c.ATTR_SEARCH_CRITERIA_ID: 3, + c.ATTR_SEARCH: "Tangerine Rays", + }, +) +async def test_search(heos: Heos) -> None: + """Test the search method.""" + + result = await heos.search(MUSIC_SOURCE_TIDAL, "Tangerine Rays", 3) + + assert result.source_id == MUSIC_SOURCE_TIDAL + assert result.criteria_id == 3 + assert result.search == "Tangerine Rays" + assert result.returned == 15 + assert result.count == 15 + assert len(result.items) == 15 + + +@pytest.mark.parametrize( + ("search", "error"), + [ + ("", "'search' parameter must not be empty"), + ("x" * 129, "'search' parameter must be less than or equal to 128 characters"), + ], +) +async def test_search_invalid_raises(heos: Heos, search: str, error: str) -> None: + """Test the search method with an invalid search raises.""" + + with pytest.raises( + ValueError, + match=error, + ): + await heos.search(MUSIC_SOURCE_TIDAL, search, 3) + + +@calls_command( + "browse.search", + { + c.ATTR_SOURCE_ID: MUSIC_SOURCE_TIDAL, + c.ATTR_SEARCH_CRITERIA_ID: 3, + c.ATTR_SEARCH: "Tangerine Rays", + c.ATTR_RANGE: "0,14", + }, +) +async def test_search_with_range(heos: Heos) -> None: + """Test the search method.""" + + result = await heos.search( + MUSIC_SOURCE_TIDAL, "Tangerine Rays", 3, range_start=0, range_end=14 + ) + + assert result.source_id == MUSIC_SOURCE_TIDAL + assert result.criteria_id == 3 + assert result.search == "Tangerine Rays" + assert result.returned == 15 + assert result.count == 15 + assert len(result.items) == 15 + + +@calls_command( + "browse.rename_playlist", + { + c.ATTR_SOURCE_ID: MUSIC_SOURCE_PLAYLISTS, + c.ATTR_CONTAINER_ID: 171566, + c.ATTR_NAME: "New Name", + }, +) +async def test_rename_playlist(heos: Heos) -> None: + """Test renaming a playlist.""" + await heos.rename_playlist(MUSIC_SOURCE_PLAYLISTS, "171566", "New Name") + + +@pytest.mark.parametrize( + ("name", "error"), + [ + ("", "'new_name' parameter must not be empty"), + ( + "x" * 129, + "'new_name' parameter must be less than or equal to 128 characters", + ), + ], +) +async def test_rename_playlist_invalid_name_raises( + heos: Heos, name: str, error: str +) -> None: + """Test renaming a playlist.""" + with pytest.raises( + ValueError, + match=error, + ): + await heos.rename_playlist(MUSIC_SOURCE_PLAYLISTS, "171566", name) + + +@calls_command( + "browse.delete_playlist", + { + c.ATTR_SOURCE_ID: MUSIC_SOURCE_PLAYLISTS, + c.ATTR_CONTAINER_ID: 171566, + }, +) +async def test_delete_playlist(heos: Heos) -> None: + """Test deleting a playlist.""" + await heos.delete_playlist(MUSIC_SOURCE_PLAYLISTS, "171566") + + +@calls_command( + "browse.retrieve_metadata", + { + c.ATTR_SOURCE_ID: MUSIC_SOURCE_NAPSTER, + c.ATTR_CONTAINER_ID: 123456, + }, +) +async def test_retrieve_metadata(heos: Heos) -> None: + """Test deleting a playlist.""" + result = await heos.retrieve_metadata(MUSIC_SOURCE_NAPSTER, "123456") + assert result.source_id == MUSIC_SOURCE_NAPSTER + assert result.container_id == "123456" + assert result.returned == 1 + assert result.count == 1 + assert len(result.metadata) == 1 + metadata = result.metadata[0] + assert metadata.album_id == "7890" + assert len(metadata.images) == 2 + image = metadata.images[0] + assert ( + image.image_url + == "http://resources.wimpmusic.com/images/fbfe5e8b/b775/4d97/9053/8f0ac7daf4fd/640x640.jpg" + ) + assert image.width == 640 + + +@calls_command( + "browse.set_service_option_add_favorite", + { + c.ATTR_OPTION_ID: SERVICE_OPTION_ADD_TO_FAVORITES, + c.ATTR_PLAYER_ID: 1, + }, +) +async def test_set_service_option_add_favorite_play(heos: Heos) -> None: + """Test setting a service option for adding to favorites.""" + await heos.set_service_option(SERVICE_OPTION_ADD_TO_FAVORITES, player_id=1) + + +@calls_command( + "browse.set_service_option_add_favorite_browse", + { + c.ATTR_OPTION_ID: SERVICE_OPTION_ADD_TO_FAVORITES, + c.ATTR_SOURCE_ID: MUSIC_SOURCE_PANDORA, + c.ATTR_MEDIA_ID: 123456, + c.ATTR_NAME: "Test Radio", + }, +) +async def test_set_service_option_add_favorite_browse(heos: Heos) -> None: + """Test setting a service option for adding to favorites.""" + await heos.set_service_option( + SERVICE_OPTION_ADD_TO_FAVORITES, + source_id=MUSIC_SOURCE_PANDORA, + media_id="123456", + name="Test Radio", + ) + + +@calls_command( + "browse.set_service_option_remove_favorite", + { + c.ATTR_OPTION_ID: SERVICE_OPTION_REMOVE_FROM_FAVORITES, + c.ATTR_MEDIA_ID: 4277097921440801039, + }, +) +async def test_set_service_option_remove_favorite(heos: Heos) -> None: + """Test setting a service option for adding to favorites.""" + await heos.set_service_option( + SERVICE_OPTION_REMOVE_FROM_FAVORITES, media_id="4277097921440801039" + ) + + +@pytest.mark.parametrize( + "option", [SERVICE_OPTION_THUMBS_UP, SERVICE_OPTION_THUMBS_DOWN] +) +@calls_command( + "browse.set_service_option_thumbs_up_down", + { + c.ATTR_OPTION_ID: value(arg_name="option"), + c.ATTR_SOURCE_ID: MUSIC_SOURCE_PANDORA, + c.ATTR_PLAYER_ID: 1, + }, +) +async def test_set_service_option_thumbs_up_down(heos: Heos, option: int) -> None: + """Test setting thumbs up/down.""" + await heos.set_service_option( + option, + source_id=MUSIC_SOURCE_PANDORA, + player_id=1, + ) + + +@pytest.mark.parametrize( + "option", + [ + SERVICE_OPTION_ADD_TRACK_TO_LIBRARY, + SERVICE_OPTION_ADD_STATION_TO_LIBRARY, + SERVICE_OPTION_REMOVE_TRACK_FROM_LIBRARY, + SERVICE_OPTION_REMOVE_STATION_FROM_LIBRARY, + ], +) +@calls_command( + "browse.set_service_option_track_station", + { + c.ATTR_OPTION_ID: value(arg_name="option"), + c.ATTR_SOURCE_ID: MUSIC_SOURCE_PANDORA, + c.ATTR_MEDIA_ID: 1234, + }, +) +async def test_set_service_option_track_station(heos: Heos, option: int) -> None: + """Test setting track and station options.""" + await heos.set_service_option( + option, + source_id=MUSIC_SOURCE_PANDORA, + media_id="1234", + ) + + +@pytest.mark.parametrize( + "option", + [ + SERVICE_OPTION_ADD_ALBUM_TO_LIBRARY, + SERVICE_OPTION_REMOVE_ALBUM_FROM_LIBRARY, + SERVICE_OPTION_REMOVE_PLAYLIST_FROM_LIBRARY, + ], +) +@calls_command( + "browse.set_service_option_album_remove_playlist", + { + c.ATTR_OPTION_ID: value(arg_name="option"), + c.ATTR_SOURCE_ID: MUSIC_SOURCE_PANDORA, + c.ATTR_CONTAINER_ID: 1234, + }, +) +async def test_set_service_option_album_remove_playlist( + heos: Heos, option: int +) -> None: + """Test setting albumn options and remove playlist options.""" + await heos.set_service_option( + option, + source_id=MUSIC_SOURCE_PANDORA, + container_id="1234", + ) + + +@calls_command( + "browse.set_service_option_add_playlist", + { + c.ATTR_OPTION_ID: SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, + c.ATTR_SOURCE_ID: MUSIC_SOURCE_PANDORA, + c.ATTR_CONTAINER_ID: 1234, + c.ATTR_NAME: "Test Playlist", + }, +) +async def test_set_service_option_add_playlist(heos: Heos) -> None: + """Test setting albumn options and remove playlist options.""" + await heos.set_service_option( + SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, + source_id=MUSIC_SOURCE_PANDORA, + container_id="1234", + name="Test Playlist", + ) + + +@calls_command( + "browse.set_service_option_new_station", + { + c.ATTR_OPTION_ID: SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + c.ATTR_SOURCE_ID: MUSIC_SOURCE_PANDORA, + c.ATTR_SEARCH_CRITERIA_ID: 1234, + c.ATTR_NAME: "Test", + c.ATTR_RANGE: "0,14", + }, +) +async def test_set_service_option_new_station(heos: Heos) -> None: + """Test setting creating a new station option.""" + await heos.set_service_option( + SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + source_id=MUSIC_SOURCE_PANDORA, + criteria_id=1234, + name="Test", + range_start=0, + range_end=14, + ) + + +@pytest.mark.parametrize( + ("kwargs", "error"), + [ + ( + {"option_id": 200}, + "Unknown option_id", + ), + # SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY + ( + {"option_id": SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY}, + "source_id, container_id, and name parameters are required", + ), + ( + { + "option_id": SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, + "source_id": 1234, + }, + "source_id, container_id, and name parameters are required", + ), + ( + { + "option_id": SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, + "container_id": 1234, + }, + "source_id, container_id, and name parameters are required", + ), + ( + { + "option_id": SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, + "name": 1234, + }, + "source_id, container_id, and name parameters are required", + ), + ( + { + "option_id": SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, + "source_id": 1234, + "name": 1234, + "media_id": 1234, + "container_id": 1234, + }, + "parameters are not allowed", + ), + # SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA + ( + {"option_id": SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA}, + "source_id, name, and criteria_id parameters are required", + ), + ( + { + "option_id": SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + "source_id": 1234, + }, + "source_id, name, and criteria_id parameters are required", + ), + ( + { + "option_id": SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + "name": 1234, + }, + "source_id, name, and criteria_id parameters are required", + ), + ( + { + "option_id": SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + "criteria_id": 1234, + }, + "source_id, name, and criteria_id parameters are required", + ), + ( + { + "option_id": SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + "criteria_id": 1234, + "name": 1234, + }, + "source_id, name, and criteria_id parameters are required", + ), + ( + { + "option_id": SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + "criteria_id": 1234, + "source_id": 1234, + }, + "source_id, name, and criteria_id parameters are required", + ), + ( + { + "option_id": SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + "name": 1234, + "source_id": 1234, + }, + "source_id, name, and criteria_id parameters are required", + ), + ( + { + "option_id": SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + "criteria_id": 1234, + "name": 1234, + "source_id": 1234, + "player_id": 1234, + }, + "parameters are not allowed", + ), + # SERVICE_OPTION_REMOVE_FROM_FAVORITES + ( + {"option_id": SERVICE_OPTION_REMOVE_FROM_FAVORITES}, + "media_id parameter is required", + ), + ( + { + "option_id": SERVICE_OPTION_REMOVE_FROM_FAVORITES, + "media_id": 1234, + "container_id": 1234, + }, + "parameters are not allowed", + ), + ], +) +async def test_set_sevice_option_invalid_raises( + kwargs: dict[str, Any], error: str +) -> None: + """Test calling with invalid combinations of parameters raises.""" + heos = Heos(HeosOptions("127.0.0.1")) + + with pytest.raises(ValueError, match=error): + await heos.set_service_option(**kwargs) + + +@pytest.mark.parametrize( + "option", + [ + SERVICE_OPTION_ADD_TRACK_TO_LIBRARY, + SERVICE_OPTION_ADD_STATION_TO_LIBRARY, + SERVICE_OPTION_REMOVE_TRACK_FROM_LIBRARY, + SERVICE_OPTION_REMOVE_STATION_FROM_LIBRARY, + ], +) +@pytest.mark.parametrize( + ("kwargs", "error"), + [ + ( + {}, + "source_id and media_id parameters are required", + ), + ( + {"media_id": 1234}, + "source_id and media_id parameters are required", + ), + ( + {"source_id": 1234}, + "source_id and media_id parameters are required", + ), + ( + {"source_id": 1234, "media_id": 1234, "container_id": 1234}, + "parameters are not allowed for service option_id", + ), + ], +) +async def test_set_sevice_option_invalid_track_station_raises( + option: int, kwargs: dict[str, Any], error: str +) -> None: + """Test calling with invalid combinations of parameters raises.""" + heos = Heos(HeosOptions("127.0.0.1")) + with pytest.raises(ValueError, match=error): + await heos.set_service_option(option_id=option, **kwargs) + + +@pytest.mark.parametrize( + "option", + [ + SERVICE_OPTION_ADD_ALBUM_TO_LIBRARY, + SERVICE_OPTION_REMOVE_ALBUM_FROM_LIBRARY, + SERVICE_OPTION_REMOVE_PLAYLIST_FROM_LIBRARY, + ], +) +@pytest.mark.parametrize( + ("kwargs", "error"), + [ + ( + {}, + "source_id and container_id parameters are required", + ), + ( + {"source_id": 1234}, + "source_id and container_id parameters are required", + ), + ( + {"container_id": 1234}, + "source_id and container_id parameters are required", + ), + ( + {"source_id": 1234, "media_id": 1234, "container_id": 1234}, + "parameters are not allowed for service option_id", + ), + ], +) +async def test_set_sevice_option_invalid_album_remove_playlist_raises( + option: int, kwargs: dict[str, Any], error: str +) -> None: + """Test calling with invalid combinations of parameters raises.""" + heos = Heos(HeosOptions("127.0.0.1")) + with pytest.raises(ValueError, match=error): + await heos.set_service_option(option_id=option, **kwargs) + + +@pytest.mark.parametrize( + "option", + [ + SERVICE_OPTION_THUMBS_UP, + SERVICE_OPTION_THUMBS_DOWN, + ], +) +@pytest.mark.parametrize( + ("kwargs", "error"), + [ + ( + {}, + "source_id and player_id parameters are required", + ), + ( + {"source_id": 1234}, + "source_id and player_id parameters are required", + ), + ( + {"player_id": 1234}, + "source_id and player_id parameters are required", + ), + ( + {"source_id": 1234, "player_id": 1234, "container_id": 1234}, + "parameters are not allowed for service option_id", + ), + ], +) +async def test_set_sevice_option_invalid_thumbs_up_down_raises( + option: int, kwargs: dict[str, Any], error: str +) -> None: + """Test calling with invalid combinations of parameters raises.""" + heos = Heos(HeosOptions("127.0.0.1")) + with pytest.raises(ValueError, match=error): + await heos.set_service_option(option_id=option, **kwargs) + + +@pytest.mark.parametrize( + ("kwargs", "error"), + [ + ( + {}, + "Either parameters player_id OR source_id, media_id, and name are required", + ), + ( + {"source_id": 1234}, + "Either parameters player_id OR source_id, media_id, and name are required", + ), + ( + {"media_id": 1234}, + "Either parameters player_id OR source_id, media_id, and name are required", + ), + ( + {"name": 1234}, + "Either parameters player_id OR source_id, media_id, and name are required", + ), + ( + {"source_id": 1234, "media_id": 1234}, + "Either parameters player_id OR source_id, media_id, and name are required", + ), + ( + {"source_id": 1234, "name": 1234}, + "Either parameters player_id OR source_id, media_id, and name are required", + ), + ( + {"media_id": 1234, "name": 1234}, + "Either parameters player_id OR source_id, media_id, and name are required", + ), + ( + {"player_id": 1234, "media_id": 1234}, + "source_id, media_id, and name parameters are not allowed", + ), + ( + {"player_id": 1234, "source_id": 1234}, + "source_id, media_id, and name parameters are not allowed", + ), + ( + {"player_id": 1234, "name": 1234}, + "source_id, media_id, and name parameters are not allowed", + ), + ( + {"source_id": 1234, "media_id": 1234, "name": 1234, "container_id": 1234}, + "parameters are not allowed for service option_id", + ), + ( + {"player_id": 1234, "container_id": 1234}, + "parameters are not allowed for service option_id", + ), + ], +) +async def test_set_sevice_option_invalid_add_favorite_raises( + kwargs: dict[str, Any], error: str +) -> None: + """Test calling with invalid combinations of parameters raises.""" + heos = Heos(HeosOptions("127.0.0.1")) + with pytest.raises(ValueError, match=error): + await heos.set_service_option( + option_id=SERVICE_OPTION_ADD_TO_FAVORITES, **kwargs + ) + + +@calls_command( + "browse.multi_search", + { + c.ATTR_SEARCH: "Tangerine Rays", + c.ATTR_SOURCE_ID: "1,4,8,13,10", + c.ATTR_SEARCH_CRITERIA_ID: "0,1,2,3", + }, +) +async def test_multi_search(heos: Heos) -> None: + """Test the multi-search c.""" + result = await heos.multi_search( + "Tangerine Rays", + [1, 4, 8, 13, 10], + [0, 1, 2, 3], + ) + + assert result.search == "Tangerine Rays" + assert result.source_ids == [1, 4, 8, 13, 10] + assert result.criteria_ids == [0, 1, 2, 3] + assert result.returned == 74 + assert result.count == 74 + assert len(result.items) == 74 + assert len(result.statistics) == 4 + assert len(result.errors) == 2 + + +async def test_multi_search_invalid_search_rasis() -> None: + """Test the multi-search c.""" + heos = Heos(HeosOptions("127.0.0.1")) + with pytest.raises( + ValueError, + match="'search' parameter must be less than or equal to 128 characters", + ): + await heos.multi_search("x" * 129) diff --git a/tests/test_heos_callback.py b/tests/test_heos_callback.py index cbec2c7..6bd459c 100644 --- a/tests/test_heos_callback.py +++ b/tests/test_heos_callback.py @@ -2,8 +2,8 @@ from typing import Any -from pyheos import const from pyheos.heos import Heos, HeosOptions +from pyheos.types import SignalHeosEvent, SignalType async def test_add_on_connected() -> None: @@ -20,19 +20,19 @@ def callback() -> None: disconnect = heos.add_on_connected(callback) # Simulate sending event - await heos.dispatcher.wait_send(const.SIGNAL_HEOS_EVENT, const.EVENT_CONNECTED) + await heos.dispatcher.wait_send(SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED) assert called called = False # Test other events don't raise # - await heos.dispatcher.wait_send(const.SIGNAL_HEOS_EVENT, const.EVENT_DISCONNECTED) + await heos.dispatcher.wait_send(SignalType.HEOS_EVENT, SignalHeosEvent.DISCONNECTED) assert not called # Test disconnct disconnect() - await heos.dispatcher.wait_send(const.SIGNAL_HEOS_EVENT, const.EVENT_CONNECTED) + await heos.dispatcher.wait_send(SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED) assert not called @@ -49,13 +49,13 @@ async def callback() -> None: disconnect = heos.add_on_connected(callback) # Simulate sending event - await heos.dispatcher.wait_send(const.SIGNAL_HEOS_EVENT, const.EVENT_CONNECTED) + await heos.dispatcher.wait_send(SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED) assert called # Test disconnct called = False disconnect() - await heos.dispatcher.wait_send(const.SIGNAL_HEOS_EVENT, const.EVENT_CONNECTED) + await heos.dispatcher.wait_send(SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED) assert not called @@ -72,13 +72,13 @@ def callback() -> None: disconnect = heos.add_on_disconnected(callback) # Simulate sending event - await heos.dispatcher.wait_send(const.SIGNAL_HEOS_EVENT, const.EVENT_DISCONNECTED) + await heos.dispatcher.wait_send(SignalType.HEOS_EVENT, SignalHeosEvent.DISCONNECTED) assert called # Test disconnct called = False disconnect() - await heos.dispatcher.wait_send(const.SIGNAL_HEOS_EVENT, const.EVENT_DISCONNECTED) + await heos.dispatcher.wait_send(SignalType.HEOS_EVENT, SignalHeosEvent.DISCONNECTED) assert not called @@ -95,13 +95,13 @@ async def callback() -> None: disconnect = heos.add_on_disconnected(callback) # Simulate sending event - await heos.dispatcher.wait_send(const.SIGNAL_HEOS_EVENT, const.EVENT_DISCONNECTED) + await heos.dispatcher.wait_send(SignalType.HEOS_EVENT, SignalHeosEvent.DISCONNECTED) assert called # Test disconnct called = False disconnect() - await heos.dispatcher.wait_send(const.SIGNAL_HEOS_EVENT, const.EVENT_DISCONNECTED) + await heos.dispatcher.wait_send(SignalType.HEOS_EVENT, SignalHeosEvent.DISCONNECTED) assert not called @@ -119,7 +119,7 @@ def callback() -> None: # Simulate sending event await heos.dispatcher.wait_send( - const.SIGNAL_HEOS_EVENT, const.EVENT_USER_CREDENTIALS_INVALID + SignalType.HEOS_EVENT, SignalHeosEvent.USER_CREDENTIALS_INVALID ) assert called @@ -127,7 +127,7 @@ def callback() -> None: called = False disconnect() await heos.dispatcher.wait_send( - const.SIGNAL_HEOS_EVENT, const.EVENT_USER_CREDENTIALS_INVALID + SignalType.HEOS_EVENT, SignalHeosEvent.USER_CREDENTIALS_INVALID ) assert not called @@ -146,7 +146,7 @@ async def callback() -> None: # Simulate sending event await heos.dispatcher.wait_send( - const.SIGNAL_HEOS_EVENT, const.EVENT_USER_CREDENTIALS_INVALID + SignalType.HEOS_EVENT, SignalHeosEvent.USER_CREDENTIALS_INVALID ) assert called @@ -154,7 +154,7 @@ async def callback() -> None: called = False disconnect() await heos.dispatcher.wait_send( - const.SIGNAL_HEOS_EVENT, const.EVENT_USER_CREDENTIALS_INVALID + SignalType.HEOS_EVENT, SignalHeosEvent.USER_CREDENTIALS_INVALID ) assert not called @@ -178,18 +178,18 @@ def callback(event: str, data: Any) -> None: # Simulate sending event await heos.dispatcher.wait_send( - const.SIGNAL_CONTROLLER_EVENT, target_event, target_data + SignalType.CONTROLLER_EVENT, target_event, target_data ) assert called called = False # Test other events don't raise # - await heos.dispatcher.wait_send(const.SIGNAL_GROUP_EVENT, target_event, target_data) + await heos.dispatcher.wait_send(SignalType.GROUP_EVENT, target_event, target_data) assert not called # Test disconnct disconnect() - await heos.dispatcher.wait_send(const.SIGNAL_HEOS_EVENT, const.EVENT_CONNECTED) + await heos.dispatcher.wait_send(SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED) assert not called diff --git a/tests/test_media.py b/tests/test_media.py index 7c75875..4e96dc7 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -5,33 +5,35 @@ import pytest -from pyheos import command, const +from pyheos import command as c +from pyheos.const import MUSIC_SOURCE_FAVORITES from pyheos.heos import Heos from pyheos.media import BrowseResult, MediaItem, MediaMusicSource from pyheos.message import HeosMessage +from pyheos.types import AddCriteriaType, MediaType from tests import calls_command -from tests.common import MediaItems +from tests.common import MediaItems, MediaMusicSources async def test_media_music_source_from_data() -> None: """Test creating a media music source from data.""" data = { - const.ATTR_NAME: "Pandora", - const.ATTR_IMAGE_URL: "https://production.ws.skyegloup.com:443/media/images/service/logos/pandora.png", - const.ATTR_TYPE: const.MediaType.MUSIC_SERVICE, - const.ATTR_SOURCE_ID: 1, - const.ATTR_AVAILABLE: const.VALUE_TRUE, - const.ATTR_SERVICE_USER_NAME: "test@test.com", + c.ATTR_NAME: "Pandora", + c.ATTR_IMAGE_URL: "https://production.ws.skyegloup.com:443/media/images/service/logos/pandora.png", + c.ATTR_TYPE: MediaType.MUSIC_SERVICE, + c.ATTR_SOURCE_ID: 1, + c.ATTR_AVAILABLE: c.VALUE_TRUE, + c.ATTR_SERVICE_USER_NAME: "test@test.com", } source = MediaMusicSource.from_data(data) - assert source.name == data[const.ATTR_NAME] - assert source.image_url == data[const.ATTR_IMAGE_URL] - assert source.type == const.MediaType.MUSIC_SERVICE - assert source.source_id == data[const.ATTR_SOURCE_ID] + assert source.name == data[c.ATTR_NAME] + assert source.image_url == data[c.ATTR_IMAGE_URL] + assert source.type == MediaType.MUSIC_SERVICE + assert source.source_id == data[c.ATTR_SOURCE_ID] assert source.available - assert source.service_username == data[const.ATTR_SERVICE_USER_NAME] + assert source.service_username == data[c.ATTR_SERVICE_USER_NAME] with pytest.raises( AssertionError, match="Heos instance not set", @@ -39,9 +41,7 @@ async def test_media_music_source_from_data() -> None: await source.browse() -@calls_command( - "browse.browse_favorites", {const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_FAVORITES} -) +@calls_command("browse.browse_favorites", {c.ATTR_SOURCE_ID: MUSIC_SOURCE_FAVORITES}) async def test_media_music_source_browse( media_music_source: MediaMusicSource, ) -> None: @@ -49,7 +49,14 @@ async def test_media_music_source_browse( result = await media_music_source.browse() assert result.returned == 3 - assert result.source_id == const.MUSIC_SOURCE_FAVORITES + assert result.source_id == MUSIC_SOURCE_FAVORITES + + assert len(result.options) == 1 + option = result.options[0] + assert option.context == "browse" + assert option.name == "Remove from HEOS Favorites" + assert option.id == 20 + # further testing of the result is done in test_browse_result_from_data @@ -57,22 +64,26 @@ async def test_browse_result_from_data() -> None: """Test creating a browse result from data.""" heos = Mock(Heos) message = HeosMessage( - command.COMMAND_BROWSE_BROWSE, + c.COMMAND_BROWSE_BROWSE, True, - {const.ATTR_SOURCE_ID: "1025", const.ATTR_RETURNED: "1", const.ATTR_COUNT: "1"}, + { + c.ATTR_SOURCE_ID: "1025", + c.ATTR_RETURNED: "1", + c.ATTR_COUNT: "1", + }, [ { - const.ATTR_CONTAINER: const.VALUE_YES, - const.ATTR_TYPE: str(const.MediaType.PLAYLIST), - const.ATTR_CONTAINER_ID: "171566", - const.ATTR_PLAYABLE: const.VALUE_YES, - const.ATTR_NAME: "Rockin Songs", - const.ATTR_IMAGE_URL: "", + c.ATTR_CONTAINER: c.VALUE_YES, + c.ATTR_TYPE: str(MediaType.PLAYLIST), + c.ATTR_CONTAINER_ID: "171566", + c.ATTR_PLAYABLE: c.VALUE_YES, + c.ATTR_NAME: "Rockin Songs", + c.ATTR_IMAGE_URL: "", } ], ) - result = BrowseResult.from_data(message, heos) + result = BrowseResult._from_message(message, heos) assert result.returned == 1 assert result.count == 1 @@ -88,30 +99,30 @@ async def test_media_item_from_data() -> None: source_id = 1 container_id = "My Music" data = { - const.ATTR_NAME: "Imaginary Parties", - const.ATTR_IMAGE_URL: "http://resources.wimpmusic.com/images/7e7bacc1/3e75/4761/a822/9342239edfa0/640x640.jpg", - const.ATTR_TYPE: str(const.MediaType.SONG), - const.ATTR_CONTAINER: const.VALUE_NO, - const.ATTR_MEDIA_ID: "78374741", - const.ATTR_ARTIST: "Superfruit", - const.ATTR_ALBUM: "Future Friends", - const.ATTR_ALBUM_ID: "78374740", - const.ATTR_PLAYABLE: const.VALUE_YES, + c.ATTR_NAME: "Imaginary Parties", + c.ATTR_IMAGE_URL: "http://resources.wimpmusic.com/images/7e7bacc1/3e75/4761/a822/9342239edfa0/640x640.jpg", + c.ATTR_TYPE: str(MediaType.SONG), + c.ATTR_CONTAINER: c.VALUE_NO, + c.ATTR_MEDIA_ID: "78374741", + c.ATTR_ARTIST: "Superfruit", + c.ATTR_ALBUM: "Future Friends", + c.ATTR_ALBUM_ID: "78374740", + c.ATTR_PLAYABLE: c.VALUE_YES, } source = MediaItem.from_data(data, source_id, container_id) - assert source.name == data[const.ATTR_NAME] - assert source.image_url == data[const.ATTR_IMAGE_URL] - assert source.type == const.MediaType.SONG + assert source.name == data[c.ATTR_NAME] + assert source.image_url == data[c.ATTR_IMAGE_URL] + assert source.type == MediaType.SONG assert source.container_id == container_id assert source.source_id == source_id assert source.playable is True assert source.browsable is False - assert source.album == data[const.ATTR_ALBUM] - assert source.artist == data[const.ATTR_ARTIST] - assert source.album_id == data[const.ATTR_ALBUM_ID] - assert source.media_id == data[const.ATTR_MEDIA_ID] + assert source.album == data[c.ATTR_ALBUM] + assert source.artist == data[c.ATTR_ARTIST] + assert source.album_id == data[c.ATTR_ALBUM_ID] + assert source.media_id == data[c.ATTR_MEDIA_ID] with pytest.raises( AssertionError, match="Heos instance not set", @@ -127,11 +138,11 @@ async def test_media_item_from_data() -> None: async def test_media_item_from_data_source_id_not_present_raises() -> None: """Test creating a MediaItem from data.""" data = { - const.ATTR_NAME: "Video", - const.ATTR_IMAGE_URL: "", - const.ATTR_TYPE: str(const.MediaType.CONTAINER), - const.ATTR_CONTAINER: const.VALUE_YES, - const.ATTR_CONTAINER_ID: "94467912-bd40-4d2f-ad25-7b8423f7b05a", + c.ATTR_NAME: "Video", + c.ATTR_IMAGE_URL: "", + c.ATTR_TYPE: str(MediaType.CONTAINER), + c.ATTR_CONTAINER: c.VALUE_YES, + c.ATTR_CONTAINER_ID: "94467912-bd40-4d2f-ad25-7b8423f7b05a", } with pytest.raises( @@ -144,18 +155,18 @@ async def test_media_item_from_data_source_id_not_present_raises() -> None: async def test_media_item_from_data_source() -> None: """Test creating a MediaItem from data.""" data = { - const.ATTR_NAME: "Plex Media Server", - const.ATTR_IMAGE_URL: "https://production.ws.skyegloup.com:443/media/images/service/logos/musicsource_logo_servers.png", - const.ATTR_TYPE: str(const.MediaType.HEOS_SERVER), - const.ATTR_SOURCE_ID: 123456789, + c.ATTR_NAME: "Plex Media Server", + c.ATTR_IMAGE_URL: "https://production.ws.skyegloup.com:443/media/images/service/logos/musicsource_logo_servers.png", + c.ATTR_TYPE: str(MediaType.HEOS_SERVER), + c.ATTR_SOURCE_ID: 123456789, } source = MediaItem.from_data(data) - assert source.name == data[const.ATTR_NAME] - assert source.image_url == data[const.ATTR_IMAGE_URL] - assert source.type == const.MediaType.HEOS_SERVER - assert source.source_id == data[const.ATTR_SOURCE_ID] + assert source.name == data[c.ATTR_NAME] + assert source.image_url == data[c.ATTR_IMAGE_URL] + assert source.type == MediaType.HEOS_SERVER + assert source.source_id == data[c.ATTR_SOURCE_ID] assert source.container_id is None assert source.playable is False assert source.browsable is True @@ -169,19 +180,19 @@ async def test_media_item_from_data_container() -> None: """Test creating a MediaItem from data.""" source_id = 123456789 data = { - const.ATTR_NAME: "Video", - const.ATTR_IMAGE_URL: "", - const.ATTR_TYPE: str(const.MediaType.CONTAINER), - const.ATTR_CONTAINER: const.VALUE_YES, - const.ATTR_CONTAINER_ID: "94467912-bd40-4d2f-ad25-7b8423f7b05a", + c.ATTR_NAME: "Video", + c.ATTR_IMAGE_URL: "", + c.ATTR_TYPE: str(MediaType.CONTAINER), + c.ATTR_CONTAINER: c.VALUE_YES, + c.ATTR_CONTAINER_ID: "94467912-bd40-4d2f-ad25-7b8423f7b05a", } source = MediaItem.from_data(data, source_id) - assert source.name == data[const.ATTR_NAME] - assert source.image_url == data[const.ATTR_IMAGE_URL] - assert source.type == const.MediaType.CONTAINER - assert source.container_id == data[const.ATTR_CONTAINER_ID] + assert source.name == data[c.ATTR_NAME] + assert source.image_url == data[c.ATTR_IMAGE_URL] + assert source.type == MediaType.CONTAINER + assert source.container_id == data[c.ATTR_CONTAINER_ID] assert source.source_id == source_id assert source.playable is False assert source.browsable is True @@ -192,7 +203,7 @@ async def test_media_item_from_data_container() -> None: @calls_command( - "browse.browse_heos_drive", {const.ATTR_SOURCE_ID: MediaItems.DEVICE.source_id} + "browse.browse_heos_drive", {c.ATTR_SOURCE_ID: MediaItems.DEVICE.source_id} ) async def test_media_item_browse(media_item_device: MediaItem) -> None: """Test browsing a media music source.""" @@ -205,17 +216,35 @@ async def test_media_item_browse(media_item_device: MediaItem) -> None: assert len(result.items) == 8 +@calls_command( + "browse.get_source_info", + {c.ATTR_SOURCE_ID: MediaMusicSources.FAVORITES.source_id}, +) +async def test_refresh(media_music_source: MediaMusicSource) -> None: + """Test refresh updates the data.""" + await media_music_source.refresh() + assert media_music_source.source_id == 1 + assert media_music_source.name == "Pandora" + assert ( + media_music_source.image_url + == "https://production.ws.skyegloup.com:443/media/images/service/logos/pandora.png" + ) + assert media_music_source.type == MediaType.MUSIC_SERVICE + assert media_music_source.available + assert media_music_source.service_username == "email@email.com" + + @calls_command( "browse.add_to_queue_track", { - const.ATTR_PLAYER_ID: 1, - const.ATTR_SOURCE_ID: MediaItems.SONG.source_id, - const.ATTR_CONTAINER_ID: MediaItems.SONG.container_id, - const.ATTR_MEDIA_ID: MediaItems.SONG.media_id, - const.ATTR_ADD_CRITERIA_ID: const.AddCriteriaType.REPLACE_AND_PLAY, + c.ATTR_PLAYER_ID: 1, + c.ATTR_SOURCE_ID: MediaItems.SONG.source_id, + c.ATTR_CONTAINER_ID: MediaItems.SONG.container_id, + c.ATTR_MEDIA_ID: MediaItems.SONG.media_id, + c.ATTR_ADD_CRITERIA_ID: AddCriteriaType.REPLACE_AND_PLAY, }, add_command_under_process=True, ) async def test_media_item_play(media_item_song: MediaItem) -> None: """Test playing a media music source.""" - await media_item_song.play_media(1, const.AddCriteriaType.REPLACE_AND_PLAY) + await media_item_song.play_media(1, AddCriteriaType.REPLACE_AND_PLAY) diff --git a/tests/test_message.py b/tests/test_message.py index ce6c3f8..ec76d89 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -4,14 +4,14 @@ import pytest -from pyheos import command +from pyheos import command as c from pyheos.message import HeosMessage def test_get_message_value_missing_key_raises() -> None: """Test creating a browse result from data.""" - message = HeosMessage(command.COMMAND_BROWSE_BROWSE) + message = HeosMessage(c.COMMAND_BROWSE_BROWSE) with pytest.raises( KeyError, match=re.escape("Key 'missing_key' not found in message parameters.") diff --git a/tests/test_player.py b/tests/test_player.py index 8517cca..fdd0000 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -4,76 +4,96 @@ import pytest -from pyheos import const +from pyheos import command as c +from pyheos.const import ( + INPUT_AUX_IN_1, + MUSIC_SOURCE_DEEZER, + MUSIC_SOURCE_PLAYLISTS, + MUSIC_SOURCE_TIDAL, + SEARCHED_TRACKS, +) from pyheos.media import MediaItem from pyheos.player import HeosPlayer -from tests import calls_command, value +from pyheos.types import ( + AddCriteriaType, + LineOutLevelType, + NetworkType, + PlayState, + RepeatType, +) +from tests import CallCommand, calls_command, calls_commands, value from tests.common import MediaItems -def test_from_data() -> None: - """Test the __str__ function.""" +@pytest.mark.parametrize( + ("network", "expected_network"), + [ + (None, NetworkType.UNKNOWN), + ("wired", NetworkType.WIRED), + ("invalid", NetworkType.UNKNOWN), # Invalid network type + ], +) +def test_from_data(network: str | None, expected_network: NetworkType) -> None: + """Test the from_data function.""" data = { - const.ATTR_NAME: "Back Patio", - const.ATTR_PLAYER_ID: 1, - const.ATTR_MODEL: "HEOS Drive", - const.ATTR_VERSION: "1.493.180", - const.ATTR_IP_ADDRESS: "192.168.0.1", - const.ATTR_NETWORK: const.NETWORK_TYPE_WIRED, - const.ATTR_LINE_OUT: 1, - const.ATTR_SERIAL: "1234567890", + c.ATTR_NAME: "Back Patio", + c.ATTR_PLAYER_ID: 1, + c.ATTR_MODEL: "HEOS Drive", + c.ATTR_VERSION: "1.493.180", + c.ATTR_IP_ADDRESS: "192.168.0.1", + c.ATTR_NETWORK: network, + c.ATTR_LINE_OUT: 1, + c.ATTR_SERIAL: "1234567890", } - player = HeosPlayer.from_data(data, None) + player = HeosPlayer._from_data(data, None) assert player.name == "Back Patio" assert player.player_id == 1 assert player.model == "HEOS Drive" assert player.version == "1.493.180" assert player.ip_address == "192.168.0.1" - assert player.network == const.NETWORK_TYPE_WIRED - assert player.line_out == 1 + assert player.network == expected_network + assert player.line_out == LineOutLevelType.VARIABLE assert player.serial == "1234567890" async def test_update_from_data(player: HeosPlayer) -> None: """Test the __str__ function.""" data = { - const.ATTR_NAME: "Patio", - const.ATTR_PLAYER_ID: 2, - const.ATTR_MODEL: "HEOS Drives", - const.ATTR_VERSION: "2.0.0", - const.ATTR_IP_ADDRESS: "192.168.0.2", - const.ATTR_NETWORK: const.NETWORK_TYPE_WIFI, - const.ATTR_LINE_OUT: "0", - const.ATTR_SERIAL: "0987654321", + c.ATTR_NAME: "Patio", + c.ATTR_PLAYER_ID: 2, + c.ATTR_MODEL: "HEOS Drives", + c.ATTR_VERSION: "2.0.0", + c.ATTR_IP_ADDRESS: "192.168.0.2", + c.ATTR_NETWORK: "wifi", + c.ATTR_LINE_OUT: "0", + c.ATTR_SERIAL: "0987654321", } - player.update_from_data(data) + player._update_from_data(data) assert player.name == "Patio" assert player.player_id == 2 assert player.model == "HEOS Drives" assert player.version == "2.0.0" assert player.ip_address == "192.168.0.2" - assert player.network == const.NETWORK_TYPE_WIFI - assert player.line_out == 0 + assert player.network == NetworkType.WIFI + assert player.line_out == LineOutLevelType.UNKNOWN assert player.serial == "0987654321" -@pytest.mark.parametrize( - "state", (const.PlayState.PAUSE, const.PlayState.PLAY, const.PlayState.STOP) -) +@pytest.mark.parametrize("state", (PlayState.PAUSE, PlayState.PLAY, PlayState.STOP)) @calls_command( "player.set_play_state", - {const.ATTR_PLAYER_ID: 1, const.ATTR_STATE: value(arg_name="state")}, + {c.ATTR_PLAYER_ID: 1, c.ATTR_STATE: value(arg_name="state")}, ) -async def test_set_state(player: HeosPlayer, state: const.PlayState) -> None: +async def test_set_state(player: HeosPlayer, state: PlayState) -> None: """Test the play, pause, and stop commands.""" await player.set_state(state) @calls_command( "player.set_play_state", - {const.ATTR_PLAYER_ID: 1, const.ATTR_STATE: const.PlayState.PLAY}, + {c.ATTR_PLAYER_ID: 1, c.ATTR_STATE: PlayState.PLAY}, ) async def test_set_play(player: HeosPlayer) -> None: """Test the pause commands.""" @@ -82,7 +102,7 @@ async def test_set_play(player: HeosPlayer) -> None: @calls_command( "player.set_play_state", - {const.ATTR_PLAYER_ID: 1, const.ATTR_STATE: const.PlayState.PAUSE}, + {c.ATTR_PLAYER_ID: 1, c.ATTR_STATE: PlayState.PAUSE}, ) async def test_set_pause(player: HeosPlayer) -> None: """Test the play commands.""" @@ -91,7 +111,7 @@ async def test_set_pause(player: HeosPlayer) -> None: @calls_command( "player.set_play_state", - {const.ATTR_PLAYER_ID: 1, const.ATTR_STATE: const.PlayState.STOP}, + {c.ATTR_PLAYER_ID: 1, c.ATTR_STATE: PlayState.STOP}, ) async def test_set_stop(player: HeosPlayer) -> None: """Test the stop commands.""" @@ -105,9 +125,9 @@ async def test_set_volume_invalid_raises(player: HeosPlayer, level: int) -> None await player.set_volume(level) -@calls_command("player.set_volume", {const.ATTR_PLAYER_ID: 1, const.ATTR_LEVEL: 100}) +@calls_command("player.set_volume", {c.ATTR_PLAYER_ID: 1, c.ATTR_LEVEL: 100}) async def test_set_volume(player: HeosPlayer) -> None: - """Test the set_volume command.""" + """Test the set_volume c.""" await player.set_volume(100) @@ -115,34 +135,33 @@ async def test_set_volume(player: HeosPlayer) -> None: @calls_command( "player.set_mute", { - const.ATTR_PLAYER_ID: 1, - const.ATTR_STATE: value(arg_name="mute", formatter="on_off"), + c.ATTR_PLAYER_ID: 1, + c.ATTR_STATE: value(arg_name="mute", formatter="on_off"), }, ) async def test_set_mute(player: HeosPlayer, mute: bool) -> None: - """Test the set_mute command.""" + """Test the set_mute c.""" await player.set_mute(mute) -@calls_command( - "player.set_mute", {const.ATTR_PLAYER_ID: 1, const.ATTR_STATE: const.VALUE_ON} -) +@calls_command("player.set_mute", {c.ATTR_PLAYER_ID: 1, c.ATTR_STATE: c.VALUE_ON}) async def test_mute(player: HeosPlayer) -> None: - """Test the mute command.""" + """Test the mute c.""" await player.mute() @calls_command( - "player.set_mute", {const.ATTR_PLAYER_ID: 1, const.ATTR_STATE: const.VALUE_OFF} + "player.set_mute", + {c.ATTR_PLAYER_ID: 1, c.ATTR_STATE: c.VALUE_OFF}, ) async def test_unmute(player: HeosPlayer) -> None: - """Test the unmute command.""" + """Test the unmute c.""" await player.unmute() -@calls_command("player.toggle_mute", {const.ATTR_PLAYER_ID: 1}) +@calls_command("player.toggle_mute", {c.ATTR_PLAYER_ID: 1}) async def test_toggle_mute(player: HeosPlayer) -> None: - """Test the toggle_mute command.""" + """Test the toggle_mute c.""" await player.toggle_mute() @@ -153,9 +172,9 @@ async def test_volume_up_invalid_step_raises(player: HeosPlayer, step: int) -> N await player.volume_up(step) -@calls_command("player.volume_up", {const.ATTR_PLAYER_ID: 1, const.ATTR_STEP: 6}) +@calls_command("player.volume_up", {c.ATTR_PLAYER_ID: 1, c.ATTR_STEP: 6}) async def test_volume_up(player: HeosPlayer) -> None: - """Test the volume_up command.""" + """Test the volume_up c.""" await player.volume_up(6) @@ -166,40 +185,40 @@ async def test_volume_down_invalid_step_raises(player: HeosPlayer, step: int) -> await player.volume_down(step) -@calls_command("player.volume_down", {const.ATTR_PLAYER_ID: 1, const.ATTR_STEP: 6}) +@calls_command("player.volume_down", {c.ATTR_PLAYER_ID: 1, c.ATTR_STEP: 6}) async def test_volume_down(player: HeosPlayer) -> None: - """Test the volume_down command.""" + """Test the volume_down c.""" await player.volume_down(6) @calls_command( "player.set_play_mode", { - const.ATTR_PLAYER_ID: 1, - const.ATTR_REPEAT: const.RepeatType.ON_ALL, - const.ATTR_SHUFFLE: const.VALUE_ON, + c.ATTR_PLAYER_ID: 1, + c.ATTR_REPEAT: RepeatType.ON_ALL, + c.ATTR_SHUFFLE: c.VALUE_ON, }, ) async def test_set_play_mode(player: HeosPlayer) -> None: - """Test the set play mode command.""" - await player.set_play_mode(const.RepeatType.ON_ALL, True) + """Test the set play mode c.""" + await player.set_play_mode(RepeatType.ON_ALL, True) -@calls_command("player.play_next", {const.ATTR_PLAYER_ID: 1}) +@calls_command("player.play_next", {c.ATTR_PLAYER_ID: 1}) async def test_play_next(player: HeosPlayer) -> None: - """Test the play next command.""" + """Test the play next c.""" await player.play_next() -@calls_command("player.play_previous", {const.ATTR_PLAYER_ID: 1}) +@calls_command("player.play_previous", {c.ATTR_PLAYER_ID: 1}) async def test_play_previous(player: HeosPlayer) -> None: - """Test the play previous command.""" + """Test the play previous c.""" await player.play_previous() @calls_command( "player.clear_queue", - {const.ATTR_PLAYER_ID: 1}, + {c.ATTR_PLAYER_ID: 1}, add_command_under_process=True, ) async def test_clear_queue(player: HeosPlayer) -> None: @@ -207,20 +226,100 @@ async def test_clear_queue(player: HeosPlayer) -> None: await player.clear_queue() +@calls_command("player.get_queue", {c.ATTR_PLAYER_ID: 1}) +async def test_get_queue(player: HeosPlayer) -> None: + """Test the get queue c.""" + result = await player.get_queue() + + assert len(result) == 11 + item = result[0] + assert item.song == "Baby" + assert item.album == "22 Break" + assert item.artist == "Oh Wonder" + assert ( + item.image_url + == "http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg" + ) + assert item.queue_id == 1 + assert item.media_id == "199555606" + assert item.album_id == "199555605" + + +@calls_command("player.play_queue", {c.ATTR_PLAYER_ID: 1, c.ATTR_QUEUE_ID: 1}) +async def test_play_queue(player: HeosPlayer) -> None: + """Test the play_queue c.""" + await player.play_queue(1) + + +@calls_command( + "player.remove_from_queue", + {c.ATTR_PLAYER_ID: 1, c.ATTR_QUEUE_ID: "1,2,3"}, +) +async def test_remove_from_queue(player: HeosPlayer) -> None: + """Test the play_queue c.""" + await player.remove_from_queue([1, 2, 3]) + + +@calls_command("player.save_queue", {c.ATTR_PLAYER_ID: 1, c.ATTR_NAME: "Test"}) +async def test_save_queue(player: HeosPlayer) -> None: + """Test the save_queue c.""" + await player.save_queue("Test") + + +async def test_save_queue_too_long_raises(player: HeosPlayer) -> None: + """Test the save_queue c.""" + with pytest.raises( + ValueError, match="'name' must be less than or equal to 128 characters" + ): + await player.save_queue("S" * 129) + + +@calls_command( + "player.move_queue_item", + { + c.ATTR_PLAYER_ID: 1, + c.ATTR_SOURCE_QUEUE_ID: "2,3,4", + c.ATTR_DESTINATION_QUEUE_ID: 1, + }, +) +async def test_move_queue_item(player: HeosPlayer) -> None: + """Test the move_queue_item c.""" + await player.move_queue_item([2, 3, 4], 1) + + +@calls_command("player.get_queue", {c.ATTR_PLAYER_ID: 1, c.ATTR_RANGE: "0,10"}) +async def test_get_queue_with_range(player: HeosPlayer) -> None: + """Test the check_update c.""" + result = await player.get_queue(0, 10) + + assert len(result) == 11 + item = result[0] + assert item.song == "Baby" + assert item.album == "22 Break" + assert item.artist == "Oh Wonder" + assert ( + item.image_url + == "http://resources.wimpmusic.com/images/bdfd93c2/0b3a/495e/a557/4493fcbb7ab3/640x640.jpg" + ) + assert item.queue_id == 1 + assert item.media_id == "199555606" + assert item.album_id == "199555605" + + @calls_command( "browse.play_input", { - const.ATTR_PLAYER_ID: 1, - const.ATTR_INPUT: const.INPUT_AUX_IN_1, - const.ATTR_SOURCE_PLAYER_ID: 2, + c.ATTR_PLAYER_ID: 1, + c.ATTR_INPUT: INPUT_AUX_IN_1, + c.ATTR_SOURCE_PLAYER_ID: 2, }, ) async def test_play_input_source(player: HeosPlayer) -> None: """Test the play input source.""" - await player.play_input_source(const.INPUT_AUX_IN_1, 2) + await player.play_input_source(INPUT_AUX_IN_1, 2) -@calls_command("browse.play_preset", {const.ATTR_PLAYER_ID: 1, const.ATTR_PRESET: 1}) +@calls_command("browse.play_preset", {c.ATTR_PLAYER_ID: 1, c.ATTR_PRESET: 1}) async def test_play_preset_station(player: HeosPlayer) -> None: """Test the play favorite.""" await player.play_preset_station(1) @@ -235,8 +334,8 @@ async def test_play_preset_station_invalid_index(player: HeosPlayer) -> None: @calls_command( "browse.play_stream", { - const.ATTR_PLAYER_ID: 1, - const.ATTR_URL: "https://my.website.com/podcast.mp3?patron-auth=qwerty", + c.ATTR_PLAYER_ID: 1, + c.ATTR_URL: "https://my.website.com/podcast.mp3?patron-auth=qwerty", }, ) async def test_play_url(player: HeosPlayer) -> None: @@ -253,7 +352,7 @@ async def test_play_quick_select_invalid_raises( await player.play_quick_select(quick_select) -@calls_command("player.play_quickselect", {const.ATTR_PLAYER_ID: 1, const.ATTR_ID: 2}) +@calls_command("player.play_quickselect", {c.ATTR_PLAYER_ID: 1, c.ATTR_ID: 2}) async def test_play_quick_select(player: HeosPlayer) -> None: """Test the play quick select.""" await player.play_quick_select(2) @@ -266,13 +365,13 @@ async def test_set_quick_select_invalid_raises(player: HeosPlayer, index: int) - await player.set_quick_select(index) -@calls_command("player.set_quickselect", {const.ATTR_PLAYER_ID: 1, const.ATTR_ID: 2}) +@calls_command("player.set_quickselect", {c.ATTR_PLAYER_ID: 1, c.ATTR_ID: 2}) async def test_set_quick_select(player: HeosPlayer) -> None: """Test the play favorite.""" await player.set_quick_select(2) -@calls_command("player.get_quickselects", {const.ATTR_PLAYER_ID: 1}) +@calls_command("player.get_quickselects", {c.ATTR_PLAYER_ID: 1}) async def test_get_quick_selects(player: HeosPlayer) -> None: """Test the play favorite.""" selects = await player.get_quick_selects() @@ -294,16 +393,16 @@ async def test_play_media_unplayable_source( with pytest.raises( ValueError, match=re.escape(f"Media '{media_item_album}' is not playable") ): - await player.play_media(media_item_album, const.AddCriteriaType.PLAY_NOW) + await player.play_media(media_item_album, AddCriteriaType.PLAY_NOW) @calls_command( "browse.add_to_queue_container", { - const.ATTR_PLAYER_ID: 1, - const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_PLAYLISTS, - const.ATTR_CONTAINER_ID: "123", - const.ATTR_ADD_CRITERIA_ID: const.AddCriteriaType.PLAY_NOW, + c.ATTR_PLAYER_ID: 1, + c.ATTR_SOURCE_ID: MUSIC_SOURCE_PLAYLISTS, + c.ATTR_CONTAINER_ID: "123", + c.ATTR_ADD_CRITERIA_ID: AddCriteriaType.PLAY_NOW, }, add_command_under_process=True, ) @@ -311,44 +410,59 @@ async def test_play_media_container( player: HeosPlayer, media_item_playlist: MediaItem ) -> None: """Test adding a container to the queue.""" - await player.play_media(media_item_playlist, const.AddCriteriaType.PLAY_NOW) + await player.play_media(media_item_playlist, AddCriteriaType.PLAY_NOW) @calls_command( "browse.add_to_queue_track", { - const.ATTR_PLAYER_ID: 1, - const.ATTR_SOURCE_ID: MediaItems.SONG.source_id, - const.ATTR_CONTAINER_ID: MediaItems.SONG.container_id, - const.ATTR_MEDIA_ID: MediaItems.SONG.media_id, - const.ATTR_ADD_CRITERIA_ID: const.AddCriteriaType.PLAY_NOW, + c.ATTR_PLAYER_ID: 1, + c.ATTR_SOURCE_ID: MediaItems.SONG.source_id, + c.ATTR_CONTAINER_ID: MediaItems.SONG.container_id, + c.ATTR_MEDIA_ID: MediaItems.SONG.media_id, + c.ATTR_ADD_CRITERIA_ID: AddCriteriaType.PLAY_NOW, }, add_command_under_process=True, ) async def test_play_media_track(player: HeosPlayer, media_item_song: MediaItem) -> None: """Test adding a track to the queue.""" - await player.play_media(media_item_song, const.AddCriteriaType.PLAY_NOW) + await player.play_media(media_item_song, AddCriteriaType.PLAY_NOW) @calls_command( "browse.add_to_queue_track", { - const.ATTR_PLAYER_ID: 1, - const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_DEEZER, - const.ATTR_CONTAINER_ID: "123", - const.ATTR_MEDIA_ID: "456", - const.ATTR_ADD_CRITERIA_ID: const.AddCriteriaType.PLAY_NOW, + c.ATTR_PLAYER_ID: 1, + c.ATTR_SOURCE_ID: MUSIC_SOURCE_DEEZER, + c.ATTR_CONTAINER_ID: "123", + c.ATTR_MEDIA_ID: "456", + c.ATTR_ADD_CRITERIA_ID: AddCriteriaType.PLAY_NOW, }, add_command_under_process=True, ) async def test_add_to_queue(player: HeosPlayer) -> None: """Test adding a track to the queue.""" await player.add_to_queue( - const.MUSIC_SOURCE_DEEZER, "123", "456", const.AddCriteriaType.PLAY_NOW + MUSIC_SOURCE_DEEZER, "123", "456", AddCriteriaType.PLAY_NOW ) -@calls_command("player.get_now_playing_media_blank", {const.ATTR_PLAYER_ID: 1}) +@calls_command( + "browse.add_to_queue_search", + { + c.ATTR_PLAYER_ID: 1, + c.ATTR_SOURCE_ID: MUSIC_SOURCE_TIDAL, + c.ATTR_CONTAINER_ID: SEARCHED_TRACKS + "Tangerine Rays", + c.ATTR_ADD_CRITERIA_ID: AddCriteriaType.PLAY_NOW, + }, + add_command_under_process=True, +) +async def test_add_search_to_queue(player: HeosPlayer) -> None: + """Test adding a track to the queue.""" + await player.add_search_to_queue(MUSIC_SOURCE_TIDAL, "Tangerine Rays") + + +@calls_command("player.get_now_playing_media_blank", {c.ATTR_PLAYER_ID: 1}) async def test_now_playing_media_unavailable(player: HeosPlayer) -> None: """Test edge case where now_playing_media returns an empty payload.""" await player.refresh_now_playing_media() @@ -361,3 +475,46 @@ async def test_now_playing_media_unavailable(player: HeosPlayer) -> None: assert player.now_playing_media.image_url is None assert player.now_playing_media.album_id is None assert player.now_playing_media.media_id is None + assert player.now_playing_media.options == [] + + +@calls_commands( + CallCommand("player.get_player_info", {c.ATTR_PLAYER_ID: 1}), + CallCommand("player.get_play_state", {c.ATTR_PLAYER_ID: -263109739}), + CallCommand("player.get_now_playing_media", {c.ATTR_PLAYER_ID: -263109739}), + CallCommand("player.get_volume", {c.ATTR_PLAYER_ID: -263109739}), + CallCommand("player.get_mute", {c.ATTR_PLAYER_ID: -263109739}), + CallCommand("player.get_play_mode", {c.ATTR_PLAYER_ID: -263109739}), +) +async def test_refresh(player: HeosPlayer) -> None: + """Test refresh, including base, updates the correct information.""" + await player.refresh() + + assert player.name == "Zone 1" + assert player.player_id == -263109739 + assert player.model == "HEOS Drive" + assert player.version == "3.34.620" + assert player.ip_address == "127.0.0.1" + assert player.serial == "123456789" + + +@calls_commands( + CallCommand("player.get_play_state", {c.ATTR_PLAYER_ID: 1}), + CallCommand("player.get_now_playing_media", {c.ATTR_PLAYER_ID: 1}), + CallCommand("player.get_volume", {c.ATTR_PLAYER_ID: 1}), + CallCommand("player.get_mute", {c.ATTR_PLAYER_ID: 1}), + CallCommand("player.get_play_mode", {c.ATTR_PLAYER_ID: 1}), +) +async def test_refresh_no_base_update(player: HeosPlayer) -> None: + """Test refresh updates the correct information.""" + await player.refresh(refresh_base_info=False) + + assert player.name == "Back Patio" + assert player.player_id == 1 + + +@calls_command("player.check_update", {c.ATTR_PLAYER_ID: 1}) +async def test_check_update(player: HeosPlayer) -> None: + """Test the check_update c.""" + result = await player.check_update() + assert result