From c13c6eb5c0b9cd577961ef12f5f02edfc98a54c6 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 7 Jan 2025 12:05:26 -0600 Subject: [PATCH 01/25] Move callback code outside lock (#59) --- pyheos/connection.py | 5 ++++- tests/test_heos.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/pyheos/connection.py b/pyheos/connection.py index 9b2c13f..5f77626 100644 --- a/pyheos/connection.py +++ b/pyheos/connection.py @@ -219,14 +219,17 @@ async def _command_impl() -> HeosMessage: 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.""" diff --git a/tests/test_heos.py b/tests/test_heos.py index 04c7080..804d9ae 100644 --- a/tests/test_heos.py +++ b/tests/test_heos.py @@ -150,6 +150,38 @@ 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", + {const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_FAVORITES}, + add_command_under_process=True, + ), + 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(CommandFailedError): + 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.""" From 184a85b5f03951aca205c0910bb0214ced26ef3b Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 7 Jan 2025 13:44:57 -0600 Subject: [PATCH 02/25] Add remaining Group commands (#61) * Add Get Group Info and ability to refresh group * Allow update of existing * Add tests * Update comment --- pyheos/command/__init__.py | 1 + pyheos/command/group.py | 14 +++-- pyheos/group.py | 47 ++++++++++++----- pyheos/heos.py | 55 +++++++++++++++++++- pyproject.toml | 1 + tests/conftest.py | 2 +- tests/fixtures/group.get_group_info.json | 1 + tests/test_group.py | 35 ++++++++++++- tests/test_heos.py | 66 ++++++++++++++++++++++++ 9 files changed, 202 insertions(+), 20 deletions(-) create mode 100644 tests/fixtures/group.get_group_info.json diff --git a/pyheos/command/__init__.py b/pyheos/command/__init__.py index 4d16548..ed1f643 100644 --- a/pyheos/command/__init__.py +++ b/pyheos/command/__init__.py @@ -33,6 +33,7 @@ # Group commands COMMAND_GET_GROUPS: Final = "group/get_groups" +COMMAND_GET_GROUP_INFO: Final = "group/get_group_info" COMMAND_SET_GROUP: Final = "group/set_group" COMMAND_GET_GROUP_VOLUME: Final = "group/get_volume" COMMAND_SET_GROUP_VOLUME: Final = "group/set_volume" diff --git a/pyheos/command/group.py b/pyheos/command/group.py index 1896e44..da992e1 100644 --- a/pyheos/command/group.py +++ b/pyheos/command/group.py @@ -2,10 +2,6 @@ Define the group command module. This module creates HEOS group commands. - -Commands not currently implemented: - 4.3.2 Get Group Info - """ from collections.abc import Sequence @@ -25,6 +21,16 @@ def get_groups() -> HeosCommand: 4.3.1 Get Groups""" return HeosCommand(command.COMMAND_GET_GROUPS) + @staticmethod + def get_group_info(group_id: int) -> HeosCommand: + """Get information about a group. + + References: + 4.3.2 Get Group Info""" + return HeosCommand( + command.COMMAND_GET_GROUP_INFO, {const.ATTR_GROUP_ID: group_id} + ) + @staticmethod def set_group(player_ids: Sequence[int]) -> HeosCommand: """Create, modify, or ungroup players. diff --git a/pyheos/group.py b/pyheos/group.py index e1524ec..25233c3 100644 --- a/pyheos/group.py +++ b/pyheos/group.py @@ -34,15 +34,7 @@ def from_data( """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") + player_id, player_ids = cls.__get_ids(data[const.ATTR_PLAYERS]) return cls( name=data[const.ATTR_NAME], group_id=int(data[const.ATTR_GROUP_ID]), @@ -51,6 +43,30 @@ def from_data( heos=heos, ) + @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[const.ATTR_PLAYER_ID]) + if member_player[const.ATTR_ROLE] == const.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[const.ATTR_NAME] + self.group_id = int(data[const.ATTR_GROUP_ID]) + self.lead_player_id, self.member_player_ids = self.__get_ids( + data[const.ATTR_PLAYERS] + ) + async def on_event(self, event: HeosMessage) -> bool: """Handle a group update event.""" if not ( @@ -62,10 +78,17 @@ async def on_event(self, event: HeosMessage) -> bool: self.is_muted = event.get_message_value(const.ATTR_MUTE) == const.VALUE_ON return True - async def refresh(self) -> None: - """Pull current state.""" + 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.""" diff --git a/pyheos/heos.py b/pyheos/heos.py index e5af9fb..8699fb9 100644 --- a/pyheos/heos.py +++ b/pyheos/heos.py @@ -739,7 +739,10 @@ def groups(self) -> dict[int, HeosGroup]: return self._groups async def get_groups(self, *, refresh: bool = False) -> dict[int, HeosGroup]: - """Get available groups.""" + """Get available groups. + + References: + 4.3.1 Get Groups""" if not self._groups_loaded or refresh: groups = {} result = await self._connection.command(GroupCommands.get_groups()) @@ -749,10 +752,58 @@ async def get_groups(self, *, refresh: bool = False) -> dict[int, HeosGroup]: groups[group.group_id] = group self._groups = groups # Update all statuses - await asyncio.gather(*[group.refresh() for group in self._groups.values()]) + 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. + + 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( + GroupCommands.get_group_info(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. diff --git a/pyproject.toml b/pyproject.toml index 993454a..dcfc51b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,6 +106,7 @@ disable = [ "consider-using-namedtuple-or-dataclass", "consider-using-assignment-expr", "possibly-used-before-assignment", + "duplicate-code", # Handled by ruff # Ref: diff --git a/tests/conftest.py b/tests/conftest.py index a6bd76c..48e1693 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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/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/test_group.py b/tests/test_group.py index c6de132..cb33510 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -6,7 +6,7 @@ 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: @@ -126,3 +126,36 @@ async def test_unmute(group: HeosGroup) -> None: async def test_toggle_mute(group: HeosGroup) -> None: """Test toggle mute command.""" await group.toggle_mute() + + +@calls_commands( + CallCommand("group.get_group_info", {const.ATTR_GROUP_ID: 1}), + CallCommand("group.get_volume", {const.ATTR_GROUP_ID: -263109739}), + CallCommand("group.get_mute", {const.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", {const.ATTR_GROUP_ID: 1}), + CallCommand("group.get_mute", {const.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 804d9ae..845ac06 100644 --- a/tests/test_heos.py +++ b/tests/test_heos.py @@ -11,6 +11,7 @@ from pyheos.credentials import Credentials from pyheos.dispatch import Dispatcher from pyheos.error import CommandError, CommandFailedError, HeosError +from pyheos.group import HeosGroup from pyheos.heos import Heos, HeosOptions from pyheos.media import MediaItem, MediaMusicSource from tests.common import MediaItems @@ -1248,6 +1249,71 @@ async def test_get_groups(heos: Heos) -> None: assert not group.is_muted +@calls_commands( + CallCommand("group.get_group_info", {const.ATTR_GROUP_ID: -263109739}), + CallCommand("group.get_volume", {const.ATTR_GROUP_ID: -263109739}), + CallCommand("group.get_mute", {const.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", {const.ATTR_GROUP_ID: 1}), + CallCommand("group.get_volume", {const.ATTR_GROUP_ID: -263109739}), + CallCommand("group.get_mute", {const.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( + heos: Heos, group_id: int | None, group: HeosGroup | None, error: str +) -> None: + """Test retrieving group info with invalid parameters raises.""" + with pytest.raises(ValueError, match=error): + await heos.get_group_info(group_id=group_id, group=group) + + @calls_command("group.set_group_create", {const.ATTR_PLAYER_ID: "1,2,3"}) async def test_create_group(heos: Heos) -> None: """Test creating a group.""" From 624cc5b5ec5bf6390af5e25cbac27cea0fbe1119 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 7 Jan 2025 14:37:33 -0600 Subject: [PATCH 03/25] Add Get Player Info (#63) * Add get_player_info * Add tests * Add group_id to player --- pyheos/command/__init__.py | 1 + pyheos/command/player.py | 11 +++- pyheos/heos.py | 54 ++++++++++++++++- pyheos/player.py | 36 ++++++++---- tests/fixtures/player.get_player_info.json | 1 + tests/fixtures/player.get_players.json | 1 + tests/test_heos.py | 67 ++++++++++++++++++++++ tests/test_player.py | 39 ++++++++++++- 8 files changed, 195 insertions(+), 15 deletions(-) create mode 100644 tests/fixtures/player.get_player_info.json diff --git a/pyheos/command/__init__.py b/pyheos/command/__init__.py index ed1f643..cba58af 100644 --- a/pyheos/command/__init__.py +++ b/pyheos/command/__init__.py @@ -12,6 +12,7 @@ # Player commands COMMAND_GET_PLAYERS: Final = "player/get_players" +COMMAND_GET_PLAYER_INFO: Final = "player/get_player_info" 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" diff --git a/pyheos/command/player.py b/pyheos/command/player.py index b854e3d..1c99cac 100644 --- a/pyheos/command/player.py +++ b/pyheos/command/player.py @@ -4,7 +4,6 @@ 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 @@ -31,6 +30,16 @@ def get_players() -> HeosCommand: """ return HeosCommand(command.COMMAND_GET_PLAYERS) + @staticmethod + def get_player_info(player_id: int) -> HeosCommand: + """Get player information. + + References: + 4.2.2 Get Player Info""" + return HeosCommand( + command.COMMAND_GET_PLAYER_INFO, {const.ATTR_PLAYER_ID: player_id} + ) + @staticmethod def get_play_state(player_id: int) -> HeosCommand: """Get the state of the player. diff --git a/pyheos/heos.py b/pyheos/heos.py index 8699fb9..80cceb3 100644 --- a/pyheos/heos.py +++ b/pyheos/heos.py @@ -501,6 +501,52 @@ async def get_players(self, *, refresh: bool = False) -> dict[int, HeosPlayer]: 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. + + 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( + PlayerCommands.get_player_info(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) -> dict[str, list | dict]: """Refresh the players.""" new_player_ids = [] @@ -528,7 +574,7 @@ async def load_players(self) -> dict[str, list | dict]: # 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._update_from_data(player_data) player.available = True players[player_id] = player existing.remove(player) @@ -544,7 +590,11 @@ async def load_players(self) -> dict[str, list | dict]: # Update all statuses await asyncio.gather( - *[player.refresh() for player in players.values() if player.available] + *[ + player.refresh(refresh_base_info=False) + for player in players.values() + if player.available + ] ) self._players = players self._players_loaded = True diff --git a/pyheos/player.py b/pyheos/player.py index 96c53ba..cc9f1c9 100644 --- a/pyheos/player.py +++ b/pyheos/player.py @@ -136,8 +136,15 @@ 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) + @staticmethod + def __get_optional_int(value: str | None) -> int | None: + if value is not None: + return int(value) + return None + @classmethod def from_data( cls, @@ -154,10 +161,11 @@ def from_data( ip_address=data[const.ATTR_IP_ADDRESS], network=data[const.ATTR_NETWORK], line_out=int(data[const.ATTR_LINE_OUT]), + group_id=HeosPlayer.__get_optional_int(data.get(const.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]) @@ -167,6 +175,7 @@ def update_from_data(self, data: dict[str, Any]) -> None: self.ip_address = data[const.ATTR_IP_ADDRESS] self.network = data[const.ATTR_NETWORK] self.line_out = int(data[const.ATTR_LINE_OUT]) + self.group_id = HeosPlayer.__get_optional_int(data.get(const.ATTR_GROUP_ID)) async def on_event(self, event: HeosMessage, all_progress_events: bool) -> bool: """Updates the player based on the received HEOS event. @@ -208,16 +217,23 @@ def add_on_player_event(self, callback: EventCallbackType) -> DisconnectType: 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.""" 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..ccb6cef 100644 --- a/tests/fixtures/player.get_players.json +++ b/tests/fixtures/player.get_players.json @@ -16,6 +16,7 @@ }, { "name": "Front Porch", "pid": 2, + "gid": 2, "model": "HEOS Drive", "version": "1.493.180", "ip": "127.0.0.2", diff --git a/tests/test_heos.py b/tests/test_heos.py index 845ac06..1a9dcf6 100644 --- a/tests/test_heos.py +++ b/tests/test_heos.py @@ -14,6 +14,7 @@ from pyheos.group import HeosGroup from pyheos.heos import Heos, HeosOptions from pyheos.media import MediaItem, MediaMusicSource +from pyheos.player import HeosPlayer from tests.common import MediaItems from . import ( @@ -431,6 +432,72 @@ async def test_get_players(heos: Heos) -> None: 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", {const.ATTR_PLAYER_ID: -263109739}), + CallCommand("player.get_play_state", {const.ATTR_PLAYER_ID: -263109739}), + CallCommand("player.get_now_playing_media", {const.ATTR_PLAYER_ID: -263109739}), + CallCommand("player.get_volume", {const.ATTR_PLAYER_ID: -263109739}), + CallCommand("player.get_mute", {const.ATTR_PLAYER_ID: -263109739}), + CallCommand("player.get_play_mode", {const.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", {const.ATTR_PLAYER_ID: 1}), + CallCommand("player.get_play_state", {const.ATTR_PLAYER_ID: -263109739}), + CallCommand("player.get_now_playing_media", {const.ATTR_PLAYER_ID: -263109739}), + CallCommand("player.get_volume", {const.ATTR_PLAYER_ID: -263109739}), + CallCommand("player.get_mute", {const.ATTR_PLAYER_ID: -263109739}), + CallCommand("player.get_play_mode", {const.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( + heos: Heos, player_id: int | None, player: HeosPlayer | None, error: str +) -> None: + """Test retrieving player info with invalid parameters raises.""" + with pytest.raises(ValueError, match=error): + await heos.get_player_info(player_id=player_id, player=player) @calls_player_commands() diff --git a/tests/test_player.py b/tests/test_player.py index 8517cca..509686c 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -7,7 +7,7 @@ from pyheos import const from pyheos.media import MediaItem from pyheos.player import HeosPlayer -from tests import calls_command, value +from tests import CallCommand, calls_command, calls_commands, value from tests.common import MediaItems @@ -47,7 +47,7 @@ async def test_update_from_data(player: HeosPlayer) -> None: const.ATTR_LINE_OUT: "0", const.ATTR_SERIAL: "0987654321", } - player.update_from_data(data) + player._update_from_data(data) assert player.name == "Patio" assert player.player_id == 2 @@ -361,3 +361,38 @@ 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 + + +@calls_commands( + CallCommand("player.get_player_info", {const.ATTR_PLAYER_ID: 1}), + CallCommand("player.get_play_state", {const.ATTR_PLAYER_ID: -263109739}), + CallCommand("player.get_now_playing_media", {const.ATTR_PLAYER_ID: -263109739}), + CallCommand("player.get_volume", {const.ATTR_PLAYER_ID: -263109739}), + CallCommand("player.get_mute", {const.ATTR_PLAYER_ID: -263109739}), + CallCommand("player.get_play_mode", {const.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", {const.ATTR_PLAYER_ID: 1}), + CallCommand("player.get_now_playing_media", {const.ATTR_PLAYER_ID: 1}), + CallCommand("player.get_volume", {const.ATTR_PLAYER_ID: 1}), + CallCommand("player.get_mute", {const.ATTR_PLAYER_ID: 1}), + CallCommand("player.get_play_mode", {const.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 From 76ff692dacc45f849b2ec81513603539b8c1f214 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 7 Jan 2025 20:41:30 +0000 Subject: [PATCH 04/25] Increase reboot delay --- tests/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/__init__.py b/tests/__init__.py index 3077e55..5ff5828 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -396,7 +396,7 @@ async def _handle_connection( if command == COMMAND_REBOOT: # Simulate a reboot by shutting down the server await self.stop() - await asyncio.sleep(0.3) + await asyncio.sleep(0.5) await self.start() return if command == COMMAND_REGISTER_FOR_CHANGE_EVENTS: From 6a01fcc97f240db4783600409965a8afccc0e6a7 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 7 Jan 2025 15:20:13 -0600 Subject: [PATCH 05/25] Add check update command (#64) * Add check_update * Tests for check_update * Fix lint --- pyheos/command/__init__.py | 1 + pyheos/command/player.py | 12 ++++++++++-- pyheos/const.py | 2 ++ pyheos/heos.py | 14 ++++++++++++++ pyheos/player.py | 8 ++++++++ tests/fixtures/player.check_update.json | 1 + tests/test_player.py | 7 +++++++ 7 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/player.check_update.json diff --git a/pyheos/command/__init__.py b/pyheos/command/__init__.py index cba58af..dee7224 100644 --- a/pyheos/command/__init__.py +++ b/pyheos/command/__init__.py @@ -31,6 +31,7 @@ COMMAND_PLAY_QUICK_SELECT: Final = "player/play_quickselect" COMMAND_SET_QUICK_SELECT: Final = "player/set_quickselect" COMMAND_GET_QUICK_SELECTS: Final = "player/get_quickselects" +COMMAND_CHECK_UPDATE: Final = "player/check_update" # Group commands COMMAND_GET_GROUPS: Final = "group/get_groups" diff --git a/pyheos/command/player.py b/pyheos/command/player.py index 1c99cac..8865c27 100644 --- a/pyheos/command/player.py +++ b/pyheos/command/player.py @@ -9,8 +9,6 @@ 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 @@ -242,3 +240,13 @@ def get_quick_selects(player_id: int) -> HeosCommand: return HeosCommand( command.COMMAND_GET_QUICK_SELECTS, {const.ATTR_PLAYER_ID: player_id} ) + + @staticmethod + def check_update(player_id: int) -> HeosCommand: + """Check for a firmware update. + + References: + 4.2.26 Check for Firmware Update""" + return HeosCommand( + command.COMMAND_CHECK_UPDATE, {const.ATTR_PLAYER_ID: player_id} + ) diff --git a/pyheos/const.py b/pyheos/const.py index 60616ed..d867053 100644 --- a/pyheos/const.py +++ b/pyheos/const.py @@ -64,6 +64,7 @@ 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" @@ -77,6 +78,7 @@ VALUE_SUCCESS: Final = "success" VALUE_LEADER: Final = "leader" VALUE_MEMBER: Final = "member" +VALUE_UPDATE_EXIST: Final = "update_exist" ERROR_INVALID_CREDNETIALS: Final = 6 ERROR_USER_NOT_LOGGED_IN: Final = 8 diff --git a/pyheos/heos.py b/pyheos/heos.py index 80cceb3..314f2ef 100644 --- a/pyheos/heos.py +++ b/pyheos/heos.py @@ -773,6 +773,20 @@ async def get_player_quick_selects(self, player_id: int) -> dict[int, str]: for data in cast(list[dict], result.payload) } + async def 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(PlayerCommands.check_update(player_id)) + payload = cast(dict[str, Any], result.payload) + return bool(payload[const.ATTR_UPDATE] == const.VALUE_UPDATE_EXIST) + class GroupMixin(PlayerMixin): """A mixin to provide access to the group commands.""" diff --git a/pyheos/player.py b/pyheos/player.py index cc9f1c9..bc499cd 100644 --- a/pyheos/player.py +++ b/pyheos/player.py @@ -390,3 +390,11 @@ 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) + + 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.check_update(self.player_id) 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/test_player.py b/tests/test_player.py index 509686c..814d5a7 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -396,3 +396,10 @@ async def test_refresh_no_base_update(player: HeosPlayer) -> None: assert player.name == "Back Patio" assert player.player_id == 1 + + +@calls_command("player.check_update", {const.ATTR_PLAYER_ID: 1}) +async def test_check_update(player: HeosPlayer) -> None: + """Test the check_update command.""" + result = await player.check_update() + assert result From bc11dec8ad2d701d9c1acfdad59aeaf8e9a8b6e5 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Wed, 8 Jan 2025 00:21:54 +0000 Subject: [PATCH 06/25] Fix for flaky 3.11 tests --- tests/conftest.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 48e1693..cff38a4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ """Test fixtures for pyheos.""" +import asyncio from collections.abc import AsyncGenerator, Callable, Coroutine from typing import Any @@ -20,9 +21,20 @@ async def mock_device_fixture() -> AsyncGenerator[MockHeosDevice]: """Fixture for mocking a HEOS device connection.""" device = MockHeosDevice() - await device.start() + + async def try_connect(): + try: + await device.start() + except OSError: + await asyncio.sleep(0.5) + await try_connect() + + await try_connect() yield device - await device.stop() + try: + await device.stop() + except Exception: # pylint: disable=broad-except + pass @pytest_asyncio.fixture(name="heos") From 980cde928a6d79179f58837f7dd3183468086901 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Wed, 8 Jan 2025 00:23:26 +0000 Subject: [PATCH 07/25] Fix lint --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index cff38a4..2c5aed5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,7 +22,7 @@ async def mock_device_fixture() -> AsyncGenerator[MockHeosDevice]: """Fixture for mocking a HEOS device connection.""" device = MockHeosDevice() - async def try_connect(): + async def try_connect() -> None: try: await device.start() except OSError: From 5cf06b166d43bbed6ade6579e78a3e0eb21063dd Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Wed, 8 Jan 2025 01:58:30 +0000 Subject: [PATCH 08/25] Try fixing flaky 3.11 tests --- tests/__init__.py | 9 +++++++-- tests/conftest.py | 5 +---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 5ff5828..9841c86 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -292,12 +292,17 @@ async def stop(self) -> None: 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( diff --git a/tests/conftest.py b/tests/conftest.py index 2c5aed5..c421419 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,10 +31,7 @@ async def try_connect() -> None: await try_connect() yield device - try: - await device.stop() - except Exception: # pylint: disable=broad-except - pass + await device.stop() @pytest_asyncio.fixture(name="heos") From 73ef7ef5eba2f3f1c9814dad866b2db68aa5e50f Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Wed, 8 Jan 2025 02:03:08 +0000 Subject: [PATCH 09/25] Undo flaky test workaround --- tests/__init__.py | 2 +- tests/conftest.py | 10 +--------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 9841c86..96be271 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -401,7 +401,7 @@ async def _handle_connection( if command == COMMAND_REBOOT: # Simulate a reboot by shutting down the server await self.stop() - await asyncio.sleep(0.5) + await asyncio.sleep(0.3) await self.start() return if command == COMMAND_REGISTER_FOR_CHANGE_EVENTS: diff --git a/tests/conftest.py b/tests/conftest.py index c421419..a4838ce 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,15 +21,7 @@ async def mock_device_fixture() -> AsyncGenerator[MockHeosDevice]: """Fixture for mocking a HEOS device connection.""" device = MockHeosDevice() - - async def try_connect() -> None: - try: - await device.start() - except OSError: - await asyncio.sleep(0.5) - await try_connect() - - await try_connect() + await device.start() yield device await device.stop() From b4103f5bf8f430b5a96da40b1b03cfa32cd02549 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 7 Jan 2025 20:04:35 -0600 Subject: [PATCH 10/25] Add Player Queue Commands (#65) * Add Get Queue * Add play queue * add remove_from_queue * add save queue * Add move queue item * Test save playlsit name too long --- pyheos/command/__init__.py | 5 ++ pyheos/command/player.py | 78 +++++++++++++++++-- pyheos/const.py | 2 + pyheos/heos.py | 65 ++++++++++++++-- pyheos/media.py | 26 +++++++ pyheos/player.py | 37 ++++++++- tests/fixtures/player.get_queue.json | 1 + tests/fixtures/player.move_queue_item.json | 1 + tests/fixtures/player.play_queue.json | 1 + tests/fixtures/player.remove_from_queue.json | 1 + tests/fixtures/player.save_queue.json | 1 + tests/test_player.py | 79 ++++++++++++++++++++ 12 files changed, 280 insertions(+), 17 deletions(-) create mode 100644 tests/fixtures/player.get_queue.json create mode 100644 tests/fixtures/player.move_queue_item.json create mode 100644 tests/fixtures/player.play_queue.json create mode 100644 tests/fixtures/player.remove_from_queue.json create mode 100644 tests/fixtures/player.save_queue.json diff --git a/pyheos/command/__init__.py b/pyheos/command/__init__.py index dee7224..a2271de 100644 --- a/pyheos/command/__init__.py +++ b/pyheos/command/__init__.py @@ -25,7 +25,12 @@ COMMAND_TOGGLE_MUTE: Final = "player/toggle_mute" COMMAND_GET_PLAY_MODE: Final = "player/get_play_mode" COMMAND_SET_PLAY_MODE: Final = "player/set_play_mode" +COMMAND_GET_QUEUE: Final = "player/get_queue" +COMMAND_REMOVE_FROM_QUEUE: Final = "player/remove_from_queue" COMMAND_CLEAR_QUEUE: Final = "player/clear_queue" +COMMAND_PLAY_QUEUE: Final = "player/play_queue" +COMMAND_SAVE_QUEUE: Final = "player/save_queue" +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_QUICK_SELECT: Final = "player/play_quickselect" diff --git a/pyheos/command/player.py b/pyheos/command/player.py index 8865c27..14e5d7c 100644 --- a/pyheos/command/player.py +++ b/pyheos/command/player.py @@ -2,15 +2,10 @@ Define the player command module. This module creates HEOS player commands. - -Commands not currently implemented: - 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 """ +from typing import Any + from pyheos import command, const from pyheos.message import HeosCommand @@ -177,6 +172,58 @@ def set_play_mode( }, ) + @staticmethod + def get_queue( + player_id: int, range_start: int | None = None, range_end: int | None = None + ) -> HeosCommand: + """Get the queue for the current player. + + References: + 4.2.15 Get Queue + """ + params: dict[str, Any] = {const.ATTR_PLAYER_ID: player_id} + if isinstance(range_start, int) and isinstance(range_end, int): + params[const.ATTR_RANGE] = f"{range_start},{range_end}" + return HeosCommand(command.COMMAND_GET_QUEUE, params) + + @staticmethod + def play_queue(player_id: int, queue_id: int) -> HeosCommand: + """Play a queue item. + + References: + 4.2.16 Play Queue Item""" + return HeosCommand( + command.COMMAND_PLAY_QUEUE, + {const.ATTR_PLAYER_ID: player_id, const.ATTR_QUEUE_ID: queue_id}, + ) + + @staticmethod + def remove_from_queue(player_id: int, queue_ids: list[int]) -> HeosCommand: + """Remove an item from the queue. + + References: + 4.2.17 Remove Item(s) from Queue""" + return HeosCommand( + command.COMMAND_REMOVE_FROM_QUEUE, + { + const.ATTR_PLAYER_ID: player_id, + const.ATTR_QUEUE_ID: ",".join(map(str, queue_ids)), + }, + ) + + @staticmethod + def save_queue(player_id: int, name: str) -> HeosCommand: + """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") + return HeosCommand( + command.COMMAND_SAVE_QUEUE, + {const.ATTR_PLAYER_ID: player_id, const.ATTR_NAME: name}, + ) + @staticmethod def clear_queue(player_id: int) -> HeosCommand: """Clear the queue. @@ -187,6 +234,23 @@ def clear_queue(player_id: int) -> HeosCommand: command.COMMAND_CLEAR_QUEUE, {const.ATTR_PLAYER_ID: player_id} ) + @staticmethod + def move_queue_item( + player_id: int, source_queue_ids: list[int], destination_queue_id: int + ) -> HeosCommand: + """Move one or more items in the queue. + + References: + 4.2.20 Move Queue""" + return HeosCommand( + command.COMMAND_MOVE_QUEUE_ITEM, + { + const.ATTR_PLAYER_ID: player_id, + const.ATTR_SOURCE_QUEUE_ID: ",".join(map(str, source_queue_ids)), + const.ATTR_DESTINATION_QUEUE_ID: destination_queue_id, + }, + ) + @staticmethod def play_next(player_id: int) -> HeosCommand: """Play next. diff --git a/pyheos/const.py b/pyheos/const.py index d867053..72c0f83 100644 --- a/pyheos/const.py +++ b/pyheos/const.py @@ -19,6 +19,7 @@ ATTR_CONTAINER_ID: Final = "cid" 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" @@ -56,6 +57,7 @@ ATTR_SONG: Final = "song" ATTR_SOURCE_ID: Final = "sid" ATTR_SOURCE_PLAYER_ID: Final = "spid" +ATTR_SOURCE_QUEUE_ID: Final = "sqid" ATTR_SIGNED_OUT: Final = "signed_out" ATTR_SIGNED_IN: Final = "signed_in" ATTR_STATE: Final = "state" diff --git a/pyheos/heos.py b/pyheos/heos.py index 314f2ef..fdfd22b 100644 --- a/pyheos/heos.py +++ b/pyheos/heos.py @@ -20,11 +20,7 @@ callback_wrapper, ) from pyheos.error import CommandError, CommandFailedError -from pyheos.media import ( - BrowseResult, - MediaItem, - MediaMusicSource, -) +from pyheos.media import BrowseResult, MediaItem, MediaMusicSource, QueueItem from pyheos.message import HeosMessage from pyheos.system import HeosHost, HeosSystem @@ -717,6 +713,48 @@ async def player_set_play_mode( PlayerCommands.set_play_mode(player_id, repeat, shuffle) ) + 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 + """ + result = await self._connection.command( + PlayerCommands.get_queue(player_id, range_start, range_end) + ) + 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(PlayerCommands.play_queue(player_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( + PlayerCommands.remove_from_queue(player_id, 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""" + await self._connection.command(PlayerCommands.save_queue(player_id, name)) + async def player_clear_queue(self, player_id: int) -> None: """Clear the queue. @@ -724,6 +762,19 @@ async def player_clear_queue(self, player_id: int) -> None: 4.2.19 Clear Queue""" await self._connection.command(PlayerCommands.clear_queue(player_id)) + 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( + PlayerCommands.move_queue_item( + player_id, source_queue_ids, destination_queue_id + ) + ) + async def player_play_next(self, player_id: int) -> None: """Play next. @@ -760,7 +811,7 @@ async def player_play_quick_select( PlayerCommands.play_quick_select(player_id, quick_select_id) ) - async def get_player_quick_selects(self, player_id: int) -> dict[int, str]: + async def player_get_quick_selects(self, player_id: int) -> dict[int, str]: """Get quick selects. References: @@ -773,7 +824,7 @@ async def get_player_quick_selects(self, player_id: int) -> dict[int, str]: for data in cast(list[dict], result.payload) } - async def check_update(self, player_id: int) -> bool: + async def player_check_update(self, player_id: int) -> bool: """Check for a firmware update. Args: diff --git a/pyheos/media.py b/pyheos/media.py index 5ea4a3f..9b2dd4b 100644 --- a/pyheos/media.py +++ b/pyheos/media.py @@ -11,6 +11,32 @@ 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[const.ATTR_QUEUE_ID]), + song=data[const.ATTR_SONG], + album=data[const.ATTR_ALBUM], + artist=data[const.ATTR_ARTIST], + image_url=data[const.ATTR_IMAGE_URL], + media_id=data[const.ATTR_MEDIA_ID], + album_id=data[const.ATTR_ALBUM_ID], + ) + + @dataclass(init=False) class Media: """ diff --git a/pyheos/player.py b/pyheos/player.py index bc499cd..1f0fe29 100644 --- a/pyheos/player.py +++ b/pyheos/player.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any, Optional, cast from pyheos.dispatch import DisconnectType, EventCallbackType, callback_wrapper -from pyheos.media import MediaItem +from pyheos.media import MediaItem, QueueItem from pyheos.message import HeosMessage from . import const @@ -317,11 +317,42 @@ async def set_play_mode(self, repeat: const.RepeatType, shuffle: bool) -> None: 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" @@ -389,7 +420,7 @@ 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. @@ -397,4 +428,4 @@ async def check_update(self) -> bool: Returns: True if an update is available, otherwise False.""" assert self.heos, "Heos instance not set" - return await self.heos.check_update(self.player_id) + return await self.heos.player_check_update(self.player_id) 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_player.py b/tests/test_player.py index 814d5a7..992633f 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -207,6 +207,85 @@ async def test_clear_queue(player: HeosPlayer) -> None: await player.clear_queue() +@calls_command("player.get_queue", {const.ATTR_PLAYER_ID: 1}) +async def test_get_queue(player: HeosPlayer) -> None: + """Test the get queue command.""" + 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", {const.ATTR_PLAYER_ID: 1, const.ATTR_QUEUE_ID: 1}) +async def test_play_queue(player: HeosPlayer) -> None: + """Test the play_queue command.""" + await player.play_queue(1) + + +@calls_command( + "player.remove_from_queue", {const.ATTR_PLAYER_ID: 1, const.ATTR_QUEUE_ID: "1,2,3"} +) +async def test_remove_from_queue(player: HeosPlayer) -> None: + """Test the play_queue command.""" + await player.remove_from_queue([1, 2, 3]) + + +@calls_command("player.save_queue", {const.ATTR_PLAYER_ID: 1, const.ATTR_NAME: "Test"}) +async def test_save_queue(player: HeosPlayer) -> None: + """Test the save_queue command.""" + await player.save_queue("Test") + + +async def test_save_queue_too_long_raises(player: HeosPlayer) -> None: + """Test the save_queue command.""" + 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", + { + const.ATTR_PLAYER_ID: 1, + const.ATTR_SOURCE_QUEUE_ID: "2,3,4", + const.ATTR_DESTINATION_QUEUE_ID: 1, + }, +) +async def test_move_queue_item(player: HeosPlayer) -> None: + """Test the move_queue_item command.""" + await player.move_queue_item([2, 3, 4], 1) + + +@calls_command("player.get_queue", {const.ATTR_PLAYER_ID: 1, const.ATTR_RANGE: "0,10"}) +async def test_get_queue_with_range(player: HeosPlayer) -> None: + """Test the check_update command.""" + 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", { From cfd879b7237103025fcb6e0b8a55eaf6f625515f Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Wed, 8 Jan 2025 02:12:52 +0000 Subject: [PATCH 11/25] Handle error on mock connection close --- tests/__init__.py | 5 ++++- tests/conftest.py | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 96be271..e04b2fb 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -505,7 +505,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/conftest.py b/tests/conftest.py index a4838ce..48e1693 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,5 @@ """Test fixtures for pyheos.""" -import asyncio from collections.abc import AsyncGenerator, Callable, Coroutine from typing import Any From ec41cec3be1b496bc0c09142dc29eb3dfbbf9e86 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Wed, 8 Jan 2025 20:36:20 +0000 Subject: [PATCH 12/25] Add CommandAuthenticationError --- pyheos/__init__.py | 8 +++++- pyheos/connection.py | 2 +- pyheos/error.py | 63 +++++++++++++++++++++++++++++++++----------- 3 files changed, 55 insertions(+), 18 deletions(-) diff --git a/pyheos/__init__.py b/pyheos/__init__.py index e86ed5c..f5522fc 100644 --- a/pyheos/__init__.py +++ b/pyheos/__init__.py @@ -13,7 +13,12 @@ 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 .media import ( @@ -28,6 +33,7 @@ __all__ = [ "BrowseResult", "CallbackType", + "CommandAuthenticationError", "CommandError", "CommandFailedError", "ConnectType", diff --git a/pyheos/connection.py b/pyheos/connection.py index 5f77626..eb47fc6 100644 --- a/pyheos/connection.py +++ b/pyheos/connection.py @@ -213,7 +213,7 @@ 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) + raise CommandFailedError._from_message(response) _LOGGER.debug(f"Command executed '{command.uri_masked}': '{response}'") return response diff --git a/pyheos/error.py b/pyheos/error.py index 3914837..46f3c1b 100644 --- a/pyheos/error.py +++ b/pyheos/error.py @@ -1,7 +1,6 @@ """Define the error module for HEOS.""" import asyncio -from functools import cached_property from typing import Final from pyheos import const @@ -27,13 +26,13 @@ def format_error_message(error: Exception) -> str: 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 cammand send fails.""" def __init__(self, command: str, message: str): """Create a new instance of the error.""" @@ -47,7 +46,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, @@ -61,10 +60,26 @@ def __init__( self._error_text = text self._error_id = error_id self._system_error_number = system_error_number + self._is_credential_error: bool = False 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 == const.ERROR_SYSTEM_ERROR: + return system_error_number in ( + const.SYSTEM_ERROR_USER_NOT_LOGGED_IN, + const.SYSTEM_ERROR_USER_NOT_FOUND, + ) + return error_id in ( + const.ERROR_USER_NOT_LOGGED_IN, + const.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) system_error_number = None @@ -75,6 +90,11 @@ def from_message(cls, message: HeosMessage) -> "CommandFailedError": ) 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 +114,27 @@ def system_error_number(self) -> int | None: """Return the system error number if available.""" return self._system_error_number - @cached_property + @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, - ) + return self._is_credential_error + + +class CommandAuthenticationError(CommandFailedError): + """Define an error for when a command succeeds, but an authentication error is returned.""" + + def __init__( + self, + command: str, + text: str, + error_id: int, + system_error_number: int | None = None, + ): + """Create a new instance of the error.""" + super().__init__(command, text, error_id, system_error_number) + self._is_credential_error = True + + @property + def is_credential_error(self) -> bool: + """Return True if the error is related to authentication, otherwise False.""" + return self._is_credential_error From adf7ff0e52854e823a583ebd3e4fec0ad035455a Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Wed, 8 Jan 2025 20:51:17 +0000 Subject: [PATCH 13/25] Test CommandAuthenticationError --- pyheos/error.py | 23 ++--------------------- pyheos/heos.py | 2 +- tests/test_heos.py | 19 ++++++++++++++----- 3 files changed, 17 insertions(+), 27 deletions(-) diff --git a/pyheos/error.py b/pyheos/error.py index 46f3c1b..6067c60 100644 --- a/pyheos/error.py +++ b/pyheos/error.py @@ -60,7 +60,6 @@ def __init__( self._error_text = text self._error_id = error_id self._system_error_number = system_error_number - self._is_credential_error: bool = False super().__init__(command, f"{text} ({error_id})") @staticmethod @@ -74,6 +73,7 @@ def __is_authentication_error( const.SYSTEM_ERROR_USER_NOT_FOUND, ) return error_id in ( + const.ERROR_INVALID_CREDNETIALS, const.ERROR_USER_NOT_LOGGED_IN, const.ERROR_USER_NOT_FOUND, ) @@ -114,27 +114,8 @@ def system_error_number(self) -> int | None: """Return the system error number if available.""" return self._system_error_number - @property - def is_credential_error(self) -> bool: - """Return True if the error is related to authentication, otherwise False.""" - return self._is_credential_error - class CommandAuthenticationError(CommandFailedError): """Define an error for when a command succeeds, but an authentication error is returned.""" - def __init__( - self, - command: str, - text: str, - error_id: int, - system_error_number: int | None = None, - ): - """Create a new instance of the error.""" - super().__init__(command, text, error_id, system_error_number) - self._is_credential_error = True - - @property - def is_credential_error(self) -> bool: - """Return True if the error is related to authentication, otherwise False.""" - return self._is_credential_error + pass diff --git a/pyheos/heos.py b/pyheos/heos.py index fdfd22b..956961e 100644 --- a/pyheos/heos.py +++ b/pyheos/heos.py @@ -1184,7 +1184,7 @@ async def _on_disconnected(self, from_error: bool) -> None: 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: + if isinstance(error, CommandFailedError) and error.command != COMMAND_SIGN_IN: self._signed_in_username = None _LOGGER.debug( "HEOS Account credentials are no longer valid: %s", diff --git a/tests/test_heos.py b/tests/test_heos.py index 1a9dcf6..0b222ab 100644 --- a/tests/test_heos.py +++ b/tests/test_heos.py @@ -10,7 +10,12 @@ from pyheos import const from pyheos.credentials import Credentials from pyheos.dispatch import Dispatcher -from pyheos.error import CommandError, CommandFailedError, HeosError +from pyheos.error import ( + CommandAuthenticationError, + CommandError, + CommandFailedError, + HeosError, +) from pyheos.group import HeosGroup from pyheos.heos import Heos, HeosOptions from pyheos.media import MediaItem, MediaMusicSource @@ -144,7 +149,7 @@ async def test_command_credential_error_dispatches_event(heos: Heos) -> None: heos, const.SIGNAL_HEOS_EVENT, const.EVENT_USER_CREDENTIALS_INVALID ) - with pytest.raises(CommandFailedError): + with pytest.raises(CommandAuthenticationError): await heos.get_favorites() assert signal.is_set() @@ -179,7 +184,7 @@ async def callback() -> None: heos.add_on_user_credentials_invalid(callback) - with pytest.raises(CommandFailedError): + with pytest.raises(CommandAuthenticationError): await heos.get_favorites() assert callback_invoked @@ -521,8 +526,12 @@ 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 @calls_player_commands() @@ -1258,7 +1267,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=********':" From e9e512ad2a85d15b1da4b42e1629da59c1cb2ff6 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Wed, 8 Jan 2025 21:56:10 +0000 Subject: [PATCH 14/25] Tests and tweak behavior --- pyheos/error.py | 2 +- pyheos/heos.py | 41 ++++++++++++++++++++++++++--------------- tests/test_heos.py | 41 ++++++++++++++++++++++++++++++++++++----- 3 files changed, 63 insertions(+), 21 deletions(-) diff --git a/pyheos/error.py b/pyheos/error.py index 6067c60..bdd6c31 100644 --- a/pyheos/error.py +++ b/pyheos/error.py @@ -32,7 +32,7 @@ class HeosError(Exception): class CommandError(HeosError): - """Define an error for when a HEOS cammand send fails.""" + """Define an error for when a HEOS command send fails.""" def __init__(self, command: str, message: str): """Create a new instance of the error.""" diff --git a/pyheos/heos.py b/pyheos/heos.py index 956961e..c4029c5 100644 --- a/pyheos/heos.py +++ b/pyheos/heos.py @@ -19,7 +19,7 @@ EventCallbackType, callback_wrapper, ) -from pyheos.error import CommandError, CommandFailedError +from pyheos.error import CommandAuthenticationError, CommandFailedError from pyheos.media import BrowseResult, MediaItem, MediaMusicSource, QueueItem from pyheos.message import HeosMessage from pyheos.system import HeosHost, HeosSystem @@ -115,6 +115,11 @@ def current_credentials(self) -> Credentials | None: """Return the current credential, if any set.""" return self._current_credentials + @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. @@ -156,7 +161,7 @@ async def sign_in( ) self._signed_in_username = result.get_message_value(const.ATTR_USER_NAME) if update_credential: - self._current_credentials = Credentials(username, password) + self.current_credentials = Credentials(username, password) return self._signed_in_username async def sign_out(self, *, update_credential: bool = True) -> None: @@ -170,7 +175,7 @@ async def sign_out(self, *, update_credential: bool = True) -> None: await self._connection.command(SystemCommands.sign_out()) self._signed_in_username = None if update_credential: - self._current_credentials = None + self.current_credentials = None async def heart_beat(self) -> None: """Send a heart beat message to the HEOS device. @@ -1145,18 +1150,18 @@ async def _on_connected(self) -> None: const.SIGNAL_HEOS_EVENT, const.EVENT_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, @@ -1184,13 +1189,19 @@ async def _on_disconnected(self, from_error: bool) -> None: async def _on_command_error(self, error: CommandFailedError) -> None: """Handle when a command error occurs.""" - if isinstance(error, CommandFailedError) 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, diff --git a/tests/test_heos.py b/tests/test_heos.py index 0b222ab..47a6441 100644 --- a/tests/test_heos.py +++ b/tests/test_heos.py @@ -106,6 +106,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() @@ -128,17 +129,45 @@ async def test_connect_with_bad_credentials_dispatches_event( 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", + {const.ATTR_SOURCE_ID: const.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", + {const.ATTR_SOURCE_ID: const.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.""" @@ -163,6 +192,7 @@ async def test_command_credential_error_dispatches_event(heos: Heos) -> None: {const.ATTR_SOURCE_ID: const.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( @@ -532,6 +562,7 @@ async def test_get_players_error(heos: Heos) -> None: 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() From 274dba4a20250d4642921b95a74002bf3b1f3739 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Thu, 9 Jan 2025 15:08:41 -0600 Subject: [PATCH 15/25] Add remaining browse commands (#68) * Add get_music_source_info * Add get search criteria * Add Search * Add bounds checking for search param * Add rename playlist command * Add delete playlist * Add retrieve_metadata * Add service options to BrowseResult * Add service options to now playing media * Add set_service_option and happy-path tests * Param tests * Refactoring * Add multi-search * Optimize some tests * Add missing exports --- pyheos/__init__.py | 19 +- pyheos/command/__init__.py | 12 +- pyheos/command/browse.py | 298 +++++++- pyheos/command/player.py | 2 +- pyheos/connection.py | 10 +- pyheos/const.py | 35 +- pyheos/error.py | 2 +- pyheos/heos.py | 182 ++++- pyheos/media.py | 122 ++- pyheos/message.py | 11 +- pyheos/player.py | 33 +- pyheos/search.py | 163 ++++ pyproject.toml | 3 + tests/__init__.py | 1 + tests/fixtures/browse.delete_playlist.json | 1 + .../fixtures/browse.get_search_criteria.json | 1 + tests/fixtures/browse.get_source_info.json | 1 + tests/fixtures/browse.multi_search.json | 1 + tests/fixtures/browse.rename_playlist.json | 1 + tests/fixtures/browse.retrieve_metadata.json | 22 + tests/fixtures/browse.search.json | 2 + ...rowse.set_service_option_add_favorite.json | 1 + ...et_service_option_add_favorite_browse.json | 1 + ...rowse.set_service_option_add_playlist.json | 1 + ..._service_option_album_remove_playlist.json | 1 + ...browse.set_service_option_new_station.json | 1 + ...se.set_service_option_remove_favorite.json | 1 + ...wse.set_service_option_thumbs_up_down.json | 1 + ...owse.set_service_option_track_station.json | 1 + .../player.get_now_playing_media_changed.json | 2 +- tests/test_heos.py | 32 +- tests/test_heos_browse.py | 722 ++++++++++++++++++ tests/test_media.py | 29 +- tests/test_player.py | 3 +- 34 files changed, 1646 insertions(+), 72 deletions(-) create mode 100644 pyheos/search.py create mode 100644 tests/fixtures/browse.delete_playlist.json create mode 100644 tests/fixtures/browse.get_search_criteria.json create mode 100644 tests/fixtures/browse.get_source_info.json create mode 100644 tests/fixtures/browse.multi_search.json create mode 100644 tests/fixtures/browse.rename_playlist.json create mode 100644 tests/fixtures/browse.retrieve_metadata.json create mode 100644 tests/fixtures/browse.search.json create mode 100644 tests/fixtures/browse.set_service_option_add_favorite.json create mode 100644 tests/fixtures/browse.set_service_option_add_favorite_browse.json create mode 100644 tests/fixtures/browse.set_service_option_add_playlist.json create mode 100644 tests/fixtures/browse.set_service_option_album_remove_playlist.json create mode 100644 tests/fixtures/browse.set_service_option_new_station.json create mode 100644 tests/fixtures/browse.set_service_option_remove_favorite.json create mode 100644 tests/fixtures/browse.set_service_option_thumbs_up_down.json create mode 100644 tests/fixtures/browse.set_service_option_track_station.json create mode 100644 tests/test_heos_browse.py diff --git a/pyheos/__init__.py b/pyheos/__init__.py index f5522fc..417f070 100644 --- a/pyheos/__init__.py +++ b/pyheos/__init__.py @@ -22,15 +22,22 @@ from .group import HeosGroup from .heos import Heos, HeosOptions from .media import ( + AlbumMetadata, BrowseResult, + ImageMetadata, Media, MediaItem, MediaMusicSource, + QueueItem, + RetreiveMetadataResult, + ServiceOption, ) from .player import HeosNowPlayingMedia, HeosPlayer, PlayMode +from .search import MultiSearchResult, SearchCriteria, SearchResult, SearchStatistic from .system import HeosHost, HeosSystem __all__ = [ + "AlbumMetadata", "BrowseResult", "CallbackType", "CommandAuthenticationError", @@ -47,15 +54,23 @@ "HeosError", "HeosGroup", "HeosHost", + "HeosNowPlayingMedia", "HeosOptions", "HeosPlayer", - "HeosNowPlayingMedia", "HeosSystem", + "ImageMetadata", "Media", "MediaItem", "MediaMusicSource", - "PlayerEventCallbackType", + "MultiSearchResult", + "QueueItem", + "ServiceOption", "PlayMode", + "PlayerEventCallbackType", + "RetreiveMetadataResult", + "SearchCriteria", + "SearchResult", + "SearchStatistic", "SendType", "TargetType", ] diff --git a/pyheos/command/__init__.py b/pyheos/command/__init__.py index a2271de..0c140cc 100644 --- a/pyheos/command/__init__.py +++ b/pyheos/command/__init__.py @@ -3,12 +3,20 @@ from typing import Final # 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" diff --git a/pyheos/command/browse.py b/pyheos/command/browse.py index 4a2bf37..b92e8f5 100644 --- a/pyheos/command/browse.py +++ b/pyheos/command/browse.py @@ -3,17 +3,10 @@ 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 @@ -60,6 +53,60 @@ def get_music_sources(refresh: bool = False) -> HeosCommand: params[const.ATTR_REFRESH] = const.VALUE_ON return HeosCommand(command.COMMAND_BROWSE_GET_SOURCES, params) + @staticmethod + def get_music_source_info(source_id: int) -> HeosCommand: + """ + Create a HEOS command to get information about a music source. + + References: + 4.4.2 Get Source Info + """ + return HeosCommand( + command.COMMAND_BROWSE_GET_SOURCE_INFO, {const.ATTR_SOURCE_ID: source_id} + ) + + @staticmethod + def get_search_criteria(source_id: int) -> HeosCommand: + """ + Create a HEOS command to get the search criteria. + + References: + 4.4.5 Get Search Criteria + """ + return HeosCommand( + command.COMMAND_BROWSE_GET_SEARCH_CRITERIA, + {const.ATTR_SOURCE_ID: source_id}, + ) + + @staticmethod + def search( + source_id: int, + search: str, + criteria_id: int, + range_start: int | None = None, + range_end: int | None = None, + ) -> HeosCommand: + """ + Create a HEOS command to search for media. + + References: + 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 = { + const.ATTR_SOURCE_ID: source_id, + const.ATTR_SEARCH: search, + const.ATTR_SEARCH_CRITERIA_ID: criteria_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_SEARCH, params) + @staticmethod def play_station( player_id: int, @@ -154,3 +201,232 @@ def add_to_queue( if media_id is not None: params[const.ATTR_MEDIA_ID] = media_id return HeosCommand(command.COMMAND_BROWSE_ADD_TO_QUEUE, params) + + @staticmethod + def rename_playlist( + source_id: int, container_id: str, new_name: str + ) -> HeosCommand: + """ + Create a HEOS command to rename a 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" + ) + return HeosCommand( + command.COMMAND_BROWSE_RENAME_PLAYLIST, + { + const.ATTR_SOURCE_ID: source_id, + const.ATTR_CONTAINER_ID: container_id, + const.ATTR_NAME: new_name, + }, + ) + + @staticmethod + def delete_playlist(source_id: int, container_id: str) -> HeosCommand: + """ + Create a HEOS command to delete a playlist. + + References: + 4.4.15 Delete HEOS Playlist""" + return HeosCommand( + command.COMMAND_BROWSE_DELETE__PLAYLIST, + {const.ATTR_SOURCE_ID: source_id, const.ATTR_CONTAINER_ID: container_id}, + ) + + @staticmethod + def retrieve_metadata(source_it: int, container_id: str) -> HeosCommand: + """ + Create a HEOS command to retrieve metadata. + + References: + 4.4.17 Retrieve Metadata + """ + return HeosCommand( + command.COMMAND_BROWSE_RETRIEVE_METADATA, + {const.ATTR_SOURCE_ID: source_it, const.ATTR_CONTAINER_ID: container_id}, + ) + + @staticmethod + def set_service_option( + option_id: int, + source_id: int | None, + container_id: str | None, + media_id: str | None, + player_id: int | None, + name: str | None, + criteria_id: int | None, + range_start: int | None = None, + range_end: int | None = None, + ) -> HeosCommand: + """ + Create a HEOS command to set a service option. + + References: + 4.4.19 Set Service Option + """ + params: dict[str, Any] = {const.ATTR_OPTION_ID: option_id} + disallowed_params = {} + + if option_id in ( + const.SERVICE_OPTION_ADD_TRACK_TO_LIBRARY, + const.SERVICE_OPTION_ADD_STATION_TO_LIBRARY, + const.SERVICE_OPTION_REMOVE_TRACK_FROM_LIBRARY, + const.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[const.ATTR_SOURCE_ID] = source_id + params[const.ATTR_MEDIA_ID] = media_id + elif option_id in ( + const.SERVICE_OPTION_ADD_ALBUM_TO_LIBRARY, + const.SERVICE_OPTION_REMOVE_ALBUM_FROM_LIBRARY, + const.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[const.ATTR_SOURCE_ID] = source_id + params[const.ATTR_CONTAINER_ID] = container_id + elif option_id == const.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[const.ATTR_SOURCE_ID] = source_id + params[const.ATTR_CONTAINER_ID] = container_id + params[const.ATTR_NAME] = name + elif option_id in ( + const.SERVICE_OPTION_THUMBS_UP, + const.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[const.ATTR_SOURCE_ID] = source_id + params[const.ATTR_PLAYER_ID] = player_id + elif option_id == const.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[const.ATTR_SOURCE_ID] = source_id + params[const.ATTR_SEARCH_CRITERIA_ID] = criteria_id + params[const.ATTR_NAME] = name + if isinstance(range_start, int) and isinstance(range_end, int): + params[const.ATTR_RANGE] = f"{range_start},{range_end}" + elif option_id == const.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[const.ATTR_PLAYER_ID] = player_id + else: + params[const.ATTR_SOURCE_ID] = source_id + params[const.ATTR_MEDIA_ID] = media_id + params[const.ATTR_NAME] = name + disallowed_params = { + "container_id": container_id, + "criteria_id": criteria_id, + "range_start": range_start, + "range_end": range_end, + } + elif option_id == const.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[const.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}" + ) + + # return the command + return HeosCommand(command.COMMAND_BROWSE_SET_SERVICE_OPTION, params) + + @staticmethod + def multi_search( + search: str, source_ids: list[int] | None, criteria_ids: list[int] | None + ) -> HeosCommand: + """ + 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 = {const.ATTR_SEARCH: search} + if source_ids is not None: + params[const.ATTR_SOURCE_ID] = ",".join(map(str, source_ids)) + if criteria_ids is not None: + params[const.ATTR_SEARCH_CRITERIA_ID] = ",".join(map(str, criteria_ids)) + return HeosCommand(command.COMMAND_BROWSE_MULTI_SEARCH, params) diff --git a/pyheos/command/player.py b/pyheos/command/player.py index 14e5d7c..19a7d5a 100644 --- a/pyheos/command/player.py +++ b/pyheos/command/player.py @@ -217,7 +217,7 @@ def save_queue(player_id: int, name: str) -> HeosCommand: References: 4.2.18 Save Queue as Playlist""" - if len(name) >= 128: + if len(name) > 128: raise ValueError("'name' must be less than or equal to 128 characters") return HeosCommand( command.COMMAND_SAVE_QUEUE, diff --git a/pyheos/connection.py b/pyheos/connection.py index eb47fc6..f1403e5 100644 --- a/pyheos/connection.py +++ b/pyheos/connection.py @@ -15,7 +15,7 @@ STATE_DISCONNECTED, STATE_RECONNECTING, ) -from .error import CommandError, CommandFailedError, HeosError, format_error_message +from .error import CommandError, CommandFailedError, HeosError, _format_error_message CLI_PORT: Final = 1255 SEPARATOR: Final = "\r\n" @@ -145,7 +145,7 @@ 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: @@ -182,7 +182,7 @@ 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) + message = _format_error_message(error) _LOGGER.debug(f"Command failed '{command.uri_masked}': {message}") raise CommandError(command.command, message) from error else: @@ -202,7 +202,7 @@ async def _command_impl() -> HeosMessage: # Occurs when the command times out _LOGGER.debug(f"Command timed out '{command.uri_masked}'") raise CommandError( - command.command, format_error_message(error) + command.command, _format_error_message(error) ) from error finally: self._pending_command_event.clear() @@ -241,7 +241,7 @@ async def connect(self) -> None: asyncio.open_connection(self._host, CLI_PORT), self._timeout ) except (OSError, ConnectionError, asyncio.TimeoutError) as err: - raise HeosError(format_error_message(err)) from err + raise HeosError(_format_error_message(err)) from err # Start read handler self._register_task(self._read_handler(reader)) diff --git a/pyheos/const.py b/pyheos/const.py index 72c0f83..120170c 100644 --- a/pyheos/const.py +++ b/pyheos/const.py @@ -10,8 +10,8 @@ DEFAULT_STEP: Final = 5 ATTR_ADD_CRITERIA_ID: Final = "aid" -ATTR_ALBUM_ID: Final = "album_id" ATTR_ALBUM: Final = "album" +ATTR_ALBUM_ID: Final = "album_id" ATTR_ARTIST: Final = "artist" ATTR_AVAILABLE: Final = "available" ATTR_COMMAND: Final = "command" @@ -24,9 +24,11 @@ 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_IMAGES: Final = "images" ATTR_IMAGE_URL: Final = "image_url" ATTR_INPUT: Final = "input" ATTR_IP_ADDRESS: Final = "ip" @@ -38,6 +40,8 @@ ATTR_MUTE: Final = "mute" ATTR_NAME: Final = "name" ATTR_NETWORK: Final = "network" +ATTR_OPTIONS: Final = "options" +ATTR_OPTION_ID: Final = "option" ATTR_PASSWORD: Final = "pw" ATTR_PAYLOAD: Final = "payload" ATTR_PLAYABLE: Final = "playable" @@ -51,16 +55,19 @@ 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_SIGNED_OUT: Final = "signed_out" -ATTR_SIGNED_IN: Final = "signed_in" ATTR_STATE: Final = "state" +ATTR_STATS: Final = "stats" ATTR_STATION: Final = "station" ATTR_STEP: Final = "step" ATTR_SYSTEM_ERROR_NUMBER: Final = "syserrno" @@ -70,6 +77,9 @@ 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" @@ -355,6 +365,22 @@ class AddCriteriaType(IntEnum): REPLACE_AND_PLAY = 4 +# Service options +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 + + # Signals SIGNAL_PLAYER_EVENT: Final = "player_event" SIGNAL_GROUP_EVENT: Final = "group_event" @@ -364,9 +390,6 @@ class AddCriteriaType(IntEnum): EVENT_DISCONNECTED: Final = "disconnected" EVENT_USER_CREDENTIALS_INVALID: Final = "user credentials invalid" -BASE_URI: Final = "heos://" - - # Events EVENT_PLAYER_STATE_CHANGED: Final = "event/player_state_changed" EVENT_PLAYER_NOW_PLAYING_CHANGED: Final = "event/player_now_playing_changed" diff --git a/pyheos/error.py b/pyheos/error.py index bdd6c31..49fb3aa 100644 --- a/pyheos/error.py +++ b/pyheos/error.py @@ -17,7 +17,7 @@ } -def format_error_message(error: Exception) -> str: +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: diff --git a/pyheos/heos.py b/pyheos/heos.py index c4029c5..404021b 100644 --- a/pyheos/heos.py +++ b/pyheos/heos.py @@ -20,8 +20,15 @@ callback_wrapper, ) from pyheos.error import CommandAuthenticationError, CommandFailedError -from pyheos.media import BrowseResult, MediaItem, MediaMusicSource, QueueItem +from pyheos.media import ( + BrowseResult, + MediaItem, + MediaMusicSource, + QueueItem, + RetreiveMetadataResult, +) from pyheos.message import HeosMessage +from pyheos.search import MultiSearchResult, SearchCriteria, SearchResult from pyheos.system import HeosHost, HeosSystem from . import const @@ -238,6 +245,43 @@ async def get_music_sources( 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 + + if music_source is None or refresh: + # Get the latest information + result = await self._connection.command( + BrowseCommands.get_music_source_info(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 + async def browse( self, source_id: int, @@ -264,7 +308,7 @@ async def browse( message = await self._connection.command( BrowseCommands.browse(source_id, container_id, range_start, range_end) ) - return BrowseResult.from_data(message, cast("Heos", self)) + return BrowseResult._from_message(message, cast("Heos", self)) async def browse_media( self, @@ -298,6 +342,40 @@ async def browse_media( 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.5 Get Search Criteria + """ + result = await self._connection.command( + BrowseCommands.get_search_criteria(source_id) + ) + payload = cast(list[dict[str, str]], result.payload) + return [SearchCriteria._from_data(data) for data in payload] + + async def search( + self, + source_id: int, + search: str, + criteria_id: int, + range_start: int | None = None, + range_end: int | None = None, + ) -> SearchResult: + """ + Create a HEOS command to search for media. + + References: + 4.4.6 Search""" + + result = await self._connection.command( + BrowseCommands.search( + source_id, search, criteria_id, range_start, range_end + ) + ) + return SearchResult._from_message(result, cast("Heos", self)) + async def play_input_source( self, player_id: int, input: str, source_player_id: int | None = None ) -> None: @@ -395,6 +473,75 @@ async def add_to_queue( ) ) + 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 + """ + await self._connection.command( + BrowseCommands.rename_playlist(source_id, container_id, 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( + BrowseCommands.delete_playlist(source_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( + BrowseCommands.retrieve_metadata(source_it, 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 + """ + await this._connection.command( + BrowseCommands.set_service_option( + option_id, + source_id, + container_id, + media_id, + player_id, + name, + criteria_id, + range_start, + range_end, + ) + ) + async def play_media( self, player_id: int, @@ -476,6 +623,23 @@ async def get_playlists(self) -> Sequence[MediaItem]: result = await self.browse(const.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 + """ + result = await self._connection.command( + BrowseCommands.multi_search(search, source_ids, criteria_ids) + ) + return MultiSearchResult._from_message(result, cast("Heos", self)) + class PlayerMixin(ConnectionMixin): """A mixin to provide access to the player commands.""" @@ -542,7 +706,7 @@ async def get_player_info( payload = cast(dict[str, Any], result.payload) if player is None: - player = HeosPlayer.from_data(payload, cast("Heos", self)) + player = HeosPlayer._from_data(payload, cast("Heos", self)) else: player._update_from_data(payload) await player.refresh(refresh_base_info=False) @@ -581,7 +745,7 @@ async def load_players(self) -> dict[str, list | dict]: existing.remove(player) else: # New player - player = HeosPlayer.from_data(player_data, cast("Heos", self)) + 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 @@ -641,7 +805,7 @@ async def get_now_playing_media( PlayerCommands.get_now_playing_media(player_id) ) instance = update or HeosNowPlayingMedia() - instance.update_from_message(result) + instance._update_from_message(result) return instance async def player_get_volume(self, player_id: int) -> int: @@ -705,7 +869,7 @@ async def player_get_play_mode(self, player_id: int) -> PlayMode: References: 4.2.13 Get Play Mode""" result = await self._connection.command(PlayerCommands.get_play_mode(player_id)) - return PlayMode.from_data(result) + return PlayMode._from_data(result) async def player_set_play_mode( self, player_id: int, repeat: const.RepeatType, shuffle: bool @@ -844,7 +1008,7 @@ async def player_check_update(self, player_id: int) -> bool: return bool(payload[const.ATTR_UPDATE] == const.VALUE_UPDATE_EXIST) -class GroupMixin(PlayerMixin): +class GroupMixin(ConnectionMixin): """A mixin to provide access to the group commands.""" def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -1243,7 +1407,9 @@ 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 = 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, player_id, diff --git a/pyheos/media.py b/pyheos/media.py index 9b2dd4b..d642e64 100644 --- a/pyheos/media.py +++ b/pyheos/media.py @@ -80,6 +80,15 @@ def from_data( heos=heos, ) + def _update_from_data(self, data: dict[str, Any]) -> None: + """Update the instance with new data.""" + self.source_id = int(data[const.ATTR_SOURCE_ID]) + self.name = data[const.ATTR_NAME] + self.type = const.MediaType(data[const.ATTR_TYPE]) + self.image_url = data[const.ATTR_IMAGE_URL] + self.available = data[const.ATTR_AVAILABLE] == const.VALUE_TRUE + self.service_username = data.get(const.ATTR_SERVICE_USER_NAME) + def clone(self) -> "MediaMusicSource": """Create a new instance from the current instance.""" return MediaMusicSource( @@ -92,6 +101,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. @@ -193,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[const.ATTR_ID]), name=data[const.ATTR_NAME] + ) + + @dataclass class BrowseResult: """Define the result of a browse operation.""" @@ -201,18 +253,19 @@ 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) - return cls( + return BrowseResult( count=message.get_message_value_int(const.ATTR_COUNT), returned=message.get_message_value_int(const.ATTR_RETURNED), source_id=source_id, @@ -223,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[const.ATTR_IMAGE_URL], + width=int(data[const.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[const.ATTR_ALBUM_ID], + images=[ + ImageMetadata._from_data(cast(dict[str, Any], image)) + for image in data[const.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(const.ATTR_SOURCE_ID), + container_id=message.get_message_value(const.ATTR_CONTAINER_ID), + returned=message.get_message_value_int(const.ATTR_RETURNED), + count=message.get_message_value_int(const.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..b3c6eed 100644 --- a/pyheos/message.py +++ b/pyheos/message.py @@ -8,6 +8,7 @@ from pyheos import const +BASE_URI: Final = "heos://" QUOTE_MAP: Final = {"&": "%26", "=": "%3D", "%": "%25"} MASKED_PARAMS: Final = {const.ATTR_PASSWORD} MASK: Final = "********" @@ -37,7 +38,7 @@ def _get_uri(self, mask: bool = False) -> str: 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: @@ -67,6 +68,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,12 +78,12 @@ 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( + instance = HeosMessage( command=str(heos[const.ATTR_COMMAND]), result=bool( heos.get(const.ATTR_RESULT, const.VALUE_SUCCESS) == const.VALUE_SUCCESS @@ -90,6 +92,7 @@ def from_raw_message(cls, raw_message: str) -> "HeosMessage": parse_qsl(heos.get(const.ATTR_MESSAGE, ""), keep_blank_values=True) ), payload=container.get(const.ATTR_PAYLOAD), + options=container.get(const.ATTR_OPTIONS), ) instance._raw_message = raw_message return instance diff --git a/pyheos/player.py b/pyheos/player.py index 1f0fe29..3004f4d 100644 --- a/pyheos/player.py +++ b/pyheos/player.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any, Optional, cast from pyheos.dispatch import DisconnectType, EventCallbackType, callback_wrapper -from pyheos.media import MediaItem, QueueItem +from pyheos.media import MediaItem, QueueItem, ServiceOption from pyheos.message import HeosMessage from . import const @@ -36,12 +36,15 @@ class HeosNowPlayingMedia: supported_controls: Sequence[str] = field( default_factory=lambda: const.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) @@ -52,13 +55,14 @@ def update_from_message(self, message: HeosMessage) -> None: 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.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.options = ServiceOption._from_options(message.options) self._update_supported_controls() self.clear_progress() @staticmethod - def get_optional_int(value: Any) -> int | None: + def __get_optional_int(value: Any) -> int | None: try: return int(str(value)) except (TypeError, ValueError): @@ -76,7 +80,7 @@ def _update_supported_controls(self) -> None: ) 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( @@ -101,10 +105,10 @@ class PlayMode: repeat: const.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( + return PlayMode( repeat=const.RepeatType(data.get_message_value(const.ATTR_REPEAT)), shuffle=data.get_message_value(const.ATTR_SHUFFLE) == const.VALUE_ON, ) @@ -145,14 +149,13 @@ def __get_optional_int(value: str | None) -> int | None: return int(value) return 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( + return HeosPlayer( name=data[const.ATTR_NAME], player_id=int(data[const.ATTR_PLAYER_ID]), model=data[const.ATTR_MODEL], @@ -177,7 +180,7 @@ def _update_from_data(self, data: dict[str, Any]) -> None: self.line_out = int(data[const.ATTR_LINE_OUT]) self.group_id = HeosPlayer.__get_optional_int(data.get(const.ATTR_GROUP_ID)) - async def on_event(self, event: HeosMessage, all_progress_events: bool) -> bool: + 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. @@ -185,7 +188,7 @@ 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: diff --git a/pyheos/search.py b/pyheos/search.py new file mode 100644 index 0000000..46a89a3 --- /dev/null +++ b/pyheos/search.py @@ -0,0 +1,163 @@ +"""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 const +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[const.ATTR_NAME], + criteria_id=int(data[const.ATTR_SEARCH_CRITERIA_ID]), + wildcard=data[const.ATTR_WILDCARD] == const.VALUE_YES, + container_id=data.get(const.ATTR_CONTAINER_ID), + playable=data.get(const.ATTR_PLAYABLE) == const.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(const.ATTR_SOURCE_ID) + + return SearchResult( + heos=heos, + source_id=source_id, + criteria_id=message.get_message_value_int(const.ATTR_SEARCH_CRITERIA_ID), + search=message.get_message_value(const.ATTR_SEARCH), + returned=message.get_message_value_int(const.ATTR_RETURNED), + count=message.get_message_value_int(const.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(const.ATTR_SOURCE_ID).split(",") + criteria_ids = message.get_message_value(const.ATTR_SEARCH_CRITERIA_ID).split( + "," + ) + statisics = SearchStatistic._from_string( + message.get_message_value(const.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(const.ATTR_SEARCH), + returned=message.get_message_value_int(const.ATTR_RETURNED), + count=message.get_message_value_int(const.ATTR_COUNT), + items=items, + statistics=statisics, + errors=SearchStatistic._from_string( + message.get_message_value(const.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/pyproject.toml b/pyproject.toml index dcfc51b..6f80a6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -365,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 e04b2fb..44e47af 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -473,6 +473,7 @@ async def _get_response(self, response: str, query: dict) -> str: const.ATTR_PLAYER_ID: "{player_id}", const.ATTR_STATE: "{state}", const.ATTR_LEVEL: "{level}", + const.ATTR_OPTIONS: "{options}", } for key, token in keys.items(): value = query.get(key) 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/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/test_heos.py b/tests/test_heos.py index 47a6441..983f70c 100644 --- a/tests/test_heos.py +++ b/tests/test_heos.py @@ -528,9 +528,10 @@ async def test_get_player_info_by_id_already_loaded_refresh(heos: Heos) -> None: ], ) async def test_get_player_info_invalid_parameters_raises( - heos: Heos, player_id: int | None, player: HeosPlayer | None, error: str + 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) @@ -620,6 +621,11 @@ async def test_player_now_playing_changed_event( assert now_playing.queue_id == 1 assert now_playing.source_id == 13 assert now_playing.supported_controls == const.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() @@ -660,6 +666,11 @@ async def handler(player_id: int, event: str) -> 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 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() @@ -1084,10 +1095,10 @@ async def test_browse_media_music_source( 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) @@ -1112,19 +1123,19 @@ 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( @@ -1149,9 +1160,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( @@ -1189,9 +1201,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( @@ -1414,9 +1427,10 @@ async def test_get_group_info_by_id_already_loaded_refresh(heos: Heos) -> None: ], ) async def test_get_group_info_invalid_parameters_raises( - heos: Heos, group_id: int | None, group: HeosGroup | None, error: str + 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) diff --git a/tests/test_heos_browse.py b/tests/test_heos_browse.py new file mode 100644 index 0000000..032dcba --- /dev/null +++ b/tests/test_heos_browse.py @@ -0,0 +1,722 @@ +"""Tests for the browse mixin of the Heos module.""" + +from typing import Any + +import pytest + +from pyheos import const +from pyheos.heos import Heos, HeosOptions +from pyheos.media import MediaMusicSource +from tests import calls_command, value +from tests.common import MediaMusicSources + + +@calls_command("browse.get_source_info", {const.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 == const.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[const.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", + {const.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", {const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_TIDAL} +) +async def test_get_search_criteria(heos: Heos) -> None: + """Test retrieving search criteria.""" + criteria = await heos.get_search_criteria(const.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", + { + const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_TIDAL, + const.ATTR_SEARCH_CRITERIA_ID: 3, + const.ATTR_SEARCH: "Tangerine Rays", + }, +) +async def test_search(heos: Heos) -> None: + """Test the search method.""" + + result = await heos.search(const.MUSIC_SOURCE_TIDAL, "Tangerine Rays", 3) + + assert result.source_id == const.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(const.MUSIC_SOURCE_TIDAL, search, 3) + + +@calls_command( + "browse.search", + { + const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_TIDAL, + const.ATTR_SEARCH_CRITERIA_ID: 3, + const.ATTR_SEARCH: "Tangerine Rays", + const.ATTR_RANGE: "0,14", + }, +) +async def test_search_with_range(heos: Heos) -> None: + """Test the search method.""" + + result = await heos.search( + const.MUSIC_SOURCE_TIDAL, "Tangerine Rays", 3, range_start=0, range_end=14 + ) + + assert result.source_id == const.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", + { + const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_PLAYLISTS, + const.ATTR_CONTAINER_ID: 171566, + const.ATTR_NAME: "New Name", + }, +) +async def test_rename_playlist(heos: Heos) -> None: + """Test renaming a playlist.""" + await heos.rename_playlist(const.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(const.MUSIC_SOURCE_PLAYLISTS, "171566", name) + + +@calls_command( + "browse.delete_playlist", + { + const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_PLAYLISTS, + const.ATTR_CONTAINER_ID: 171566, + }, +) +async def test_delete_playlist(heos: Heos) -> None: + """Test deleting a playlist.""" + await heos.delete_playlist(const.MUSIC_SOURCE_PLAYLISTS, "171566") + + +@calls_command( + "browse.retrieve_metadata", + { + const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_NAPSTER, + const.ATTR_CONTAINER_ID: 123456, + }, +) +async def test_retrieve_metadata(heos: Heos) -> None: + """Test deleting a playlist.""" + result = await heos.retrieve_metadata(const.MUSIC_SOURCE_NAPSTER, "123456") + assert result.source_id == const.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", + { + const.ATTR_OPTION_ID: const.SERVICE_OPTION_ADD_TO_FAVORITES, + const.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(const.SERVICE_OPTION_ADD_TO_FAVORITES, player_id=1) + + +@calls_command( + "browse.set_service_option_add_favorite_browse", + { + const.ATTR_OPTION_ID: const.SERVICE_OPTION_ADD_TO_FAVORITES, + const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_PANDORA, + const.ATTR_MEDIA_ID: 123456, + const.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( + const.SERVICE_OPTION_ADD_TO_FAVORITES, + source_id=const.MUSIC_SOURCE_PANDORA, + media_id="123456", + name="Test Radio", + ) + + +@calls_command( + "browse.set_service_option_remove_favorite", + { + const.ATTR_OPTION_ID: const.SERVICE_OPTION_REMOVE_FROM_FAVORITES, + const.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( + const.SERVICE_OPTION_REMOVE_FROM_FAVORITES, media_id="4277097921440801039" + ) + + +@pytest.mark.parametrize( + "option", [const.SERVICE_OPTION_THUMBS_UP, const.SERVICE_OPTION_THUMBS_DOWN] +) +@calls_command( + "browse.set_service_option_thumbs_up_down", + { + const.ATTR_OPTION_ID: value(arg_name="option"), + const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_PANDORA, + const.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=const.MUSIC_SOURCE_PANDORA, + player_id=1, + ) + + +@pytest.mark.parametrize( + "option", + [ + const.SERVICE_OPTION_ADD_TRACK_TO_LIBRARY, + const.SERVICE_OPTION_ADD_STATION_TO_LIBRARY, + const.SERVICE_OPTION_REMOVE_TRACK_FROM_LIBRARY, + const.SERVICE_OPTION_REMOVE_STATION_FROM_LIBRARY, + ], +) +@calls_command( + "browse.set_service_option_track_station", + { + const.ATTR_OPTION_ID: value(arg_name="option"), + const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_PANDORA, + const.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=const.MUSIC_SOURCE_PANDORA, + media_id="1234", + ) + + +@pytest.mark.parametrize( + "option", + [ + const.SERVICE_OPTION_ADD_ALBUM_TO_LIBRARY, + const.SERVICE_OPTION_REMOVE_ALBUM_FROM_LIBRARY, + const.SERVICE_OPTION_REMOVE_PLAYLIST_FROM_LIBRARY, + ], +) +@calls_command( + "browse.set_service_option_album_remove_playlist", + { + const.ATTR_OPTION_ID: value(arg_name="option"), + const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_PANDORA, + const.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=const.MUSIC_SOURCE_PANDORA, + container_id="1234", + ) + + +@calls_command( + "browse.set_service_option_add_playlist", + { + const.ATTR_OPTION_ID: const.SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, + const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_PANDORA, + const.ATTR_CONTAINER_ID: 1234, + const.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( + const.SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, + source_id=const.MUSIC_SOURCE_PANDORA, + container_id="1234", + name="Test Playlist", + ) + + +@calls_command( + "browse.set_service_option_new_station", + { + const.ATTR_OPTION_ID: const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_PANDORA, + const.ATTR_SEARCH_CRITERIA_ID: 1234, + const.ATTR_NAME: "Test", + const.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( + const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + source_id=const.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": const.SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY}, + "source_id, container_id, and name parameters are required", + ), + ( + { + "option_id": const.SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, + "source_id": 1234, + }, + "source_id, container_id, and name parameters are required", + ), + ( + { + "option_id": const.SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, + "container_id": 1234, + }, + "source_id, container_id, and name parameters are required", + ), + ( + { + "option_id": const.SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, + "name": 1234, + }, + "source_id, container_id, and name parameters are required", + ), + ( + { + "option_id": const.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": const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA}, + "source_id, name, and criteria_id parameters are required", + ), + ( + { + "option_id": const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + "source_id": 1234, + }, + "source_id, name, and criteria_id parameters are required", + ), + ( + { + "option_id": const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + "name": 1234, + }, + "source_id, name, and criteria_id parameters are required", + ), + ( + { + "option_id": const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + "criteria_id": 1234, + }, + "source_id, name, and criteria_id parameters are required", + ), + ( + { + "option_id": const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + "criteria_id": 1234, + "name": 1234, + }, + "source_id, name, and criteria_id parameters are required", + ), + ( + { + "option_id": const.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": const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + "name": 1234, + "source_id": 1234, + }, + "source_id, name, and criteria_id parameters are required", + ), + ( + { + "option_id": const.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": const.SERVICE_OPTION_REMOVE_FROM_FAVORITES}, + "media_id parameter is required", + ), + ( + { + "option_id": const.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", + [ + const.SERVICE_OPTION_ADD_TRACK_TO_LIBRARY, + const.SERVICE_OPTION_ADD_STATION_TO_LIBRARY, + const.SERVICE_OPTION_REMOVE_TRACK_FROM_LIBRARY, + const.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", + [ + const.SERVICE_OPTION_ADD_ALBUM_TO_LIBRARY, + const.SERVICE_OPTION_REMOVE_ALBUM_FROM_LIBRARY, + const.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", + [ + const.SERVICE_OPTION_THUMBS_UP, + const.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=const.SERVICE_OPTION_ADD_TO_FAVORITES, **kwargs + ) + + +@calls_command( + "browse.multi_search", + { + const.ATTR_SEARCH: "Tangerine Rays", + const.ATTR_SOURCE_ID: "1,4,8,13,10", + const.ATTR_SEARCH_CRITERIA_ID: "0,1,2,3", + }, +) +async def test_multi_search(heos: Heos) -> None: + """Test the multi-search command.""" + 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 command.""" + 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_media.py b/tests/test_media.py index 7c75875..9077fb3 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -10,7 +10,7 @@ from pyheos.media import BrowseResult, MediaItem, MediaMusicSource from pyheos.message import HeosMessage 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: @@ -50,6 +50,13 @@ async def test_media_music_source_browse( assert result.returned == 3 assert result.source_id == const.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 @@ -72,7 +79,7 @@ async def test_browse_result_from_data() -> None: ], ) - result = BrowseResult.from_data(message, heos) + result = BrowseResult._from_message(message, heos) assert result.returned == 1 assert result.count == 1 @@ -205,6 +212,24 @@ async def test_media_item_browse(media_item_device: MediaItem) -> None: assert len(result.items) == 8 +@calls_command( + "browse.get_source_info", + {const.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 == const.MediaType.MUSIC_SERVICE + assert media_music_source.available + assert media_music_source.service_username == "email@email.com" + + @calls_command( "browse.add_to_queue_track", { diff --git a/tests/test_player.py b/tests/test_player.py index 992633f..b278567 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -23,7 +23,7 @@ def test_from_data() -> None: const.ATTR_LINE_OUT: 1, const.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 @@ -440,6 +440,7 @@ 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( From 5de82865fb65aa380ddd60dc80716162e3ad848d Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Fri, 10 Jan 2025 14:15:53 -0600 Subject: [PATCH 16/25] Refactor consts (#71) * Add ConnectionStateType * Move PlayState * Refactor RepeatType * Update consts * Add NetworkType enum * Add types module and move AddQueueOption * More refactoring * Move more consts * Refactor control groups * Refactor * Create enums for signals and events * Describe event handler types * Add add_on_group_event * Move ATTR and VALUE constants * Refactor command import overlap * Move ConnectionState to types * Move MediaType * Move error string logic * Sort exports * Fix function name --- pyheos/__init__.py | 36 +++- pyheos/command/__init__.py | 167 +++++++++++++--- pyheos/command/browse.py | 182 ++++++++++-------- pyheos/command/group.py | 44 ++--- pyheos/command/player.py | 125 ++++++------ pyheos/command/system.py | 28 +-- pyheos/connection.py | 43 +++-- pyheos/const.py | 244 +++--------------------- pyheos/dispatch.py | 13 +- pyheos/error.py | 51 ++--- pyheos/group.py | 49 +++-- pyheos/heos.py | 102 +++++----- pyheos/media.py | 96 +++++----- pyheos/message.py | 20 +- pyheos/player.py | 188 +++++++++++------- pyheos/search.py | 38 ++-- pyheos/system.py | 19 +- pyheos/types.py | 85 +++++++++ tests/__init__.py | 44 ++--- tests/common.py | 19 +- tests/conftest.py | 6 +- tests/test_group.py | 73 ++++--- tests/test_heos.py | 366 ++++++++++++++++++++---------------- tests/test_heos_browse.py | 235 ++++++++++++----------- tests/test_heos_callback.py | 34 ++-- tests/test_media.py | 146 +++++++------- tests/test_message.py | 4 +- tests/test_player.py | 234 ++++++++++++----------- 28 files changed, 1461 insertions(+), 1230 deletions(-) create mode 100644 pyheos/types.py diff --git a/pyheos/__init__.py b/pyheos/__init__.py index 417f070..5d09cac 100644 --- a/pyheos/__init__.py +++ b/pyheos/__init__.py @@ -9,6 +9,7 @@ DisconnectType, Dispatcher, EventCallbackType, + GroupEventCallbackType, PlayerEventCallbackType, SendType, TargetType, @@ -32,17 +33,39 @@ RetreiveMetadataResult, ServiceOption, ) -from .player import HeosNowPlayingMedia, HeosPlayer, PlayMode +from .player import ( + CONTROLS_ALL, + CONTROLS_FORWARD_ONLY, + CONTROLS_PLAY_STOP, + HeosNowPlayingMedia, + HeosPlayer, + PlayMode, +) from .search import MultiSearchResult, SearchCriteria, SearchResult, SearchStatistic from .system import HeosHost, HeosSystem +from .types import ( + AddCriteriaType, + ConnectionState, + MediaType, + NetworkType, + PlayState, + RepeatType, + SignalHeosEvent, + SignalType, +) __all__ = [ + "AddCriteriaType", "AlbumMetadata", "BrowseResult", "CallbackType", "CommandAuthenticationError", "CommandError", "CommandFailedError", + "CONTROLS_ALL", + "CONTROLS_FORWARD_ONLY", + "CONTROLS_PLAY_STOP", + "ConnectionState", "ConnectType", "const", "ControllerEventCallbackType", @@ -50,6 +73,7 @@ "DisconnectType", "Dispatcher", "EventCallbackType", + "GroupEventCallbackType", "Heos", "HeosError", "HeosGroup", @@ -62,15 +86,21 @@ "Media", "MediaItem", "MediaMusicSource", + "MediaType", "MultiSearchResult", - "QueueItem", - "ServiceOption", + "NetworkType", "PlayMode", + "PlayState", "PlayerEventCallbackType", + "QueueItem", + "RepeatType", "RetreiveMetadataResult", "SearchCriteria", "SearchResult", "SearchStatistic", "SendType", + "ServiceOption", + "SignalHeosEvent", + "SignalType", "TargetType", ] diff --git a/pyheos/command/__init__.py b/pyheos/command/__init__.py index 0c140cc..b5f162b 100644 --- a/pyheos/command/__init__.py +++ b/pyheos/command/__init__.py @@ -1,6 +1,94 @@ """Define the HEOS command module.""" -from typing import Final +import logging +from enum import StrEnum +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_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_ADD_TO_QUEUE: Final = "browse/add_to_queue" @@ -19,49 +107,74 @@ COMMAND_BROWSE_SET_SERVICE_OPTION: Final = "browse/set_service_option" # Player commands -COMMAND_GET_PLAYERS: Final = "player/get_players" -COMMAND_GET_PLAYER_INFO: Final = "player/get_player_info" -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_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_REMOVE_FROM_QUEUE: Final = "player/remove_from_queue" -COMMAND_CLEAR_QUEUE: Final = "player/clear_queue" -COMMAND_PLAY_QUEUE: Final = "player/play_queue" -COMMAND_SAVE_QUEUE: Final = "player/save_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_CHECK_UPDATE: Final = "player/check_update" +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_GET_GROUP_INFO: Final = "group/get_group_info" -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_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__) + +TStrEnum = TypeVar("TStrEnum", bound=StrEnum) + + +def parse_enum( + key: str, data: dict[str, Any], enum_type: type[TStrEnum], default: TStrEnum +) -> TStrEnum: + """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 b92e8f5..a36eb8a 100644 --- a/pyheos/command/browse.py +++ b/pyheos/command/browse.py @@ -11,8 +11,24 @@ from typing import Any -from pyheos import command, const +from pyheos import command as c +from pyheos.const import ( + 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.message import HeosCommand +from pyheos.types import AddCriteriaType class BrowseCommands: @@ -33,12 +49,12 @@ def browse( 4.4.13 Get HEOS Playlists 4.4.16 Get HEOS History """ - 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}" + return HeosCommand(c.COMMAND_BROWSE_BROWSE, params) @staticmethod def get_music_sources(refresh: bool = False) -> HeosCommand: @@ -50,8 +66,8 @@ def get_music_sources(refresh: bool = False) -> HeosCommand: """ params = {} if refresh: - params[const.ATTR_REFRESH] = const.VALUE_ON - return HeosCommand(command.COMMAND_BROWSE_GET_SOURCES, params) + params[c.ATTR_REFRESH] = c.VALUE_ON + return HeosCommand(c.COMMAND_BROWSE_GET_SOURCES, params) @staticmethod def get_music_source_info(source_id: int) -> HeosCommand: @@ -62,7 +78,7 @@ def get_music_source_info(source_id: int) -> HeosCommand: 4.4.2 Get Source Info """ return HeosCommand( - command.COMMAND_BROWSE_GET_SOURCE_INFO, {const.ATTR_SOURCE_ID: source_id} + c.COMMAND_BROWSE_GET_SOURCE_INFO, {c.ATTR_SOURCE_ID: source_id} ) @staticmethod @@ -74,8 +90,8 @@ def get_search_criteria(source_id: int) -> HeosCommand: 4.4.5 Get Search Criteria """ return HeosCommand( - command.COMMAND_BROWSE_GET_SEARCH_CRITERIA, - {const.ATTR_SOURCE_ID: source_id}, + c.COMMAND_BROWSE_GET_SEARCH_CRITERIA, + {c.ATTR_SOURCE_ID: source_id}, ) @staticmethod @@ -99,13 +115,13 @@ def search( "'search' parameter must be less than or equal to 128 characters" ) params = { - const.ATTR_SOURCE_ID: source_id, - const.ATTR_SEARCH: search, - const.ATTR_SEARCH_CRITERIA_ID: criteria_id, + 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[const.ATTR_RANGE] = f"{range_start},{range_end}" - return HeosCommand(command.COMMAND_BROWSE_SEARCH, params) + params[c.ATTR_RANGE] = f"{range_start},{range_end}" + return HeosCommand(c.COMMAND_BROWSE_SEARCH, params) @staticmethod def play_station( @@ -123,13 +139,13 @@ def play_station( Note: Parameters 'cid' and 'name' do not appear to be required in testing, however send 'cid' if provided. """ 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_SOURCE_ID: source_id, + c.ATTR_MEDIA_ID: media_id, } if container_id is not None: - params[const.ATTR_CONTAINER_ID] = container_id - return HeosCommand(command.COMMAND_BROWSE_PLAY_STREAM, params) + params[c.ATTR_CONTAINER_ID] = container_id + return HeosCommand(c.COMMAND_BROWSE_PLAY_STREAM, params) @staticmethod def play_preset_station(player_id: int, preset: int) -> HeosCommand: @@ -142,8 +158,8 @@ def play_preset_station(player_id: int, preset: int) -> HeosCommand: 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}, + c.COMMAND_BROWSE_PLAY_PRESET, + {c.ATTR_PLAYER_ID: player_id, c.ATTR_PRESET: preset}, ) @staticmethod @@ -157,12 +173,12 @@ def play_input_source( 4.4.9 Play Input Source """ params = { - const.ATTR_PLAYER_ID: player_id, - const.ATTR_INPUT: input_name, + c.ATTR_PLAYER_ID: player_id, + c.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) + params[c.ATTR_SOURCE_PLAYER_ID] = source_player_id + return HeosCommand(c.COMMAND_BROWSE_PLAY_INPUT, params) @staticmethod def play_url(player_id: int, url: str) -> HeosCommand: @@ -173,8 +189,8 @@ def play_url(player_id: int, url: str) -> HeosCommand: 4.4.10 Play URL """ return HeosCommand( - command.COMMAND_BROWSE_PLAY_STREAM, - {const.ATTR_PLAYER_ID: player_id, const.ATTR_URL: url}, + c.COMMAND_BROWSE_PLAY_STREAM, + {c.ATTR_PLAYER_ID: player_id, c.ATTR_URL: url}, ) @staticmethod @@ -183,7 +199,7 @@ 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, ) -> HeosCommand: """ Create a HEOS command to add the specified media to the queue. @@ -193,14 +209,14 @@ def add_to_queue( 4.4.12 Add Track to Queue with Options """ 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 + return HeosCommand(c.COMMAND_BROWSE_ADD_TO_QUEUE, params) @staticmethod def rename_playlist( @@ -219,11 +235,11 @@ def rename_playlist( "'new_name' parameter must be less than or equal to 128 characters" ) return HeosCommand( - command.COMMAND_BROWSE_RENAME_PLAYLIST, + c.COMMAND_BROWSE_RENAME_PLAYLIST, { - const.ATTR_SOURCE_ID: source_id, - const.ATTR_CONTAINER_ID: container_id, - const.ATTR_NAME: new_name, + c.ATTR_SOURCE_ID: source_id, + c.ATTR_CONTAINER_ID: container_id, + c.ATTR_NAME: new_name, }, ) @@ -235,8 +251,11 @@ def delete_playlist(source_id: int, container_id: str) -> HeosCommand: References: 4.4.15 Delete HEOS Playlist""" return HeosCommand( - command.COMMAND_BROWSE_DELETE__PLAYLIST, - {const.ATTR_SOURCE_ID: source_id, const.ATTR_CONTAINER_ID: container_id}, + c.COMMAND_BROWSE_DELETE__PLAYLIST, + { + c.ATTR_SOURCE_ID: source_id, + c.ATTR_CONTAINER_ID: container_id, + }, ) @staticmethod @@ -248,8 +267,11 @@ def retrieve_metadata(source_it: int, container_id: str) -> HeosCommand: 4.4.17 Retrieve Metadata """ return HeosCommand( - command.COMMAND_BROWSE_RETRIEVE_METADATA, - {const.ATTR_SOURCE_ID: source_it, const.ATTR_CONTAINER_ID: container_id}, + c.COMMAND_BROWSE_RETRIEVE_METADATA, + { + c.ATTR_SOURCE_ID: source_it, + c.ATTR_CONTAINER_ID: container_id, + }, ) @staticmethod @@ -270,14 +292,14 @@ def set_service_option( References: 4.4.19 Set Service Option """ - params: dict[str, Any] = {const.ATTR_OPTION_ID: option_id} + params: dict[str, Any] = {c.ATTR_OPTION_ID: option_id} disallowed_params = {} if option_id in ( - const.SERVICE_OPTION_ADD_TRACK_TO_LIBRARY, - const.SERVICE_OPTION_ADD_STATION_TO_LIBRARY, - const.SERVICE_OPTION_REMOVE_TRACK_FROM_LIBRARY, - const.SERVICE_OPTION_REMOVE_STATION_FROM_LIBRARY, + 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( @@ -291,12 +313,12 @@ def set_service_option( "range_start": range_start, "range_end": range_end, } - params[const.ATTR_SOURCE_ID] = source_id - params[const.ATTR_MEDIA_ID] = media_id + params[c.ATTR_SOURCE_ID] = source_id + params[c.ATTR_MEDIA_ID] = media_id elif option_id in ( - const.SERVICE_OPTION_ADD_ALBUM_TO_LIBRARY, - const.SERVICE_OPTION_REMOVE_ALBUM_FROM_LIBRARY, - const.SERVICE_OPTION_REMOVE_PLAYLIST_FROM_LIBRARY, + 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( @@ -310,9 +332,9 @@ def set_service_option( "range_start": range_start, "range_end": range_end, } - params[const.ATTR_SOURCE_ID] = source_id - params[const.ATTR_CONTAINER_ID] = container_id - elif option_id == const.SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY: + 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}" @@ -324,12 +346,12 @@ def set_service_option( "range_start": range_start, "range_end": range_end, } - params[const.ATTR_SOURCE_ID] = source_id - params[const.ATTR_CONTAINER_ID] = container_id - params[const.ATTR_NAME] = name + params[c.ATTR_SOURCE_ID] = source_id + params[c.ATTR_CONTAINER_ID] = container_id + params[c.ATTR_NAME] = name elif option_id in ( - const.SERVICE_OPTION_THUMBS_UP, - const.SERVICE_OPTION_THUMBS_DOWN, + SERVICE_OPTION_THUMBS_UP, + SERVICE_OPTION_THUMBS_DOWN, ): if source_id is None or player_id is None: raise ValueError( @@ -343,9 +365,9 @@ def set_service_option( "range_start": range_start, "range_end": range_end, } - params[const.ATTR_SOURCE_ID] = source_id - params[const.ATTR_PLAYER_ID] = player_id - elif option_id == const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA: + 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}" @@ -355,12 +377,12 @@ def set_service_option( "container_id": container_id, "player_id": player_id, } - params[const.ATTR_SOURCE_ID] = source_id - params[const.ATTR_SEARCH_CRITERIA_ID] = criteria_id - params[const.ATTR_NAME] = name + 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[const.ATTR_RANGE] = f"{range_start},{range_end}" - elif option_id == const.SERVICE_OPTION_ADD_TO_FAVORITES: + 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 ): @@ -372,23 +394,23 @@ def set_service_option( raise ValueError( f"source_id, media_id, and name parameters are not allowed when using player_id for service option_id {option_id}" ) - params[const.ATTR_PLAYER_ID] = player_id + params[c.ATTR_PLAYER_ID] = player_id else: - params[const.ATTR_SOURCE_ID] = source_id - params[const.ATTR_MEDIA_ID] = media_id - params[const.ATTR_NAME] = name + 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 == const.SERVICE_OPTION_REMOVE_FROM_FAVORITES: + 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[const.ATTR_MEDIA_ID] = media_id + params[c.ATTR_MEDIA_ID] = media_id disallowed_params = { "source_id": source_id, "player_id": player_id, @@ -408,7 +430,7 @@ def set_service_option( ) # return the command - return HeosCommand(command.COMMAND_BROWSE_SET_SERVICE_OPTION, params) + return HeosCommand(c.COMMAND_BROWSE_SET_SERVICE_OPTION, params) @staticmethod def multi_search( @@ -424,9 +446,9 @@ def multi_search( raise ValueError( "'search' parameter must be less than or equal to 128 characters" ) - params = {const.ATTR_SEARCH: search} + params = {c.ATTR_SEARCH: search} if source_ids is not None: - params[const.ATTR_SOURCE_ID] = ",".join(map(str, source_ids)) + params[c.ATTR_SOURCE_ID] = ",".join(map(str, source_ids)) if criteria_ids is not None: - params[const.ATTR_SEARCH_CRITERIA_ID] = ",".join(map(str, criteria_ids)) - return HeosCommand(command.COMMAND_BROWSE_MULTI_SEARCH, params) + params[c.ATTR_SEARCH_CRITERIA_ID] = ",".join(map(str, criteria_ids)) + return HeosCommand(c.COMMAND_BROWSE_MULTI_SEARCH, params) diff --git a/pyheos/command/group.py b/pyheos/command/group.py index da992e1..81a0ce1 100644 --- a/pyheos/command/group.py +++ b/pyheos/command/group.py @@ -6,7 +6,7 @@ from collections.abc import Sequence -from pyheos import command, const +from pyheos import command as c from pyheos.message import HeosCommand @@ -15,11 +15,11 @@ class GroupCommands: @staticmethod def get_groups() -> HeosCommand: - """Create a get groups command. + """Create a get groups c. References: 4.3.1 Get Groups""" - return HeosCommand(command.COMMAND_GET_GROUPS) + return HeosCommand(c.COMMAND_GET_GROUPS) @staticmethod def get_group_info(group_id: int) -> HeosCommand: @@ -27,9 +27,7 @@ def get_group_info(group_id: int) -> HeosCommand: References: 4.3.2 Get Group Info""" - return HeosCommand( - command.COMMAND_GET_GROUP_INFO, {const.ATTR_GROUP_ID: group_id} - ) + return HeosCommand(c.COMMAND_GET_GROUP_INFO, {c.ATTR_GROUP_ID: group_id}) @staticmethod def set_group(player_ids: Sequence[int]) -> HeosCommand: @@ -38,8 +36,8 @@ def set_group(player_ids: Sequence[int]) -> HeosCommand: References: 4.3.3 Set Group""" return HeosCommand( - command.COMMAND_SET_GROUP, - {const.ATTR_PLAYER_ID: ",".join(map(str, player_ids))}, + c.COMMAND_SET_GROUP, + {c.ATTR_PLAYER_ID: ",".join(map(str, player_ids))}, ) @staticmethod @@ -50,9 +48,7 @@ def get_group_volume(group_id: int) -> HeosCommand: References: 4.3.4 Get Group Volume """ - return HeosCommand( - command.COMMAND_GET_GROUP_VOLUME, {const.ATTR_GROUP_ID: group_id} - ) + return HeosCommand(c.COMMAND_GET_GROUP_VOLUME, {c.ATTR_GROUP_ID: group_id}) @staticmethod def set_group_volume(group_id: int, level: int) -> HeosCommand: @@ -63,8 +59,8 @@ def set_group_volume(group_id: int, level: int) -> HeosCommand: 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}, + c.COMMAND_SET_GROUP_VOLUME, + {c.ATTR_GROUP_ID: group_id, c.ATTR_LEVEL: level}, ) @staticmethod @@ -76,8 +72,8 @@ def group_volume_up(group_id: int, step: int) -> HeosCommand: 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}, + c.COMMAND_GROUP_VOLUME_UP, + {c.ATTR_GROUP_ID: group_id, c.ATTR_STEP: step}, ) @staticmethod @@ -89,8 +85,8 @@ def group_volume_down(group_id: int, step: int) -> HeosCommand: 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}, + c.COMMAND_GROUP_VOLUME_DOWN, + {c.ATTR_GROUP_ID: group_id, c.ATTR_STEP: step}, ) @staticmethod @@ -99,9 +95,7 @@ def get_group_mute(group_id: int) -> HeosCommand: References: 4.3.8 Get Group Mute""" - return HeosCommand( - command.COMMAND_GET_GROUP_MUTE, {const.ATTR_GROUP_ID: group_id} - ) + return HeosCommand(c.COMMAND_GET_GROUP_MUTE, {c.ATTR_GROUP_ID: group_id}) @staticmethod def group_set_mute(group_id: int, state: bool) -> HeosCommand: @@ -110,10 +104,10 @@ def group_set_mute(group_id: int, state: bool) -> HeosCommand: References: 4.3.9 Set Group Mute""" return HeosCommand( - command.COMMAND_SET_GROUP_MUTE, + c.COMMAND_SET_GROUP_MUTE, { - const.ATTR_GROUP_ID: group_id, - const.ATTR_STATE: const.VALUE_ON if state else const.VALUE_OFF, + c.ATTR_GROUP_ID: group_id, + c.ATTR_STATE: c.VALUE_ON if state else c.VALUE_OFF, }, ) @@ -123,6 +117,4 @@ def group_toggle_mute(group_id: int) -> HeosCommand: References: 4.3.10 Toggle Group Mute""" - return HeosCommand( - command.COMMAND_GROUP_TOGGLE_MUTE, {const.ATTR_GROUP_ID: group_id} - ) + return 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 19a7d5a..ac75ce5 100644 --- a/pyheos/command/player.py +++ b/pyheos/command/player.py @@ -6,8 +6,11 @@ from typing import Any -from pyheos import command, const +from pyheos import command as c +from pyheos.const import DEFAULT_STEP from pyheos.message import HeosCommand +from pyheos.player import PlayState +from pyheos.types import RepeatType class PlayerCommands: @@ -21,7 +24,7 @@ def get_players() -> HeosCommand: References: 4.2.1 Get Players """ - return HeosCommand(command.COMMAND_GET_PLAYERS) + return HeosCommand(c.COMMAND_GET_PLAYERS) @staticmethod def get_player_info(player_id: int) -> HeosCommand: @@ -29,9 +32,7 @@ def get_player_info(player_id: int) -> HeosCommand: References: 4.2.2 Get Player Info""" - return HeosCommand( - command.COMMAND_GET_PLAYER_INFO, {const.ATTR_PLAYER_ID: player_id} - ) + return HeosCommand(c.COMMAND_GET_PLAYER_INFO, {c.ATTR_PLAYER_ID: player_id}) @staticmethod def get_play_state(player_id: int) -> HeosCommand: @@ -39,19 +40,17 @@ def get_play_state(player_id: int) -> HeosCommand: References: 4.2.3 Get Play State""" - return HeosCommand( - command.COMMAND_GET_PLAY_STATE, {const.ATTR_PLAYER_ID: player_id} - ) + return HeosCommand(c.COMMAND_GET_PLAY_STATE, {c.ATTR_PLAYER_ID: player_id}) @staticmethod - def set_play_state(player_id: int, state: const.PlayState) -> HeosCommand: + def set_play_state(player_id: int, state: PlayState) -> HeosCommand: """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}, + c.COMMAND_SET_PLAY_STATE, + {c.ATTR_PLAYER_ID: player_id, c.ATTR_STATE: state}, ) @staticmethod @@ -61,7 +60,7 @@ def get_now_playing_media(player_id: int) -> HeosCommand: References: 4.2.5 Get Now Playing Media""" return HeosCommand( - command.COMMAND_GET_NOW_PLAYING_MEDIA, {const.ATTR_PLAYER_ID: player_id} + c.COMMAND_GET_NOW_PLAYING_MEDIA, {c.ATTR_PLAYER_ID: player_id} ) @staticmethod @@ -70,9 +69,7 @@ def get_volume(player_id: int) -> HeosCommand: References: 4.2.6 Get Volume""" - return HeosCommand( - command.COMMAND_GET_VOLUME, {const.ATTR_PLAYER_ID: player_id} - ) + return HeosCommand(c.COMMAND_GET_VOLUME, {c.ATTR_PLAYER_ID: player_id}) @staticmethod def set_volume(player_id: int, level: int) -> HeosCommand: @@ -83,12 +80,12 @@ def set_volume(player_id: int, level: int) -> HeosCommand: 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}, + 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: + def volume_up(player_id: int, step: int = DEFAULT_STEP) -> HeosCommand: """Increase the volume level. References: @@ -96,12 +93,12 @@ def volume_up(player_id: int, step: int = const.DEFAULT_STEP) -> HeosCommand: 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}, + 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: + def volume_down(player_id: int, step: int = DEFAULT_STEP) -> HeosCommand: """Increase the volume level. References: @@ -109,8 +106,8 @@ def volume_down(player_id: int, step: int = const.DEFAULT_STEP) -> HeosCommand: 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}, + c.COMMAND_VOLUME_DOWN, + {c.ATTR_PLAYER_ID: player_id, c.ATTR_STEP: step}, ) @staticmethod @@ -119,7 +116,7 @@ def get_mute(player_id: int) -> HeosCommand: References: 4.2.10 Get Mute""" - return HeosCommand(command.COMMAND_GET_MUTE, {const.ATTR_PLAYER_ID: player_id}) + return HeosCommand(c.COMMAND_GET_MUTE, {c.ATTR_PLAYER_ID: player_id}) @staticmethod def set_mute(player_id: int, state: bool) -> HeosCommand: @@ -128,10 +125,10 @@ def set_mute(player_id: int, state: bool) -> HeosCommand: References: 4.2.11 Set Mute""" return HeosCommand( - command.COMMAND_SET_MUTE, + c.COMMAND_SET_MUTE, { - const.ATTR_PLAYER_ID: player_id, - const.ATTR_STATE: const.VALUE_ON if state else const.VALUE_OFF, + c.ATTR_PLAYER_ID: player_id, + c.ATTR_STATE: c.VALUE_ON if state else c.VALUE_OFF, }, ) @@ -141,9 +138,7 @@ def toggle_mute(player_id: int) -> HeosCommand: References: 4.2.12 Toggle Mute""" - return HeosCommand( - command.COMMAND_TOGGLE_MUTE, {const.ATTR_PLAYER_ID: player_id} - ) + return HeosCommand(c.COMMAND_TOGGLE_MUTE, {c.ATTR_PLAYER_ID: player_id}) @staticmethod def get_play_mode(player_id: int) -> HeosCommand: @@ -151,24 +146,20 @@ def get_play_mode(player_id: int) -> HeosCommand: References: 4.2.13 Get Play Mode""" - return HeosCommand( - command.COMMAND_GET_PLAY_MODE, {const.ATTR_PLAYER_ID: player_id} - ) + return HeosCommand(c.COMMAND_GET_PLAY_MODE, {c.ATTR_PLAYER_ID: player_id}) @staticmethod - def set_play_mode( - player_id: int, repeat: const.RepeatType, shuffle: bool - ) -> HeosCommand: + def set_play_mode(player_id: int, repeat: RepeatType, shuffle: bool) -> HeosCommand: """Set the current play mode. References: 4.2.14 Set Play Mode""" return HeosCommand( - command.COMMAND_SET_PLAY_MODE, + c.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, + c.ATTR_PLAYER_ID: player_id, + c.ATTR_REPEAT: repeat, + c.ATTR_SHUFFLE: c.VALUE_ON if shuffle else c.VALUE_OFF, }, ) @@ -181,10 +172,10 @@ def get_queue( References: 4.2.15 Get Queue """ - params: dict[str, Any] = {const.ATTR_PLAYER_ID: player_id} + params: dict[str, Any] = {c.ATTR_PLAYER_ID: player_id} if isinstance(range_start, int) and isinstance(range_end, int): - params[const.ATTR_RANGE] = f"{range_start},{range_end}" - return HeosCommand(command.COMMAND_GET_QUEUE, params) + params[c.ATTR_RANGE] = f"{range_start},{range_end}" + return HeosCommand(c.COMMAND_GET_QUEUE, params) @staticmethod def play_queue(player_id: int, queue_id: int) -> HeosCommand: @@ -193,8 +184,8 @@ def play_queue(player_id: int, queue_id: int) -> HeosCommand: References: 4.2.16 Play Queue Item""" return HeosCommand( - command.COMMAND_PLAY_QUEUE, - {const.ATTR_PLAYER_ID: player_id, const.ATTR_QUEUE_ID: queue_id}, + c.COMMAND_PLAY_QUEUE, + {c.ATTR_PLAYER_ID: player_id, c.ATTR_QUEUE_ID: queue_id}, ) @staticmethod @@ -204,10 +195,10 @@ def remove_from_queue(player_id: int, queue_ids: list[int]) -> HeosCommand: References: 4.2.17 Remove Item(s) from Queue""" return HeosCommand( - command.COMMAND_REMOVE_FROM_QUEUE, + c.COMMAND_REMOVE_FROM_QUEUE, { - const.ATTR_PLAYER_ID: player_id, - const.ATTR_QUEUE_ID: ",".join(map(str, queue_ids)), + c.ATTR_PLAYER_ID: player_id, + c.ATTR_QUEUE_ID: ",".join(map(str, queue_ids)), }, ) @@ -220,8 +211,8 @@ def save_queue(player_id: int, name: str) -> HeosCommand: if len(name) > 128: raise ValueError("'name' must be less than or equal to 128 characters") return HeosCommand( - command.COMMAND_SAVE_QUEUE, - {const.ATTR_PLAYER_ID: player_id, const.ATTR_NAME: name}, + c.COMMAND_SAVE_QUEUE, + {c.ATTR_PLAYER_ID: player_id, c.ATTR_NAME: name}, ) @staticmethod @@ -230,9 +221,7 @@ def clear_queue(player_id: int) -> HeosCommand: References: 4.2.19 Clear Queue""" - return HeosCommand( - command.COMMAND_CLEAR_QUEUE, {const.ATTR_PLAYER_ID: player_id} - ) + return HeosCommand(c.COMMAND_CLEAR_QUEUE, {c.ATTR_PLAYER_ID: player_id}) @staticmethod def move_queue_item( @@ -243,11 +232,11 @@ def move_queue_item( References: 4.2.20 Move Queue""" return HeosCommand( - command.COMMAND_MOVE_QUEUE_ITEM, + c.COMMAND_MOVE_QUEUE_ITEM, { - const.ATTR_PLAYER_ID: player_id, - const.ATTR_SOURCE_QUEUE_ID: ",".join(map(str, source_queue_ids)), - const.ATTR_DESTINATION_QUEUE_ID: destination_queue_id, + 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, }, ) @@ -257,7 +246,7 @@ def play_next(player_id: int) -> HeosCommand: References: 4.2.21 Play Next""" - return HeosCommand(command.COMMAND_PLAY_NEXT, {const.ATTR_PLAYER_ID: player_id}) + return HeosCommand(c.COMMAND_PLAY_NEXT, {c.ATTR_PLAYER_ID: player_id}) @staticmethod def play_previous(player_id: int) -> HeosCommand: @@ -265,9 +254,7 @@ def play_previous(player_id: int) -> HeosCommand: References: 4.2.22 Play Previous""" - return HeosCommand( - command.COMMAND_PLAY_PREVIOUS, {const.ATTR_PLAYER_ID: player_id} - ) + return HeosCommand(c.COMMAND_PLAY_PREVIOUS, {c.ATTR_PLAYER_ID: player_id}) @staticmethod def set_quick_select(player_id: int, quick_select_id: int) -> HeosCommand: @@ -278,8 +265,8 @@ def set_quick_select(player_id: int, quick_select_id: int) -> HeosCommand: 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}, + c.COMMAND_SET_QUICK_SELECT, + {c.ATTR_PLAYER_ID: player_id, c.ATTR_ID: quick_select_id}, ) @staticmethod @@ -291,8 +278,8 @@ def play_quick_select(player_id: int, quick_select_id: int) -> HeosCommand: 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}, + c.COMMAND_PLAY_QUICK_SELECT, + {c.ATTR_PLAYER_ID: player_id, c.ATTR_ID: quick_select_id}, ) @staticmethod @@ -301,9 +288,7 @@ def get_quick_selects(player_id: int) -> HeosCommand: References: 4.2.25 Get QuickSelects""" - return HeosCommand( - command.COMMAND_GET_QUICK_SELECTS, {const.ATTR_PLAYER_ID: player_id} - ) + return HeosCommand(c.COMMAND_GET_QUICK_SELECTS, {c.ATTR_PLAYER_ID: player_id}) @staticmethod def check_update(player_id: int) -> HeosCommand: @@ -311,6 +296,4 @@ def check_update(player_id: int) -> HeosCommand: References: 4.2.26 Check for Firmware Update""" - return HeosCommand( - command.COMMAND_CHECK_UPDATE, {const.ATTR_PLAYER_ID: player_id} - ) + return HeosCommand(c.COMMAND_CHECK_UPDATE, {c.ATTR_PLAYER_ID: player_id}) diff --git a/pyheos/command/system.py b/pyheos/command/system.py index 1a60b98..20eb4b0 100644 --- a/pyheos/command/system.py +++ b/pyheos/command/system.py @@ -9,7 +9,7 @@ This command will not be implemented in the library. """ -from pyheos import command, const +from pyheos import command as c from pyheos.message import HeosCommand @@ -23,49 +23,49 @@ def register_for_change_events(enable: bool) -> HeosCommand: 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}, + 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. + """Create a check account c. References: 4.1.2 HEOS Account Check""" - return HeosCommand(command.COMMAND_ACCOUNT_CHECK) + return HeosCommand(c.COMMAND_ACCOUNT_CHECK) @staticmethod def sign_in(username: str, password: str) -> HeosCommand: - """Create a sign in command. + """Create a sign in c. References: 4.1.3 HEOS Account Sign In""" return HeosCommand( - command.COMMAND_SIGN_IN, - {const.ATTR_USER_NAME: username, const.ATTR_PASSWORD: password}, + c.COMMAND_SIGN_IN, + {c.ATTR_USER_NAME: username, c.ATTR_PASSWORD: password}, ) @staticmethod def sign_out() -> HeosCommand: - """Create a sign out command. + """Create a sign out c. References: 4.1.4 HEOS Account Sign Out""" - return HeosCommand(command.COMMAND_SIGN_OUT) + return HeosCommand(c.COMMAND_SIGN_OUT) @staticmethod def heart_beat() -> HeosCommand: - """Create a heart beat command. + """Create a heart beat c. References: 4.1.5 HEOS System Heart Beat""" - return HeosCommand(command.COMMAND_HEART_BEAT) + return HeosCommand(c.COMMAND_HEART_BEAT) @staticmethod def reboot() -> HeosCommand: - """Create a reboot command. + """Create a reboot c. References: 4.1.6 HEOS Speaker Reboot""" - return HeosCommand(command.COMMAND_REBOOT) + return HeosCommand(c.COMMAND_REBOOT) diff --git a/pyheos/connection.py b/pyheos/connection.py index f1403e5..d1e0ba5 100644 --- a/pyheos/connection.py +++ b/pyheos/connection.py @@ -9,21 +9,34 @@ from pyheos.command import COMMAND_REBOOT from pyheos.command.system import SystemCommands 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() - _LOGGER: Final = logging.getLogger(__name__) +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 ConnectionBase: """ @@ -36,7 +49,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 +64,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,7 +134,7 @@ 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.""" @@ -167,7 +180,7 @@ 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: + if self._state is not ConnectionState.CONNECTED: _LOGGER.debug( f"Command failed '{command.uri_masked}': Not connected to device" ) @@ -233,7 +246,7 @@ async def _command_impl() -> HeosMessage: 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: @@ -246,7 +259,7 @@ async def connect(self) -> None: # Start read handler self._register_task(self._read_handler(reader)) self._last_activity = datetime.now() - self._state = STATE_CONNECTED + self._state = ConnectionState.CONNECTED _LOGGER.debug(f"Connected to {self._host}") await self._on_connected() @@ -291,7 +304,7 @@ 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: @@ -306,7 +319,7 @@ async def _attempt_reconnect(self) -> None: """Attempt to reconnect after disconnection from error.""" attempts = 0 unlimited_attempts = self._reconnect_max_attempts == 0 - self._state = STATE_RECONNECTING + self._state = ConnectionState.RECONNECTING while (attempts < self._reconnect_max_attempts) or unlimited_attempts: try: await asyncio.sleep(self._reconnect_delay) diff --git a/pyheos/const.py b/pyheos/const.py index 120170c..79d70e6 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,142 +12,35 @@ DEFAULT_HEART_BEAT: Final = 10.0 DEFAULT_STEP: Final = 5 -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_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_IMAGES: Final = "images" -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_OPTIONS: Final = "options" -ATTR_OPTION_ID: Final = "option" -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" - +# 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.""" - - 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 @@ -167,67 +63,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" @@ -355,17 +191,7 @@ 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 - - -# Service options +# 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 @@ -380,17 +206,7 @@ class AddCriteriaType(IntEnum): SERVICE_OPTION_ADD_TO_FAVORITES: Final = 19 SERVICE_OPTION_REMOVE_FROM_FAVORITES: Final = 20 - -# 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" - -# Events +# 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 49fb3aa..5fe1127 100644 --- a/pyheos/error.py +++ b/pyheos/error.py @@ -1,29 +1,16 @@ """Define the error module for HEOS.""" -import asyncio -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.""" @@ -67,26 +54,26 @@ 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 == const.ERROR_SYSTEM_ERROR: + if error_id == ERROR_SYSTEM_ERROR: return system_error_number in ( - const.SYSTEM_ERROR_USER_NOT_LOGGED_IN, - const.SYSTEM_ERROR_USER_NOT_FOUND, + SYSTEM_ERROR_USER_NOT_LOGGED_IN, + SYSTEM_ERROR_USER_NOT_FOUND, ) return error_id in ( - const.ERROR_INVALID_CREDNETIALS, - const.ERROR_USER_NOT_LOGGED_IN, - const.ERROR_USER_NOT_FOUND, + ERROR_INVALID_CREDNETIALS, + ERROR_USER_NOT_LOGGED_IN, + ERROR_USER_NOT_FOUND, ) @classmethod 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}" diff --git a/pyheos/group.py b/pyheos/group.py index 25233c3..37c35aa 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 @@ -34,10 +37,10 @@ def from_data( """Create a new instance from the provided data.""" player_id: int | None = None player_ids: list[int] = [] - player_id, player_ids = cls.__get_ids(data[const.ATTR_PLAYERS]) + player_id, player_ids = cls.__get_ids(data[c.ATTR_PLAYERS]) return cls( - name=data[const.ATTR_NAME], - group_id=int(data[const.ATTR_GROUP_ID]), + 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, @@ -50,8 +53,8 @@ def __get_ids(players: list[dict[str, Any]]) -> tuple[int, list[int]]: member_player_ids: list[int] = [] for member_player in players: # Find the loaded player - member_player_id = int(member_player[const.ATTR_PLAYER_ID]) - if member_player[const.ATTR_ROLE] == const.VALUE_LEADER: + 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) @@ -61,23 +64,37 @@ def __get_ids(players: list[dict[str, Any]]) -> tuple[int, list[int]]: def _update_from_data(self, data: dict[str, Any]) -> None: """Update the group with the provided data.""" - self.name = data[const.ATTR_NAME] - self.group_id = int(data[const.ATTR_GROUP_ID]) + 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[const.ATTR_PLAYERS] + data[c.ATTR_PLAYERS] ) - async def on_event(self, event: HeosMessage) -> bool: + 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 + 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. @@ -105,12 +122,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 404021b..0080e58 100644 --- a/pyheos/heos.py +++ b/pyheos/heos.py @@ -31,15 +31,29 @@ from pyheos.search import MultiSearchResult, SearchCriteria, SearchResult from pyheos.system import HeosHost, 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 ( + AddCriteriaType, + ConnectionState, + MediaType, + PlayState, + RepeatType, + SignalHeosEvent, + SignalType, +) _LOGGER: Final = logging.getLogger(__name__) +DATA_NEW: Final = "new" +DATA_MAPPED_IDS: Final = "mapped_ids" + + @dataclass(frozen=True) class HeosOptions: """ @@ -92,7 +106,7 @@ def __init__(self, options: HeosOptions) -> None: ) @property - def connection_state(self) -> str: + def connection_state(self) -> ConnectionState: """Get the state of the connection.""" return self._connection.state @@ -142,8 +156,8 @@ async def check_account(self) -> str | None: 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) + 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 @@ -166,7 +180,7 @@ async def sign_in( result = await self._connection.command( SystemCommands.sign_in(username, password) ) - self._signed_in_username = result.get_message_value(const.ATTR_USER_NAME) + 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 @@ -447,7 +461,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 media item to the queue of the specified player. @@ -546,7 +560,7 @@ async def play_media( self, player_id: int, media: MediaItem, - add_criteria: const.AddCriteriaType = const.AddCriteriaType.PLAY_NOW, + add_criteria: AddCriteriaType = AddCriteriaType.PLAY_NOW, ) -> None: """ Play the specified media item on the specified player. @@ -561,7 +575,7 @@ async def play_media( 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: + 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( @@ -721,9 +735,9 @@ async def load_players(self) -> dict[str, list | dict]: 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] + player_id = player_data[c.ATTR_PLAYER_ID] + name = player_data[c.ATTR_NAME] + version = player_data[c.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( @@ -764,11 +778,11 @@ async def load_players(self) -> dict[str, list | dict]: self._players = players self._players_loaded = True return { - const.DATA_NEW: new_player_ids, - const.DATA_MAPPED_IDS: mapped_player_ids, + DATA_NEW: new_player_ids, + DATA_MAPPED_IDS: mapped_player_ids, } - async def player_get_play_state(self, player_id: int) -> const.PlayState: + async def player_get_play_state(self, player_id: int) -> PlayState: """Get the state of the player. References: @@ -776,11 +790,9 @@ async def player_get_play_state(self, player_id: int) -> const.PlayState: response = await self._connection.command( PlayerCommands.get_play_state(player_id) ) - return const.PlayState(response.get_message_value(const.ATTR_STATE)) + return PlayState(response.get_message_value(c.ATTR_STATE)) - async def player_set_play_state( - self, player_id: int, state: const.PlayState - ) -> None: + async def player_set_play_state(self, player_id: int, state: PlayState) -> None: """Set the state of the player. References: @@ -814,7 +826,7 @@ async def player_get_volume(self, player_id: int) -> int: 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) + return result.get_message_value_int(c.ATTR_LEVEL) async def player_set_volume(self, player_id: int, level: int) -> None: """Set the volume of the player. @@ -847,7 +859,7 @@ async def player_get_mute(self, player_id: int) -> bool: 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 + return result.get_message_value(c.ATTR_STATE) == c.VALUE_ON async def player_set_mute(self, player_id: int, state: bool) -> None: """Set the mute state of the player. @@ -872,7 +884,7 @@ async def player_get_play_mode(self, player_id: int) -> PlayMode: return PlayMode._from_data(result) async def player_set_play_mode( - self, player_id: int, repeat: const.RepeatType, shuffle: bool + self, player_id: int, repeat: RepeatType, shuffle: bool ) -> None: """Set the play mode of the player. @@ -989,7 +1001,7 @@ async def player_get_quick_selects(self, player_id: int) -> dict[int, str]: PlayerCommands.get_quick_selects(player_id) ) return { - int(data[const.ATTR_ID]): data[const.ATTR_NAME] + int(data[c.ATTR_ID]): data[c.ATTR_NAME] for data in cast(list[dict], result.payload) } @@ -1005,7 +1017,7 @@ async def player_check_update(self, player_id: int) -> bool: 4.2.26 Check for Firmware Update""" result = await self._connection.command(PlayerCommands.check_update(player_id)) payload = cast(dict[str, Any], result.payload) - return bool(payload[const.ATTR_UPDATE] == const.VALUE_UPDATE_EXIST) + return bool(payload[c.ATTR_UPDATE] == c.VALUE_UPDATE_EXIST) class GroupMixin(ConnectionMixin): @@ -1145,7 +1157,7 @@ async def get_group_volume(self, group_id: int) -> int: result = await self._connection.command( GroupCommands.get_group_volume(group_id) ) - return result.get_message_value_int(const.ATTR_LEVEL) + return result.get_message_value_int(c.ATTR_LEVEL) async def set_group_volume(self, group_id: int, level: int) -> None: """Set the volume of the group. @@ -1178,7 +1190,7 @@ async def get_group_mute(self, group_id: int) -> bool: 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 + return result.get_message_value(c.ATTR_STATE) == c.VALUE_ON async def group_set_mute(self, group_id: int, state: bool) -> None: """Set the mute state of the group. @@ -1262,7 +1274,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. @@ -1271,7 +1283,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. @@ -1281,7 +1293,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: @@ -1292,7 +1304,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: @@ -1303,15 +1315,15 @@ 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: @@ -1327,8 +1339,8 @@ async def _on_connected(self) -> None: ) 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: @@ -1343,12 +1355,12 @@ 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: @@ -1367,8 +1379,8 @@ async def _on_command_error(self, error: CommandFailedError) -> None: # 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, ) @@ -1391,27 +1403,27 @@ async def _on_event_heos(self, event: HeosMessage) -> None: 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) ): await self.dispatcher.wait_send( - const.SIGNAL_PLAYER_EVENT, + SignalType.PLAYER_EVENT, player_id, event.command, return_exceptions=True, @@ -1420,11 +1432,11 @@ async def _on_event_player(self, event: HeosMessage) -> None: 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, diff --git a/pyheos/media.py b/pyheos/media.py index d642e64..f243296 100644 --- a/pyheos/media.py +++ b/pyheos/media.py @@ -4,8 +4,9 @@ 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 @@ -27,13 +28,13 @@ class QueueItem: def from_data(cls, data: dict[str, str]) -> "QueueItem": """Create a new instance from the provided data.""" return cls( - queue_id=int(data[const.ATTR_QUEUE_ID]), - song=data[const.ATTR_SONG], - album=data[const.ATTR_ALBUM], - artist=data[const.ATTR_ARTIST], - image_url=data[const.ATTR_IMAGE_URL], - media_id=data[const.ATTR_MEDIA_ID], - album_id=data[const.ATTR_ALBUM_ID], + 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], ) @@ -47,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) @@ -71,23 +72,23 @@ 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[const.ATTR_SOURCE_ID]) - self.name = data[const.ATTR_NAME] - self.type = const.MediaType(data[const.ATTR_TYPE]) - self.image_url = data[const.ATTR_IMAGE_URL] - self.available = data[const.ATTR_AVAILABLE] == const.VALUE_TRUE - self.service_username = data.get(const.ATTR_SERVICE_USER_NAME) + 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.""" @@ -138,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, ) @@ -196,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. @@ -241,7 +241,7 @@ def _from_options( 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[const.ATTR_ID]), name=data[const.ATTR_NAME] + context=context, id=int(data[c.ATTR_ID]), name=data[c.ATTR_NAME] ) @@ -262,12 +262,12 @@ 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 BrowseResult( - count=message.get_message_value_int(const.ATTR_COUNT), - returned=message.get_message_value_int(const.ATTR_RETURNED), + 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( @@ -292,8 +292,8 @@ class ImageMetadata: def _from_data(data: dict[str, Any]) -> "ImageMetadata": """Create a new instance from the provided data.""" return ImageMetadata( - image_url=data[const.ATTR_IMAGE_URL], - width=int(data[const.ATTR_WIDTH]), + image_url=data[c.ATTR_IMAGE_URL], + width=int(data[c.ATTR_WIDTH]), ) @@ -308,10 +308,10 @@ class AlbumMetadata: def _from_data(data: dict[str, Any]) -> "AlbumMetadata": """Create a new instance from the provided data.""" return AlbumMetadata( - album_id=data[const.ATTR_ALBUM_ID], + album_id=data[c.ATTR_ALBUM_ID], images=[ ImageMetadata._from_data(cast(dict[str, Any], image)) - for image in data[const.ATTR_IMAGES] + for image in data[c.ATTR_IMAGES] ], ) @@ -330,10 +330,10 @@ class RetreiveMetadataResult: def _from_message(message: HeosMessage) -> "RetreiveMetadataResult": "Create a new instance from the provided data." return RetreiveMetadataResult( - source_id=message.get_message_value_int(const.ATTR_SOURCE_ID), - container_id=message.get_message_value(const.ATTR_CONTAINER_ID), - returned=message.get_message_value_int(const.ATTR_RETURNED), - count=message.get_message_value_int(const.ATTR_COUNT), + 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 b3c6eed..5386edc 100644 --- a/pyheos/message.py +++ b/pyheos/message.py @@ -6,11 +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 = "********" @@ -53,7 +53,7 @@ def _encode_query(cls, items: dict[str, Any], *, mask: bool = False) -> str: value = MASK if mask and key in MASKED_PARAMS else items[key] 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) @@ -82,17 +82,15 @@ def __repr__(self) -> str: 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] + heos = container[c.ATTR_HEOS] instance = HeosMessage( - command=str(heos[const.ATTR_COMMAND]), - result=bool( - heos.get(const.ATTR_RESULT, const.VALUE_SUCCESS) == const.VALUE_SUCCESS - ), + 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), - options=container.get(const.ATTR_OPTIONS), + payload=container.get(c.ATTR_PAYLOAD), + options=container.get(c.ATTR_OPTIONS), ) instance._raw_message = raw_message return instance diff --git a/pyheos/player.py b/pyheos/player.py index 3004f4d..0236d69 100644 --- a/pyheos/player.py +++ b/pyheos/player.py @@ -4,17 +4,80 @@ 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 parse_enum from pyheos.dispatch import DisconnectType, EventCallbackType, callback_wrapper from pyheos.media import MediaItem, QueueItem, ServiceOption from pyheos.message import HeosMessage - +from pyheos.types import ( + AddCriteriaType, + ControlType, + MediaType, + NetworkType, + PlayState, + RepeatType, + SignalType, +) + +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 HeosNowPlayingMedia: @@ -34,7 +97,7 @@ class HeosNowPlayingMedia: current_position_updated: datetime | None = None duration: int | None = None supported_controls: Sequence[str] = field( - default_factory=lambda: const.CONTROLS_ALL, init=False + default_factory=lambda: CONTROLS_ALL, init=False ) options: Sequence[ServiceOption] = field( repr=False, hash=False, compare=False, default_factory=list @@ -47,16 +110,16 @@ def __post_init__(self, *args: Any, **kwargs: Any) -> 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 = self.__get_optional_int(data.get(c.ATTR_QUEUE_ID)) + self.source_id = self.__get_optional_int(data.get(c.ATTR_SOURCE_ID)) self.options = ServiceOption._from_options(message.options) self._update_supported_controls() self.clear_progress() @@ -70,24 +133,20 @@ def __get_optional_int(value: Any) -> int | None: 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: """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 @@ -102,15 +161,15 @@ def clear_progress(self) -> None: class PlayMode: """Define the play mode options for a player.""" - repeat: const.RepeatType + repeat: RepeatType shuffle: bool @staticmethod def _from_data(data: HeosMessage) -> "PlayMode": """Create a new instance from the provided data.""" return PlayMode( - repeat=const.RepeatType(data.get_message_value(const.ATTR_REPEAT)), - shuffle=data.get_message_value(const.ATTR_SHUFFLE) == const.VALUE_ON, + repeat=RepeatType(data.get_message_value(c.ATTR_REPEAT)), + shuffle=data.get_message_value(c.ATTR_SHUFFLE) == c.VALUE_ON, ) @@ -126,13 +185,11 @@ class HeosPlayer: 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 - ) + 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( @@ -155,30 +212,33 @@ def _from_data( heos: Optional["Heos"] = None, ) -> "HeosPlayer": """Create a new instance from the provided data.""" + return HeosPlayer( - 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]), - group_id=HeosPlayer.__get_optional_int(data.get(const.ATTR_GROUP_ID)), + 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=int(data[c.ATTR_LINE_OUT]), + group_id=HeosPlayer.__get_optional_int(data.get(c.ATTR_GROUP_ID)), heos=heos, ) 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]) - self.group_id = HeosPlayer.__get_optional_int(data.get(const.ATTR_GROUP_ID)) + 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 = int(data[c.ATTR_LINE_OUT]) + self.group_id = HeosPlayer.__get_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. @@ -190,24 +250,24 @@ async def _on_event(self, event: HeosMessage, all_progress_events: bool) -> bool if event.command == const.EVENT_PLAYER_NOW_PLAYING_PROGRESS: 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.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. @@ -216,7 +276,7 @@ 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}), ) @@ -265,22 +325,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.""" @@ -315,7 +375,7 @@ 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) @@ -393,7 +453,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" @@ -404,7 +464,7 @@ async def add_to_queue( 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. diff --git a/pyheos/search.py b/pyheos/search.py index 46a89a3..7bac43d 100644 --- a/pyheos/search.py +++ b/pyheos/search.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Final, Optional, cast -from pyheos import const +from pyheos import command as c from pyheos.media import MediaItem from pyheos.message import HeosMessage @@ -29,11 +29,11 @@ class SearchCriteria: def _from_data(data: dict[str, str]) -> "SearchCriteria": """Create a new instance from the provided data.""" return SearchCriteria( - name=data[const.ATTR_NAME], - criteria_id=int(data[const.ATTR_SEARCH_CRITERIA_ID]), - wildcard=data[const.ATTR_WILDCARD] == const.VALUE_YES, - container_id=data.get(const.ATTR_CONTAINER_ID), - playable=data.get(const.ATTR_PLAYABLE) == const.VALUE_YES, + 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, ) @@ -52,15 +52,15 @@ class SearchResult: @staticmethod def _from_message(message: HeosMessage, heos: "Heos") -> "SearchResult": """Create a new instance from a message.""" - source_id = message.get_message_value_int(const.ATTR_SOURCE_ID) + 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(const.ATTR_SEARCH_CRITERIA_ID), - search=message.get_message_value(const.ATTR_SEARCH), - returned=message.get_message_value_int(const.ATTR_RETURNED), - count=message.get_message_value_int(const.ATTR_COUNT), + 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) @@ -89,12 +89,10 @@ class MultiSearchResult: @staticmethod def _from_message(message: HeosMessage, heos: "Heos") -> "MultiSearchResult": """Create a new instance from a message.""" - source_ids = message.get_message_value(const.ATTR_SOURCE_ID).split(",") - criteria_ids = message.get_message_value(const.ATTR_SEARCH_CRITERIA_ID).split( - "," - ) + 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(const.ATTR_STATS) + 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 @@ -112,13 +110,13 @@ def _from_message(message: HeosMessage, heos: "Heos") -> "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(const.ATTR_SEARCH), - returned=message.get_message_value_int(const.ATTR_RETURNED), - count=message.get_message_value_int(const.ATTR_COUNT), + 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(const.ATTR_ERROR_NUMBER) + message.get_message_value(c.ATTR_ERROR_NUMBER) ), ) diff --git a/pyheos/system.py b/pyheos/system.py index 5585477..65b8092 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) @@ -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], + data[c.ATTR_NETWORK], ) @@ -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..433ce21 --- /dev/null +++ b/pyheos/types.py @@ -0,0 +1,85 @@ +"""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 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/tests/__init__.py b/tests/__init__.py index 44e47af..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,7 +281,7 @@ 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.""" @@ -398,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 = ( @@ -470,10 +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}", - const.ATTR_OPTIONS: "{options}", + 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) 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 48e1693..ec995b3 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 NetworkType from tests.common import MediaItems, MediaMusicSources from . import MockHeos, MockHeosDevice @@ -141,7 +141,7 @@ async def player_fixture(heos: MockHeos) -> HeosPlayer: serial="B1A2C3K", version="1.493.180", ip_address="127.0.0.1", - network=const.NETWORK_TYPE_WIRED, + network=NetworkType.WIRED, line_out=1, heos=heos, ) @@ -157,7 +157,7 @@ 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, + network=NetworkType.WIFI, line_out=1, heos=heos, ) diff --git a/tests/test_group.py b/tests/test_group.py index cb33510..9830a84 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -2,7 +2,8 @@ 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 @@ -12,11 +13,17 @@ 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"): @@ -26,9 +33,9 @@ def test_group_from_data_no_leader_raises() -> 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,23 +132,27 @@ 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", {const.ATTR_GROUP_ID: 1}), - CallCommand("group.get_volume", {const.ATTR_GROUP_ID: -263109739}), - CallCommand("group.get_mute", {const.ATTR_GROUP_ID: -263109739}), + 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.""" @@ -146,8 +167,8 @@ async def test_refresh(group: HeosGroup) -> None: @calls_commands( - 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}), ) async def test_refresh_no_base_update(group: HeosGroup) -> None: """Test refresh updates the correct information.""" diff --git a/tests/test_heos.py b/tests/test_heos.py index 983f70c..8e65363 100644 --- a/tests/test_heos.py +++ b/tests/test_heos.py @@ -6,8 +6,28 @@ 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 ( @@ -17,9 +37,19 @@ HeosError, ) from pyheos.group import HeosGroup -from pyheos.heos import Heos, HeosOptions +from pyheos.heos import DATA_MAPPED_IDS, DATA_NEW, Heos, HeosOptions from pyheos.media import MediaItem, MediaMusicSource -from pyheos.player import HeosPlayer +from pyheos.player import CONTROLS_ALL, CONTROLS_FORWARD_ONLY, HeosPlayer +from pyheos.types import ( + AddCriteriaType, + ConnectionState, + MediaType, + NetworkType, + PlayState, + RepeatType, + SignalHeosEvent, + SignalType, +) from tests.common import MediaItems from . import ( @@ -39,7 +69,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") @@ -56,14 +86,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" @@ -75,10 +105,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 @@ -98,7 +128,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.""" @@ -114,7 +147,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, @@ -124,7 +160,7 @@ 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() @@ -139,7 +175,7 @@ async def test_connect_with_bad_credentials_dispatches_event( @calls_commands( CallCommand( "browse.browse_fail_user_not_logged_in", - {const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_FAVORITES}, + {c.ATTR_SOURCE_ID: MUSIC_SOURCE_FAVORITES}, add_command_under_process=True, ), CallCommand("system.sign_out"), @@ -164,7 +200,7 @@ async def test_stale_credentials_cleared_afer_auth_error(heos: Heos) -> None: @calls_commands( CallCommand( "browse.browse_fail_user_not_logged_in", - {const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_FAVORITES}, + {c.ATTR_SOURCE_ID: MUSIC_SOURCE_FAVORITES}, add_command_under_process=True, ), CallCommand("system.sign_out"), @@ -175,7 +211,7 @@ 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(CommandAuthenticationError): @@ -189,7 +225,7 @@ async def test_command_credential_error_dispatches_event(heos: Heos) -> None: @calls_commands( CallCommand( "browse.browse_fail_user_not_logged_in", - {const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_FAVORITES}, + {c.ATTR_SOURCE_ID: MUSIC_SOURCE_FAVORITES}, add_command_under_process=True, ), CallCommand("system.sign_out"), @@ -225,7 +261,7 @@ async def test_background_heart_beat(mock_device: MockHeosDevice) -> None: 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() @@ -260,11 +296,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 @@ -277,10 +313,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( @@ -289,11 +325,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 @@ -303,13 +339,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( @@ -317,7 +353,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 @@ -328,7 +364,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: @@ -344,28 +380,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() @@ -383,16 +420,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 @@ -404,7 +441,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() @@ -422,28 +459,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() @@ -458,12 +495,12 @@ async def test_get_players(heos: Heos) -> None: assert player.ip_address == "127.0.0.1" assert player.line_out == 1 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 @@ -472,12 +509,12 @@ async def test_get_players(heos: Heos) -> None: @calls_commands( - CallCommand("player.get_player_info", {const.ATTR_PLAYER_ID: -263109739}), - CallCommand("player.get_play_state", {const.ATTR_PLAYER_ID: -263109739}), - CallCommand("player.get_now_playing_media", {const.ATTR_PLAYER_ID: -263109739}), - CallCommand("player.get_volume", {const.ATTR_PLAYER_ID: -263109739}), - CallCommand("player.get_mute", {const.ATTR_PLAYER_ID: -263109739}), - CallCommand("player.get_play_mode", {const.ATTR_PLAYER_ID: -263109739}), + 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.""" @@ -498,12 +535,12 @@ async def test_get_player_info_by_id_already_loaded(heos: Heos) -> None: @calls_player_commands( (1, 2), - CallCommand("player.get_player_info", {const.ATTR_PLAYER_ID: 1}), - CallCommand("player.get_play_state", {const.ATTR_PLAYER_ID: -263109739}), - CallCommand("player.get_now_playing_media", {const.ATTR_PLAYER_ID: -263109739}), - CallCommand("player.get_volume", {const.ATTR_PLAYER_ID: -263109739}), - CallCommand("player.get_mute", {const.ATTR_PLAYER_ID: -263109739}), - CallCommand("player.get_play_mode", {const.ATTR_PLAYER_ID: -263109739}), + 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.""" @@ -574,13 +611,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) @@ -588,14 +625,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() @@ -620,7 +657,7 @@ 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 @@ -632,14 +669,14 @@ async def test_player_now_playing_changed_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, @@ -665,7 +702,7 @@ 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 @@ -689,10 +726,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( @@ -700,7 +737,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, }, ) @@ -730,10 +767,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( @@ -776,7 +813,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( @@ -808,17 +845,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") @@ -826,7 +863,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() @@ -844,10 +881,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") @@ -868,15 +905,18 @@ async def test_players_changed_event(mock_device: MockHeosDevice, heos: Heos) -> 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: {}} + assert event == EVENT_PLAYERS_CHANGED + assert data == {DATA_NEW: [3], DATA_MAPPED_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, 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") @@ -904,15 +944,15 @@ async def test_players_changed_event_new_ids( 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}} + assert event == EVENT_PLAYERS_CHANGED + assert data == {DATA_NEW: [], DATA_MAPPED_IDS: {101: 1, 102: 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, + c.COMMAND_GET_PLAYERS, None, "player.get_players_firmware_update", replace=True, @@ -937,15 +977,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, ) @@ -954,7 +994,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() @@ -965,14 +1005,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") @@ -992,10 +1032,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") @@ -1015,10 +1055,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") @@ -1039,12 +1079,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") @@ -1060,10 +1099,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") @@ -1080,7 +1119,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, @@ -1088,7 +1128,7 @@ 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 @@ -1106,9 +1146,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: @@ -1141,17 +1181,17 @@ async def test_play_media_unplayable_raises(media_item_album: MediaItem) -> None 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: @@ -1176,9 +1216,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: @@ -1189,10 +1229,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: @@ -1219,23 +1259,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.""" @@ -1243,14 +1284,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.""" @@ -1265,11 +1307,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.""" @@ -1280,13 +1323,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.""" @@ -1301,7 +1347,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: @@ -1325,7 +1374,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( @@ -1370,9 +1422,9 @@ async def test_get_groups(heos: Heos) -> None: @calls_commands( - CallCommand("group.get_group_info", {const.ATTR_GROUP_ID: -263109739}), - CallCommand("group.get_volume", {const.ATTR_GROUP_ID: -263109739}), - CallCommand("group.get_mute", {const.ATTR_GROUP_ID: -263109739}), + 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.""" @@ -1396,9 +1448,9 @@ async def test_get_group_info_by_id_already_loaded(heos: Heos) -> None: @calls_group_commands( - CallCommand("group.get_group_info", {const.ATTR_GROUP_ID: 1}), - CallCommand("group.get_volume", {const.ATTR_GROUP_ID: -263109739}), - CallCommand("group.get_mute", {const.ATTR_GROUP_ID: -263109739}), + 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.""" @@ -1435,25 +1487,25 @@ async def test_get_group_info_invalid_parameters_raises( await heos.get_group_info(group_id=group_id, group=group) -@calls_command("group.set_group_create", {const.ATTR_PLAYER_ID: "1,2,3"}) +@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) @@ -1471,12 +1523,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() @@ -1489,24 +1541,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 index 032dcba..1e62d3e 100644 --- a/tests/test_heos_browse.py +++ b/tests/test_heos_browse.py @@ -4,14 +4,35 @@ import pytest -from pyheos import const +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", {const.ATTR_SOURCE_ID: 123456}) +@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) @@ -21,7 +42,7 @@ async def test_get_music_source_by_id(heos: Heos) -> None: source.image_url == "https://production.ws.skyegloup.com:443/media/images/service/logos/pandora.png" ) - assert source.type == const.MediaType.MUSIC_SERVICE + assert source.type == MediaType.MUSIC_SERVICE assert source.available assert source.service_username == "email@email.com" @@ -30,14 +51,14 @@ async def test_get_music_source_by_id(heos: Heos) -> None: 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[const.MUSIC_SOURCE_FAVORITES] + 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", - {const.ATTR_SOURCE_ID: MediaMusicSources.FAVORITES.source_id}, + {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 @@ -72,12 +93,10 @@ async def test_get_music_source_info_invalid_parameters_raises( await heos.get_music_source_info(source_id=source_id, music_source=music_source) -@calls_command( - "browse.get_search_criteria", {const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_TIDAL} -) +@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(const.MUSIC_SOURCE_TIDAL) + criteria = await heos.get_search_criteria(MUSIC_SOURCE_TIDAL) assert len(criteria) == 4 item = criteria[2] assert item.name == "Track" @@ -90,17 +109,17 @@ async def test_get_search_criteria(heos: Heos) -> None: @calls_command( "browse.search", { - const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_TIDAL, - const.ATTR_SEARCH_CRITERIA_ID: 3, - const.ATTR_SEARCH: "Tangerine Rays", + 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(const.MUSIC_SOURCE_TIDAL, "Tangerine Rays", 3) + result = await heos.search(MUSIC_SOURCE_TIDAL, "Tangerine Rays", 3) - assert result.source_id == const.MUSIC_SOURCE_TIDAL + assert result.source_id == MUSIC_SOURCE_TIDAL assert result.criteria_id == 3 assert result.search == "Tangerine Rays" assert result.returned == 15 @@ -122,26 +141,26 @@ async def test_search_invalid_raises(heos: Heos, search: str, error: str) -> Non ValueError, match=error, ): - await heos.search(const.MUSIC_SOURCE_TIDAL, search, 3) + await heos.search(MUSIC_SOURCE_TIDAL, search, 3) @calls_command( "browse.search", { - const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_TIDAL, - const.ATTR_SEARCH_CRITERIA_ID: 3, - const.ATTR_SEARCH: "Tangerine Rays", - const.ATTR_RANGE: "0,14", + 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( - const.MUSIC_SOURCE_TIDAL, "Tangerine Rays", 3, range_start=0, range_end=14 + MUSIC_SOURCE_TIDAL, "Tangerine Rays", 3, range_start=0, range_end=14 ) - assert result.source_id == const.MUSIC_SOURCE_TIDAL + assert result.source_id == MUSIC_SOURCE_TIDAL assert result.criteria_id == 3 assert result.search == "Tangerine Rays" assert result.returned == 15 @@ -152,14 +171,14 @@ async def test_search_with_range(heos: Heos) -> None: @calls_command( "browse.rename_playlist", { - const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_PLAYLISTS, - const.ATTR_CONTAINER_ID: 171566, - const.ATTR_NAME: "New Name", + 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(const.MUSIC_SOURCE_PLAYLISTS, "171566", "New Name") + await heos.rename_playlist(MUSIC_SOURCE_PLAYLISTS, "171566", "New Name") @pytest.mark.parametrize( @@ -180,32 +199,32 @@ async def test_rename_playlist_invalid_name_raises( ValueError, match=error, ): - await heos.rename_playlist(const.MUSIC_SOURCE_PLAYLISTS, "171566", name) + await heos.rename_playlist(MUSIC_SOURCE_PLAYLISTS, "171566", name) @calls_command( "browse.delete_playlist", { - const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_PLAYLISTS, - const.ATTR_CONTAINER_ID: 171566, + 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(const.MUSIC_SOURCE_PLAYLISTS, "171566") + await heos.delete_playlist(MUSIC_SOURCE_PLAYLISTS, "171566") @calls_command( "browse.retrieve_metadata", { - const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_NAPSTER, - const.ATTR_CONTAINER_ID: 123456, + 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(const.MUSIC_SOURCE_NAPSTER, "123456") - assert result.source_id == const.MUSIC_SOURCE_NAPSTER + 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 @@ -224,29 +243,29 @@ async def test_retrieve_metadata(heos: Heos) -> None: @calls_command( "browse.set_service_option_add_favorite", { - const.ATTR_OPTION_ID: const.SERVICE_OPTION_ADD_TO_FAVORITES, - const.ATTR_PLAYER_ID: 1, + 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(const.SERVICE_OPTION_ADD_TO_FAVORITES, player_id=1) + await heos.set_service_option(SERVICE_OPTION_ADD_TO_FAVORITES, player_id=1) @calls_command( "browse.set_service_option_add_favorite_browse", { - const.ATTR_OPTION_ID: const.SERVICE_OPTION_ADD_TO_FAVORITES, - const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_PANDORA, - const.ATTR_MEDIA_ID: 123456, - const.ATTR_NAME: "Test Radio", + 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( - const.SERVICE_OPTION_ADD_TO_FAVORITES, - source_id=const.MUSIC_SOURCE_PANDORA, + SERVICE_OPTION_ADD_TO_FAVORITES, + source_id=MUSIC_SOURCE_PANDORA, media_id="123456", name="Test Radio", ) @@ -255,33 +274,33 @@ async def test_set_service_option_add_favorite_browse(heos: Heos) -> None: @calls_command( "browse.set_service_option_remove_favorite", { - const.ATTR_OPTION_ID: const.SERVICE_OPTION_REMOVE_FROM_FAVORITES, - const.ATTR_MEDIA_ID: 4277097921440801039, + 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( - const.SERVICE_OPTION_REMOVE_FROM_FAVORITES, media_id="4277097921440801039" + SERVICE_OPTION_REMOVE_FROM_FAVORITES, media_id="4277097921440801039" ) @pytest.mark.parametrize( - "option", [const.SERVICE_OPTION_THUMBS_UP, const.SERVICE_OPTION_THUMBS_DOWN] + "option", [SERVICE_OPTION_THUMBS_UP, SERVICE_OPTION_THUMBS_DOWN] ) @calls_command( "browse.set_service_option_thumbs_up_down", { - const.ATTR_OPTION_ID: value(arg_name="option"), - const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_PANDORA, - const.ATTR_PLAYER_ID: 1, + 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=const.MUSIC_SOURCE_PANDORA, + source_id=MUSIC_SOURCE_PANDORA, player_id=1, ) @@ -289,25 +308,25 @@ async def test_set_service_option_thumbs_up_down(heos: Heos, option: int) -> Non @pytest.mark.parametrize( "option", [ - const.SERVICE_OPTION_ADD_TRACK_TO_LIBRARY, - const.SERVICE_OPTION_ADD_STATION_TO_LIBRARY, - const.SERVICE_OPTION_REMOVE_TRACK_FROM_LIBRARY, - const.SERVICE_OPTION_REMOVE_STATION_FROM_LIBRARY, + 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", { - const.ATTR_OPTION_ID: value(arg_name="option"), - const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_PANDORA, - const.ATTR_MEDIA_ID: 1234, + 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=const.MUSIC_SOURCE_PANDORA, + source_id=MUSIC_SOURCE_PANDORA, media_id="1234", ) @@ -315,17 +334,17 @@ async def test_set_service_option_track_station(heos: Heos, option: int) -> None @pytest.mark.parametrize( "option", [ - const.SERVICE_OPTION_ADD_ALBUM_TO_LIBRARY, - const.SERVICE_OPTION_REMOVE_ALBUM_FROM_LIBRARY, - const.SERVICE_OPTION_REMOVE_PLAYLIST_FROM_LIBRARY, + 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", { - const.ATTR_OPTION_ID: value(arg_name="option"), - const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_PANDORA, - const.ATTR_CONTAINER_ID: 1234, + 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( @@ -334,7 +353,7 @@ async def test_set_service_option_album_remove_playlist( """Test setting albumn options and remove playlist options.""" await heos.set_service_option( option, - source_id=const.MUSIC_SOURCE_PANDORA, + source_id=MUSIC_SOURCE_PANDORA, container_id="1234", ) @@ -342,17 +361,17 @@ async def test_set_service_option_album_remove_playlist( @calls_command( "browse.set_service_option_add_playlist", { - const.ATTR_OPTION_ID: const.SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, - const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_PANDORA, - const.ATTR_CONTAINER_ID: 1234, - const.ATTR_NAME: "Test 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( - const.SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, - source_id=const.MUSIC_SOURCE_PANDORA, + SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, + source_id=MUSIC_SOURCE_PANDORA, container_id="1234", name="Test Playlist", ) @@ -361,18 +380,18 @@ async def test_set_service_option_add_playlist(heos: Heos) -> None: @calls_command( "browse.set_service_option_new_station", { - const.ATTR_OPTION_ID: const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, - const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_PANDORA, - const.ATTR_SEARCH_CRITERIA_ID: 1234, - const.ATTR_NAME: "Test", - const.ATTR_RANGE: "0,14", + 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( - const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, - source_id=const.MUSIC_SOURCE_PANDORA, + SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + source_id=MUSIC_SOURCE_PANDORA, criteria_id=1234, name="Test", range_start=0, @@ -389,33 +408,33 @@ async def test_set_service_option_new_station(heos: Heos) -> None: ), # SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY ( - {"option_id": const.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": const.SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, + "option_id": SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, "source_id": 1234, }, "source_id, container_id, and name parameters are required", ), ( { - "option_id": const.SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, + "option_id": SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, "container_id": 1234, }, "source_id, container_id, and name parameters are required", ), ( { - "option_id": const.SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, + "option_id": SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, "name": 1234, }, "source_id, container_id, and name parameters are required", ), ( { - "option_id": const.SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, + "option_id": SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, "source_id": 1234, "name": 1234, "media_id": 1234, @@ -425,33 +444,33 @@ async def test_set_service_option_new_station(heos: Heos) -> None: ), # SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA ( - {"option_id": const.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": const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + "option_id": SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, "source_id": 1234, }, "source_id, name, and criteria_id parameters are required", ), ( { - "option_id": const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + "option_id": SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, "name": 1234, }, "source_id, name, and criteria_id parameters are required", ), ( { - "option_id": const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + "option_id": SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, "criteria_id": 1234, }, "source_id, name, and criteria_id parameters are required", ), ( { - "option_id": const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + "option_id": SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, "criteria_id": 1234, "name": 1234, }, @@ -459,7 +478,7 @@ async def test_set_service_option_new_station(heos: Heos) -> None: ), ( { - "option_id": const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + "option_id": SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, "criteria_id": 1234, "source_id": 1234, }, @@ -467,7 +486,7 @@ async def test_set_service_option_new_station(heos: Heos) -> None: ), ( { - "option_id": const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + "option_id": SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, "name": 1234, "source_id": 1234, }, @@ -475,7 +494,7 @@ async def test_set_service_option_new_station(heos: Heos) -> None: ), ( { - "option_id": const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + "option_id": SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, "criteria_id": 1234, "name": 1234, "source_id": 1234, @@ -485,12 +504,12 @@ async def test_set_service_option_new_station(heos: Heos) -> None: ), # SERVICE_OPTION_REMOVE_FROM_FAVORITES ( - {"option_id": const.SERVICE_OPTION_REMOVE_FROM_FAVORITES}, + {"option_id": SERVICE_OPTION_REMOVE_FROM_FAVORITES}, "media_id parameter is required", ), ( { - "option_id": const.SERVICE_OPTION_REMOVE_FROM_FAVORITES, + "option_id": SERVICE_OPTION_REMOVE_FROM_FAVORITES, "media_id": 1234, "container_id": 1234, }, @@ -511,10 +530,10 @@ async def test_set_sevice_option_invalid_raises( @pytest.mark.parametrize( "option", [ - const.SERVICE_OPTION_ADD_TRACK_TO_LIBRARY, - const.SERVICE_OPTION_ADD_STATION_TO_LIBRARY, - const.SERVICE_OPTION_REMOVE_TRACK_FROM_LIBRARY, - const.SERVICE_OPTION_REMOVE_STATION_FROM_LIBRARY, + 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( @@ -550,9 +569,9 @@ async def test_set_sevice_option_invalid_track_station_raises( @pytest.mark.parametrize( "option", [ - const.SERVICE_OPTION_ADD_ALBUM_TO_LIBRARY, - const.SERVICE_OPTION_REMOVE_ALBUM_FROM_LIBRARY, - const.SERVICE_OPTION_REMOVE_PLAYLIST_FROM_LIBRARY, + SERVICE_OPTION_ADD_ALBUM_TO_LIBRARY, + SERVICE_OPTION_REMOVE_ALBUM_FROM_LIBRARY, + SERVICE_OPTION_REMOVE_PLAYLIST_FROM_LIBRARY, ], ) @pytest.mark.parametrize( @@ -588,8 +607,8 @@ async def test_set_sevice_option_invalid_album_remove_playlist_raises( @pytest.mark.parametrize( "option", [ - const.SERVICE_OPTION_THUMBS_UP, - const.SERVICE_OPTION_THUMBS_DOWN, + SERVICE_OPTION_THUMBS_UP, + SERVICE_OPTION_THUMBS_DOWN, ], ) @pytest.mark.parametrize( @@ -682,20 +701,20 @@ async def test_set_sevice_option_invalid_add_favorite_raises( heos = Heos(HeosOptions("127.0.0.1")) with pytest.raises(ValueError, match=error): await heos.set_service_option( - option_id=const.SERVICE_OPTION_ADD_TO_FAVORITES, **kwargs + option_id=SERVICE_OPTION_ADD_TO_FAVORITES, **kwargs ) @calls_command( "browse.multi_search", { - const.ATTR_SEARCH: "Tangerine Rays", - const.ATTR_SOURCE_ID: "1,4,8,13,10", - const.ATTR_SEARCH_CRITERIA_ID: "0,1,2,3", + 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 command.""" + """Test the multi-search c.""" result = await heos.multi_search( "Tangerine Rays", [1, 4, 8, 13, 10], @@ -713,7 +732,7 @@ async def test_multi_search(heos: Heos) -> None: async def test_multi_search_invalid_search_rasis() -> None: - """Test the multi-search command.""" + """Test the multi-search c.""" heos = Heos(HeosOptions("127.0.0.1")) with pytest.raises( ValueError, 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 9077fb3..4e96dc7 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -5,10 +5,12 @@ 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, MediaMusicSources @@ -16,22 +18,22 @@ 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,7 @@ 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] @@ -64,17 +64,21 @@ 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: "", } ], ) @@ -95,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", @@ -134,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( @@ -151,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 @@ -176,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 @@ -199,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.""" @@ -214,7 +218,7 @@ async def test_media_item_browse(media_item_device: MediaItem) -> None: @calls_command( "browse.get_source_info", - {const.ATTR_SOURCE_ID: MediaMusicSources.FAVORITES.source_id}, + {c.ATTR_SOURCE_ID: MediaMusicSources.FAVORITES.source_id}, ) async def test_refresh(media_music_source: MediaMusicSource) -> None: """Test refresh updates the data.""" @@ -225,7 +229,7 @@ async def test_refresh(media_music_source: MediaMusicSource) -> None: media_music_source.image_url == "https://production.ws.skyegloup.com:443/media/images/service/logos/pandora.png" ) - assert media_music_source.type == const.MediaType.MUSIC_SERVICE + assert media_music_source.type == MediaType.MUSIC_SERVICE assert media_music_source.available assert media_music_source.service_username == "email@email.com" @@ -233,14 +237,14 @@ async def test_refresh(media_music_source: MediaMusicSource) -> None: @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 b278567..619b266 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -4,24 +4,34 @@ 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 from pyheos.media import MediaItem from pyheos.player import HeosPlayer +from pyheos.types import AddCriteriaType, 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) @@ -30,7 +40,7 @@ def test_from_data() -> None: 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.network == expected_network assert player.line_out == 1 assert player.serial == "1234567890" @@ -38,14 +48,14 @@ def test_from_data() -> None: 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) @@ -54,26 +64,24 @@ async def test_update_from_data(player: HeosPlayer) -> None: 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.network == NetworkType.WIFI assert player.line_out == 0 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 +90,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 +99,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 +113,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 +123,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 +160,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 +173,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,9 +214,9 @@ async def test_clear_queue(player: HeosPlayer) -> None: await player.clear_queue() -@calls_command("player.get_queue", {const.ATTR_PLAYER_ID: 1}) +@calls_command("player.get_queue", {c.ATTR_PLAYER_ID: 1}) async def test_get_queue(player: HeosPlayer) -> None: - """Test the get queue command.""" + """Test the get queue c.""" result = await player.get_queue() assert len(result) == 11 @@ -226,28 +233,29 @@ async def test_get_queue(player: HeosPlayer) -> None: assert item.album_id == "199555605" -@calls_command("player.play_queue", {const.ATTR_PLAYER_ID: 1, const.ATTR_QUEUE_ID: 1}) +@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 command.""" + """Test the play_queue c.""" await player.play_queue(1) @calls_command( - "player.remove_from_queue", {const.ATTR_PLAYER_ID: 1, const.ATTR_QUEUE_ID: "1,2,3"} + "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 command.""" + """Test the play_queue c.""" await player.remove_from_queue([1, 2, 3]) -@calls_command("player.save_queue", {const.ATTR_PLAYER_ID: 1, const.ATTR_NAME: "Test"}) +@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 command.""" + """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 command.""" + """Test the save_queue c.""" with pytest.raises( ValueError, match="'name' must be less than or equal to 128 characters" ): @@ -257,19 +265,19 @@ async def test_save_queue_too_long_raises(player: HeosPlayer) -> None: @calls_command( "player.move_queue_item", { - const.ATTR_PLAYER_ID: 1, - const.ATTR_SOURCE_QUEUE_ID: "2,3,4", - const.ATTR_DESTINATION_QUEUE_ID: 1, + 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 command.""" + """Test the move_queue_item c.""" await player.move_queue_item([2, 3, 4], 1) -@calls_command("player.get_queue", {const.ATTR_PLAYER_ID: 1, const.ATTR_RANGE: "0,10"}) +@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 command.""" + """Test the check_update c.""" result = await player.get_queue(0, 10) assert len(result) == 11 @@ -289,17 +297,17 @@ async def test_get_queue_with_range(player: HeosPlayer) -> None: @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) @@ -314,8 +322,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: @@ -332,7 +340,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) @@ -345,13 +353,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() @@ -373,16 +381,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, ) @@ -390,44 +398,44 @@ 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("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() @@ -444,12 +452,12 @@ async def test_now_playing_media_unavailable(player: HeosPlayer) -> None: @calls_commands( - CallCommand("player.get_player_info", {const.ATTR_PLAYER_ID: 1}), - CallCommand("player.get_play_state", {const.ATTR_PLAYER_ID: -263109739}), - CallCommand("player.get_now_playing_media", {const.ATTR_PLAYER_ID: -263109739}), - CallCommand("player.get_volume", {const.ATTR_PLAYER_ID: -263109739}), - CallCommand("player.get_mute", {const.ATTR_PLAYER_ID: -263109739}), - CallCommand("player.get_play_mode", {const.ATTR_PLAYER_ID: -263109739}), + 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.""" @@ -464,11 +472,11 @@ async def test_refresh(player: HeosPlayer) -> None: @calls_commands( - CallCommand("player.get_play_state", {const.ATTR_PLAYER_ID: 1}), - CallCommand("player.get_now_playing_media", {const.ATTR_PLAYER_ID: 1}), - CallCommand("player.get_volume", {const.ATTR_PLAYER_ID: 1}), - CallCommand("player.get_mute", {const.ATTR_PLAYER_ID: 1}), - CallCommand("player.get_play_mode", {const.ATTR_PLAYER_ID: 1}), + 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.""" @@ -478,8 +486,8 @@ async def test_refresh_no_base_update(player: HeosPlayer) -> None: assert player.player_id == 1 -@calls_command("player.check_update", {const.ATTR_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 command.""" + """Test the check_update c.""" result = await player.check_update() assert result From aeeef5a52714377cd1c338778a849144a07e3014 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Fri, 10 Jan 2025 14:55:51 -0600 Subject: [PATCH 17/25] Add enums for player fields (#73) * Add LineOutLevelType * Use enums --- pyheos/command/__init__.py | 15 +++++--- pyheos/player.py | 48 ++++++++++++++------------ pyheos/system.py | 4 +-- pyheos/types.py | 18 ++++++++++ tests/conftest.py | 6 ++-- tests/fixtures/player.get_players.json | 3 +- tests/test_heos.py | 5 ++- tests/test_player.py | 12 +++++-- 8 files changed, 74 insertions(+), 37 deletions(-) diff --git a/pyheos/command/__init__.py b/pyheos/command/__init__.py index b5f162b..0d8e3ce 100644 --- a/pyheos/command/__init__.py +++ b/pyheos/command/__init__.py @@ -1,7 +1,7 @@ """Define the HEOS command module.""" import logging -from enum import StrEnum +from enum import ReprEnum from typing import Any, Final, TypeVar REPORT_ISSUE_TEXT: Final = ( @@ -16,6 +16,7 @@ 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" @@ -156,12 +157,18 @@ _LOGGER: Final = logging.getLogger(__name__) -TStrEnum = TypeVar("TStrEnum", bound=StrEnum) +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[TStrEnum], default: TStrEnum -) -> TStrEnum: + 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: diff --git a/pyheos/player.py b/pyheos/player.py index 0236d69..b5f8778 100644 --- a/pyheos/player.py +++ b/pyheos/player.py @@ -6,18 +6,20 @@ from datetime import datetime from typing import TYPE_CHECKING, Any, Final, Optional, cast -from pyheos.command import parse_enum +from pyheos.command import optional_int, parse_enum from pyheos.dispatch import DisconnectType, EventCallbackType, callback_wrapper 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 @@ -96,7 +98,7 @@ class HeosNowPlayingMedia: current_position: int | None = None current_position_updated: datetime | None = None duration: int | None = None - supported_controls: Sequence[str] = field( + supported_controls: Sequence[ControlType] = field( default_factory=lambda: CONTROLS_ALL, init=False ) options: Sequence[ServiceOption] = field( @@ -118,19 +120,12 @@ def _update_from_message(self, message: HeosMessage) -> None: 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 = self.__get_optional_int(data.get(c.ATTR_QUEUE_ID)) - self.source_id = self.__get_optional_int(data.get(c.ATTR_SOURCE_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 - def _update_supported_controls(self) -> None: """Updates the supported controls based on the source and type.""" new_supported_controls = CONTROLS_ALL if self.source_id is not None else [] @@ -183,8 +178,11 @@ 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) + 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) @@ -200,12 +198,6 @@ class HeosPlayer: 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) - @staticmethod - def __get_optional_int(value: str | None) -> int | None: - if value is not None: - return int(value) - return None - @staticmethod def _from_data( data: dict[str, Any], @@ -221,8 +213,13 @@ def _from_data( version=data[c.ATTR_VERSION], ip_address=data[c.ATTR_IP_ADDRESS], network=parse_enum(c.ATTR_NETWORK, data, NetworkType, NetworkType.UNKNOWN), - line_out=int(data[c.ATTR_LINE_OUT]), - group_id=HeosPlayer.__get_optional_int(data.get(c.ATTR_GROUP_ID)), + 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, ) @@ -237,8 +234,13 @@ def _update_from_data(self, data: dict[str, Any]) -> None: self.network = parse_enum( c.ATTR_NETWORK, data, NetworkType, NetworkType.UNKNOWN ) - self.line_out = int(data[c.ATTR_LINE_OUT]) - self.group_id = HeosPlayer.__get_optional_int(data.get(c.ATTR_GROUP_ID)) + 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. diff --git a/pyheos/system.py b/pyheos/system.py index 65b8092..1ebc8bf 100644 --- a/pyheos/system.py +++ b/pyheos/system.py @@ -19,7 +19,7 @@ class HeosHost: serial: str | None version: str ip_address: str - network: str + network: NetworkType @classmethod def from_data(cls, data: dict[str, str]) -> "HeosHost": @@ -37,7 +37,7 @@ def from_data(cls, data: dict[str, str]) -> "HeosHost": data.get(c.ATTR_SERIAL), data[c.ATTR_VERSION], data[c.ATTR_IP_ADDRESS], - data[c.ATTR_NETWORK], + c.parse_enum(c.ATTR_NETWORK, data, NetworkType, NetworkType.UNKNOWN), ) diff --git a/pyheos/types.py b/pyheos/types.py index 433ce21..787a7f7 100644 --- a/pyheos/types.py +++ b/pyheos/types.py @@ -20,6 +20,24 @@ class ConnectionState(StrEnum): 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.""" diff --git a/tests/conftest.py b/tests/conftest.py index ec995b3..921f2e1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ from pyheos.heos import Heos, HeosOptions from pyheos.media import MediaItem, MediaMusicSource from pyheos.player import HeosPlayer -from pyheos.types import NetworkType +from pyheos.types import LineOutLevelType, NetworkType from tests.common import MediaItems, MediaMusicSources from . import MockHeos, MockHeosDevice @@ -142,7 +142,7 @@ async def player_fixture(heos: MockHeos) -> HeosPlayer: version="1.493.180", ip_address="127.0.0.1", network=NetworkType.WIRED, - line_out=1, + line_out=LineOutLevelType.FIXED, heos=heos, ) @@ -158,7 +158,7 @@ async def player_front_porch_fixture(heos: MockHeos) -> HeosPlayer: version="1.493.180", ip_address="127.0.0.2", network=NetworkType.WIFI, - line_out=1, + line_out=LineOutLevelType.FIXED, heos=heos, ) diff --git a/tests/fixtures/player.get_players.json b/tests/fixtures/player.get_players.json index ccb6cef..9d85716 100644 --- a/tests/fixtures/player.get_players.json +++ b/tests/fixtures/player.get_players.json @@ -11,7 +11,8 @@ "version": "1.493.180", "ip": "127.0.0.1", "network": "wired", - "lineout": 1, + "lineout": 2, + "control": 2, "serial": "B1A2C3K" }, { "name": "Front Porch", diff --git a/tests/test_heos.py b/tests/test_heos.py index 8e65363..357a20f 100644 --- a/tests/test_heos.py +++ b/tests/test_heos.py @@ -43,12 +43,14 @@ from pyheos.types import ( AddCriteriaType, ConnectionState, + LineOutLevelType, MediaType, NetworkType, PlayState, RepeatType, SignalHeosEvent, SignalType, + VolumeControlType, ) from tests.common import MediaItems @@ -493,7 +495,8 @@ 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 == NetworkType.WIRED assert player.state == PlayState.STOP diff --git a/tests/test_player.py b/tests/test_player.py index 619b266..cf5a420 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -8,7 +8,13 @@ from pyheos.const import INPUT_AUX_IN_1, MUSIC_SOURCE_DEEZER, MUSIC_SOURCE_PLAYLISTS from pyheos.media import MediaItem from pyheos.player import HeosPlayer -from pyheos.types import AddCriteriaType, NetworkType, PlayState, RepeatType +from pyheos.types import ( + AddCriteriaType, + LineOutLevelType, + NetworkType, + PlayState, + RepeatType, +) from tests import CallCommand, calls_command, calls_commands, value from tests.common import MediaItems @@ -41,7 +47,7 @@ def test_from_data(network: str | None, expected_network: NetworkType) -> None: assert player.version == "1.493.180" assert player.ip_address == "192.168.0.1" assert player.network == expected_network - assert player.line_out == 1 + assert player.line_out == LineOutLevelType.VARIABLE assert player.serial == "1234567890" @@ -65,7 +71,7 @@ async def test_update_from_data(player: HeosPlayer) -> None: assert player.version == "2.0.0" assert player.ip_address == "192.168.0.2" assert player.network == NetworkType.WIFI - assert player.line_out == 0 + assert player.line_out == LineOutLevelType.UNKNOWN assert player.serial == "0987654321" From 9d90c72a9f193b0bbe31bdfe5efe9918a388d753 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sat, 11 Jan 2025 03:03:31 +0000 Subject: [PATCH 18/25] Check serial --- pyheos/heos.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pyheos/heos.py b/pyheos/heos.py index 0080e58..f5b619b 100644 --- a/pyheos/heos.py +++ b/pyheos/heos.py @@ -738,13 +738,15 @@ async def load_players(self) -> dict[str, list | dict]: player_id = player_data[c.ATTR_PLAYER_ID] name = player_data[c.ATTR_NAME] version = player_data[c.ATTR_VERSION] - # Try finding existing player by id or match name when firmware - # version is different because IDs change after a firmware upgrade + 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.player_id == player_id + if (player.serial == serial and serial is not None) + or player.player_id == player_id or (player.name == name and player.version != version) ), None, From e2a722e31276e826b24244de310dc6754cfa72b9 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sat, 11 Jan 2025 03:27:17 +0000 Subject: [PATCH 19/25] Add PlayerUpdateResult --- pyheos/__init__.py | 3 ++- pyheos/heos.py | 41 +++++++++++++++++++++++++---------------- tests/test_heos.py | 14 +++++++++----- 3 files changed, 36 insertions(+), 22 deletions(-) diff --git a/pyheos/__init__.py b/pyheos/__init__.py index 5d09cac..354f174 100644 --- a/pyheos/__init__.py +++ b/pyheos/__init__.py @@ -21,7 +21,7 @@ HeosError, ) from .group import HeosGroup -from .heos import Heos, HeosOptions +from .heos import Heos, HeosOptions, PlayerUpdateResult from .media import ( AlbumMetadata, BrowseResult, @@ -92,6 +92,7 @@ "PlayMode", "PlayState", "PlayerEventCallbackType", + "PlayerUpdateResult", "QueueItem", "RepeatType", "RetreiveMetadataResult", diff --git a/pyheos/heos.py b/pyheos/heos.py index f5b619b..0911aea 100644 --- a/pyheos/heos.py +++ b/pyheos/heos.py @@ -50,8 +50,19 @@ _LOGGER: Final = logging.getLogger(__name__) -DATA_NEW: Final = "new" -DATA_MAPPED_IDS: Final = "mapped_ids" +@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(frozen=True) @@ -726,16 +737,16 @@ async def get_player_info( await player.refresh(refresh_base_info=False) return player - async def load_players(self) -> dict[str, list | dict]: + async def load_players(self) -> "PlayerUpdateResult": """Refresh the players.""" - new_player_ids = [] - mapped_player_ids = {} - players = {} + result = PlayerUpdateResult() + + players: dict[int, HeosPlayer] = {} 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[c.ATTR_PLAYER_ID] + 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) @@ -752,9 +763,9 @@ async def load_players(self) -> dict[str, list | dict]: None, ) if player: - # Existing player matched - update + # Found existing, update if player.player_id != player_id: - mapped_player_ids[player_id] = player.player_id + result.updated_player_ids[player.player_id] = player_id player._update_from_data(player_data) player.available = True players[player_id] = player @@ -762,14 +773,15 @@ async def load_players(self) -> dict[str, list | dict]: else: # New player player = HeosPlayer._from_data(player_data, cast("Heos", self)) - new_player_ids.append(player_id) + 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 - # Update all statuses + # Pull data for available players await asyncio.gather( *[ player.refresh(refresh_base_info=False) @@ -779,10 +791,7 @@ async def load_players(self) -> dict[str, list | dict]: ) self._players = players self._players_loaded = True - return { - DATA_NEW: new_player_ids, - DATA_MAPPED_IDS: mapped_player_ids, - } + return result async def player_get_play_state(self, player_id: int) -> PlayState: """Get the state of the player. @@ -1399,7 +1408,7 @@ async def _on_event(self, event: HeosMessage) -> None: 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: diff --git a/tests/test_heos.py b/tests/test_heos.py index 357a20f..3570f87 100644 --- a/tests/test_heos.py +++ b/tests/test_heos.py @@ -37,7 +37,7 @@ HeosError, ) from pyheos.group import HeosGroup -from pyheos.heos import DATA_MAPPED_IDS, DATA_NEW, Heos, HeosOptions +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 ( @@ -907,9 +907,11 @@ 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: + async def handler(event: str, result: PlayerUpdateResult) -> None: assert event == EVENT_PLAYERS_CHANGED - assert data == {DATA_NEW: [3], DATA_MAPPED_IDS: {}} + assert result.added_player_ids == [3] + assert result.updated_player_ids == {} + assert result.removed_player_ids == [2] signal.set() heos.dispatcher.connect(SignalType.CONTROLLER_EVENT, handler) @@ -946,9 +948,11 @@ async def test_players_changed_event_new_ids( # Attach dispatch handler signal = asyncio.Event() - async def handler(event: str, data: dict[str, Any]) -> None: + async def handler(event: str, result: PlayerUpdateResult) -> None: assert event == EVENT_PLAYERS_CHANGED - assert data == {DATA_NEW: [], DATA_MAPPED_IDS: {101: 1, 102: 2}} + assert result.added_player_ids == [] + assert result.updated_player_ids == {1: 101, 2: 102} + assert result.removed_player_ids == [] signal.set() heos.dispatcher.connect(SignalType.CONTROLLER_EVENT, handler) From 1938cb0aaefadd1490d2f28da9221987a895fab6 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Fri, 10 Jan 2025 21:39:16 -0600 Subject: [PATCH 20/25] Refactor internals (#76) --- pyheos/group.py | 9 ++++----- pyheos/heos.py | 6 +++--- pyheos/message.py | 12 ++++++------ pyheos/player.py | 6 +++--- pyheos/system.py | 4 ++-- tests/test_group.py | 2 +- 6 files changed, 19 insertions(+), 20 deletions(-) diff --git a/pyheos/group.py b/pyheos/group.py index 37c35aa..cdd3e04 100644 --- a/pyheos/group.py +++ b/pyheos/group.py @@ -28,17 +28,16 @@ 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] = [] - player_id, player_ids = cls.__get_ids(data[c.ATTR_PLAYERS]) - return cls( + 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, diff --git a/pyheos/heos.py b/pyheos/heos.py index 0911aea..334444b 100644 --- a/pyheos/heos.py +++ b/pyheos/heos.py @@ -230,7 +230,7 @@ async def get_system_info(self) -> HeosSystem: 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]) + 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) @@ -1055,7 +1055,7 @@ async def get_groups(self, *, refresh: bool = False) -> dict[int, HeosGroup]: 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)) + group = HeosGroup._from_data(data, cast("Heos", self)) groups[group.group_id] = group self._groups = groups # Update all statuses @@ -1105,7 +1105,7 @@ async def get_group_info( ) payload = cast(dict[str, Any], result.payload) if group is None: - group = HeosGroup.from_data(payload, cast("Heos", self)) + group = HeosGroup._from_data(payload, cast("Heos", self)) else: group._update_from_data(payload) await group.refresh(refresh_base_info=False) diff --git a/pyheos/message.py b/pyheos/message.py index 5386edc..6facb9b 100644 --- a/pyheos/message.py +++ b/pyheos/message.py @@ -34,24 +34,24 @@ 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"{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 == c.ATTR_URL: pairs.append(f"{key}={value}") diff --git a/pyheos/player.py b/pyheos/player.py index b5f8778..ac061ff 100644 --- a/pyheos/player.py +++ b/pyheos/player.py @@ -124,7 +124,7 @@ def _update_from_message(self, message: HeosMessage) -> None: 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() + self._clear_progress() def _update_supported_controls(self) -> None: """Updates the supported controls based on the source and type.""" @@ -145,7 +145,7 @@ def _on_event(self, event: HeosMessage, all_progress_events: bool) -> bool: 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 @@ -254,7 +254,7 @@ async def _on_event(self, event: HeosMessage, all_progress_events: bool) -> bool if event.command == const.EVENT_PLAYER_STATE_CHANGED: self.state = PlayState(event.get_message_value(c.ATTR_STATE)) if self.state == PlayState.PLAY: - self.now_playing_media.clear_progress() + 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: diff --git a/pyheos/system.py b/pyheos/system.py index 1ebc8bf..6db5d82 100644 --- a/pyheos/system.py +++ b/pyheos/system.py @@ -21,8 +21,8 @@ class HeosHost: ip_address: 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: diff --git a/tests/test_group.py b/tests/test_group.py index 9830a84..fe6503a 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -27,7 +27,7 @@ def test_group_from_data_no_leader_raises() -> None: ], } with pytest.raises(ValueError, match="No leader found in group data"): - HeosGroup.from_data(data, None) + HeosGroup._from_data(data, None) @pytest.mark.parametrize( From dffd597957ea223cefb76aceca114c66893f91ae Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Fri, 10 Jan 2025 22:04:04 -0600 Subject: [PATCH 21/25] Add incremental backoff (#78) --- pyheos/connection.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/pyheos/connection.py b/pyheos/connection.py index d1e0ba5..5554375 100644 --- a/pyheos/connection.py +++ b/pyheos/connection.py @@ -16,6 +16,7 @@ CLI_PORT: Final = 1255 SEPARATOR: Final = "\r\n" SEPARATOR_BYTES: Final = SEPARATOR.encode() +MAX_RECONNECT_DELAY = 600 _LOGGER: Final = logging.getLogger(__name__) @@ -317,20 +318,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 = ConnectionState.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) + _LOGGER.debug( + "Attempting to reconnect to %s in %s seconds", self._host, delay + ) + await asyncio.sleep(delay) await self.connect() except HeosError as err: attempts += 1 + delay = min(delay * 2, MAX_RECONNECT_DELAY) _LOGGER.debug( - f"Failed reconnect attempt {attempts} to {self._host}: {err}" + "Failed reconnect attempt %s to %s: %s", attempts, self._host, err ) 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.""" From 1d49cc06605985bb83335f13215f365b0df609d3 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Fri, 10 Jan 2025 22:30:05 -0600 Subject: [PATCH 22/25] Add add_search_to_queue (#80) * Add add_search_to_queue * Removed unneeded player_id --- pyheos/const.py | 3 +++ pyheos/heos.py | 27 +++++++++++++++++++ pyheos/player.py | 27 +++++++++++++++++++ .../fixtures/browse.add_to_queue_search.json | 1 + tests/test_player.py | 23 +++++++++++++++- 5 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/browse.add_to_queue_search.json diff --git a/pyheos/const.py b/pyheos/const.py index 79d70e6..04ff0a0 100644 --- a/pyheos/const.py +++ b/pyheos/const.py @@ -40,6 +40,9 @@ SYSTEM_ERROR_CONTENT_AUTHORIZATION_ERROR: Final = -1232 SYSTEM_ERROR_ACCOUNT_PARAMETERS_INVALID: Final = -1239 +# Search Crtieria Container IDs (keep discrete values as we do not control the list) +SEARCHED_TRACKS: Final = "SEARCHED_TRACKS-" + # 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 diff --git a/pyheos/heos.py b/pyheos/heos.py index 334444b..1500175 100644 --- a/pyheos/heos.py +++ b/pyheos/heos.py @@ -498,6 +498,33 @@ async def add_to_queue( ) ) + async def add_search_to_queue( + self, + player_id: int, + 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: + 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: diff --git a/pyheos/player.py b/pyheos/player.py index ac061ff..a3a7a7c 100644 --- a/pyheos/player.py +++ b/pyheos/player.py @@ -463,6 +463,33 @@ 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, 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/test_player.py b/tests/test_player.py index cf5a420..fdd0000 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -5,7 +5,13 @@ import pytest from pyheos import command as c -from pyheos.const import INPUT_AUX_IN_1, MUSIC_SOURCE_DEEZER, MUSIC_SOURCE_PLAYLISTS +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 pyheos.types import ( @@ -441,6 +447,21 @@ async def test_add_to_queue(player: HeosPlayer) -> None: ) +@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.""" From d01b1d117367119954261d2de333e4ced22c5b63 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Fri, 10 Jan 2025 23:19:32 -0600 Subject: [PATCH 23/25] Combine mixins with command creation (#82) * Move SystemMixin * Move group commands * Move browse commands * Move player mixin --- pyheos/__init__.py | 4 +- pyheos/command/browse.py | 524 +++++++++++---- pyheos/command/connection.py | 27 + pyheos/command/group.py | 215 ++++-- pyheos/command/player.py | 453 +++++++++---- pyheos/command/system.py | 127 +++- pyheos/connection.py | 5 +- pyheos/heos.py | 1222 +--------------------------------- pyheos/options.py | 42 ++ pyheos/player.py | 15 + 10 files changed, 1069 insertions(+), 1565 deletions(-) create mode 100644 pyheos/command/connection.py create mode 100644 pyheos/options.py diff --git a/pyheos/__init__.py b/pyheos/__init__.py index 354f174..ede1ab5 100644 --- a/pyheos/__init__.py +++ b/pyheos/__init__.py @@ -21,7 +21,7 @@ HeosError, ) from .group import HeosGroup -from .heos import Heos, HeosOptions, PlayerUpdateResult +from .heos import Heos from .media import ( AlbumMetadata, BrowseResult, @@ -33,12 +33,14 @@ RetreiveMetadataResult, ServiceOption, ) +from .options import HeosOptions from .player import ( CONTROLS_ALL, CONTROLS_FORWARD_ONLY, CONTROLS_PLAY_STOP, HeosNowPlayingMedia, HeosPlayer, + PlayerUpdateResult, PlayMode, ) from .search import MultiSearchResult, SearchCriteria, SearchResult, SearchStatistic diff --git a/pyheos/command/browse.py b/pyheos/command/browse.py index a36eb8a..92538ec 100644 --- a/pyheos/command/browse.py +++ b/pyheos/command/browse.py @@ -9,10 +9,16 @@ 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 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, @@ -26,88 +32,193 @@ 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.types import AddCriteriaType +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. -class BrowseCommands: - """Define functions for creating browse commands.""" + 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. - @staticmethod - def browse( + 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 + + 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 + + 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] = {c.ATTR_SOURCE_ID: source_id} if container_id: params[c.ATTR_CONTAINER_ID] = container_id if isinstance(range_start, int) and isinstance(range_end, int): params[c.ATTR_RANGE] = f"{range_start},{range_end}" - return HeosCommand(c.COMMAND_BROWSE_BROWSE, params) + 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: - """ - Create a HEOS command to get the music sources. + 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.1 Get Music Sources - """ - params = {} - if refresh: - params[c.ATTR_REFRESH] = c.VALUE_ON - return HeosCommand(c.COMMAND_BROWSE_GET_SOURCES, params) - - @staticmethod - def get_music_source_info(source_id: int) -> HeosCommand: - """ - Create a HEOS command to get information about a music source. + 4.4.3 Browse Source + 4.4.4 Browse Source Containers + 4.4.13 Get HEOS Playlists + 4.4.16 Get HEOS History - References: - 4.4.2 Get Source Info + 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. """ - return HeosCommand( - c.COMMAND_BROWSE_GET_SOURCE_INFO, {c.ATTR_SOURCE_ID: source_id} - ) + 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 + ) - @staticmethod - def get_search_criteria(source_id: int) -> HeosCommand: + async def get_search_criteria(self, source_id: int) -> list[SearchCriteria]: """ Create a HEOS command to get the search criteria. References: 4.4.5 Get Search Criteria """ - return HeosCommand( - c.COMMAND_BROWSE_GET_SEARCH_CRITERIA, - {c.ATTR_SOURCE_ID: source_id}, + 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 search( + async def search( + self, source_id: int, search: str, criteria_id: int, range_start: int | None = None, range_end: int | None = None, - ) -> HeosCommand: + ) -> SearchResult: """ Create a HEOS command to search for media. References: - 4.4.6 Search - """ + 4.4.6 Search""" if search == "": raise ValueError("'search' parameter must not be empty") if len(search) > 128: @@ -121,22 +232,47 @@ def search( } if isinstance(range_start, int) and isinstance(range_end, int): params[c.ATTR_RANGE] = f"{range_start},{range_end}" - return HeosCommand(c.COMMAND_BROWSE_SEARCH, params) + result = await self._connection.command( + HeosCommand(c.COMMAND_BROWSE_SEARCH, params) + ) + return SearchResult._from_message(result, cast("Heos", self)) - @staticmethod - def play_station( - player_id: int, - source_id: int, - container_id: str | None, - media_id: str, - ) -> HeosCommand: + 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. """ - Create a HEOS command to play a station. + params = { + c.ATTR_PLAYER_ID: player_id, + c.ATTR_INPUT: input_name, + } + 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)) + + 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 - Note: Parameters 'cid' and 'name' do not appear to be required in testing, however send 'cid' if provided. + 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. """ params = { c.ATTR_PLAYER_ID: player_id, @@ -145,68 +281,69 @@ def play_station( } if container_id is not None: params[c.ATTR_CONTAINER_ID] = container_id - return HeosCommand(c.COMMAND_BROWSE_PLAY_STREAM, params) + await self._connection.command( + HeosCommand(c.COMMAND_BROWSE_PLAY_STREAM, params) + ) - @staticmethod - def play_preset_station(player_id: int, preset: int) -> HeosCommand: + async def play_preset_station(self, player_id: int, index: int) -> None: """ - Create a HEOS command to play a preset station. + Play the preset station on the specified player (favorite) References: 4.4.8 Play Preset Station - """ - if preset < 1: - raise ValueError(f"Invalid preset: {preset}") - return HeosCommand( - c.COMMAND_BROWSE_PLAY_PRESET, - {c.ATTR_PLAYER_ID: player_id, c.ATTR_PRESET: preset}, - ) - @staticmethod - def play_input_source( - player_id: int, input_name: str, source_player_id: int | None = None - ) -> HeosCommand: + Args: + player_id: The identifier of the player to play the preset station. + index: The index of the preset station to play. """ - Create a HEOS command to play the specified input source. - - References: - 4.4.9 Play Input Source - """ - params = { - c.ATTR_PLAYER_ID: player_id, - c.ATTR_INPUT: input_name, - } - if source_player_id is not None: - params[c.ATTR_SOURCE_PLAYER_ID] = source_player_id - return HeosCommand(c.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( - c.COMMAND_BROWSE_PLAY_STREAM, - {c.ATTR_PLAYER_ID: player_id, c.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: AddCriteriaType = AddCriteriaType.PLAY_NOW, - ) -> HeosCommand: + ) -> 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 = { c.ATTR_PLAYER_ID: player_id, @@ -216,14 +353,42 @@ def add_to_queue( } if media_id is not None: params[c.ATTR_MEDIA_ID] = media_id - return HeosCommand(c.COMMAND_BROWSE_ADD_TO_QUEUE, params) + await self._connection.command( + HeosCommand(c.COMMAND_BROWSE_ADD_TO_QUEUE, params) + ) - @staticmethod - def rename_playlist( - source_id: int, container_id: str, new_name: str - ) -> HeosCommand: + 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. """ - Create a HEOS command to rename a playlist. + 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 @@ -234,58 +399,66 @@ def rename_playlist( raise ValueError( "'new_name' parameter must be less than or equal to 128 characters" ) - return HeosCommand( - c.COMMAND_BROWSE_RENAME_PLAYLIST, - { - c.ATTR_SOURCE_ID: source_id, - c.ATTR_CONTAINER_ID: container_id, - c.ATTR_NAME: new_name, - }, + 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, + }, + ) ) - @staticmethod - def delete_playlist(source_id: int, container_id: str) -> HeosCommand: + 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""" - return HeosCommand( - c.COMMAND_BROWSE_DELETE__PLAYLIST, - { - c.ATTR_SOURCE_ID: source_id, - c.ATTR_CONTAINER_ID: container_id, - }, + + await self._connection.command( + HeosCommand( + c.COMMAND_BROWSE_DELETE__PLAYLIST, + { + c.ATTR_SOURCE_ID: source_id, + c.ATTR_CONTAINER_ID: container_id, + }, + ) ) - @staticmethod - def retrieve_metadata(source_it: int, container_id: str) -> HeosCommand: + async def retrieve_metadata( + self, source_it: int, container_id: str + ) -> RetreiveMetadataResult: """ - Create a HEOS command to retrieve metadata. + Create a HEOS command to retrieve metadata. Only supported by Rhapsody/Napster music sources. References: 4.4.17 Retrieve Metadata """ - return HeosCommand( - c.COMMAND_BROWSE_RETRIEVE_METADATA, - { - c.ATTR_SOURCE_ID: source_it, - c.ATTR_CONTAINER_ID: container_id, - }, + 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) - @staticmethod - def set_service_option( + async def set_service_option( + this, option_id: int, - source_id: int | None, - container_id: str | None, - media_id: str | None, - player_id: int | None, - name: str | None, - criteria_id: int | None, + 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, - ) -> HeosCommand: + ) -> None: """ Create a HEOS command to set a service option. @@ -429,13 +602,97 @@ def set_service_option( f"{', '.join(disallowed_params.keys())} parameters are not allowed for service option_id {option_id}" ) - # return the command - return HeosCommand(c.COMMAND_BROWSE_SET_SERVICE_OPTION, params) + 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. - @staticmethod - def multi_search( - search: str, source_ids: list[int] | None, criteria_ids: list[int] | None - ) -> HeosCommand: + 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. @@ -451,4 +708,7 @@ def multi_search( 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)) - return HeosCommand(c.COMMAND_BROWSE_MULTI_SEARCH, params) + 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 81a0ce1..bfb4b71 100644 --- a/pyheos/command/group.py +++ b/pyheos/command/group.py @@ -4,117 +4,236 @@ This module creates HEOS group commands. """ +import asyncio from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, cast 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: - """Define functions for creating group commands.""" - @staticmethod - def get_groups() -> HeosCommand: - """Create a get groups c. +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 + + @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. References: 4.3.1 Get Groups""" - return HeosCommand(c.COMMAND_GET_GROUPS) - - @staticmethod - def get_group_info(group_id: int) -> HeosCommand: + 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. + References: 4.3.2 Get Group Info""" - return HeosCommand(c.COMMAND_GET_GROUP_INFO, {c.ATTR_GROUP_ID: group_id}) - - @staticmethod - def set_group(player_ids: Sequence[int]) -> HeosCommand: + 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( - c.COMMAND_SET_GROUP, - {c.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(c.COMMAND_GET_GROUP_VOLUME, {c.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( - c.COMMAND_SET_GROUP_VOLUME, - {c.ATTR_GROUP_ID: group_id, c.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( - c.COMMAND_GROUP_VOLUME_UP, - {c.ATTR_GROUP_ID: group_id, c.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( - c.COMMAND_GROUP_VOLUME_DOWN, - {c.ATTR_GROUP_ID: group_id, c.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(c.COMMAND_GET_GROUP_MUTE, {c.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( - c.COMMAND_SET_GROUP_MUTE, - { - c.ATTR_GROUP_ID: group_id, - c.ATTR_STATE: c.VALUE_ON if state else c.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(c.COMMAND_GROUP_TOGGLE_MUTE, {c.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 ac75ce5..badc74e 100644 --- a/pyheos/command/player.py +++ b/pyheos/command/player.py @@ -4,169 +4,322 @@ This module creates HEOS player commands. """ -from typing import Any +import asyncio +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, cast from pyheos import command as c -from pyheos.const import DEFAULT_STEP +from pyheos import const +from pyheos.command.connection import ConnectionMixin +from pyheos.media import QueueItem from pyheos.message import HeosCommand -from pyheos.player import PlayState +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.""" - References: - 4.2.1 Get Players - """ - return HeosCommand(c.COMMAND_GET_PLAYERS) + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Init a new instance of the BrowseMixin.""" + super(PlayerCommands, self).__init__(*args, **kwargs) - @staticmethod - def get_player_info(player_id: int) -> HeosCommand: - """Get player information. + 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 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. References: 4.2.2 Get Player Info""" - return HeosCommand(c.COMMAND_GET_PLAYER_INFO, {c.ATTR_PLAYER_ID: player_id}) + 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 - @staticmethod - def get_play_state(player_id: int) -> HeosCommand: + 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(c.COMMAND_GET_PLAY_STATE, {c.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: 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( - c.COMMAND_SET_PLAY_STATE, - {c.ATTR_PLAYER_ID: player_id, c.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( - c.COMMAND_GET_NOW_PLAYING_MEDIA, {c.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(c.COMMAND_GET_VOLUME, {c.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( - c.COMMAND_SET_VOLUME, - {c.ATTR_PLAYER_ID: player_id, c.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 = 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( - c.COMMAND_VOLUME_UP, - {c.ATTR_PLAYER_ID: player_id, c.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 = 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( - c.COMMAND_VOLUME_DOWN, - {c.ATTR_PLAYER_ID: player_id, c.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(c.COMMAND_GET_MUTE, {c.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( - c.COMMAND_SET_MUTE, - { - c.ATTR_PLAYER_ID: player_id, - c.ATTR_STATE: c.VALUE_ON if state else c.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(c.COMMAND_TOGGLE_MUTE, {c.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(c.COMMAND_GET_PLAY_MODE, {c.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: 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( - 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, - }, + 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, + }, + ) ) - @staticmethod - def get_queue( - player_id: int, range_start: int | None = None, range_end: int | None = None - ) -> HeosCommand: + 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: @@ -175,125 +328,157 @@ def 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}" - return HeosCommand(c.COMMAND_GET_QUEUE, params) + 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] - @staticmethod - def play_queue(player_id: int, queue_id: int) -> HeosCommand: + async def player_play_queue(self, player_id: int, queue_id: int) -> None: """Play a queue item. References: 4.2.16 Play Queue Item""" - return HeosCommand( - c.COMMAND_PLAY_QUEUE, - {c.ATTR_PLAYER_ID: player_id, c.ATTR_QUEUE_ID: queue_id}, + await self._connection.command( + HeosCommand( + c.COMMAND_PLAY_QUEUE, + {c.ATTR_PLAYER_ID: player_id, c.ATTR_QUEUE_ID: queue_id}, + ) ) - @staticmethod - def remove_from_queue(player_id: int, queue_ids: list[int]) -> HeosCommand: + 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""" - return HeosCommand( - c.COMMAND_REMOVE_FROM_QUEUE, - { - c.ATTR_PLAYER_ID: player_id, - c.ATTR_QUEUE_ID: ",".join(map(str, queue_ids)), - }, + 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)), + }, + ) ) - @staticmethod - def save_queue(player_id: int, name: str) -> HeosCommand: + 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") - return HeosCommand( - c.COMMAND_SAVE_QUEUE, - {c.ATTR_PLAYER_ID: player_id, c.ATTR_NAME: name}, + 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(c.COMMAND_CLEAR_QUEUE, {c.ATTR_PLAYER_ID: player_id}) + await self._connection.command( + HeosCommand(c.COMMAND_CLEAR_QUEUE, {c.ATTR_PLAYER_ID: player_id}) + ) - @staticmethod - def move_queue_item( - player_id: int, source_queue_ids: list[int], destination_queue_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""" - return 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, - }, + 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, + }, + ) ) - @staticmethod - def play_next(player_id: int) -> HeosCommand: + async def player_play_next(self, player_id: int) -> None: """Play next. References: 4.2.21 Play Next""" - return HeosCommand(c.COMMAND_PLAY_NEXT, {c.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(c.COMMAND_PLAY_PREVIOUS, {c.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( - c.COMMAND_SET_QUICK_SELECT, - {c.ATTR_PLAYER_ID: player_id, c.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( - c.COMMAND_PLAY_QUICK_SELECT, - {c.ATTR_PLAYER_ID: player_id, c.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(c.COMMAND_GET_QUICK_SELECTS, {c.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) + } - @staticmethod - def check_update(player_id: int) -> HeosCommand: + 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""" - return HeosCommand(c.COMMAND_CHECK_UPDATE, {c.ATTR_PLAYER_ID: player_id}) + 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 20eb4b0..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 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( - c.COMMAND_REGISTER_FOR_CHANGE_EVENTS, - {c.ATTR_ENABLE: c.VALUE_ON if enable else c.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 c. + async def check_account(self) -> str | None: + """Return the logged in username. References: 4.1.2 HEOS Account Check""" - return HeosCommand(c.COMMAND_ACCOUNT_CHECK) - - @staticmethod - def sign_in(username: str, password: str) -> HeosCommand: - """Create a sign in c. + 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( - c.COMMAND_SIGN_IN, - {c.ATTR_USER_NAME: username, c.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 c. + 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(c.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 c. + 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(c.COMMAND_HEART_BEAT) + await self._connection.command(HeosCommand(c.COMMAND_HEART_BEAT)) - @staticmethod - def reboot() -> HeosCommand: - """Create a reboot c. + async def reboot(self) -> None: + """Reboot the HEOS device. References: 4.1.6 HEOS Speaker Reboot""" - return HeosCommand(c.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 5554375..859ead8 100644 --- a/pyheos/connection.py +++ b/pyheos/connection.py @@ -6,8 +6,7 @@ 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 @@ -309,7 +308,7 @@ async def _heart_beat_handler(self) -> None: 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 diff --git a/pyheos/heos.py b/pyheos/heos.py index 1500175..6475693 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, @@ -20,29 +16,16 @@ callback_wrapper, ) from pyheos.error import CommandAuthenticationError, CommandFailedError -from pyheos.media import ( - BrowseResult, - MediaItem, - MediaMusicSource, - QueueItem, - RetreiveMetadataResult, -) from pyheos.message import HeosMessage -from pyheos.search import MultiSearchResult, SearchCriteria, SearchResult -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 ( - AddCriteriaType, ConnectionState, - MediaType, - PlayState, - RepeatType, SignalHeosEvent, SignalType, ) @@ -50,1202 +33,7 @@ _LOGGER: Final = logging.getLogger(__name__) -@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(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) -> ConnectionState: - """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 - - @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""" - 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 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""" - result = await self._connection.command( - SystemCommands.sign_in(username, 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. - - 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 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 - - if music_source is None or refresh: - # Get the latest information - result = await self._connection.command( - BrowseCommands.get_music_source_info(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 - - 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_message(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 get_search_criteria(self, source_id: int) -> list[SearchCriteria]: - """ - Create a HEOS command to get the search criteria. - - References: - 4.4.5 Get Search Criteria - """ - result = await self._connection.command( - BrowseCommands.get_search_criteria(source_id) - ) - payload = cast(list[dict[str, str]], result.payload) - return [SearchCriteria._from_data(data) for data in payload] - - async def search( - self, - source_id: int, - search: str, - criteria_id: int, - range_start: int | None = None, - range_end: int | None = None, - ) -> SearchResult: - """ - Create a HEOS command to search for media. - - References: - 4.4.6 Search""" - - result = await self._connection.command( - BrowseCommands.search( - source_id, search, criteria_id, range_start, range_end - ) - ) - return SearchResult._from_message(result, cast("Heos", self)) - - 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: AddCriteriaType = 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 add_search_to_queue( - self, - player_id: int, - 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: - 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 - """ - await self._connection.command( - BrowseCommands.rename_playlist(source_id, container_id, 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( - BrowseCommands.delete_playlist(source_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( - BrowseCommands.retrieve_metadata(source_it, 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 - """ - await this._connection.command( - BrowseCommands.set_service_option( - option_id, - source_id, - container_id, - media_id, - player_id, - name, - criteria_id, - range_start, - range_end, - ) - ) - - 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 const.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(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 - - 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 - """ - result = await self._connection.command( - BrowseCommands.multi_search(search, source_ids, criteria_ids) - ) - return MultiSearchResult._from_message(result, cast("Heos", self)) - - -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 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. - - 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( - PlayerCommands.get_player_info(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(PlayerCommands.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""" - response = await self._connection.command( - PlayerCommands.get_play_state(player_id) - ) - return PlayState(response.get_message_value(c.ATTR_STATE)) - - 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""" - 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(c.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(c.ATTR_STATE) == c.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: 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_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 - """ - result = await self._connection.command( - PlayerCommands.get_queue(player_id, range_start, range_end) - ) - 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(PlayerCommands.play_queue(player_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( - PlayerCommands.remove_from_queue(player_id, 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""" - await self._connection.command(PlayerCommands.save_queue(player_id, name)) - - 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_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( - PlayerCommands.move_queue_item( - player_id, source_queue_ids, destination_queue_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 player_get_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[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(PlayerCommands.check_update(player_id)) - payload = cast(dict[str, Any], result.payload) - return bool(payload[c.ATTR_UPDATE] == c.VALUE_UPDATE_EXIST) - - -class GroupMixin(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(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. - - References: - 4.3.1 Get 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(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. - - 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( - GroupCommands.get_group_info(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""" - 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(c.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(c.ATTR_STATE) == c.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 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 a3a7a7c..2b5c5f0 100644 --- a/pyheos/player.py +++ b/pyheos/player.py @@ -81,6 +81,21 @@ } +@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: """Define now playing media information.""" From e1d13929687c846fbe07b5388df47b84a54dea21 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sat, 11 Jan 2025 15:31:53 -0600 Subject: [PATCH 24/25] Implement feedback from 1.0.0-rc (#84) * Add missing items to __all__ * Remove redundant logging * Update log messaging --- pyheos/__init__.py | 6 +++ pyheos/command/__init__.py | 2 +- pyheos/connection.py | 80 ++++++++++++++++++-------------------- pyheos/heos.py | 7 ++-- pyheos/message.py | 8 +++- 5 files changed, 53 insertions(+), 50 deletions(-) diff --git a/pyheos/__init__.py b/pyheos/__init__.py index ede1ab5..6b3f223 100644 --- a/pyheos/__init__.py +++ b/pyheos/__init__.py @@ -48,12 +48,15 @@ from .types import ( AddCriteriaType, ConnectionState, + ControlType, + LineOutLevelType, MediaType, NetworkType, PlayState, RepeatType, SignalHeosEvent, SignalType, + VolumeControlType, ) __all__ = [ @@ -61,6 +64,7 @@ "AlbumMetadata", "BrowseResult", "CallbackType", + "ControlType", "CommandAuthenticationError", "CommandError", "CommandFailedError", @@ -85,6 +89,7 @@ "HeosPlayer", "HeosSystem", "ImageMetadata", + "LineOutLevelType", "Media", "MediaItem", "MediaMusicSource", @@ -106,4 +111,5 @@ "SignalHeosEvent", "SignalType", "TargetType", + "VolumeControlType", ] diff --git a/pyheos/command/__init__.py b/pyheos/command/__init__.py index 0d8e3ce..9442f0c 100644 --- a/pyheos/command/__init__.py +++ b/pyheos/command/__init__.py @@ -177,7 +177,7 @@ def parse_enum( return enum_type(value) except ValueError: _LOGGER.warning( - "Unrecognized '%s' value: '%s', using default value: '%s'. Full data: %s. %s.", + "Unrecognized '%s' value: '%s', using default value: '%s'. Full data: %s. %s", key, value, default, diff --git a/pyheos/connection.py b/pyheos/connection.py index 859ead8..032b236 100644 --- a/pyheos/connection.py +++ b/pyheos/connection.py @@ -19,24 +19,6 @@ _LOGGER: Final = logging.getLogger(__name__) -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 ConnectionBase: """ @@ -139,7 +121,12 @@ async def _reset(self) -> None: 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: @@ -164,10 +151,10 @@ async def _read_handler(self, reader: asyncio.StreamReader) -> None: 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 @@ -181,9 +168,7 @@ async def command(self, command: HeosCommand) -> HeosMessage: async def _command_impl() -> HeosMessage: """Implementation of the command.""" if self._state is not ConnectionState.CONNECTED: - _LOGGER.debug( - f"Command failed '{command.uri_masked}': Not connected to device" - ) + _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 @@ -195,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 @@ -213,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() @@ -225,10 +211,10 @@ async def _command_impl() -> HeosMessage: # Check the result if not response.result: - _LOGGER.debug(f"Command failed '{command.uri_masked}': '{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 @@ -253,20 +239,28 @@ async def connect(self) -> None: 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 = ConnectionState.CONNECTED - _LOGGER.debug(f"Connected to {self._host}") + _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() @@ -324,16 +318,16 @@ async def _attempt_reconnect(self) -> None: while (attempts < self._reconnect_max_attempts) or unlimited_attempts: try: _LOGGER.debug( - "Attempting to reconnect to %s in %s seconds", self._host, delay + "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 as err: + except HeosError: attempts += 1 delay = min(delay * 2, MAX_RECONNECT_DELAY) - _LOGGER.debug( - "Failed reconnect attempt %s to %s: %s", attempts, self._host, err - ) else: return # This never actually hits as the task is cancelled when the connection is established, but it's here for completeness. diff --git a/pyheos/heos.py b/pyheos/heos.py index 6475693..8ba60f0 100644 --- a/pyheos/heos.py +++ b/pyheos/heos.py @@ -219,7 +219,7 @@ 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.""" @@ -239,7 +239,6 @@ async def _on_event_heos(self, event: HeosMessage) -> None: await self._dispatcher.wait_send( 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.""" @@ -254,7 +253,7 @@ async def _on_event_player(self, event: HeosMessage) -> None: 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.""" @@ -267,7 +266,7 @@ async def _on_event_group(self, event: HeosMessage) -> None: 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/message.py b/pyheos/message.py index 6facb9b..6163229 100644 --- a/pyheos/message.py +++ b/pyheos/message.py @@ -21,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) From 28c28ad2b11e2e2de72bbb50dbd430eb36c49ac9 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sat, 11 Jan 2025 21:33:32 +0000 Subject: [PATCH 25/25] Update version and status --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6f80a6c..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",