diff --git a/.gitignore b/.gitignore index de040da..a12c166 100644 --- a/.gitignore +++ b/.gitignore @@ -104,4 +104,7 @@ venv.bak/ .mypy_cache/ # pycharm -.idea/ \ No newline at end of file +.idea/ + +# vscode +.vscode/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ac16564 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "python.pythonPath": ".venv\\Scripts\\python.exe", + "[python]": { + "editor.rulers": [79] + } +} \ No newline at end of file diff --git a/pyheos/__init__.py b/pyheos/__init__.py index 8f1f250..6c48383 100644 --- a/pyheos/__init__.py +++ b/pyheos/__init__.py @@ -1,5 +1,4 @@ """pyheos - a library for interacting with HEOS devices.""" - from . import const from .dispatch import Dispatcher from .heos import Heos diff --git a/pyheos/command.py b/pyheos/command.py index ab60c64..4de6027 100644 --- a/pyheos/command.py +++ b/pyheos/command.py @@ -1,5 +1,5 @@ """Define the HEOS command module.""" -from typing import Sequence, Tuple +from typing import Optional, Sequence, Tuple from . import const from .player import HeosNowPlayingMedia @@ -15,10 +15,30 @@ def __init__(self, connection): async def heart_beat(self, *, raise_for_result=False) -> bool: """Perform heart beat command.""" response = await self._connection.command( - const.COMMAND_HEART_BEAT, raise_for_result, + const.COMMAND_HEART_BEAT, None, raise_for_result=raise_for_result) return response.result + async def check_account(self) -> Optional[str]: + """Return the logged in username.""" + response = await self._connection.command( + const.COMMAND_ACCOUNT_CHECK, None, True) + if response.has_message('signed_in'): + return response.get_message('un') + return None + + async def sign_in(self, username: str, password: str): + """Sign in to the HEOS account using the provided credential.""" + params = { + 'un': username, + 'pw': password + } + await self._connection.command(const.COMMAND_SIGN_IN, params, True) + + async def sign_out(self): + """Sign out of the HEOS account.""" + await self._connection.command(const.COMMAND_SIGN_OUT, None, True) + async def register_for_change_events( self, enable=True, *, raise_for_result=False) -> bool: """Enable or disable change event notifications.""" @@ -77,7 +97,7 @@ async def get_volume(self, player_id: int) -> int: } response = await self._connection.command( const.COMMAND_GET_VOLUME, params, raise_for_result=True) - return int(response.get_message('level')) + return int(float(response.get_message('level'))) async def set_volume(self, player_id: int, level: int, *, raise_for_result=False) -> bool: diff --git a/pyheos/const.py b/pyheos/const.py index 87ffcb5..01c4c28 100644 --- a/pyheos/const.py +++ b/pyheos/const.py @@ -1,7 +1,7 @@ """Define consts for the pyheos package.""" __title__ = "pyheos" -__version__ = "0.3.0" +__version__ = "0.3.1" CLI_PORT = 1255 DEFAULT_TIMEOUT = 10.0 @@ -208,6 +208,9 @@ # System commands COMMAND_REGISTER_FOR_CHANGE_EVENTS = "system/register_for_change_events" COMMAND_HEART_BEAT = "system/heart_beat" +COMMAND_ACCOUNT_CHECK = "system/check_account" +COMMAND_SIGN_IN = "system/sign_in" +COMMAND_SIGN_OUT = "system/sign_out" # Events EVENT_PLAYER_STATE_CHANGED = "event/player_state_changed" diff --git a/pyheos/heos.py b/pyheos/heos.py index 3825867..f4a8186 100644 --- a/pyheos/heos.py +++ b/pyheos/heos.py @@ -30,12 +30,15 @@ def __init__(self, host: str, *, self._players_loaded = False self._music_sources = {} # type: Dict[int, HeosSource] self._music_sources_loaded = False + self._signed_in_username = None # type: str async def connect(self, *, auto_reconnect=False, reconnect_delay: float = const.DEFAULT_RECONNECT_DELAY): """Connect to the CLI.""" await self._connection.connect(auto_reconnect=auto_reconnect, reconnect_delay=reconnect_delay) + self._signed_in_username = \ + await self._connection.commands.check_account() async def disconnect(self): """Disconnect from the CLI.""" @@ -49,8 +52,19 @@ async def _handle_event(self, event: HeosResponse) -> bool: if event.command == const.EVENT_SOURCES_CHANGED \ and self._music_sources_loaded: await self.get_music_sources(refresh=True) + if event.command == const.EVENT_USER_CHANGED: + self._signed_in_username = event.get_message('un') \ + if event.has_message("signed_in") else None return True + async def sign_in(self, username: str, password: str): + """Sign-in to the HEOS account on the device directly connected.""" + await self._connection.commands.sign_in(username, password) + + async def sign_out(self): + """Sign-out of the HEOS account on the device directly connected.""" + await self._connection.commands.sign_out() + async def get_players(self, *, refresh=False) -> Dict[int, HeosPlayer]: """Get available players.""" # get players and pull initial state @@ -131,3 +145,13 @@ def music_sources(self) -> Dict[int, HeosSource]: def connection_state(self): """Get the state of the connection.""" return self._connection.state + + @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) -> Optional[str]: + """Return the signed-in username.""" + return self._signed_in_username diff --git a/pyheos/player.py b/pyheos/player.py index e40f0f2..b19e911 100644 --- a/pyheos/player.py +++ b/pyheos/player.py @@ -308,7 +308,7 @@ async def event_update(self, event: HeosResponse, 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 = int(event.get_message('level')) + self._volume = int(float(event.get_message('level'))) self._is_muted = event.get_message('mute') == 'on' elif event.command == const.EVENT_REPEAT_MODE_CHANGED: self._repeat = event.get_message('repeat') diff --git a/pyheos/response.py b/pyheos/response.py index 6870653..b573052 100644 --- a/pyheos/response.py +++ b/pyheos/response.py @@ -65,6 +65,10 @@ def get_message(self, key: str) -> Any: if self._message: return self._message.get(key) + def has_message(self, key: str) -> bool: + """Determine if the key within the message.""" + return self._message and key in self._message + def get_player_id(self) -> int: """Get the player_id from the message.""" return int(self._message['pid']) diff --git a/tests/__init__.py b/tests/__init__.py index abcf32c..5ea59a2 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -53,6 +53,8 @@ async def start(self): self._handle_connection, '127.0.0.1', const.CLI_PORT) self.register(const.COMMAND_HEART_BEAT, None, 'system.heart_beat') + self.register(const.COMMAND_ACCOUNT_CHECK, None, + 'system.check_account') self.register(const.COMMAND_GET_PLAYERS, None, 'player.get_players') self.register(const.COMMAND_GET_PLAY_STATE, None, 'player.get_play_state') diff --git a/tests/fixtures/event.user_changed.json b/tests/fixtures/event.user_changed.json deleted file mode 100644 index 72a1149..0000000 --- a/tests/fixtures/event.user_changed.json +++ /dev/null @@ -1 +0,0 @@ -{"heos": {"command": "event/user_changed", "message": "signed_out"}} \ No newline at end of file diff --git a/tests/fixtures/event.user_changed_signed_in.json b/tests/fixtures/event.user_changed_signed_in.json new file mode 100644 index 0000000..6bc8d9f --- /dev/null +++ b/tests/fixtures/event.user_changed_signed_in.json @@ -0,0 +1 @@ +{"heos": {"command": "event/user_changed", "message": "signed_in&un=example@example.com"}} \ No newline at end of file diff --git a/tests/fixtures/event.user_changed_signed_out.json b/tests/fixtures/event.user_changed_signed_out.json new file mode 100644 index 0000000..76459a9 --- /dev/null +++ b/tests/fixtures/event.user_changed_signed_out.json @@ -0,0 +1 @@ +{"heos": {"command": "event/user_changed", "message": "signed_out&un=Unknown"}} \ No newline at end of file diff --git a/tests/fixtures/player.get_volume.json b/tests/fixtures/player.get_volume.json index 8b03033..1b669b0 100644 --- a/tests/fixtures/player.get_volume.json +++ b/tests/fixtures/player.get_volume.json @@ -1 +1 @@ -{"heos": {"result": "success", "command": "player/get_volume", "message": "pid={player_id}&level=36&sequence={sequence}"}} \ No newline at end of file +{"heos": {"result": "success", "command": "player/get_volume", "message": "pid={player_id}&level=36.0&sequence={sequence}"}} \ No newline at end of file diff --git a/tests/fixtures/system.check_account.json b/tests/fixtures/system.check_account.json new file mode 100644 index 0000000..407c3fc --- /dev/null +++ b/tests/fixtures/system.check_account.json @@ -0,0 +1 @@ +{"heos": {"command": "system/check_account", "result": "success", "message": "signed_in&un=example@example.com"}} \ No newline at end of file diff --git a/tests/fixtures/system.check_account_logged_out.json b/tests/fixtures/system.check_account_logged_out.json new file mode 100644 index 0000000..549202c --- /dev/null +++ b/tests/fixtures/system.check_account_logged_out.json @@ -0,0 +1 @@ +{"heos": {"command": "system/check_account", "result": "success", "message": "signed_out"}} \ No newline at end of file diff --git a/tests/fixtures/system.sign_in.json b/tests/fixtures/system.sign_in.json new file mode 100644 index 0000000..c2a1fc9 --- /dev/null +++ b/tests/fixtures/system.sign_in.json @@ -0,0 +1 @@ +{"heos": {"command": "system/sign_in", "result": "success", "message": "signed_in&un=example@example.com"}} \ No newline at end of file diff --git a/tests/fixtures/system.sign_in_failure.json b/tests/fixtures/system.sign_in_failure.json new file mode 100644 index 0000000..83d0c21 --- /dev/null +++ b/tests/fixtures/system.sign_in_failure.json @@ -0,0 +1 @@ +{"heos": {"command": "system/sign_in", "result": "fail", "message": "eid=10&text=User not found"}} \ No newline at end of file diff --git a/tests/fixtures/system.sign_out.json b/tests/fixtures/system.sign_out.json new file mode 100644 index 0000000..1b42512 --- /dev/null +++ b/tests/fixtures/system.sign_out.json @@ -0,0 +1 @@ +{"heos": {"command": "system/sign_out", "result": "success", "message": "signed_out"}} \ No newline at end of file diff --git a/tests/test_heos.py b/tests/test_heos.py index 2e4d0b7..35fddbd 100644 --- a/tests/test_heos.py +++ b/tests/test_heos.py @@ -33,10 +33,22 @@ async def test_connect(mock_device): assert len(mock_device.connections) == 1 connection = mock_device.connections[0] assert connection.is_registered_for_events - + assert heos.is_signed_in + assert heos.signed_in_username == "example@example.com" await heos.disconnect() +@pytest.mark.asyncio +async def test_connect_not_logged_in(mock_device, heos): + """Test signed-in status shows correctly when logged out.""" + mock_device.register(const.COMMAND_ACCOUNT_CHECK, None, + 'system.check_account_logged_out', replace=True) + heos = Heos('127.0.0.1') + await heos.connect() + assert not heos.is_signed_in + assert not heos.signed_in_username + + @pytest.mark.asyncio async def test_heart_beat(mock_device): """Test heart beat fires at interval.""" @@ -339,7 +351,7 @@ async def handler(player_id: int, event: str): # Write event through mock device event_to_raise = (await get_fixture("event.player_volume_changed")) \ .replace("{player_id}", str(player.player_id)) \ - .replace("{level}", '50') \ + .replace("{level}", '50.0') \ .replace("{mute}", 'on') await mock_device.write_event(event_to_raise) @@ -612,7 +624,7 @@ async def handler(group_id: int, event: str): @pytest.mark.asyncio async def test_user_changed_event(mock_device, heos): - """Test user changed fires dispatcher.""" + """Test user changed fires dispatcher and updates logged in user.""" signal = asyncio.Event() async def handler(event: str): @@ -620,12 +632,20 @@ async def handler(event: str): signal.set() heos.dispatcher.connect(const.SIGNAL_CONTROLLER_EVENT, handler) - # Write event through mock device - event_to_raise = await get_fixture("event.user_changed") + # Test signed out event + event_to_raise = await get_fixture("event.user_changed_signed_out") await mock_device.write_event(event_to_raise) + await signal.wait() + assert not heos.is_signed_in + assert not heos.signed_in_username - # Wait until the signal is set + # Test signed in event + signal.clear() + event_to_raise = await get_fixture("event.user_changed_signed_in") + await mock_device.write_event(event_to_raise) await signal.wait() + assert heos.is_signed_in + assert heos.signed_in_username == "example@example.com" @pytest.mark.asyncio @@ -678,3 +698,23 @@ async def test_get_favorites(mock_device, heos): assert fav.playable assert fav.name == 'Thumbprint Radio' assert fav.type == const.TYPE_STATION + + +@pytest.mark.asyncio +async def test_sign_in_and_out(mock_device, heos): + """Test the sign in and sign out methods.""" + data = {'un': "example@example.com", 'pw': 'example'} + # Test sign-in failure + mock_device.register(const.COMMAND_SIGN_IN, data, 'system.sign_in_failure') + with pytest.raises(CommandError) as e_info: + await heos.sign_in("example@example.com", "example") + assert str(e_info.value.error_text) == "User not found" + + # Test sign-in success + mock_device.register(const.COMMAND_SIGN_IN, data, 'system.sign_in', + replace=True) + await heos.sign_in("example@example.com", "example") + + # Test sign-out + mock_device.register(const.COMMAND_SIGN_OUT, None, 'system.sign_out') + await heos.sign_out()