diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 9fd276c244e3b1..3b38e5c935adc7 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from datetime import timedelta import logging +from typing import Any from pyheos import ( Credentials, @@ -13,6 +14,8 @@ HeosError, HeosOptions, HeosPlayer, + PlayerUpdateResult, + SignalHeosEvent, const as heos_const, ) @@ -98,14 +101,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool # Auth failure handler must be added before connecting to the host, otherwise # the event will be missed when login fails during connection. - async def auth_failure(event: str) -> None: + async def auth_failure() -> None: """Handle authentication failure.""" - if event == heos_const.EVENT_USER_CREDENTIALS_INVALID: - entry.async_start_reauth(hass) + entry.async_start_reauth(hass) - entry.async_on_unload( - controller.dispatcher.connect(heos_const.SIGNAL_HEOS_EVENT, auth_failure) - ) + entry.async_on_unload(controller.add_on_user_credentials_invalid(auth_failure)) try: # Auto reconnect only operates if initial connection was successful. @@ -168,11 +168,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> boo class ControllerManager: """Class that manages events of the controller.""" - def __init__(self, hass, controller): + def __init__(self, hass: HomeAssistant, controller: Heos) -> None: """Init the controller manager.""" self._hass = hass - self._device_registry = None - self._entity_registry = None + self._device_registry: dr.DeviceRegistry | None = None + self._entity_registry: er.EntityRegistry | None = None self.controller = controller async def connect_listeners(self): @@ -181,56 +181,59 @@ async def connect_listeners(self): self._entity_registry = er.async_get(self._hass) # Handle controller events - self.controller.dispatcher.connect( - heos_const.SIGNAL_CONTROLLER_EVENT, self._controller_event - ) + self.controller.add_on_controller_event(self._controller_event) # Handle connection-related events - self.controller.dispatcher.connect( - heos_const.SIGNAL_HEOS_EVENT, self._heos_event - ) + self.controller.add_on_heos_event(self._heos_event) async def disconnect(self): """Disconnect subscriptions.""" self.controller.dispatcher.disconnect_all() await self.controller.disconnect() - async def _controller_event(self, event, data): + async def _controller_event( + self, event: str, data: PlayerUpdateResult | None + ) -> None: """Handle controller event.""" if event == heos_const.EVENT_PLAYERS_CHANGED: - self.update_ids(data[heos_const.DATA_MAPPED_IDS]) + assert data is not None + self.update_ids(data.updated_player_ids) # Update players async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) async def _heos_event(self, event): """Handle connection event.""" - if event == heos_const.EVENT_CONNECTED: + if event == SignalHeosEvent.CONNECTED: try: # Retrieve latest players and refresh status data = await self.controller.load_players() - self.update_ids(data[heos_const.DATA_MAPPED_IDS]) + self.update_ids(data.updated_player_ids) except HeosError as ex: _LOGGER.error("Unable to refresh players: %s", ex) # Update players + _LOGGER.debug("HEOS Controller event called, calling dispatcher") async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) def update_ids(self, mapped_ids: dict[int, int]): """Update the IDs in the device and entity registry.""" # mapped_ids contains the mapped IDs (new:old) - for new_id, old_id in mapped_ids.items(): + for old_id, new_id in mapped_ids.items(): # update device registry + assert self._device_registry is not None entry = self._device_registry.async_get_device( - identifiers={(DOMAIN, old_id)} + identifiers={(DOMAIN, old_id)} # type: ignore[arg-type] # Fix in the future ) new_identifiers = {(DOMAIN, new_id)} if entry: self._device_registry.async_update_device( - entry.id, new_identifiers=new_identifiers + entry.id, + new_identifiers=new_identifiers, # type: ignore[arg-type] # Fix in the future ) _LOGGER.debug( "Updated device %s identifiers to %s", entry.id, new_identifiers ) # update entity registry + assert self._entity_registry is not None entity_id = self._entity_registry.async_get_entity_id( Platform.MEDIA_PLAYER, DOMAIN, str(old_id) ) @@ -249,7 +252,7 @@ def __init__( ) -> None: """Init group manager.""" self._hass = hass - self._group_membership: dict[str, str] = {} + self._group_membership: dict[str, list[str]] = {} self._disconnect_player_added = None self._initialized = False self.controller = controller @@ -268,7 +271,7 @@ async def async_get_group_membership(self) -> dict[str, list[str]]: } try: - groups = await self.controller.get_groups(refresh=True) + groups = await self.controller.get_groups() except HeosError as err: _LOGGER.error("Unable to get HEOS group info: %s", err) return group_info_by_entity_id @@ -326,29 +329,26 @@ async def async_unjoin_player(self, player_id: int, player_entity_id: str): err, ) - async def async_update_groups(self, event, data=None): + async def async_update_groups(self) -> None: """Update the group membership from the controller.""" - if event in ( - heos_const.EVENT_GROUPS_CHANGED, - heos_const.EVENT_CONNECTED, - SIGNAL_HEOS_PLAYER_ADDED, - ): - if groups := await self.async_get_group_membership(): - self._group_membership = groups - _LOGGER.debug("Groups updated due to change event") - # Let players know to update - async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) - else: - _LOGGER.debug("Groups empty") + if groups := await self.async_get_group_membership(): + self._group_membership = groups + _LOGGER.debug("Groups updated due to change event") + # Let players know to update + async_dispatcher_send(self._hass, SIGNAL_HEOS_UPDATED) + else: + _LOGGER.debug("Groups empty") + @callback def connect_update(self): """Connect listener for when groups change and signal player update.""" - self.controller.dispatcher.connect( - heos_const.SIGNAL_CONTROLLER_EVENT, self.async_update_groups - ) - self.controller.dispatcher.connect( - heos_const.SIGNAL_HEOS_EVENT, self.async_update_groups - ) + + async def _on_controller_event(event: str, data: Any | None) -> None: + if event == heos_const.EVENT_GROUPS_CHANGED: + await self.async_update_groups() + + self.controller.add_on_controller_event(_on_controller_event) + self.controller.add_on_connected(self.async_update_groups) # When adding a new HEOS player we need to update the groups. async def _async_handle_player_added(): @@ -356,7 +356,7 @@ async def _async_handle_player_added(): # fully populated yet. This may only happen during early startup. if len(self.players) <= len(self.entity_id_map) and not self._initialized: self._initialized = True - await self.async_update_groups(SIGNAL_HEOS_PLAYER_ADDED) + await self.async_update_groups() self._disconnect_player_added = async_dispatcher_connect( self._hass, SIGNAL_HEOS_PLAYER_ADDED, _async_handle_player_added @@ -462,7 +462,8 @@ def get_current_source(self, now_playing_media): None, ) - def connect_update(self, hass, controller): + @callback + def connect_update(self, hass: HomeAssistant, controller: Heos) -> None: """Connect listener for when sources change and signal player update. EVENT_SOURCES_CHANGED is often raised multiple times in response to a @@ -492,21 +493,22 @@ async def get_sources(): else: return favorites, inputs - async def update_sources(event, data=None): + async def _update_sources() -> None: + # If throttled, it will return None + if sources := await get_sources(): + self.favorites, self.inputs = sources + self.source_list = self._build_source_list() + _LOGGER.debug("Sources updated due to changed event") + # Let players know to update + async_dispatcher_send(hass, SIGNAL_HEOS_UPDATED) + + async def _on_controller_event(event: str, data: Any | None) -> None: if event in ( heos_const.EVENT_SOURCES_CHANGED, heos_const.EVENT_USER_CHANGED, - heos_const.EVENT_CONNECTED, ): - # If throttled, it will return None - if sources := await get_sources(): - self.favorites, self.inputs = sources - self.source_list = self._build_source_list() - _LOGGER.debug("Sources updated due to changed event") - # Let players know to update - async_dispatcher_send(hass, SIGNAL_HEOS_UPDATED) - - controller.dispatcher.connect( - heos_const.SIGNAL_CONTROLLER_EVENT, update_sources - ) - controller.dispatcher.connect(heos_const.SIGNAL_HEOS_EVENT, update_sources) + await _update_sources() + + controller.add_on_connected(_update_sources) + controller.add_on_user_credentials_invalid(_update_sources) + controller.add_on_controller_event(_on_controller_event) diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index c47d83d3475e16..d9b1b77a671bc4 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any, cast from urllib.parse import urlparse -from pyheos import CommandFailedError, Heos, HeosError, HeosOptions +from pyheos import CommandAuthenticationError, Heos, HeosError, HeosOptions import voluptuous as vol from homeassistant.components import ssdp @@ -79,13 +79,9 @@ async def _validate_auth( # Attempt to login (both username and password provided) try: await heos.sign_in(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) - except CommandFailedError as err: - if err.error_id in (6, 8, 10): # Auth-specific errors - errors["base"] = "invalid_auth" - _LOGGER.warning("Failed to sign-in to HEOS Account: %s", err) - else: - errors["base"] = "unknown" - _LOGGER.exception("Unexpected error occurred during sign-in") + except CommandAuthenticationError as err: + errors["base"] = "invalid_auth" + _LOGGER.warning("Failed to sign-in to HEOS Account: %s", err) return False except HeosError: errors["base"] = "unknown" diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index d14ad71ff494c1..6a631861b1c3dd 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/heos", "iot_class": "local_push", "loggers": ["pyheos"], - "requirements": ["pyheos==0.9.0"], + "requirements": ["pyheos==1.0.0"], "single_config_entry": true, "ssdp": [ { diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 924dcbe6b92ecd..981a39f53dc72e 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -8,7 +8,14 @@ from operator import ior from typing import Any -from pyheos import HeosError, const as heos_const +from pyheos import ( + AddCriteriaType, + ControlType, + HeosError, + HeosPlayer, + PlayState, + const as heos_const, +) from homeassistant.components import media_source from homeassistant.components.media_player import ( @@ -47,25 +54,25 @@ ) PLAY_STATE_TO_STATE = { - heos_const.PlayState.PLAY: MediaPlayerState.PLAYING, - heos_const.PlayState.STOP: MediaPlayerState.IDLE, - heos_const.PlayState.PAUSE: MediaPlayerState.PAUSED, + PlayState.PLAY: MediaPlayerState.PLAYING, + PlayState.STOP: MediaPlayerState.IDLE, + PlayState.PAUSE: MediaPlayerState.PAUSED, } CONTROL_TO_SUPPORT = { - heos_const.CONTROL_PLAY: MediaPlayerEntityFeature.PLAY, - heos_const.CONTROL_PAUSE: MediaPlayerEntityFeature.PAUSE, - heos_const.CONTROL_STOP: MediaPlayerEntityFeature.STOP, - heos_const.CONTROL_PLAY_PREVIOUS: MediaPlayerEntityFeature.PREVIOUS_TRACK, - heos_const.CONTROL_PLAY_NEXT: MediaPlayerEntityFeature.NEXT_TRACK, + ControlType.PLAY: MediaPlayerEntityFeature.PLAY, + ControlType.PAUSE: MediaPlayerEntityFeature.PAUSE, + ControlType.STOP: MediaPlayerEntityFeature.STOP, + ControlType.PLAY_PREVIOUS: MediaPlayerEntityFeature.PREVIOUS_TRACK, + ControlType.PLAY_NEXT: MediaPlayerEntityFeature.NEXT_TRACK, } HA_HEOS_ENQUEUE_MAP = { - None: heos_const.AddCriteriaType.REPLACE_AND_PLAY, - MediaPlayerEnqueue.ADD: heos_const.AddCriteriaType.ADD_TO_END, - MediaPlayerEnqueue.REPLACE: heos_const.AddCriteriaType.REPLACE_AND_PLAY, - MediaPlayerEnqueue.NEXT: heos_const.AddCriteriaType.PLAY_NEXT, - MediaPlayerEnqueue.PLAY: heos_const.AddCriteriaType.PLAY_NOW, + None: AddCriteriaType.REPLACE_AND_PLAY, + MediaPlayerEnqueue.ADD: AddCriteriaType.ADD_TO_END, + MediaPlayerEnqueue.REPLACE: AddCriteriaType.REPLACE_AND_PLAY, + MediaPlayerEnqueue.NEXT: AddCriteriaType.PLAY_NEXT, + MediaPlayerEnqueue.PLAY: AddCriteriaType.PLAY_NOW, } _LOGGER = logging.getLogger(__name__) @@ -118,11 +125,14 @@ class HeosMediaPlayer(MediaPlayerEntity): _attr_name = None def __init__( - self, player, source_manager: SourceManager, group_manager: GroupManager + self, + player: HeosPlayer, + source_manager: SourceManager, + group_manager: GroupManager, ) -> None: """Initialize.""" self._media_position_updated_at = None - self._player = player + self._player: HeosPlayer = player self._source_manager = source_manager self._group_manager = group_manager self._attr_unique_id = str(player.player_id) @@ -134,10 +144,8 @@ def __init__( sw_version=player.version, ) - async def _player_update(self, player_id, event): + async def _player_update(self, event): """Handle player attribute updated.""" - if self._player.player_id != player_id: - return if event == heos_const.EVENT_PLAYER_NOW_PLAYING_PROGRESS: self._media_position_updated_at = utcnow() await self.async_update_ha_state(True) @@ -149,11 +157,7 @@ async def _heos_updated(self) -> None: async def async_added_to_hass(self) -> None: """Device added to hass.""" # Update state when attributes of the player change - self.async_on_remove( - self._player.heos.dispatcher.connect( - heos_const.SIGNAL_PLAYER_EVENT, self._player_update - ) - ) + self.async_on_remove(self._player.add_on_player_event(self._player_update)) # Update state when heos changes self.async_on_remove( async_dispatcher_connect(self.hass, SIGNAL_HEOS_UPDATED, self._heos_updated) diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index edd9cf37714e1a..a780c26fca6124 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -2,7 +2,7 @@ import logging -from pyheos import CommandFailedError, Heos, HeosError, const +from pyheos import CommandAuthenticationError, Heos, HeosError import voluptuous as vol from homeassistant.config_entries import ConfigEntryState @@ -69,16 +69,12 @@ def _get_controller(hass: HomeAssistant) -> Heos: async def _sign_in_handler(service: ServiceCall) -> None: """Sign in to the HEOS account.""" - controller = _get_controller(service.hass) - if controller.connection_state != const.STATE_CONNECTED: - _LOGGER.error("Unable to sign in because HEOS is not connected") - return username = service.data[ATTR_USERNAME] password = service.data[ATTR_PASSWORD] try: await controller.sign_in(username, password) - except CommandFailedError as err: + except CommandAuthenticationError as err: _LOGGER.error("Sign in failed: %s", err) except HeosError as err: _LOGGER.error("Unable to sign in: %s", err) @@ -88,9 +84,6 @@ async def _sign_out_handler(service: ServiceCall) -> None: """Sign out of the HEOS account.""" controller = _get_controller(service.hass) - if controller.connection_state != const.STATE_CONNECTED: - _LOGGER.error("Unable to sign out because HEOS is not connected") - return try: await controller.sign_out() except HeosError as err: diff --git a/requirements_all.txt b/requirements_all.txt index ba852635bc52bf..1d88272397eed2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1980,7 +1980,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==0.9.0 +pyheos==1.0.0 # homeassistant.components.hive pyhiveapi==0.5.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ec2516bca9981d..527243ba62c6a1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1609,7 +1609,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==0.9.0 +pyheos==1.0.0 # homeassistant.components.hive pyhiveapi==0.5.16 diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index eec74d2dd18355..38d2f237907e5d 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -3,9 +3,24 @@ from __future__ import annotations from collections.abc import Sequence -from unittest.mock import Mock, patch - -from pyheos import Dispatcher, Heos, HeosGroup, HeosPlayer, MediaItem, const +from unittest.mock import AsyncMock, Mock, patch + +from pyheos import ( + CONTROLS_ALL, + Dispatcher, + Heos, + HeosGroup, + HeosOptions, + HeosPlayer, + LineOutLevelType, + MediaItem, + MediaType, + NetworkType, + PlayerUpdateResult, + PlayState, + RepeatType, + const, +) import pytest import pytest_asyncio @@ -71,26 +86,27 @@ def controller_fixture( players, favorites, input_sources, playlists, change_data, dispatcher, group ): """Create a mock Heos controller fixture.""" - mock_heos = Mock(Heos) + mock_heos = Heos(HeosOptions(host="127.0.0.1", dispatcher=dispatcher)) for player in players.values(): player.heos = mock_heos - mock_heos.return_value = mock_heos - mock_heos.dispatcher = dispatcher - mock_heos.get_players.return_value = players - mock_heos.players = players - mock_heos.get_favorites.return_value = favorites - mock_heos.get_input_sources.return_value = input_sources - mock_heos.get_playlists.return_value = playlists - mock_heos.load_players.return_value = change_data - mock_heos.is_signed_in = True - mock_heos.signed_in_username = "user@user.com" - mock_heos.connection_state = const.STATE_CONNECTED - mock_heos.get_groups.return_value = group - mock_heos.create_group.return_value = None - + mock_heos.connect = AsyncMock() + mock_heos.disconnect = AsyncMock() + mock_heos.sign_in = AsyncMock() + mock_heos.sign_out = AsyncMock() + mock_heos.get_players = AsyncMock(return_value=players) + mock_heos._players = players + mock_heos.get_favorites = AsyncMock(return_value=favorites) + mock_heos.get_input_sources = AsyncMock(return_value=input_sources) + mock_heos.get_playlists = AsyncMock(return_value=playlists) + mock_heos.load_players = AsyncMock(return_value=change_data) + mock_heos._signed_in_username = "user@user.com" + mock_heos.get_groups = AsyncMock(return_value=group) + mock_heos.create_group = AsyncMock(return_value=None) + new_mock = Mock(return_value=mock_heos) + mock_heos.new_mock = new_mock with ( - patch("homeassistant.components.heos.Heos", new=mock_heos), - patch("homeassistant.components.heos.config_flow.Heos", new=mock_heos), + patch("homeassistant.components.heos.Heos", new=new_mock), + patch("homeassistant.components.heos.config_flow.Heos", new=new_mock), ): yield mock_heos @@ -106,24 +122,25 @@ def player_fixture(quick_selects): """Create two mock HeosPlayers.""" players = {} for i in (1, 2): - player = Mock(HeosPlayer) - player.player_id = i - if i > 1: - player.name = f"Test Player {i}" - else: - player.name = "Test Player" - player.model = "Test Model" - player.version = "1.0.0" - player.is_muted = False - player.available = True - player.state = const.PlayState.STOP - player.ip_address = f"127.0.0.{i}" - player.network = "wired" - player.shuffle = False - player.repeat = const.RepeatType.OFF - player.volume = 25 + player = HeosPlayer( + player_id=i, + name="Test Player" if i == 1 else f"Test Player {i}", + model="Test Model", + serial="", + version="1.0.0", + line_out=LineOutLevelType.VARIABLE, + is_muted=False, + available=True, + state=PlayState.STOP, + ip_address=f"127.0.0.{i}", + network=NetworkType.WIRED, + shuffle=False, + repeat=RepeatType.OFF, + volume=25, + heos=None, + ) player.now_playing_media = Mock() - player.now_playing_media.supported_controls = const.CONTROLS_ALL + player.now_playing_media.supported_controls = CONTROLS_ALL player.now_playing_media.album_id = 1 player.now_playing_media.queue_id = 1 player.now_playing_media.source_id = 1 @@ -136,13 +153,30 @@ def player_fixture(quick_selects): player.now_playing_media.current_position = None player.now_playing_media.image_url = "http://" player.now_playing_media.song = "Song" - player.get_quick_selects.return_value = quick_selects + player.add_to_queue = AsyncMock() + player.clear_queue = AsyncMock() + player.get_quick_selects = AsyncMock(return_value=quick_selects) + player.mute = AsyncMock() + player.pause = AsyncMock() + player.play = AsyncMock() + player.play_input_source = AsyncMock() + player.play_next = AsyncMock() + player.play_previous = AsyncMock() + player.play_preset_station = AsyncMock() + player.play_quick_select = AsyncMock() + player.play_url = AsyncMock() + player.set_mute = AsyncMock() + player.set_play_mode = AsyncMock() + player.set_quick_select = AsyncMock() + player.set_volume = AsyncMock() + player.stop = AsyncMock() + player.unmute = AsyncMock() players[player.player_id] = player return players @pytest.fixture(name="group") -def group_fixture(players): +def group_fixture(): """Create a HEOS group consisting of two players.""" group = HeosGroup( name="Group", group_id=999, lead_player_id=1, member_player_ids=[2] @@ -158,7 +192,7 @@ def favorites_fixture() -> dict[int, MediaItem]: source_id=const.MUSIC_SOURCE_PANDORA, name="Today's Hits Radio", media_id="123456789", - type=const.MediaType.STATION, + type=MediaType.STATION, playable=True, browsable=False, image_url="", @@ -168,7 +202,7 @@ def favorites_fixture() -> dict[int, MediaItem]: source_id=const.MUSIC_SOURCE_TUNEIN, name="Classical MPR (Classical Music)", media_id="s1234", - type=const.MediaType.STATION, + type=MediaType.STATION, playable=True, browsable=False, image_url="", @@ -184,7 +218,7 @@ def input_sources_fixture() -> Sequence[MediaItem]: source_id=1, name="HEOS Drive - Line In 1", media_id=const.INPUT_AUX_IN_1, - type=const.MediaType.STATION, + type=MediaType.STATION, playable=True, browsable=False, image_url="", @@ -256,7 +290,7 @@ def playlists_fixture() -> Sequence[MediaItem]: playlist = MediaItem( source_id=const.MUSIC_SOURCE_PLAYLISTS, name="Awesome Music", - type=const.MediaType.PLAYLIST, + type=MediaType.PLAYLIST, playable=True, browsable=True, image_url="", @@ -268,10 +302,10 @@ def playlists_fixture() -> Sequence[MediaItem]: @pytest.fixture(name="change_data") def change_data_fixture() -> dict: """Create player change data for testing.""" - return {const.DATA_MAPPED_IDS: {}, const.DATA_NEW: []} + return PlayerUpdateResult() @pytest.fixture(name="change_data_mapped_ids") def change_data_mapped_ids_fixture() -> dict: """Create player change data for testing.""" - return {const.DATA_MAPPED_IDS: {101: 1}, const.DATA_NEW: []} + return PlayerUpdateResult(updated_player_ids={1: 101}) diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 45c2fbf4eb149e..0a1da2d986f42c 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -1,6 +1,6 @@ """Tests for the Heos config flow module.""" -from pyheos import CommandFailedError, HeosError +from pyheos import CommandAuthenticationError, CommandFailedError, HeosError import pytest from homeassistant.components import heos, ssdp @@ -199,14 +199,9 @@ async def test_reconfigure_cannot_connect_recovers( ("error", "expected_error_key"), [ ( - CommandFailedError("sign_in", "Invalid credentials", 6), + CommandAuthenticationError("sign_in", "Invalid credentials", 6), "invalid_auth", ), - ( - CommandFailedError("sign_in", "User not logged in", 8), - "invalid_auth", - ), - (CommandFailedError("sign_in", "user not found", 10), "invalid_auth"), (CommandFailedError("sign_in", "System error", 12), "unknown"), (HeosError(), "unknown"), ], @@ -337,14 +332,9 @@ async def test_options_flow_missing_one_param_recovers( ("error", "expected_error_key"), [ ( - CommandFailedError("sign_in", "Invalid credentials", 6), - "invalid_auth", - ), - ( - CommandFailedError("sign_in", "User not logged in", 8), + CommandAuthenticationError("sign_in", "Invalid credentials", 6), "invalid_auth", ), - (CommandFailedError("sign_in", "user not found", 10), "invalid_auth"), (CommandFailedError("sign_in", "System error", 12), "unknown"), (HeosError(), "unknown"), ], diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index 905346b8b4a563..a8cd4bea1d2cb6 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -4,7 +4,7 @@ from typing import cast from unittest.mock import Mock, patch -from pyheos import CommandFailedError, HeosError, const +from pyheos import CommandFailedError, HeosError, SignalHeosEvent, SignalType, const import pytest from homeassistant.components.heos import ( @@ -82,7 +82,7 @@ async def test_async_setup_entry_with_options_loads_platforms( # Assert options passed and methods called assert config_entry_options.state is ConfigEntryState.LOADED - options = cast(HeosOptions, controller.call_args[0][0]) + options = cast(HeosOptions, controller.new_mock.call_args[0][0]) assert options.host == config_entry_options.data[CONF_HOST] assert options.credentials.username == config_entry_options.options[CONF_USERNAME] assert options.credentials.password == config_entry_options.options[CONF_PASSWORD] @@ -103,10 +103,9 @@ async def test_async_setup_entry_auth_failure_starts_reauth( # Simulates what happens when the controller can't sign-in during connection async def connect_send_auth_failure() -> None: - controller.is_signed_in = False - controller.signed_in_username = None + controller._signed_in_username = None controller.dispatcher.send( - const.SIGNAL_HEOS_EVENT, const.EVENT_USER_CREDENTIALS_INVALID + SignalType.HEOS_EVENT, SignalHeosEvent.USER_CREDENTIALS_INVALID ) controller.connect.side_effect = connect_send_auth_failure @@ -133,8 +132,7 @@ async def test_async_setup_entry_not_signed_in_loads_platforms( ) -> None: """Test setup does not retrieve favorites when not logged in.""" config_entry.add_to_hass(hass) - controller.is_signed_in = False - controller.signed_in_username = None + controller._signed_in_username = None with patch.object( hass.config_entries, "async_forward_entry_setups" ) as forward_mock: @@ -213,7 +211,7 @@ async def test_update_sources_retry( source_manager.max_retry_attempts = 1 controller.get_favorites.side_effect = CommandFailedError("Test", "test", 0) controller.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {} + SignalType.CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {} ) # Wait until it's finished while "Unable to update sources" not in caplog.text: diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 155c425b91e41c..f2b54ecec815e5 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -3,8 +3,15 @@ import asyncio from typing import Any -from pyheos import CommandFailedError, const -from pyheos.error import HeosError +from pyheos import ( + AddCriteriaType, + CommandFailedError, + HeosError, + PlayState, + SignalHeosEvent, + SignalType, + const, +) import pytest from homeassistant.components.heos import media_player @@ -115,18 +122,18 @@ async def test_updates_from_signals( player = controller.players[1] # Test player does not update for other players - player.state = const.PlayState.PLAY + player.state = PlayState.PLAY player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, 2, const.EVENT_PLAYER_STATE_CHANGED + SignalType.PLAYER_EVENT, 2, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") assert state.state == STATE_IDLE # Test player_update standard events - player.state = const.PlayState.PLAY + player.state = PlayState.PLAY player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() @@ -137,7 +144,7 @@ async def test_updates_from_signals( player.now_playing_media.duration = 360000 player.now_playing_media.current_position = 1000 player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_NOW_PLAYING_PROGRESS, ) @@ -167,7 +174,7 @@ async def set_signal(): # Connected player.available = True - player.heos.dispatcher.send(const.SIGNAL_HEOS_EVENT, const.EVENT_CONNECTED) + player.heos.dispatcher.send(SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED) await event.wait() state = hass.states.get("media_player.test_player") assert state.state == STATE_IDLE @@ -175,10 +182,9 @@ async def set_signal(): # Disconnected event.clear() - player.reset_mock() controller.load_players.reset_mock() player.available = False - player.heos.dispatcher.send(const.SIGNAL_HEOS_EVENT, const.EVENT_DISCONNECTED) + player.heos.dispatcher.send(SignalType.HEOS_EVENT, SignalHeosEvent.DISCONNECTED) await event.wait() state = hass.states.get("media_player.test_player") assert state.state == STATE_UNAVAILABLE @@ -186,11 +192,10 @@ async def set_signal(): # Connected handles refresh failure event.clear() - player.reset_mock() controller.load_players.reset_mock() controller.load_players.side_effect = CommandFailedError(None, "Failure", 1) player.available = True - player.heos.dispatcher.send(const.SIGNAL_HEOS_EVENT, const.EVENT_CONNECTED) + player.heos.dispatcher.send(SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED) await event.wait() state = hass.states.get("media_player.test_player") assert state.state == STATE_IDLE @@ -213,7 +218,7 @@ async def set_signal(): input_sources.clear() player.heos.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {} + SignalType.CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {} ) await event.wait() source_list = config_entry.runtime_data.source_manager.source_list @@ -241,9 +246,9 @@ async def set_signal(): async_dispatcher_connect(hass, SIGNAL_HEOS_UPDATED, set_signal) assert hass.states.get("media_player.test_player").state == STATE_IDLE - player.state = const.PlayState.PLAY + player.state = PlayState.PLAY player.heos.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, change_data + SignalType.CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, change_data ) await event.wait() await hass.async_block_till_done() @@ -279,7 +284,7 @@ async def set_signal(): async_dispatcher_connect(hass, SIGNAL_HEOS_UPDATED, set_signal) player.heos.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, + SignalType.CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, change_data_mapped_ids, ) @@ -309,10 +314,9 @@ async def set_signal(): async_dispatcher_connect(hass, SIGNAL_HEOS_UPDATED, set_signal) - controller.is_signed_in = False - controller.signed_in_username = None + controller._signed_in_username = None player.heos.dispatcher.send( - const.SIGNAL_CONTROLLER_EVENT, const.EVENT_USER_CHANGED, None + SignalType.CONTROLLER_EVENT, const.EVENT_USER_CHANGED, None ) await event.wait() source_list = config_entry.runtime_data.source_manager.source_list @@ -555,7 +559,7 @@ async def test_select_favorite( # Test state is matched by station name player.now_playing_media.station = favorite.name player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") @@ -581,7 +585,7 @@ async def test_select_radio_favorite( player.now_playing_media.station = "Classical" player.now_playing_media.album_id = favorite.media_id player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") @@ -634,7 +638,7 @@ async def test_select_input_source( player.now_playing_media.source_id = const.MUSIC_SOURCE_AUX_INPUT player.now_playing_media.media_id = const.INPUT_AUX_IN_1 player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") @@ -831,7 +835,7 @@ async def test_play_media_playlist( blocking=True, ) player.add_to_queue.assert_called_once_with( - playlist, const.AddCriteriaType.REPLACE_AND_PLAY + playlist, AddCriteriaType.REPLACE_AND_PLAY ) # Play with enqueuing player.add_to_queue.reset_mock() @@ -846,9 +850,7 @@ async def test_play_media_playlist( }, blocking=True, ) - player.add_to_queue.assert_called_once_with( - playlist, const.AddCriteriaType.ADD_TO_END - ) + player.add_to_queue.assert_called_once_with(playlist, AddCriteriaType.ADD_TO_END) # Invalid name player.add_to_queue.reset_mock() await hass.services.async_call( @@ -1028,7 +1030,7 @@ async def test_media_player_unjoin_group( player = controller.players[1] player.heos.dispatcher.send( - const.SIGNAL_PLAYER_EVENT, + SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED, ) diff --git a/tests/components/heos/test_services.py b/tests/components/heos/test_services.py index b1cffe0891e0f8..175e072e8e7b20 100644 --- a/tests/components/heos/test_services.py +++ b/tests/components/heos/test_services.py @@ -1,6 +1,6 @@ """Tests for the services module.""" -from pyheos import CommandFailedError, HeosError, const +from pyheos import CommandAuthenticationError, HeosError import pytest from homeassistant.components.heos.const import ( @@ -38,30 +38,14 @@ async def test_sign_in(hass: HomeAssistant, config_entry, controller) -> None: controller.sign_in.assert_called_once_with("test@test.com", "password") -async def test_sign_in_not_connected( - hass: HomeAssistant, config_entry, controller, caplog: pytest.LogCaptureFixture -) -> None: - """Test sign-in service logs error when not connected.""" - await setup_component(hass, config_entry) - controller.connection_state = const.STATE_RECONNECTING - - await hass.services.async_call( - DOMAIN, - SERVICE_SIGN_IN, - {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, - blocking=True, - ) - - assert controller.sign_in.call_count == 0 - assert "Unable to sign in because HEOS is not connected" in caplog.text - - async def test_sign_in_failed( hass: HomeAssistant, config_entry, controller, caplog: pytest.LogCaptureFixture ) -> None: """Test sign-in service logs error when not connected.""" await setup_component(hass, config_entry) - controller.sign_in.side_effect = CommandFailedError("", "Invalid credentials", 6) + controller.sign_in.side_effect = CommandAuthenticationError( + "", "Invalid credentials", 6 + ) await hass.services.async_call( DOMAIN, @@ -115,19 +99,6 @@ async def test_sign_out(hass: HomeAssistant, config_entry, controller) -> None: assert controller.sign_out.call_count == 1 -async def test_sign_out_not_connected( - hass: HomeAssistant, config_entry, controller, caplog: pytest.LogCaptureFixture -) -> None: - """Test the sign-out service.""" - await setup_component(hass, config_entry) - controller.connection_state = const.STATE_RECONNECTING - - await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True) - - assert controller.sign_out.call_count == 0 - assert "Unable to sign out because HEOS is not connected" in caplog.text - - async def test_sign_out_not_loaded_raises(hass: HomeAssistant, config_entry) -> None: """Test the sign-out service when entry not loaded raises exception.""" await setup_component(hass, config_entry)