Bump pyheos to 1.0.0 (#135415)
parent
52c57eb2e5
commit
11fa6b2e4e
|
@ -6,6 +6,7 @@ import asyncio
|
|||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyheos import (
|
||||
Credentials,
|
||||
|
@ -13,6 +14,8 @@ from pyheos import (
|
|||
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_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 @@ class ControllerManager:
|
|||
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 @@ class GroupManager:
|
|||
) -> 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 @@ class GroupManager:
|
|||
}
|
||||
|
||||
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,13 +329,8 @@ class GroupManager:
|
|||
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")
|
||||
|
@ -341,14 +339,16 @@ class GroupManager:
|
|||
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 @@ class GroupManager:
|
|||
# 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 @@ class SourceManager:
|
|||
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,12 +493,7 @@ class SourceManager:
|
|||
else:
|
||||
return favorites, inputs
|
||||
|
||||
async def update_sources(event, data=None):
|
||||
if event in (
|
||||
heos_const.EVENT_SOURCES_CHANGED,
|
||||
heos_const.EVENT_USER_CHANGED,
|
||||
heos_const.EVENT_CONNECTED,
|
||||
):
|
||||
async def _update_sources() -> None:
|
||||
# If throttled, it will return None
|
||||
if sources := await get_sources():
|
||||
self.favorites, self.inputs = sources
|
||||
|
@ -506,7 +502,13 @@ class SourceManager:
|
|||
# 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)
|
||||
async def _on_controller_event(event: str, data: Any | None) -> None:
|
||||
if event in (
|
||||
heos_const.EVENT_SOURCES_CHANGED,
|
||||
heos_const.EVENT_USER_CHANGED,
|
||||
):
|
||||
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)
|
||||
|
|
|
@ -5,7 +5,7 @@ import logging
|
|||
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
|
||||
except CommandAuthenticationError as err:
|
||||
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")
|
||||
return False
|
||||
except HeosError:
|
||||
errors["base"] = "unknown"
|
||||
|
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
@ -8,7 +8,14 @@ import logging
|
|||
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 @@ BASE_SUPPORTED_FEATURES = (
|
|||
)
|
||||
|
||||
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 @@ class HeosMediaPlayer(MediaPlayerEntity):
|
|||
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 @@ class HeosMediaPlayer(MediaPlayerEntity):
|
|||
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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -3,9 +3,24 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from pyheos import Dispatcher, Heos, HeosGroup, HeosPlayer, MediaItem, const
|
||||
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})
|
||||
|
|
|
@ -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),
|
||||
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"),
|
||||
],
|
||||
|
|
|
@ -4,7 +4,7 @@ import asyncio
|
|||
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:
|
||||
|
|
|
@ -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 test_updates_from_connection_event(
|
|||
|
||||
# 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 test_updates_from_connection_event(
|
|||
|
||||
# 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 test_updates_from_connection_event(
|
|||
|
||||
# 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 test_updates_from_sources_updated(
|
|||
|
||||
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 test_updates_from_players_changed(
|
|||
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 test_updates_from_players_changed_new_ids(
|
|||
|
||||
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 test_updates_from_user_changed(
|
|||
|
||||
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,
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue