mirror of
https://github.com/home-assistant/core.git
synced 2026-09-08 06:31:35 +01:00
Co-authored-by: Tao Jiang <tao.jiang@linkplay.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1520 lines
50 KiB
Python
1520 lines
50 KiB
Python
"""Tests for the WiiM media player via services and the state machine."""
|
|
|
|
from collections.abc import Callable
|
|
from functools import partial
|
|
from http import HTTPStatus
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import aiohttp
|
|
import pytest
|
|
from wiim.consts import PlayingStatus
|
|
from wiim.exceptions import WiimRequestException
|
|
from wiim.models import (
|
|
WiimGroupRole,
|
|
WiimGroupSnapshot,
|
|
WiimLoopState,
|
|
WiimMediaMetadata,
|
|
WiimPreset,
|
|
WiimQueueItem,
|
|
WiimQueueSnapshot,
|
|
WiimRepeatMode,
|
|
WiimTransportCapabilities,
|
|
)
|
|
from wiim.wiim_device import WiimDevice
|
|
|
|
from homeassistant.components.media_player import (
|
|
ATTR_ENTITY_PICTURE_LOCAL,
|
|
ATTR_GROUP_MEMBERS,
|
|
ATTR_INPUT_SOURCE,
|
|
ATTR_MEDIA_ALBUM_NAME,
|
|
ATTR_MEDIA_CONTENT_ID,
|
|
ATTR_MEDIA_CONTENT_TYPE,
|
|
ATTR_MEDIA_DURATION,
|
|
ATTR_MEDIA_POSITION,
|
|
ATTR_MEDIA_REPEAT,
|
|
ATTR_MEDIA_SHUFFLE,
|
|
ATTR_MEDIA_TITLE,
|
|
ATTR_MEDIA_VOLUME_LEVEL,
|
|
ATTR_MEDIA_VOLUME_MUTED,
|
|
DOMAIN as MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_BROWSE_MEDIA,
|
|
SERVICE_JOIN,
|
|
SERVICE_MEDIA_NEXT_TRACK,
|
|
SERVICE_MEDIA_PAUSE,
|
|
SERVICE_MEDIA_PLAY,
|
|
SERVICE_MEDIA_PREVIOUS_TRACK,
|
|
SERVICE_MEDIA_SEEK,
|
|
SERVICE_MEDIA_STOP,
|
|
SERVICE_PLAY_MEDIA,
|
|
SERVICE_REPEAT_SET,
|
|
SERVICE_SELECT_SOURCE,
|
|
SERVICE_SHUFFLE_SET,
|
|
SERVICE_UNJOIN,
|
|
SERVICE_VOLUME_MUTE,
|
|
SERVICE_VOLUME_SET,
|
|
BrowseError,
|
|
BrowseMedia,
|
|
MediaClass,
|
|
MediaPlayerEntityFeature,
|
|
MediaPlayerState,
|
|
MediaType,
|
|
RepeatMode,
|
|
)
|
|
import homeassistant.components.wiim as wiim_component
|
|
from homeassistant.components.wiim.const import DOMAIN
|
|
from homeassistant.const import (
|
|
ATTR_ENTITY_ID,
|
|
ATTR_ENTITY_PICTURE,
|
|
CONF_HOST,
|
|
STATE_UNAVAILABLE,
|
|
)
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
|
|
from . import fire_general_update, fire_transport_update, setup_integration
|
|
|
|
from tests.common import MockConfigEntry
|
|
from tests.test_util.aiohttp import AiohttpClientMocker
|
|
from tests.typing import ClientSessionGenerator
|
|
|
|
MEDIA_PLAYER_ENTITY_ID = "media_player.test_wiim_device"
|
|
|
|
|
|
def _build_mock_wiim_device(
|
|
*,
|
|
udn: str,
|
|
name: str,
|
|
ip_address: str,
|
|
base_device: MagicMock,
|
|
) -> AsyncMock:
|
|
"""Build a mocked WiiM device for a second integration entry."""
|
|
device = AsyncMock(spec=WiimDevice)
|
|
device.udn = udn
|
|
device.name = name
|
|
device.model_name = "WiiM Pro"
|
|
device.manufacturer = "Linkplay Tech"
|
|
device.firmware_version = "4.8.523456"
|
|
device.ip_address = ip_address
|
|
device.http_api_url = f"http://{ip_address}:8080"
|
|
device.presentation_url = f"http://{ip_address}:8080/web_interface"
|
|
device.available = True
|
|
device.volume = 40
|
|
device.is_muted = False
|
|
device.supports_http_api = False
|
|
device.playing_status = PlayingStatus.STOPPED
|
|
device.play_mode = "Network"
|
|
device.loop_state = WiimLoopState(
|
|
repeat=WiimRepeatMode.OFF,
|
|
shuffle=False,
|
|
)
|
|
device.output_mode = "speaker"
|
|
device.current_media = None
|
|
device.supported_input_modes = base_device.supported_input_modes
|
|
device.supported_output_modes = base_device.supported_output_modes
|
|
device.async_get_transport_capabilities = AsyncMock(
|
|
return_value=WiimTransportCapabilities(
|
|
can_next=False,
|
|
can_previous=False,
|
|
can_repeat=False,
|
|
can_shuffle=False,
|
|
)
|
|
)
|
|
device.general_event_callback = None
|
|
device.av_transport_event_callback = None
|
|
device.rendering_control_event_callback = None
|
|
device.play_queue_event_callback = None
|
|
return device
|
|
|
|
|
|
async def test_state_machine_updates_from_device_callbacks(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_wiim_device: MagicMock,
|
|
mock_wiim_controller: MagicMock,
|
|
) -> None:
|
|
"""Test cached device state is reflected in Home Assistant."""
|
|
await setup_integration(hass, mock_config_entry)
|
|
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
|
assert state.state == MediaPlayerState.IDLE
|
|
assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.5
|
|
assert state.attributes[ATTR_INPUT_SOURCE] == "Network"
|
|
assert state.attributes["supported_features"] == int(
|
|
MediaPlayerEntityFeature.PLAY
|
|
| MediaPlayerEntityFeature.PAUSE
|
|
| MediaPlayerEntityFeature.STOP
|
|
| MediaPlayerEntityFeature.VOLUME_SET
|
|
| MediaPlayerEntityFeature.VOLUME_MUTE
|
|
| MediaPlayerEntityFeature.BROWSE_MEDIA
|
|
| MediaPlayerEntityFeature.PLAY_MEDIA
|
|
| MediaPlayerEntityFeature.SELECT_SOURCE
|
|
| MediaPlayerEntityFeature.SEEK
|
|
| MediaPlayerEntityFeature.GROUPING
|
|
)
|
|
|
|
mock_wiim_device.volume = 60
|
|
mock_wiim_device.playing_status = PlayingStatus.PLAYING
|
|
mock_wiim_device.play_mode = "Bluetooth"
|
|
mock_wiim_device.output_mode = "optical"
|
|
mock_wiim_device.loop_state = WiimLoopState(
|
|
repeat=WiimRepeatMode.ALL,
|
|
shuffle=True,
|
|
)
|
|
mock_wiim_device.current_media = WiimMediaMetadata(
|
|
title="New Song",
|
|
artist="Test Artist",
|
|
album="Test Album",
|
|
uri="http://example.com/song.flac",
|
|
duration=180,
|
|
position=42,
|
|
)
|
|
mock_wiim_device.async_get_transport_capabilities.return_value = (
|
|
WiimTransportCapabilities(
|
|
can_next=True,
|
|
can_previous=False,
|
|
can_repeat=True,
|
|
can_shuffle=True,
|
|
)
|
|
)
|
|
|
|
await fire_general_update(hass, mock_wiim_device)
|
|
|
|
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
|
assert state.state == MediaPlayerState.PLAYING
|
|
assert state.attributes[ATTR_MEDIA_TITLE] == "New Song"
|
|
assert state.attributes[ATTR_MEDIA_ALBUM_NAME] == "Test Album"
|
|
assert state.attributes[ATTR_MEDIA_DURATION] == 180
|
|
assert state.attributes[ATTR_MEDIA_POSITION] == 42
|
|
assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.6
|
|
assert state.attributes[ATTR_INPUT_SOURCE] == "Bluetooth"
|
|
assert state.attributes[ATTR_MEDIA_REPEAT] == RepeatMode.ALL
|
|
assert state.attributes[ATTR_MEDIA_SHUFFLE] is True
|
|
assert state.attributes["supported_features"] == int(
|
|
MediaPlayerEntityFeature.PLAY
|
|
| MediaPlayerEntityFeature.PAUSE
|
|
| MediaPlayerEntityFeature.STOP
|
|
| MediaPlayerEntityFeature.VOLUME_SET
|
|
| MediaPlayerEntityFeature.VOLUME_MUTE
|
|
| MediaPlayerEntityFeature.BROWSE_MEDIA
|
|
| MediaPlayerEntityFeature.PLAY_MEDIA
|
|
| MediaPlayerEntityFeature.SELECT_SOURCE
|
|
| MediaPlayerEntityFeature.SEEK
|
|
| MediaPlayerEntityFeature.NEXT_TRACK
|
|
| MediaPlayerEntityFeature.REPEAT_SET
|
|
| MediaPlayerEntityFeature.SHUFFLE_SET
|
|
| MediaPlayerEntityFeature.GROUPING
|
|
)
|
|
|
|
|
|
async def test_general_update_handles_offline_device(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_wiim_device: MagicMock,
|
|
mock_wiim_controller: MagicMock,
|
|
) -> None:
|
|
"""Test an offline update marks the media player unavailable."""
|
|
await setup_integration(hass, mock_config_entry)
|
|
mock_wiim_device.available = False
|
|
|
|
await fire_general_update(hass, mock_wiim_device)
|
|
|
|
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
|
assert state is not None
|
|
assert state.state == STATE_UNAVAILABLE
|
|
mock_wiim_controller.async_update_all_multiroom_status.assert_awaited_once_with()
|
|
|
|
|
|
@pytest.mark.usefixtures("mock_wiim_controller")
|
|
async def test_general_update_renews_http_subscriptions(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_wiim_device: MagicMock,
|
|
) -> None:
|
|
"""Test an HTTP-capable device renews subscriptions on a general update."""
|
|
await setup_integration(hass, mock_config_entry)
|
|
mock_wiim_device.supports_http_api = True
|
|
|
|
await fire_general_update(hass, mock_wiim_device)
|
|
|
|
mock_wiim_device.ensure_subscriptions.assert_awaited_once_with()
|
|
|
|
|
|
async def test_state_machine_updates_from_transport_events(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_wiim_device: MagicMock,
|
|
mock_wiim_controller: MagicMock,
|
|
) -> None:
|
|
"""Test transport events update the state machine."""
|
|
await setup_integration(hass, mock_config_entry)
|
|
mock_wiim_device.current_media = WiimMediaMetadata(
|
|
title="Queued Song",
|
|
duration=240,
|
|
position=30,
|
|
)
|
|
|
|
await fire_transport_update(hass, mock_wiim_device, PlayingStatus.PLAYING)
|
|
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
|
assert state.state == MediaPlayerState.PLAYING
|
|
assert state.attributes[ATTR_MEDIA_TITLE] == "Queued Song"
|
|
|
|
await fire_transport_update(hass, mock_wiim_device, PlayingStatus.PAUSED)
|
|
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
|
assert state.state == MediaPlayerState.PAUSED
|
|
|
|
mock_wiim_device.current_media = None
|
|
await fire_transport_update(hass, mock_wiim_device, PlayingStatus.STOPPED)
|
|
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
|
assert state.state == MediaPlayerState.IDLE
|
|
assert state.attributes.get(ATTR_MEDIA_TITLE) is None
|
|
|
|
mock_wiim_device.event_data = {"TransportState": "unknown"}
|
|
mock_wiim_device.av_transport_event_callback(MagicMock(), [])
|
|
await hass.async_block_till_done()
|
|
|
|
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
|
assert state is not None
|
|
assert state.state == MediaPlayerState.IDLE
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("service", "device_method"),
|
|
[
|
|
pytest.param(SERVICE_MEDIA_STOP, "async_stop", id="stop"),
|
|
pytest.param(SERVICE_MEDIA_NEXT_TRACK, "async_next", id="next"),
|
|
pytest.param(SERVICE_MEDIA_PREVIOUS_TRACK, "async_previous", id="previous"),
|
|
],
|
|
)
|
|
@pytest.mark.usefixtures("mock_wiim_controller")
|
|
async def test_transport_services_call_device(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_wiim_device: MagicMock,
|
|
*,
|
|
service: str,
|
|
device_method: str,
|
|
) -> None:
|
|
"""Test transport services call the matching device command."""
|
|
mock_wiim_device.async_get_transport_capabilities.return_value = (
|
|
WiimTransportCapabilities(
|
|
can_next=True,
|
|
can_previous=True,
|
|
can_repeat=False,
|
|
can_shuffle=False,
|
|
)
|
|
)
|
|
await setup_integration(hass, mock_config_entry)
|
|
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
service,
|
|
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID},
|
|
blocking=True,
|
|
)
|
|
|
|
getattr(mock_wiim_device, device_method).assert_awaited_once_with()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
(
|
|
"service",
|
|
"service_data",
|
|
"device_method",
|
|
"expected_args",
|
|
"state_update",
|
|
"state_attr",
|
|
"state_value",
|
|
),
|
|
[
|
|
(
|
|
SERVICE_VOLUME_SET,
|
|
{ATTR_MEDIA_VOLUME_LEVEL: 0.75},
|
|
"async_set_volume",
|
|
(75,),
|
|
{"volume": 75},
|
|
ATTR_MEDIA_VOLUME_LEVEL,
|
|
0.75,
|
|
),
|
|
(
|
|
SERVICE_VOLUME_MUTE,
|
|
{ATTR_MEDIA_VOLUME_MUTED: True},
|
|
"async_set_mute",
|
|
(True,),
|
|
{"is_muted": True},
|
|
ATTR_MEDIA_VOLUME_MUTED,
|
|
True,
|
|
),
|
|
(
|
|
SERVICE_SELECT_SOURCE,
|
|
{ATTR_INPUT_SOURCE: "Bluetooth"},
|
|
"async_set_play_mode",
|
|
("Bluetooth",),
|
|
{"play_mode": "Bluetooth"},
|
|
ATTR_INPUT_SOURCE,
|
|
"Bluetooth",
|
|
),
|
|
],
|
|
)
|
|
async def test_control_services_update_state_machine(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_wiim_device: MagicMock,
|
|
mock_wiim_controller: MagicMock,
|
|
*,
|
|
service: str,
|
|
service_data: dict[str, object],
|
|
device_method: str,
|
|
expected_args: tuple[object, ...],
|
|
state_update: dict[str, object],
|
|
state_attr: str,
|
|
state_value: object,
|
|
) -> None:
|
|
"""Test control services are exercised through Home Assistant."""
|
|
await setup_integration(hass, mock_config_entry)
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
service,
|
|
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, **service_data},
|
|
blocking=True,
|
|
)
|
|
|
|
getattr(mock_wiim_device, device_method).assert_awaited_once_with(*expected_args)
|
|
|
|
for attr_name, attr_value in state_update.items():
|
|
setattr(mock_wiim_device, attr_name, attr_value)
|
|
await fire_general_update(hass, mock_wiim_device)
|
|
|
|
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
|
assert state.attributes[state_attr] == state_value
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"device_error",
|
|
[WiimRequestException("request failed"), RuntimeError("command failed")],
|
|
)
|
|
async def test_command_error_uses_translation(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_wiim_device: MagicMock,
|
|
mock_wiim_controller: MagicMock,
|
|
device_error: Exception,
|
|
) -> None:
|
|
"""Test command errors raise a translated Home Assistant error."""
|
|
await setup_integration(hass, mock_config_entry)
|
|
mock_wiim_device.async_play.side_effect = device_error
|
|
|
|
with pytest.raises(HomeAssistantError) as exc_info:
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_MEDIA_PLAY,
|
|
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID},
|
|
blocking=True,
|
|
)
|
|
|
|
assert exc_info.value.translation_domain == DOMAIN
|
|
assert exc_info.value.translation_key == "command_failed"
|
|
assert exc_info.value.translation_placeholders == {
|
|
"command": "async_media_play",
|
|
"entity_id": MEDIA_PLAYER_ENTITY_ID,
|
|
}
|
|
|
|
|
|
async def test_repeat_and_shuffle_services_update_state_machine(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_wiim_device: MagicMock,
|
|
mock_wiim_controller: MagicMock,
|
|
) -> None:
|
|
"""Test repeat and shuffle go through services and state updates."""
|
|
await setup_integration(hass, mock_config_entry)
|
|
mock_wiim_device.async_get_transport_capabilities.return_value = (
|
|
WiimTransportCapabilities(
|
|
can_next=True,
|
|
can_previous=True,
|
|
can_repeat=True,
|
|
can_shuffle=True,
|
|
)
|
|
)
|
|
|
|
await fire_general_update(hass, mock_wiim_device)
|
|
|
|
repeat_loop_mode = object()
|
|
mock_wiim_device.build_loop_mode.return_value = repeat_loop_mode
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_REPEAT_SET,
|
|
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_REPEAT: RepeatMode.ALL},
|
|
blocking=True,
|
|
)
|
|
mock_wiim_device.build_loop_mode.assert_called_once_with(WiimRepeatMode.ALL, False)
|
|
mock_wiim_device.async_set_loop_mode.assert_awaited_once_with(repeat_loop_mode)
|
|
|
|
mock_wiim_device.loop_state = WiimLoopState(
|
|
repeat=WiimRepeatMode.ALL,
|
|
shuffle=False,
|
|
)
|
|
await fire_general_update(hass, mock_wiim_device)
|
|
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
|
assert state.attributes[ATTR_MEDIA_REPEAT] == RepeatMode.ALL
|
|
|
|
mock_wiim_device.build_loop_mode.reset_mock()
|
|
mock_wiim_device.async_set_loop_mode.reset_mock()
|
|
shuffle_loop_mode = object()
|
|
mock_wiim_device.build_loop_mode.return_value = shuffle_loop_mode
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_SHUFFLE_SET,
|
|
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_SHUFFLE: True},
|
|
blocking=True,
|
|
)
|
|
mock_wiim_device.build_loop_mode.assert_called_once_with(WiimRepeatMode.ALL, True)
|
|
mock_wiim_device.async_set_loop_mode.assert_awaited_once_with(shuffle_loop_mode)
|
|
|
|
mock_wiim_device.loop_state = WiimLoopState(
|
|
repeat=WiimRepeatMode.ALL,
|
|
shuffle=True,
|
|
)
|
|
await fire_general_update(hass, mock_wiim_device)
|
|
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
|
assert state.attributes[ATTR_MEDIA_SHUFFLE] is True
|
|
|
|
|
|
async def test_play_pause_and_seek_services_update_state_machine(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_wiim_device: MagicMock,
|
|
mock_wiim_controller: MagicMock,
|
|
) -> None:
|
|
"""Test playback services drive the device and state machine."""
|
|
await setup_integration(hass, mock_config_entry)
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_MEDIA_PLAY,
|
|
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID},
|
|
blocking=True,
|
|
)
|
|
mock_wiim_device.async_play.assert_awaited_once()
|
|
|
|
mock_wiim_device.current_media = WiimMediaMetadata(
|
|
title="Playing Song",
|
|
duration=200,
|
|
position=12,
|
|
)
|
|
mock_wiim_device.playing_status = PlayingStatus.PLAYING
|
|
await fire_general_update(hass, mock_wiim_device)
|
|
|
|
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
|
assert state.state == MediaPlayerState.PLAYING
|
|
assert state.attributes[ATTR_MEDIA_TITLE] == "Playing Song"
|
|
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_MEDIA_PAUSE,
|
|
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID},
|
|
blocking=True,
|
|
)
|
|
mock_wiim_device.async_pause.assert_awaited_once()
|
|
mock_wiim_device.sync_device_duration_and_position.assert_awaited_once()
|
|
|
|
await fire_transport_update(hass, mock_wiim_device, PlayingStatus.PAUSED)
|
|
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
|
assert state.state == MediaPlayerState.PAUSED
|
|
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_MEDIA_SEEK,
|
|
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, "seek_position": 60},
|
|
blocking=True,
|
|
)
|
|
mock_wiim_device.async_seek.assert_awaited_once_with(60)
|
|
|
|
mock_wiim_device.current_media = WiimMediaMetadata(
|
|
title="Playing Song",
|
|
duration=200,
|
|
position=60,
|
|
)
|
|
await fire_general_update(hass, mock_wiim_device)
|
|
|
|
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
|
assert state.attributes[ATTR_MEDIA_POSITION] == 60
|
|
|
|
|
|
async def test_follower_routes_commands_and_reads_leader_metadata(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_wiim_device: MagicMock,
|
|
mock_wiim_controller: MagicMock,
|
|
) -> None:
|
|
"""Test follower commands are routed to the leader device."""
|
|
await setup_integration(hass, mock_config_entry)
|
|
leader_device = AsyncMock(spec=WiimDevice)
|
|
leader_device.udn = "uuid:leader-1234"
|
|
leader_device.name = "Leader WiiM Device"
|
|
leader_device.playing_status = PlayingStatus.STOPPED
|
|
leader_device.play_mode = "Network"
|
|
leader_device.loop_state = WiimLoopState(
|
|
repeat=WiimRepeatMode.OFF,
|
|
shuffle=False,
|
|
)
|
|
leader_device.output_mode = "speaker"
|
|
leader_device.current_media = None
|
|
leader_device.async_get_transport_capabilities = AsyncMock(
|
|
return_value=WiimTransportCapabilities(
|
|
can_next=True,
|
|
can_previous=False,
|
|
can_repeat=True,
|
|
can_shuffle=False,
|
|
)
|
|
)
|
|
|
|
mock_wiim_controller.get_group_snapshot.return_value = WiimGroupSnapshot(
|
|
role=WiimGroupRole.FOLLOWER,
|
|
leader_udn=leader_device.udn,
|
|
member_udns=(leader_device.udn, mock_wiim_device.udn),
|
|
)
|
|
mock_wiim_controller.get_device.side_effect = lambda udn: (
|
|
leader_device if udn == leader_device.udn else mock_wiim_device
|
|
)
|
|
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_MEDIA_PLAY,
|
|
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID},
|
|
blocking=True,
|
|
)
|
|
leader_device.async_play.assert_awaited_once()
|
|
mock_wiim_device.async_play.assert_not_awaited()
|
|
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_MEDIA_SEEK,
|
|
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, "seek_position": 90},
|
|
blocking=True,
|
|
)
|
|
leader_device.async_seek.assert_awaited_once_with(90)
|
|
mock_wiim_device.async_seek.assert_not_awaited()
|
|
|
|
leader_device.playing_status = PlayingStatus.PLAYING
|
|
leader_device.play_mode = "Spotify"
|
|
leader_device.current_media = WiimMediaMetadata(
|
|
title="Leader Song",
|
|
album="Leader Album",
|
|
duration=210,
|
|
position=90,
|
|
)
|
|
await fire_general_update(hass, mock_wiim_device)
|
|
|
|
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
|
assert state.state == MediaPlayerState.PLAYING
|
|
assert state.attributes[ATTR_MEDIA_TITLE] == "Leader Song"
|
|
assert state.attributes[ATTR_MEDIA_ALBUM_NAME] == "Leader Album"
|
|
assert state.attributes[ATTR_INPUT_SOURCE] == "Spotify"
|
|
assert state.attributes[ATTR_MEDIA_POSITION] == 90
|
|
|
|
|
|
async def test_group_refresh_dispatcher_sends_to_followers_and_refreshes_member(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_wiim_device: MagicMock,
|
|
mock_wiim_controller: MagicMock,
|
|
) -> None:
|
|
"""Test leader group refresh signal makes a real follower entity refresh."""
|
|
follower_device = _build_mock_wiim_device(
|
|
udn="uuid:follower-1234",
|
|
name="Follower WiiM Device",
|
|
ip_address="192.168.1.101",
|
|
base_device=mock_wiim_device,
|
|
)
|
|
|
|
wiim_component.async_create_wiim_device.side_effect = [
|
|
mock_wiim_device,
|
|
follower_device,
|
|
]
|
|
follower_config_entry = MockConfigEntry(
|
|
domain=DOMAIN,
|
|
data={CONF_HOST: "192.168.1.101"},
|
|
title="Follower WiiM Device",
|
|
unique_id=follower_device.udn,
|
|
)
|
|
|
|
await setup_integration(hass, mock_config_entry)
|
|
await setup_integration(hass, follower_config_entry)
|
|
|
|
def group_snapshot_for(udn: str) -> WiimGroupSnapshot:
|
|
if udn == mock_wiim_device.udn:
|
|
return WiimGroupSnapshot(
|
|
role=WiimGroupRole.LEADER,
|
|
leader_udn=mock_wiim_device.udn,
|
|
member_udns=(mock_wiim_device.udn, follower_device.udn),
|
|
)
|
|
return WiimGroupSnapshot(
|
|
role=WiimGroupRole.FOLLOWER,
|
|
leader_udn=mock_wiim_device.udn,
|
|
member_udns=(mock_wiim_device.udn, follower_device.udn),
|
|
)
|
|
|
|
mock_wiim_controller.get_group_snapshot.side_effect = group_snapshot_for
|
|
mock_wiim_controller.get_device.side_effect = lambda udn: (
|
|
mock_wiim_device if udn == mock_wiim_device.udn else follower_device
|
|
)
|
|
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_JOIN,
|
|
{
|
|
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
|
|
ATTR_GROUP_MEMBERS: ["media_player.follower_wiim_device"],
|
|
},
|
|
blocking=True,
|
|
)
|
|
|
|
mock_wiim_controller.async_join_group.assert_awaited_once_with(
|
|
mock_wiim_device.udn,
|
|
[follower_device.udn],
|
|
)
|
|
|
|
mock_wiim_device.playing_status = PlayingStatus.PLAYING
|
|
mock_wiim_device.play_mode = "Spotify"
|
|
mock_wiim_device.current_media = WiimMediaMetadata(
|
|
title="Leader Signal Song",
|
|
album="Leader Signal Album",
|
|
duration=240,
|
|
position=33,
|
|
)
|
|
|
|
await fire_general_update(hass, mock_wiim_device)
|
|
|
|
follower_state = hass.states.get("media_player.follower_wiim_device")
|
|
assert follower_state is not None
|
|
assert follower_state.state == MediaPlayerState.PLAYING
|
|
assert follower_state.attributes[ATTR_MEDIA_TITLE] == "Leader Signal Song"
|
|
assert follower_state.attributes[ATTR_MEDIA_ALBUM_NAME] == "Leader Signal Album"
|
|
assert follower_state.attributes[ATTR_INPUT_SOURCE] == "Spotify"
|
|
assert follower_state.attributes[ATTR_MEDIA_POSITION] == 33
|
|
|
|
|
|
async def test_follower_routes_repeat_shuffle_and_source_commands_to_leader(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_wiim_device: MagicMock,
|
|
mock_wiim_controller: MagicMock,
|
|
) -> None:
|
|
"""Test follower repeat, shuffle, and source changes are sent to the leader."""
|
|
await setup_integration(hass, mock_config_entry)
|
|
leader_device = AsyncMock(spec=WiimDevice)
|
|
leader_device.udn = "uuid:leader-1234"
|
|
leader_device.playing_status = PlayingStatus.STOPPED
|
|
leader_device.current_media = None
|
|
leader_device.loop_state = WiimLoopState(
|
|
repeat=WiimRepeatMode.OFF,
|
|
shuffle=False,
|
|
)
|
|
leader_device.play_mode = "Network"
|
|
leader_device.async_get_transport_capabilities = AsyncMock(
|
|
return_value=WiimTransportCapabilities(
|
|
can_next=True,
|
|
can_previous=True,
|
|
can_repeat=True,
|
|
can_shuffle=True,
|
|
)
|
|
)
|
|
|
|
mock_wiim_controller.get_group_snapshot.return_value = WiimGroupSnapshot(
|
|
role=WiimGroupRole.FOLLOWER,
|
|
leader_udn=leader_device.udn,
|
|
member_udns=(leader_device.udn, mock_wiim_device.udn),
|
|
)
|
|
mock_wiim_controller.get_device.side_effect = lambda udn: (
|
|
leader_device if udn == leader_device.udn else mock_wiim_device
|
|
)
|
|
|
|
await fire_general_update(hass, mock_wiim_device)
|
|
|
|
repeat_loop_mode = object()
|
|
leader_device.build_loop_mode.return_value = repeat_loop_mode
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_REPEAT_SET,
|
|
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_REPEAT: RepeatMode.ALL},
|
|
blocking=True,
|
|
)
|
|
leader_device.build_loop_mode.assert_called_once_with(WiimRepeatMode.ALL, False)
|
|
leader_device.async_set_loop_mode.assert_awaited_once_with(repeat_loop_mode)
|
|
mock_wiim_device.async_set_loop_mode.assert_not_awaited()
|
|
|
|
leader_device.build_loop_mode.reset_mock()
|
|
leader_device.async_set_loop_mode.reset_mock()
|
|
shuffle_loop_mode = object()
|
|
leader_device.build_loop_mode.return_value = shuffle_loop_mode
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_SHUFFLE_SET,
|
|
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_SHUFFLE: True},
|
|
blocking=True,
|
|
)
|
|
leader_device.build_loop_mode.assert_called_once_with(WiimRepeatMode.OFF, True)
|
|
leader_device.async_set_loop_mode.assert_awaited_once_with(shuffle_loop_mode)
|
|
mock_wiim_device.async_set_loop_mode.assert_not_awaited()
|
|
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_SELECT_SOURCE,
|
|
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, ATTR_INPUT_SOURCE: "Bluetooth"},
|
|
blocking=True,
|
|
)
|
|
leader_device.async_set_play_mode.assert_awaited_once_with("Bluetooth")
|
|
mock_wiim_device.async_set_play_mode.assert_not_awaited()
|
|
|
|
|
|
async def test_play_media_services_call_device_commands(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_wiim_device: MagicMock,
|
|
mock_wiim_controller: MagicMock,
|
|
) -> None:
|
|
"""Test play_media services are driven through Home Assistant."""
|
|
await setup_integration(hass, mock_config_entry)
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_PLAY_MEDIA,
|
|
{
|
|
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
|
|
ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC,
|
|
ATTR_MEDIA_CONTENT_ID: "1",
|
|
},
|
|
blocking=True,
|
|
)
|
|
mock_wiim_device.play_preset.assert_awaited_once_with(1)
|
|
|
|
mock_wiim_device.current_media = WiimMediaMetadata(title="Preset 1")
|
|
mock_wiim_device.playing_status = PlayingStatus.PLAYING
|
|
await fire_general_update(hass, mock_wiim_device)
|
|
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
|
assert state.state == MediaPlayerState.PLAYING
|
|
assert state.attributes[ATTR_MEDIA_TITLE] == "Preset 1"
|
|
|
|
mock_wiim_device.play_preset.reset_mock()
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_PLAY_MEDIA,
|
|
{
|
|
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
|
|
ATTR_MEDIA_CONTENT_TYPE: "wiim_library",
|
|
ATTR_MEDIA_CONTENT_ID: "2",
|
|
},
|
|
blocking=True,
|
|
)
|
|
mock_wiim_device.play_preset.assert_awaited_once_with(2)
|
|
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_PLAY_MEDIA,
|
|
{
|
|
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
|
|
ATTR_MEDIA_CONTENT_TYPE: MediaType.TRACK,
|
|
ATTR_MEDIA_CONTENT_ID: "2",
|
|
},
|
|
blocking=True,
|
|
)
|
|
mock_wiim_device.async_play_queue_with_index.assert_awaited_once_with(2)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("media_type", "media_id", "translation_key", "translation_placeholders"),
|
|
[
|
|
(
|
|
"wiim_library",
|
|
"not-a-preset",
|
|
"invalid_preset_id",
|
|
{"media_id": "not-a-preset"},
|
|
),
|
|
(
|
|
MediaType.TRACK,
|
|
"not-a-track",
|
|
"invalid_track_id",
|
|
{"media_id": "not-a-track"},
|
|
),
|
|
(
|
|
"unsupported",
|
|
"1",
|
|
"unsupported_media_type",
|
|
{"media_type": "unsupported"},
|
|
),
|
|
(
|
|
MediaType.URL,
|
|
"http://example.com/song.mp3",
|
|
"direct_url_playback_unsupported",
|
|
None,
|
|
),
|
|
],
|
|
)
|
|
async def test_play_media_validation_error_uses_translation(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_wiim_device: MagicMock,
|
|
mock_wiim_controller: MagicMock,
|
|
*,
|
|
media_type: MediaType | str,
|
|
media_id: str,
|
|
translation_key: str,
|
|
translation_placeholders: dict[str, str] | None,
|
|
) -> None:
|
|
"""Test play media validation errors are translated."""
|
|
await setup_integration(hass, mock_config_entry)
|
|
|
|
with pytest.raises(ServiceValidationError) as exc_info:
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_PLAY_MEDIA,
|
|
{
|
|
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
|
|
ATTR_MEDIA_CONTENT_TYPE: media_type,
|
|
ATTR_MEDIA_CONTENT_ID: media_id,
|
|
},
|
|
blocking=True,
|
|
)
|
|
|
|
assert exc_info.value.translation_domain == DOMAIN
|
|
assert exc_info.value.translation_key == translation_key
|
|
assert exc_info.value.translation_placeholders == translation_placeholders
|
|
|
|
|
|
@pytest.mark.parametrize("media_type", [MediaType.MUSIC, MediaType.URL])
|
|
async def test_play_media_url_service_uses_processed_url(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_wiim_device: MagicMock,
|
|
mock_wiim_controller: MagicMock,
|
|
media_type: MediaType,
|
|
) -> None:
|
|
"""Test direct URL playback goes through the URL processor."""
|
|
await setup_integration(hass, mock_config_entry)
|
|
mock_wiim_device.supports_http_api = True
|
|
|
|
with patch(
|
|
"homeassistant.components.wiim.media_player.async_process_play_media_url",
|
|
return_value="http://processed/song.mp3",
|
|
):
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_PLAY_MEDIA,
|
|
{
|
|
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
|
|
ATTR_MEDIA_CONTENT_TYPE: media_type,
|
|
ATTR_MEDIA_CONTENT_ID: "http://example.com/song.mp3",
|
|
},
|
|
blocking=True,
|
|
)
|
|
|
|
mock_wiim_device.play_url.assert_awaited_once_with("http://processed/song.mp3")
|
|
mock_wiim_device.play_preset.assert_not_awaited()
|
|
|
|
|
|
async def test_play_media_source_service_uses_resolved_url(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_wiim_device: MagicMock,
|
|
mock_wiim_controller: MagicMock,
|
|
) -> None:
|
|
"""Test media_source playback goes through the resolver."""
|
|
await setup_integration(hass, mock_config_entry)
|
|
mock_wiim_device.supports_http_api = True
|
|
|
|
with (
|
|
patch(
|
|
"homeassistant.components.wiim.media_player.media_source.is_media_source_id",
|
|
return_value=True,
|
|
),
|
|
patch(
|
|
"homeassistant.components.wiim.media_player.media_source.async_resolve_media",
|
|
AsyncMock(return_value=MagicMock(url="http://resolved/song.mp3")),
|
|
),
|
|
patch(
|
|
"homeassistant.components.wiim.media_player.async_process_play_media_url",
|
|
return_value="http://processed/song.mp3",
|
|
),
|
|
):
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_PLAY_MEDIA,
|
|
{
|
|
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
|
|
ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC,
|
|
ATTR_MEDIA_CONTENT_ID: "media-source://media_source/local/song.mp3",
|
|
},
|
|
blocking=True,
|
|
)
|
|
|
|
mock_wiim_device.play_url.assert_awaited_once_with("http://processed/song.mp3")
|
|
|
|
|
|
async def test_browse_media_service_returns_wiim_library(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_wiim_device: MagicMock,
|
|
mock_wiim_controller: MagicMock,
|
|
) -> None:
|
|
"""Test browsing WiiM presets and queue via the media_player service."""
|
|
await setup_integration(hass, mock_config_entry)
|
|
root_result = await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_BROWSE_MEDIA,
|
|
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID},
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
root_browse = root_result[MEDIA_PLAYER_ENTITY_ID]
|
|
assert root_browse.title == mock_wiim_device.name
|
|
assert [child.title for child in root_browse.children] == ["Presets", "Queue"]
|
|
|
|
mock_wiim_device.async_get_presets.return_value = (
|
|
WiimPreset(1, "Preset 1", "http://image1"),
|
|
WiimPreset(2, "Preset 2", "http://image2"),
|
|
)
|
|
preset_result = await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_BROWSE_MEDIA,
|
|
{
|
|
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
|
|
ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST,
|
|
ATTR_MEDIA_CONTENT_ID: "wiim_library/library_root/favorites",
|
|
},
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
preset_browse = preset_result[MEDIA_PLAYER_ENTITY_ID]
|
|
assert [child.title for child in preset_browse.children] == ["Preset 1", "Preset 2"]
|
|
|
|
mock_wiim_device.async_get_queue_snapshot.return_value = WiimQueueSnapshot(
|
|
items=(
|
|
WiimQueueItem(1, "Song A", "http://image-a"),
|
|
WiimQueueItem(2, "Song B", "http://image-b"),
|
|
),
|
|
is_active=True,
|
|
)
|
|
queue_result = await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_BROWSE_MEDIA,
|
|
{
|
|
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
|
|
ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST,
|
|
ATTR_MEDIA_CONTENT_ID: "wiim_library/library_root/playlists",
|
|
},
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
queue_browse = queue_result[MEDIA_PLAYER_ENTITY_ID]
|
|
assert [child.title for child in queue_browse.children] == ["Song A", "Song B"]
|
|
|
|
|
|
@pytest.mark.usefixtures("mock_wiim_controller")
|
|
async def test_browse_media_does_not_refresh_entity_state(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_wiim_device: MagicMock,
|
|
) -> None:
|
|
"""Test browsing media does not refresh entity state after success."""
|
|
await setup_integration(hass, mock_config_entry)
|
|
|
|
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
|
assert state is not None
|
|
original_volume = state.attributes[ATTR_MEDIA_VOLUME_LEVEL]
|
|
mock_wiim_device.volume = 75
|
|
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_BROWSE_MEDIA,
|
|
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID},
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
|
|
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
|
assert state is not None
|
|
assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == original_volume
|
|
|
|
|
|
async def test_browse_media_service_includes_media_sources_when_supported(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_wiim_device: MagicMock,
|
|
mock_wiim_controller: MagicMock,
|
|
) -> None:
|
|
"""Test media sources are exposed through browse_media when HTTP API exists."""
|
|
await setup_integration(hass, mock_config_entry)
|
|
mock_wiim_device.supports_http_api = True
|
|
|
|
media_source_root = BrowseMedia(
|
|
media_class=MediaClass.DIRECTORY,
|
|
media_content_id="media-source://media_source",
|
|
media_content_type=MediaType.APPS,
|
|
title="Media Sources",
|
|
can_play=False,
|
|
can_expand=True,
|
|
children=[
|
|
BrowseMedia(
|
|
media_class=MediaClass.MUSIC,
|
|
media_content_id="media-source://media_source/local/song.mp3",
|
|
media_content_type="audio/mpeg",
|
|
title="song.mp3",
|
|
can_play=True,
|
|
can_expand=False,
|
|
)
|
|
],
|
|
)
|
|
|
|
with patch(
|
|
"homeassistant.components.wiim.media_player.media_source.async_browse_media",
|
|
AsyncMock(return_value=media_source_root),
|
|
):
|
|
result = await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_BROWSE_MEDIA,
|
|
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID},
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
|
|
browse = result[MEDIA_PLAYER_ENTITY_ID]
|
|
assert [child.title for child in browse.children] == [
|
|
"Presets",
|
|
"Queue",
|
|
"song.mp3",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
(
|
|
"media_content_type",
|
|
"media_content_id",
|
|
"translation_key",
|
|
"translation_placeholders",
|
|
),
|
|
[
|
|
pytest.param(
|
|
MediaType.MUSIC,
|
|
"media-source://media_source/local/song.mp3",
|
|
"media_sources_unsupported",
|
|
None,
|
|
id="media-source-unsupported",
|
|
),
|
|
pytest.param(
|
|
MediaType.PLAYLIST,
|
|
"wiim_library/invalid",
|
|
"invalid_browse_path",
|
|
{"media_content_id": "wiim_library/invalid"},
|
|
id="invalid-path",
|
|
),
|
|
],
|
|
)
|
|
async def test_browse_media_error_uses_translation(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_wiim_device: MagicMock,
|
|
mock_wiim_controller: MagicMock,
|
|
*,
|
|
media_content_type: MediaType,
|
|
media_content_id: str,
|
|
translation_key: str,
|
|
translation_placeholders: dict[str, str] | None,
|
|
) -> None:
|
|
"""Test browse media errors are translated."""
|
|
await setup_integration(hass, mock_config_entry)
|
|
|
|
with pytest.raises(BrowseError) as exc_info:
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_BROWSE_MEDIA,
|
|
{
|
|
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
|
|
ATTR_MEDIA_CONTENT_TYPE: media_content_type,
|
|
ATTR_MEDIA_CONTENT_ID: media_content_id,
|
|
},
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
|
|
assert exc_info.value.translation_domain == DOMAIN
|
|
assert exc_info.value.translation_key == translation_key
|
|
assert exc_info.value.translation_placeholders == translation_placeholders
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("sdk_method", "media_content_id"),
|
|
[
|
|
pytest.param(
|
|
"async_get_presets",
|
|
"wiim_library/library_root/favorites",
|
|
id="presets",
|
|
),
|
|
pytest.param(
|
|
"async_get_queue_snapshot",
|
|
"wiim_library/library_root/playlists",
|
|
id="queue",
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.usefixtures("mock_wiim_controller")
|
|
async def test_browse_media_sdk_error_uses_translation(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_wiim_device: MagicMock,
|
|
*,
|
|
sdk_method: str,
|
|
media_content_id: str,
|
|
) -> None:
|
|
"""Test browse media SDK errors raise a translated Home Assistant error."""
|
|
await setup_integration(hass, mock_config_entry)
|
|
getattr(mock_wiim_device, sdk_method).side_effect = WiimRequestException(
|
|
"request failed"
|
|
)
|
|
|
|
with pytest.raises(HomeAssistantError) as exc_info:
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_BROWSE_MEDIA,
|
|
{
|
|
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
|
|
ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST,
|
|
ATTR_MEDIA_CONTENT_ID: media_content_id,
|
|
},
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
|
|
assert exc_info.value.translation_domain == DOMAIN
|
|
assert exc_info.value.translation_key == "command_failed"
|
|
assert exc_info.value.translation_placeholders == {
|
|
"command": "async_browse_media",
|
|
"entity_id": MEDIA_PLAYER_ENTITY_ID,
|
|
}
|
|
|
|
|
|
async def test_join_and_unjoin_services_use_resolved_member_udns(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_wiim_device: MagicMock,
|
|
mock_wiim_controller: MagicMock,
|
|
) -> None:
|
|
"""Test grouping services call the controller with resolved UDNs."""
|
|
follower_device = _build_mock_wiim_device(
|
|
udn="uuid:follower-1234",
|
|
name="Follower WiiM Device",
|
|
ip_address="192.168.1.101",
|
|
base_device=mock_wiim_device,
|
|
)
|
|
second_follower_device = _build_mock_wiim_device(
|
|
udn="uuid:follower-5678",
|
|
name="Second Follower WiiM Device",
|
|
ip_address="192.168.1.102",
|
|
base_device=mock_wiim_device,
|
|
)
|
|
wiim_component.async_create_wiim_device.side_effect = [
|
|
mock_wiim_device,
|
|
follower_device,
|
|
second_follower_device,
|
|
]
|
|
follower_config_entry = MockConfigEntry(
|
|
domain=DOMAIN,
|
|
data={CONF_HOST: "192.168.1.101"},
|
|
title=follower_device.name,
|
|
unique_id=follower_device.udn,
|
|
)
|
|
second_follower_config_entry = MockConfigEntry(
|
|
domain=DOMAIN,
|
|
data={CONF_HOST: "192.168.1.102"},
|
|
title=second_follower_device.name,
|
|
unique_id=second_follower_device.udn,
|
|
)
|
|
|
|
await setup_integration(hass, mock_config_entry)
|
|
await setup_integration(hass, follower_config_entry)
|
|
await setup_integration(hass, second_follower_config_entry)
|
|
|
|
follower_entity_id = "media_player.follower_wiim_device"
|
|
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_JOIN,
|
|
{
|
|
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
|
|
ATTR_GROUP_MEMBERS: [
|
|
MEDIA_PLAYER_ENTITY_ID,
|
|
follower_entity_id,
|
|
],
|
|
},
|
|
blocking=True,
|
|
)
|
|
|
|
mock_wiim_controller.async_join_group.assert_awaited_once_with(
|
|
mock_wiim_device.udn, [follower_device.udn]
|
|
)
|
|
mock_wiim_controller.async_join_group.reset_mock()
|
|
|
|
leader_device = AsyncMock(spec=WiimDevice)
|
|
leader_device.udn = "uuid:leader-1234"
|
|
leader_device.name = "Leader WiiM Device"
|
|
leader_device.playing_status = PlayingStatus.STOPPED
|
|
leader_device.play_mode = "Network"
|
|
leader_device.loop_state = WiimLoopState(
|
|
repeat=WiimRepeatMode.OFF,
|
|
shuffle=False,
|
|
)
|
|
leader_device.current_media = None
|
|
second_follower_entity_id = "media_player.second_follower_wiim_device"
|
|
mock_wiim_controller.get_group_snapshot.return_value = WiimGroupSnapshot(
|
|
role=WiimGroupRole.FOLLOWER,
|
|
leader_udn=leader_device.udn,
|
|
member_udns=(leader_device.udn, mock_wiim_device.udn),
|
|
)
|
|
mock_wiim_controller.get_device.side_effect = lambda udn: (
|
|
leader_device if udn == leader_device.udn else mock_wiim_device
|
|
)
|
|
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_JOIN,
|
|
{
|
|
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
|
|
ATTR_GROUP_MEMBERS: [
|
|
MEDIA_PLAYER_ENTITY_ID,
|
|
second_follower_entity_id,
|
|
],
|
|
},
|
|
blocking=True,
|
|
)
|
|
|
|
mock_wiim_controller.async_join_group.assert_awaited_once_with(
|
|
leader_device.udn, [second_follower_device.udn]
|
|
)
|
|
mock_wiim_controller.async_join_group.reset_mock()
|
|
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_UNJOIN,
|
|
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID},
|
|
blocking=True,
|
|
)
|
|
|
|
mock_wiim_controller.async_ungroup_device.assert_awaited_once_with(
|
|
mock_wiim_device.udn
|
|
)
|
|
|
|
|
|
async def test_join_service_invalid_member_uses_translation(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_wiim_device: MagicMock,
|
|
mock_wiim_controller: MagicMock,
|
|
) -> None:
|
|
"""Test joining an invalid member raises a translated validation error."""
|
|
await setup_integration(hass, mock_config_entry)
|
|
invalid_entity_id = "media_player.unknown_wiim_device"
|
|
|
|
with pytest.raises(ServiceValidationError) as exc_info:
|
|
await hass.services.async_call(
|
|
MEDIA_PLAYER_DOMAIN,
|
|
SERVICE_JOIN,
|
|
{
|
|
ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID,
|
|
ATTR_GROUP_MEMBERS: [invalid_entity_id],
|
|
},
|
|
blocking=True,
|
|
)
|
|
|
|
assert exc_info.value.translation_domain == DOMAIN
|
|
assert exc_info.value.translation_key == "invalid_grouping_entity"
|
|
assert exc_info.value.translation_placeholders == {"entity_id": invalid_entity_id}
|
|
mock_wiim_controller.async_join_group.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("device_ip", "image_url", "expected_disabled_request_options", "content_type"),
|
|
[
|
|
(
|
|
"192.168.1.100",
|
|
"https://192.168.1.100/local-artwork.jpg",
|
|
{"allow_redirects", "ssl"},
|
|
"image/jpeg ; charset=binary",
|
|
),
|
|
(
|
|
"192.168.1.100",
|
|
"http://192.168.1.100/local-artwork.jpg",
|
|
set(),
|
|
"image/jpeg",
|
|
),
|
|
(
|
|
"192.168.1.100",
|
|
"https://wiim-artwork.example/remote-artwork.jpg",
|
|
set(),
|
|
"image/jpeg",
|
|
),
|
|
("8.8.8.8", "https://8.8.8.8/public-artwork.jpg", set(), "image/jpeg"),
|
|
(
|
|
"169.254.10.20",
|
|
"https://169.254.10.20/link-local-artwork.jpg",
|
|
{"allow_redirects", "ssl"},
|
|
"image/jpeg",
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.usefixtures("mock_wiim_controller")
|
|
async def test_media_image_ssl_verification(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_wiim_device: MagicMock,
|
|
aioclient_mock: AiohttpClientMocker,
|
|
hass_client: ClientSessionGenerator,
|
|
*,
|
|
device_ip: str,
|
|
image_url: str,
|
|
expected_disabled_request_options: set[str],
|
|
content_type: str,
|
|
) -> None:
|
|
"""Test SSL verification is disabled only for local WiiM HTTPS artwork."""
|
|
mock_wiim_device.ip_address = device_ip
|
|
await setup_integration(hass, mock_config_entry)
|
|
mock_wiim_device.current_media = WiimMediaMetadata(
|
|
title="Test artwork",
|
|
uri="http://example.com/test-artwork.flac",
|
|
image_url=image_url,
|
|
)
|
|
await fire_general_update(hass, mock_wiim_device)
|
|
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
|
assert state is not None
|
|
|
|
aioclient_mock.get(
|
|
image_url,
|
|
content=b"image-bytes",
|
|
headers={"Content-Type": content_type},
|
|
)
|
|
websession = async_get_clientsession(hass)
|
|
with patch.object(websession, "get", wraps=websession.get) as mock_get:
|
|
response = await (await hass_client()).get(
|
|
state.attributes[ATTR_ENTITY_PICTURE_LOCAL]
|
|
)
|
|
|
|
assert response.status == 200
|
|
assert await response.read() == b"image-bytes"
|
|
assert response.headers["Content-Type"] == "image/jpeg"
|
|
image_get_calls = [
|
|
call
|
|
for call in mock_get.call_args_list
|
|
if call.args and str(call.args[0]).partition("#")[0] == image_url
|
|
]
|
|
assert len(image_get_calls) == 1
|
|
image_get_call = image_get_calls[0]
|
|
request_options = image_get_call.kwargs
|
|
disabled_request_options = {
|
|
option
|
|
for option in ("allow_redirects", "ssl")
|
|
if request_options.get(option) is False
|
|
}
|
|
assert disabled_request_options == expected_disabled_request_options
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("status", "exception_factory"),
|
|
[
|
|
(HTTPStatus.NOT_FOUND, lambda: None),
|
|
(HTTPStatus.OK, partial(aiohttp.ClientError)),
|
|
],
|
|
)
|
|
@pytest.mark.usefixtures("mock_wiim_controller")
|
|
async def test_local_https_media_image_fetch_error(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_wiim_device: MagicMock,
|
|
aioclient_mock: AiohttpClientMocker,
|
|
hass_client: ClientSessionGenerator,
|
|
*,
|
|
status: HTTPStatus,
|
|
exception_factory: Callable[[], aiohttp.ClientError | None],
|
|
) -> None:
|
|
"""Test a local HTTPS artwork request error returns no proxy image."""
|
|
await setup_integration(hass, mock_config_entry)
|
|
image_url = "https://192.168.1.100/unavailable-artwork.jpg"
|
|
mock_wiim_device.current_media = WiimMediaMetadata(
|
|
title="Unavailable artwork",
|
|
uri="http://example.com/unavailable-artwork.flac",
|
|
image_url=image_url,
|
|
)
|
|
await fire_general_update(hass, mock_wiim_device)
|
|
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
|
assert state is not None
|
|
|
|
aioclient_mock.get(
|
|
image_url,
|
|
status=status,
|
|
exc=exception_factory(),
|
|
)
|
|
response = await (await hass_client()).get(
|
|
state.attributes[ATTR_ENTITY_PICTURE_LOCAL]
|
|
)
|
|
assert response.status == 404
|
|
|
|
|
|
@pytest.mark.usefixtures("mock_wiim_controller")
|
|
async def test_media_image_hash_changes_for_same_local_artwork_url(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_wiim_device: MagicMock,
|
|
aioclient_mock: AiohttpClientMocker,
|
|
hass_client: ClientSessionGenerator,
|
|
) -> None:
|
|
"""Test the media proxy returns new artwork when its URL is reused."""
|
|
await setup_integration(hass, mock_config_entry)
|
|
image_url = "https://192.168.1.100/changing-album-art.jpg"
|
|
|
|
client = await hass_client()
|
|
|
|
mock_wiim_device.current_media = WiimMediaMetadata(
|
|
title="First Song",
|
|
artist="Artist",
|
|
album="Album",
|
|
uri="http://example.com/first.flac",
|
|
image_url=image_url,
|
|
)
|
|
await fire_general_update(hass, mock_wiim_device)
|
|
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
|
assert state is not None
|
|
assert state.attributes[ATTR_ENTITY_PICTURE] == image_url
|
|
first_local_image = state.attributes[ATTR_ENTITY_PICTURE_LOCAL]
|
|
|
|
aioclient_mock.get(
|
|
image_url,
|
|
content=b"first-image",
|
|
headers={"Content-Type": "image/jpeg"},
|
|
)
|
|
media_response = await client.get(first_local_image)
|
|
assert media_response.status == 200
|
|
first_image = await media_response.read()
|
|
assert first_image == b"first-image"
|
|
|
|
mock_wiim_device.current_media = WiimMediaMetadata(
|
|
title="Second Song",
|
|
artist="Artist",
|
|
album="Album",
|
|
uri="http://example.com/second.flac",
|
|
image_url=image_url,
|
|
)
|
|
await fire_general_update(hass, mock_wiim_device)
|
|
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
|
|
assert state is not None
|
|
|
|
assert state.attributes[ATTR_ENTITY_PICTURE] == image_url
|
|
second_local_image = state.attributes[ATTR_ENTITY_PICTURE_LOCAL]
|
|
assert second_local_image != first_local_image
|
|
|
|
aioclient_mock.clear_requests()
|
|
aioclient_mock.get(
|
|
image_url,
|
|
content=b"second-image",
|
|
headers={"Content-Type": "image/jpeg"},
|
|
)
|
|
media_response = await client.get(second_local_image)
|
|
assert media_response.status == 200
|
|
second_image = await media_response.read()
|
|
assert second_image == b"second-image"
|
|
assert second_image != first_image
|