diff --git a/pyheos/command/player.py b/pyheos/command/player.py index 7e149cc..765f3d8 100644 --- a/pyheos/command/player.py +++ b/pyheos/command/player.py @@ -101,7 +101,7 @@ async def load_players(self) -> PlayerUpdateResult: 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] + version = player_data.get(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 @@ -111,7 +111,11 @@ async def load_players(self) -> PlayerUpdateResult: 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) + or ( + player.name == name + and player.version != version + and version is not None + ) ), None, ) diff --git a/pyheos/command/system.py b/pyheos/command/system.py index f1fcc4c..40c16ae 100644 --- a/pyheos/command/system.py +++ b/pyheos/command/system.py @@ -134,5 +134,7 @@ async def get_system_info(self) -> HeosSystem: response = await self._connection.command(HeosCommand(c.COMMAND_GET_PLAYERS)) payload = cast(Sequence[dict[str, Any]], 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) + host = next( + (host for host in hosts if host.ip_address == self._options.host), None + ) return HeosSystem(self._signed_in_username, host, hosts) diff --git a/pyheos/common.py b/pyheos/common.py new file mode 100644 index 0000000..0dbefba --- /dev/null +++ b/pyheos/common.py @@ -0,0 +1,44 @@ +"""Define the common module.""" + +from typing import cast + +from pyheos.const import TARGET_VERSION + + +def is_supported_version( + version: str | None, min_version: str = TARGET_VERSION +) -> bool: + """Check if a version is supported. + + Args: + version (str): The version to check. + min_version (str): The minimum version. + + Returns: + bool: True if the version is supported, False otherwise. + """ + if version is None: + return False + try: + sem_ver = get_sem_ver(version) + except ValueError: + return False + min_sem_ver = get_sem_ver(min_version) + for i, ver in enumerate(sem_ver): + if ver < min_sem_ver[i]: + return False + if ver > min_sem_ver[i]: + return True + return True + + +def get_sem_ver(version: str) -> tuple[int, int, int]: + """Get the semantic version from a string. + + Args: + version (str): The version string. + + Returns: + tuple: The semantic version as a tuple. + """ + return cast(tuple[int, int, int], tuple(map(int, version.split(".", 3)))) diff --git a/pyheos/connection.py b/pyheos/connection.py index 546addc..6f0692e 100644 --- a/pyheos/connection.py +++ b/pyheos/connection.py @@ -89,6 +89,15 @@ async def _on_command_error(self, error: CommandFailedError) -> None: for callback in self._on_command_error_callbacks: await callback(error) + def _log_callback_exception(self, future: asyncio.Future[Any]) -> None: + """Log uncaught exception that occurs in a callback.""" + if not future.cancelled() and future.exception(): + _LOGGER.exception( + "Unexpected exception in task: %s", + future, + exc_info=future.exception(), + ) + def _register_task( self, future: Coroutine[Any, Any, None], name: str | None = None ) -> None: @@ -96,6 +105,7 @@ def _register_task( task: asyncio.Task[None] = asyncio.create_task(future, name=name) self._running_tasks.add(task) task.add_done_callback(self._running_tasks.discard) + task.add_done_callback(self._log_callback_exception) async def _reset(self) -> None: """Reset the state of the connection.""" @@ -137,11 +147,11 @@ async def _read_handler(self, reader: asyncio.StreamReader) -> None: return else: self._last_activity = datetime.now() - await self._handle_message( + self._handle_message( HeosMessage._from_raw_message(binary_result.decode()) ) - async def _handle_message(self, message: HeosMessage) -> None: + def _handle_message(self, message: HeosMessage) -> None: """Handle a message received from the HEOS device.""" if message.is_under_process: _LOGGER.debug("Command under process '%s'", message.command) @@ -152,7 +162,10 @@ async def _handle_message(self, message: HeosMessage) -> None: return # Set the message on the pending command. - self._pending_command_event.set(message) + if not self._pending_command_event.set(message): + _LOGGER.debug( + "Unexpected response received: '%s': '%s'", message.command, message + ) async def command(self, command: HeosCommand) -> HeosMessage: """Send a command to the HEOS device.""" @@ -165,19 +178,19 @@ async def _command_impl() -> HeosMessage: raise CommandError(command.command, "Not connected to device") if TYPE_CHECKING: assert self._writer is not None - assert not self._pending_command_event.is_set() + # Send the command try: self._writer.write((command.uri + SEPARATOR).encode()) await self._writer.drain() 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), "Disconnect From Error" - ) _LOGGER.debug( "Command failed '%s': %s: %s", command, type(error).__name__, error ) + self._register_task( + self._disconnect_from_error(error), "Disconnect From Error" + ) raise CommandError( command.command, f"Command failed: {error}" ) from error @@ -192,7 +205,7 @@ async def _command_impl() -> HeosMessage: # Wait for the response with a timeout try: response = await asyncio.wait_for( - self._pending_command_event.wait(), self._timeout + self._pending_command_event.wait(command.command), self._timeout ) except asyncio.TimeoutError as error: # Occurs when the command times out @@ -201,9 +214,6 @@ async def _command_impl() -> HeosMessage: finally: self._pending_command_event.clear() - # The retrieved response should match the command - assert command.command == response.command - # Check the result if not response.result: _LOGGER.debug("Command failed '%s': '%s'", command, response) @@ -340,24 +350,27 @@ def __init__(self) -> None: """Init a new instance of the CommandEvent.""" self._event: asyncio.Event = asyncio.Event() self._response: HeosMessage | None = None + self._target_command: str | None = None - async def wait(self) -> HeosMessage: + async def wait(self, target_command: str) -> HeosMessage: """Wait until the event is set.""" + self._target_command = target_command await self._event.wait() if TYPE_CHECKING: assert self._response is not None return self._response - def set(self, response: HeosMessage) -> None: + def set(self, response: HeosMessage) -> bool: """Set the response.""" + if self._target_command is None or self._target_command != response.command: + return False + self._target_command = None self._response = response self._event.set() + return True def clear(self) -> None: """Clear the event.""" self._response = None + self._target_command = None self._event.clear() - - def is_set(self) -> bool: - """Return True if the event is set.""" - return self._event.is_set() diff --git a/pyheos/const.py b/pyheos/const.py index 04ff0a0..11fadb5 100644 --- a/pyheos/const.py +++ b/pyheos/const.py @@ -6,6 +6,8 @@ from typing import Final +TARGET_VERSION: Final = "3.34.0" + DEFAULT_TIMEOUT: Final = 10.0 DEFAULT_RECONNECT_DELAY: Final = 10.0 DEFAULT_RECONNECT_ATTEMPTS: Final = 0 # Unlimited diff --git a/pyheos/player.py b/pyheos/player.py index 46d192e..4a90b33 100644 --- a/pyheos/player.py +++ b/pyheos/player.py @@ -8,6 +8,7 @@ from pyheos.abc import RemoveHeosFieldABC from pyheos.command import optional_int, parse_enum, parse_optional_enum +from pyheos.common import is_supported_version from pyheos.dispatch import DisconnectType, EventCallbackType, callback_wrapper from pyheos.media import MediaItem, QueueItem, ServiceOption from pyheos.message import HeosMessage @@ -192,8 +193,9 @@ class HeosPlayer(RemoveHeosFieldABC): player_id: int = field(repr=True, hash=True, compare=True) model: str = field(repr=True, hash=False, compare=False) 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) + version: str | None = field(repr=True, hash=False, compare=False) + supported_version: bool = field(repr=True, hash=False, compare=False) + ip_address: str | None = field(repr=True, 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( @@ -220,14 +222,15 @@ def _from_data( heos: Optional["Heos"] = None, ) -> "HeosPlayer": """Create a new instance from the provided data.""" - + version = data.get(c.ATTR_VERSION) return HeosPlayer( name=data[c.ATTR_NAME], player_id=int(data[c.ATTR_PLAYER_ID]), model=data[c.ATTR_MODEL], serial=data.get(c.ATTR_SERIAL), - version=data[c.ATTR_VERSION], - ip_address=data[c.ATTR_IP_ADDRESS], + version=version, + supported_version=is_supported_version(version), + ip_address=data.get(c.ATTR_IP_ADDRESS), network=parse_enum(c.ATTR_NETWORK, data, NetworkType, NetworkType.UNKNOWN), line_out=parse_enum( c.ATTR_LINE_OUT, data, LineOutLevelType, LineOutLevelType.UNKNOWN @@ -245,8 +248,9 @@ def _update_from_data(self, data: dict[str, Any]) -> None: 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.version = data.get(c.ATTR_VERSION) + self.supported_version = is_supported_version(self.version) + self.ip_address = data.get(c.ATTR_IP_ADDRESS) self.network = parse_enum( c.ATTR_NETWORK, data, NetworkType, NetworkType.UNKNOWN ) diff --git a/pyheos/system.py b/pyheos/system.py index 83ac82e..25f3e1f 100644 --- a/pyheos/system.py +++ b/pyheos/system.py @@ -4,6 +4,7 @@ from typing import Any from pyheos import command as c +from pyheos.common import is_supported_version from pyheos.types import NetworkType @@ -17,9 +18,10 @@ class HeosHost: name: str model: str serial: str | None - version: str - ip_address: str + version: str | None + ip_address: str | None network: NetworkType + supported_version: bool @staticmethod def _from_data(data: dict[str, Any]) -> "HeosHost": @@ -31,13 +33,15 @@ def _from_data(data: dict[str, Any]) -> "HeosHost": Returns: HeosHost: The created HeosHost object. """ + version = data.get(c.ATTR_VERSION) return HeosHost( data[c.ATTR_NAME], data[c.ATTR_MODEL], data.get(c.ATTR_SERIAL), - data[c.ATTR_VERSION], - data[c.ATTR_IP_ADDRESS], + version, + data.get(c.ATTR_IP_ADDRESS), c.parse_enum(c.ATTR_NETWORK, data, NetworkType, NetworkType.UNKNOWN), + is_supported_version(version), ) @@ -49,7 +53,7 @@ class HeosSystem: """ signed_in_username: str | None - host: HeosHost + host: HeosHost | None hosts: list[HeosHost] is_signed_in: bool = field(init=False) preferred_hosts: list[HeosHost] = field(init=False) @@ -59,6 +63,14 @@ def __post_init__(self) -> None: """Post initialize the system.""" self.is_signed_in = self.signed_in_username is not None self.preferred_hosts = list( - [host for host in self.hosts if host.network == NetworkType.WIRED] + [ + host + for host in self.hosts + if host.network == NetworkType.WIRED + and host.supported_version + and host.ip_address is not None + ] + ) + self.connected_to_preferred_host = ( + self.host is not None and self.host in self.preferred_hosts ) - self.connected_to_preferred_host = self.host in self.preferred_hosts diff --git a/pyproject.toml b/pyproject.toml index 4c06151..9089524 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pyheos" -version = "1.0.1" +version = "1.0.2" description = "An async python library for controlling HEOS devices through the HEOS CLI Protocol" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/__init__.py b/tests/__init__.py index 3b73ce3..d08fb5e 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -3,8 +3,9 @@ import asyncio import functools import json -from collections.abc import Callable, Sequence +from collections.abc import Callable, Generator, Sequence from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager from dataclasses import dataclass, field from typing import Any, cast from urllib.parse import parse_qsl, quote_plus, urlencode, urlparse @@ -118,7 +119,7 @@ async def wrapped(*args: Any, **kwargs: Any) -> Any: ) # Register commands - assert_list: list[Callable[..., None]] = [] + assert_list: list[CommandMatcher] = [] for command in matched_commands: # Get the fixture command @@ -141,14 +142,15 @@ async def wrapped(*args: Any, **kwargs: Any) -> Any: # Store item to assert later (so we don't need to keep a copy of the resolved args) if command.assert_called: - assert_list.append(matcher.assert_called) + assert_list.append(matcher) # Call the wrapped method result = await func(*args, **kwargs) # Assert the commands were called - for callable in assert_list: - callable() + for matcher in assert_list: + if matcher in mock_device._matchers: + matcher.assert_called() return result @@ -273,6 +275,7 @@ def __init__(self) -> None: self._started: bool = False self.connections: list[ConnectionLog] = [] self._matchers: list[CommandMatcher] = [] + self.modifiers: list[CommandModifier] = [] async def start(self) -> None: """Start the heos server.""" @@ -354,6 +357,18 @@ def assert_command_called( f"Command was not registered: {target_command} with args {target_args}." ) + @contextmanager + def modify( + self, command: str, *, replay_response: int = 1, delay_response: float = 0.0 + ) -> Generator[None]: + """Modifies behavior of command processing.""" + modifier = CommandModifier( + command, replay_response=replay_response, delay_response=delay_response + ) + self.modifiers.append(modifier) + yield + self.modifiers.remove(modifier) + async def _handle_connection( self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: @@ -387,10 +402,27 @@ async def _handle_connection( None, ) if matcher: + # Apply modifiers + modifier = next( + ( + modifier + for modifier in self.modifiers + if modifier.command == command + ), + DEFAULT_MODIFIER, + ) + + # Delay the response if set + if modifier.delay_response > 0: + await asyncio.sleep(modifier.delay_response) + responses = await matcher.get_response(query) - for response in responses: - writer.write((response + SEPARATOR).encode()) - await writer.drain() + # Write the response multiple times if set + for _ in range(modifier.replay_response): + for response in responses: + writer.write((response + SEPARATOR).encode()) + await writer.drain() + continue # Special processing for known/unknown commands @@ -483,9 +515,10 @@ async def _get_response(self, response: str, query: dict[str, str]) -> str: def assert_called(self) -> None: """Assert that the command was called.""" - assert self.match_count, ( - f"Command {self.command} was not called with arguments {self._args}." - ) + if self.match_count == 0: + raise AssertionError( + f"Command {self.command} was not called with arguments {self._args}." + ) class ConnectionLog: @@ -512,3 +545,15 @@ async def write(self, payload: str) -> None: data = (payload + SEPARATOR).encode() self._writer.write(data) await self._writer.drain() + + +@dataclass +class CommandModifier: + """Define a command modifier.""" + + command: str + replay_response: int = field(kw_only=True, default=1) + delay_response: float = field(kw_only=True, default=0.0) + + +DEFAULT_MODIFIER = CommandModifier(c.COMMAND_GET_PLAYERS) diff --git a/tests/conftest.py b/tests/conftest.py index a0e01ab..db5e806 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -131,7 +131,8 @@ async def player_fixture(heos: MockHeos) -> HeosPlayer: player_id=1, model="HEOS Drive", serial="B1A2C3K", - version="1.493.180", + version="3.34.240", + supported_version=True, ip_address="127.0.0.1", network=NetworkType.WIRED, line_out=LineOutLevelType.FIXED, @@ -147,7 +148,8 @@ async def player_front_porch_fixture(heos: MockHeos) -> HeosPlayer: player_id=2, model="HEOS Drive", serial=None, - version="1.493.180", + version="3.34.240", + supported_version=True, ip_address="127.0.0.2", network=NetworkType.WIFI, line_out=LineOutLevelType.FIXED, diff --git a/tests/fixtures/player.get_now_playing_media_failed.json b/tests/fixtures/player.get_now_playing_media_failed.json new file mode 100644 index 0000000..6f0c160 --- /dev/null +++ b/tests/fixtures/player.get_now_playing_media_failed.json @@ -0,0 +1 @@ +{"heos": {"command": "player/get_now_playing_media", "result": "fail", "message": "eid=2&text=ID Not Valid&pid=1"}} \ No newline at end of file diff --git a/tests/fixtures/player.get_players.json b/tests/fixtures/player.get_players.json index 47f5d9b..7e89f34 100644 --- a/tests/fixtures/player.get_players.json +++ b/tests/fixtures/player.get_players.json @@ -9,7 +9,7 @@ "pid": 1, "gid": 2, "model": "HEOS Drive", - "version": "1.493.180", + "version": "3.34.240", "ip": "127.0.0.1", "network": "wired", "lineout": 2, @@ -20,7 +20,7 @@ "pid": 2, "gid": 2, "model": "HEOS Drive", - "version": "1.493.180", + "version": "3.34.240", "ip": "127.0.0.2", "network": "wifi", "lineout": 1 diff --git a/tests/fixtures/player.get_players_changed.json b/tests/fixtures/player.get_players_changed.json index 181276a..297696e 100644 --- a/tests/fixtures/player.get_players_changed.json +++ b/tests/fixtures/player.get_players_changed.json @@ -8,7 +8,7 @@ "name": "Backyard", "pid": 1, "model": "HEOS Drive", - "version": "1.493.180", + "version": "3.34.240", "ip": "192.168.0.1", "network": "wired", "lineout": 1, @@ -17,7 +17,7 @@ "name": "Basement", "pid": 3, "model": "HEOS Drive", - "version": "1.493.180", + "version": "3.34.240", "ip": "127.0.0.3", "network": "wifi", "lineout": 1 diff --git a/tests/fixtures/player.get_players_firmware_update.json b/tests/fixtures/player.get_players_firmware_update.json index e6c47f0..6cf383c 100644 --- a/tests/fixtures/player.get_players_firmware_update.json +++ b/tests/fixtures/player.get_players_firmware_update.json @@ -8,7 +8,7 @@ "name": "Back Patio", "pid": 101, "model": "HEOS Drive", - "version": "1.500.000", + "version": "3.34.610", "ip": "127.0.0.1", "network": "wired", "lineout": 1, @@ -17,7 +17,7 @@ "name": "Front Porch", "pid": 102, "model": "HEOS Drive", - "version": "1.500.000", + "version": "3.34.610", "ip": "127.0.0.2", "network": "wifi", "lineout": 1 diff --git a/tests/fixtures/player.get_players_no_groups.json b/tests/fixtures/player.get_players_no_groups.json index 6459ce2..f81348b 100644 --- a/tests/fixtures/player.get_players_no_groups.json +++ b/tests/fixtures/player.get_players_no_groups.json @@ -8,7 +8,7 @@ "name": "Back Patio", "pid": 1, "model": "HEOS Drive", - "version": "1.493.180", + "version": "3.34.240", "ip": "127.0.0.1", "network": "wired", "lineout": 2, @@ -18,7 +18,7 @@ "name": "Front Porch", "pid": 2, "model": "HEOS Drive", - "version": "1.493.180", + "version": "3.34.240", "ip": "127.0.0.2", "network": "wifi", "lineout": 1 diff --git a/tests/fixtures/player.get_players_unsupported.json b/tests/fixtures/player.get_players_unsupported.json new file mode 100644 index 0000000..479f34d --- /dev/null +++ b/tests/fixtures/player.get_players_unsupported.json @@ -0,0 +1,21 @@ +{ + "heos": { + "command": "player/get_players", + "result": "success", + "message": "" + }, + "payload": [{ + "name": "Back Patio", + "pid": 1, + "model": "HEOS Link" + }, { + "name": "Front Porch", + "pid": 2, + "model": "HEOS 5", + "version": "1.583.147", + "ip": "127.0.0.2", + "network": "wifi", + "lineout": 0 + } + ] +} \ No newline at end of file diff --git a/tests/snapshots/test_heos.ambr b/tests/snapshots/test_heos.ambr index 0ddcfb2..62f710e 100644 --- a/tests/snapshots/test_heos.ambr +++ b/tests/snapshots/test_heos.ambr @@ -790,7 +790,8 @@ 'serial': 'B1A2C3K', 'shuffle': False, 'state': , - 'version': '1.493.180', + 'supported_version': True, + 'version': '3.34.240', 'volume': 36, }), 2: dict({ @@ -848,7 +849,130 @@ 'serial': None, 'shuffle': False, 'state': , - 'version': '1.493.180', + 'supported_version': True, + 'version': '3.34.240', + 'volume': 36, + }), + }) +# --- +# name: test_get_players_unsupported_versions + dict({ + 1: dict({ + 'available': True, + 'control': , + 'group_id': None, + 'ip_address': None, + 'is_muted': False, + 'line_out': , + 'model': 'HEOS Link', + 'name': 'Back Patio', + 'network': , + 'now_playing_media': dict({ + 'album': 'Album', + 'album_id': '123456', + 'artist': 'Artist', + 'current_position': None, + 'current_position_updated': None, + 'duration': None, + 'image_url': 'http://cont-5.p-cdn.us/images/public/int/6/1/1/9/050087149116_500W_500H.jpg', + 'media_id': '4256592506324148495', + 'options': list([ + dict({ + 'context': 'play', + 'id': 11, + 'name': 'Thumbs Up', + }), + dict({ + 'context': 'play', + 'id': 12, + 'name': 'Thumbs Down', + }), + dict({ + 'context': 'play', + 'id': 19, + 'name': 'Add to HEOS Favorites', + }), + ]), + 'queue_id': 1, + 'song': "Disney (Children's) Radio", + 'source_id': 13, + 'station': "Disney (Children's) Radio", + 'supported_controls': list([ + , + , + , + , + , + ]), + 'type': , + }), + 'playback_error': None, + 'player_id': 1, + 'repeat': , + 'serial': None, + 'shuffle': False, + 'state': , + 'supported_version': False, + 'version': None, + 'volume': 36, + }), + 2: dict({ + 'available': True, + 'control': , + 'group_id': None, + 'ip_address': '127.0.0.2', + 'is_muted': False, + 'line_out': , + 'model': 'HEOS 5', + 'name': 'Front Porch', + 'network': , + 'now_playing_media': dict({ + 'album': 'Album', + 'album_id': '123456', + 'artist': 'Artist', + 'current_position': None, + 'current_position_updated': None, + 'duration': None, + 'image_url': 'http://cont-5.p-cdn.us/images/public/int/6/1/1/9/050087149116_500W_500H.jpg', + 'media_id': '4256592506324148495', + 'options': list([ + dict({ + 'context': 'play', + 'id': 11, + 'name': 'Thumbs Up', + }), + dict({ + 'context': 'play', + 'id': 12, + 'name': 'Thumbs Down', + }), + dict({ + 'context': 'play', + 'id': 19, + 'name': 'Add to HEOS Favorites', + }), + ]), + 'queue_id': 1, + 'song': "Disney (Children's) Radio", + 'source_id': 13, + 'station': "Disney (Children's) Radio", + 'supported_controls': list([ + , + , + , + , + , + ]), + 'type': , + }), + 'playback_error': None, + 'player_id': 2, + 'repeat': , + 'serial': None, + 'shuffle': False, + 'state': , + 'supported_version': False, + 'version': '1.583.147', 'volume': 36, }), }) @@ -960,7 +1084,8 @@ 'name': 'Back Patio', 'network': , 'serial': 'B1A2C3K', - 'version': '1.493.180', + 'supported_version': True, + 'version': '3.34.240', }), 'hosts': list([ dict({ @@ -969,7 +1094,8 @@ 'name': 'Back Patio', 'network': , 'serial': 'B1A2C3K', - 'version': '1.493.180', + 'supported_version': True, + 'version': '3.34.240', }), dict({ 'ip_address': '127.0.0.2', @@ -977,7 +1103,8 @@ 'name': 'Front Porch', 'network': , 'serial': None, - 'version': '1.493.180', + 'supported_version': True, + 'version': '3.34.240', }), ]), 'is_signed_in': True, @@ -988,9 +1115,40 @@ 'name': 'Back Patio', 'network': , 'serial': 'B1A2C3K', - 'version': '1.493.180', + 'supported_version': True, + 'version': '3.34.240', + }), + ]), + 'signed_in_username': 'example@example.com', + }) +# --- +# name: test_validate_connection_unsupported_versions + dict({ + 'connected_to_preferred_host': False, + 'host': None, + 'hosts': list([ + dict({ + 'ip_address': None, + 'model': 'HEOS Link', + 'name': 'Back Patio', + 'network': , + 'serial': None, + 'supported_version': False, + 'version': None, + }), + dict({ + 'ip_address': '127.0.0.2', + 'model': 'HEOS 5', + 'name': 'Front Porch', + 'network': , + 'serial': None, + 'supported_version': False, + 'version': '1.583.147', }), ]), + 'is_signed_in': True, + 'preferred_hosts': list([ + ]), 'signed_in_username': 'example@example.com', }) # --- diff --git a/tests/snapshots/test_player.ambr b/tests/snapshots/test_player.ambr index 8643e5b..6232cf3 100644 --- a/tests/snapshots/test_player.ambr +++ b/tests/snapshots/test_player.ambr @@ -1,5 +1,128 @@ # serializer version: 1 -# name: test_from_data[None] +# name: test_from_data_ip_address[192.168.0.1] + dict({ + 'available': True, + 'control': , + 'group_id': None, + 'ip_address': '192.168.0.1', + 'is_muted': False, + 'line_out': , + 'model': 'HEOS Drive', + 'name': 'Back Patio', + 'network': , + 'now_playing_media': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'current_position': None, + 'current_position_updated': None, + 'duration': None, + 'image_url': None, + 'media_id': None, + 'options': list([ + ]), + 'queue_id': None, + 'song': None, + 'source_id': None, + 'station': None, + 'supported_controls': list([ + ]), + 'type': None, + }), + 'playback_error': None, + 'player_id': 1, + 'repeat': , + 'serial': '1234567890', + 'shuffle': False, + 'state': None, + 'supported_version': True, + 'version': '3.34.0', + 'volume': 0, + }) +# --- +# name: test_from_data_ip_address[None] + dict({ + 'available': True, + 'control': , + 'group_id': None, + 'ip_address': None, + 'is_muted': False, + 'line_out': , + 'model': 'HEOS Drive', + 'name': 'Back Patio', + 'network': , + 'now_playing_media': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'current_position': None, + 'current_position_updated': None, + 'duration': None, + 'image_url': None, + 'media_id': None, + 'options': list([ + ]), + 'queue_id': None, + 'song': None, + 'source_id': None, + 'station': None, + 'supported_controls': list([ + ]), + 'type': None, + }), + 'playback_error': None, + 'player_id': 1, + 'repeat': , + 'serial': '1234567890', + 'shuffle': False, + 'state': None, + 'supported_version': True, + 'version': '3.34.0', + 'volume': 0, + }) +# --- +# name: test_from_data_ip_address[] + dict({ + 'available': True, + 'control': , + 'group_id': None, + 'ip_address': '', + 'is_muted': False, + 'line_out': , + 'model': 'HEOS Drive', + 'name': 'Back Patio', + 'network': , + 'now_playing_media': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'current_position': None, + 'current_position_updated': None, + 'duration': None, + 'image_url': None, + 'media_id': None, + 'options': list([ + ]), + 'queue_id': None, + 'song': None, + 'source_id': None, + 'station': None, + 'supported_controls': list([ + ]), + 'type': None, + }), + 'playback_error': None, + 'player_id': 1, + 'repeat': , + 'serial': '1234567890', + 'shuffle': False, + 'state': None, + 'supported_version': True, + 'version': '3.34.0', + 'volume': 0, + }) +# --- +# name: test_from_data_network[None] dict({ 'available': True, 'control': , @@ -35,11 +158,12 @@ 'serial': '1234567890', 'shuffle': False, 'state': None, - 'version': '1.493.180', + 'supported_version': True, + 'version': '3.34.0', 'volume': 0, }) # --- -# name: test_from_data[invalid] +# name: test_from_data_network[invalid] dict({ 'available': True, 'control': , @@ -75,11 +199,53 @@ 'serial': '1234567890', 'shuffle': False, 'state': None, - 'version': '1.493.180', + 'supported_version': True, + 'version': '3.34.0', + 'volume': 0, + }) +# --- +# name: test_from_data_network[wired] + dict({ + 'available': True, + 'control': , + 'group_id': None, + 'ip_address': '192.168.0.1', + 'is_muted': False, + 'line_out': , + 'model': 'HEOS Drive', + 'name': 'Back Patio', + 'network': , + 'now_playing_media': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'current_position': None, + 'current_position_updated': None, + 'duration': None, + 'image_url': None, + 'media_id': None, + 'options': list([ + ]), + 'queue_id': None, + 'song': None, + 'source_id': None, + 'station': None, + 'supported_controls': list([ + ]), + 'type': None, + }), + 'playback_error': None, + 'player_id': 1, + 'repeat': , + 'serial': '1234567890', + 'shuffle': False, + 'state': None, + 'supported_version': True, + 'version': '3.34.0', 'volume': 0, }) # --- -# name: test_from_data[wired] +# name: test_from_data_version[1.493.180] dict({ 'available': True, 'control': , @@ -115,10 +281,175 @@ 'serial': '1234567890', 'shuffle': False, 'state': None, + 'supported_version': False, 'version': '1.493.180', 'volume': 0, }) # --- +# name: test_from_data_version[100.0.0] + dict({ + 'available': True, + 'control': , + 'group_id': None, + 'ip_address': '192.168.0.1', + 'is_muted': False, + 'line_out': , + 'model': 'HEOS Drive', + 'name': 'Back Patio', + 'network': , + 'now_playing_media': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'current_position': None, + 'current_position_updated': None, + 'duration': None, + 'image_url': None, + 'media_id': None, + 'options': list([ + ]), + 'queue_id': None, + 'song': None, + 'source_id': None, + 'station': None, + 'supported_controls': list([ + ]), + 'type': None, + }), + 'playback_error': None, + 'player_id': 1, + 'repeat': , + 'serial': '1234567890', + 'shuffle': False, + 'state': None, + 'supported_version': True, + 'version': '100.0.0', + 'volume': 0, + }) +# --- +# name: test_from_data_version[3.34.0] + dict({ + 'available': True, + 'control': , + 'group_id': None, + 'ip_address': '192.168.0.1', + 'is_muted': False, + 'line_out': , + 'model': 'HEOS Drive', + 'name': 'Back Patio', + 'network': , + 'now_playing_media': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'current_position': None, + 'current_position_updated': None, + 'duration': None, + 'image_url': None, + 'media_id': None, + 'options': list([ + ]), + 'queue_id': None, + 'song': None, + 'source_id': None, + 'station': None, + 'supported_controls': list([ + ]), + 'type': None, + }), + 'playback_error': None, + 'player_id': 1, + 'repeat': , + 'serial': '1234567890', + 'shuffle': False, + 'state': None, + 'supported_version': True, + 'version': '3.34.0', + 'volume': 0, + }) +# --- +# name: test_from_data_version[None] + dict({ + 'available': True, + 'control': , + 'group_id': None, + 'ip_address': '192.168.0.1', + 'is_muted': False, + 'line_out': , + 'model': 'HEOS Drive', + 'name': 'Back Patio', + 'network': , + 'now_playing_media': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'current_position': None, + 'current_position_updated': None, + 'duration': None, + 'image_url': None, + 'media_id': None, + 'options': list([ + ]), + 'queue_id': None, + 'song': None, + 'source_id': None, + 'station': None, + 'supported_controls': list([ + ]), + 'type': None, + }), + 'playback_error': None, + 'player_id': 1, + 'repeat': , + 'serial': '1234567890', + 'shuffle': False, + 'state': None, + 'supported_version': False, + 'version': None, + 'volume': 0, + }) +# --- +# name: test_from_data_version[] + dict({ + 'available': True, + 'control': , + 'group_id': None, + 'ip_address': '192.168.0.1', + 'is_muted': False, + 'line_out': , + 'model': 'HEOS Drive', + 'name': 'Back Patio', + 'network': , + 'now_playing_media': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'current_position': None, + 'current_position_updated': None, + 'duration': None, + 'image_url': None, + 'media_id': None, + 'options': list([ + ]), + 'queue_id': None, + 'song': None, + 'source_id': None, + 'station': None, + 'supported_controls': list([ + ]), + 'type': None, + }), + 'playback_error': None, + 'player_id': 1, + 'repeat': , + 'serial': '1234567890', + 'shuffle': False, + 'state': None, + 'supported_version': False, + 'version': '', + 'volume': 0, + }) +# --- # name: test_get_queue list([ dict({ @@ -412,6 +743,7 @@ 'serial': '123456789', 'shuffle': False, 'state': , + 'supported_version': True, 'version': '3.34.620', 'volume': 36, }) @@ -452,7 +784,8 @@ 'serial': '0987654321', 'shuffle': False, 'state': None, - 'version': '2.0.0', + 'supported_version': True, + 'version': '3.34.610', 'volume': 0, }) # --- diff --git a/tests/test_heos.py b/tests/test_heos.py index d8bf162..5777181 100644 --- a/tests/test_heos.py +++ b/tests/test_heos.py @@ -52,6 +52,7 @@ from . import ( CallCommand, + CommandModifier, MockHeosDevice, calls_command, calls_commands, @@ -79,6 +80,15 @@ async def test_validate_connection( assert system_info == snapshot +@calls_command("player.get_players_unsupported") +async def test_validate_connection_unsupported_versions( + mock_device: MockHeosDevice, snapshot: SnapshotAssertion +) -> None: + """Test get_system_info method returns system info.""" + system_info = await Heos.validate_connection("127.0.0.1") + assert system_info == snapshot + + async def test_connect(mock_device: MockHeosDevice) -> None: """Test connect updates state and fires signal.""" heos = Heos( @@ -317,6 +327,45 @@ async def test_commands_fail_when_disconnected( ) +@calls_command("system.heart_beat") +async def test_command_timeout(mock_device: MockHeosDevice, heos: Heos) -> None: + """Test command times out.""" + with mock_device.modify(c.COMMAND_HEART_BEAT, delay_response=0.2): + with pytest.raises(CommandError): + await heos.heart_beat() + await asyncio.sleep(0.2) + await heos.heart_beat() + + +@calls_command("system.heart_beat") +async def test_command_duplicate_response( + mock_device: MockHeosDevice, heos: Heos, caplog: pytest.LogCaptureFixture +) -> None: + """Test a duplicate command response is discarded.""" + with mock_device.modify(c.COMMAND_HEART_BEAT, replay_response=2): + await heos.heart_beat() + while "Unexpected response received: 'system/heart_beat'" not in caplog.text: + await asyncio.sleep(0.1) + + +@calls_command("system.heart_beat") +async def test_event_received_during_command(mock_device: MockHeosDevice) -> None: + """Test event received during command execution.""" + heos = await Heos.create_and_connect("127.0.0.1", heart_beat=False) + + mock_device.modifiers.append( + CommandModifier(c.COMMAND_HEART_BEAT, delay_response=0.2) + ) + command_task = asyncio.create_task(heos.heart_beat()) + + await asyncio.sleep(0.1) + await mock_device.write_event("event.user_changed_signed_in") + + await command_task + + await heos.disconnect() + + async def test_connection_error(mock_device: MockHeosDevice, heos: Heos) -> None: """Test connection error during event results in disconnected.""" disconnect_signal = connect_handler( @@ -478,6 +527,19 @@ async def test_get_players(heos: Heos, snapshot: SnapshotAssertion) -> None: assert players == snapshot +@calls_player_commands( + (1, 2), + CallCommand("player.get_players_unsupported", {}, replace=True), +) +async def test_get_players_unsupported_versions( + heos: Heos, snapshot: SnapshotAssertion +) -> None: + """Test the get_players method load players with unsupported versions.""" + players = await heos.get_players() + + assert players == snapshot + + @calls_commands( CallCommand("player.get_player_info", {c.ATTR_PLAYER_ID: -263109739}), CallCommand("player.get_play_state", {c.ATTR_PLAYER_ID: -263109739}), @@ -1478,3 +1540,29 @@ async def test_unrecognized_event_logs( await heos.dispatcher.wait_all() assert "Unrecognized event: " in caplog.text + + +@calls_player_commands() +async def test_uncaught_error_in_event_callback_logs( + mock_device: MockHeosDevice, heos: Heos, caplog: pytest.LogCaptureFixture +) -> None: + """Test unexpected exception during event callback execution logs.""" + await heos.get_players() + player = heos.players[1] + + # Register command that results in an exception + command = mock_device.register( + c.COMMAND_GET_NOW_PLAYING_MEDIA, + None, + "player.get_now_playing_media_failed", + replace=True, + ) + # Write event through mock device + await mock_device.write_event( + "event.player_now_playing_changed", {"player_id": player.player_id} + ) + + while "Unexpected exception in task:" not in caplog.text: + await asyncio.sleep(0.1) + + command.assert_called() diff --git a/tests/test_player.py b/tests/test_player.py index 76947bf..c9be6d4 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -12,6 +12,7 @@ MUSIC_SOURCE_PLAYLISTS, MUSIC_SOURCE_TIDAL, SEARCHED_TRACKS, + TARGET_VERSION, ) from pyheos.media import MediaItem from pyheos.player import HeosPlayer @@ -20,24 +21,61 @@ from tests.common import MediaItems -@pytest.mark.parametrize( - "network", - [None, "wired", "invalid"], -) -def test_from_data(network: str | None, snapshot: SnapshotAssertion) -> None: +@pytest.mark.parametrize("network", [None, "wired", "invalid"]) +def test_from_data_network(network: str | None, snapshot: SnapshotAssertion) -> None: """Test the from_data function.""" data = { c.ATTR_NAME: "Back Patio", c.ATTR_PLAYER_ID: 1, c.ATTR_MODEL: "HEOS Drive", - c.ATTR_VERSION: "1.493.180", + c.ATTR_VERSION: TARGET_VERSION, 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) - assert player == snapshot + assert player == snapshot() + + +@pytest.mark.parametrize("version", [None, "", "1.493.180", TARGET_VERSION, "100.0.0"]) +def test_from_data_version(version: str | None, snapshot: SnapshotAssertion) -> None: + """Test the from_data function.""" + data = { + c.ATTR_NAME: "Back Patio", + c.ATTR_PLAYER_ID: 1, + c.ATTR_MODEL: "HEOS Drive", + c.ATTR_IP_ADDRESS: "192.168.0.1", + c.ATTR_NETWORK: "wired", + c.ATTR_LINE_OUT: 1, + c.ATTR_SERIAL: "1234567890", + } + if version is not None: + data[c.ATTR_VERSION] = version + + player = HeosPlayer._from_data(data, None) + assert player == snapshot() + + +@pytest.mark.parametrize("ip_address", [None, "", "192.168.0.1"]) +def test_from_data_ip_address( + ip_address: str | None, snapshot: SnapshotAssertion +) -> None: + """Test the from_data function.""" + data = { + c.ATTR_NAME: "Back Patio", + c.ATTR_PLAYER_ID: 1, + c.ATTR_MODEL: "HEOS Drive", + c.ATTR_VERSION: TARGET_VERSION, + c.ATTR_NETWORK: "wired", + c.ATTR_LINE_OUT: 1, + c.ATTR_SERIAL: "1234567890", + } + if ip_address is not None: + data[c.ATTR_IP_ADDRESS] = ip_address + + player = HeosPlayer._from_data(data, None) + assert player == snapshot() async def test_update_from_data( @@ -48,7 +86,7 @@ async def test_update_from_data( c.ATTR_NAME: "Patio", c.ATTR_PLAYER_ID: 2, c.ATTR_MODEL: "HEOS Drives", - c.ATTR_VERSION: "2.0.0", + c.ATTR_VERSION: "3.34.610", c.ATTR_IP_ADDRESS: "192.168.0.2", c.ATTR_NETWORK: "wifi", c.ATTR_LINE_OUT: "0",