mirror of
https://github.com/home-assistant/core.git
synced 2026-09-25 07:51:46 -05:00
Migrate the Lyngdorf media player to the lyngdorf 2.0 API (#180618)
This commit is contained in:
@@ -3,10 +3,16 @@
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, override
|
||||
|
||||
from lyngdorf.device import Receiver
|
||||
from lyngdorf.models.base import NumericRange
|
||||
from lyngdorf.states import Control, PlaybackState, Repeat
|
||||
from lyngdorf.streaming import NowPlaying
|
||||
from lyngdorf import (
|
||||
Control,
|
||||
LyngdorfReceiver,
|
||||
NowPlaying,
|
||||
NumericRange,
|
||||
PlaybackState,
|
||||
Player,
|
||||
Repeat,
|
||||
ZoneB,
|
||||
)
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDeviceClass,
|
||||
@@ -113,7 +119,7 @@ class LyngdorfDevice(LyngdorfEntity, MediaPlayerEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
receiver: Receiver,
|
||||
receiver: LyngdorfReceiver,
|
||||
config_entry: LyngdorfConfigEntry,
|
||||
device_info: DeviceInfo,
|
||||
translation_key: str | None,
|
||||
@@ -134,7 +140,7 @@ class LyngdorfZoneBDevice(LyngdorfDevice):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
receiver: Receiver,
|
||||
receiver: LyngdorfReceiver,
|
||||
config_entry: LyngdorfConfigEntry,
|
||||
device_info: DeviceInfo,
|
||||
) -> None:
|
||||
@@ -147,11 +153,19 @@ class LyngdorfZoneBDevice(LyngdorfDevice):
|
||||
"zone_b",
|
||||
)
|
||||
|
||||
@property
|
||||
def _zone_b(self) -> ZoneB:
|
||||
"""Return the Zone B controls; this entity exists only when it has them."""
|
||||
zone_b = self._receiver.zone_b
|
||||
if TYPE_CHECKING:
|
||||
assert zone_b is not None
|
||||
return zone_b
|
||||
|
||||
@override
|
||||
@property
|
||||
def state(self) -> MediaPlayerState | None:
|
||||
"""Return the state of the device."""
|
||||
if self._receiver.zone_b_power_on:
|
||||
if self._zone_b.power_on:
|
||||
return MediaPlayerState.ON
|
||||
return MediaPlayerState.OFF
|
||||
|
||||
@@ -159,73 +173,64 @@ class LyngdorfZoneBDevice(LyngdorfDevice):
|
||||
@property
|
||||
def is_volume_muted(self) -> bool | None:
|
||||
"""Return boolean if volume is currently muted."""
|
||||
return self._receiver.zone_b_mute_enabled
|
||||
|
||||
@property
|
||||
def _volume_range(self) -> NumericRange:
|
||||
"""Return the model's documented Zone B volume range."""
|
||||
volume_range = self._receiver.zone_b_volume_range
|
||||
# This entity is only created for models that have a Zone B.
|
||||
if TYPE_CHECKING:
|
||||
assert volume_range is not None
|
||||
return volume_range
|
||||
return self._zone_b.muted
|
||||
|
||||
@override
|
||||
@property
|
||||
def volume_level(self) -> float | None:
|
||||
"""Volume level of the media player (0..1)."""
|
||||
if (volume := self._receiver.zone_b_volume) is None:
|
||||
volume = self._zone_b.volume
|
||||
if volume.value is None:
|
||||
return None
|
||||
return _to_ha_volume(volume, self._volume_range)
|
||||
return _to_ha_volume(volume.value, volume.range)
|
||||
|
||||
@override
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn on media player."""
|
||||
self._receiver.zone_b_power_on = True
|
||||
await self._zone_b.set_power(True)
|
||||
|
||||
@override
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn off media player."""
|
||||
self._receiver.zone_b_power_on = False
|
||||
await self._zone_b.set_power(False)
|
||||
|
||||
@override
|
||||
async def async_volume_up(self) -> None:
|
||||
"""Volume up the media player."""
|
||||
self._receiver.zone_b_volume_up()
|
||||
await self._zone_b.volume.up()
|
||||
|
||||
@override
|
||||
async def async_volume_down(self) -> None:
|
||||
"""Volume down the media player."""
|
||||
self._receiver.zone_b_volume_down()
|
||||
await self._zone_b.volume.down()
|
||||
|
||||
@override
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Set volume level, range 0..1."""
|
||||
self._receiver.set_zone_b_volume(
|
||||
_to_lyngdorf_volume(volume, self._volume_range)
|
||||
)
|
||||
control = self._zone_b.volume
|
||||
await control.set(_to_lyngdorf_volume(volume, control.range))
|
||||
|
||||
@override
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Send mute command."""
|
||||
self._receiver.zone_b_mute_enabled = mute
|
||||
await self._zone_b.set_muted(mute)
|
||||
|
||||
@override
|
||||
@property
|
||||
def source(self) -> str | None:
|
||||
"""Return the current input source."""
|
||||
return self._receiver.zone_b_source
|
||||
return self._zone_b.source
|
||||
|
||||
@override
|
||||
@property
|
||||
def source_list(self) -> list[str] | None:
|
||||
"""Return the list of available sources."""
|
||||
return self._receiver.zone_b_available_sources
|
||||
return self._zone_b.sources
|
||||
|
||||
@override
|
||||
async def async_select_source(self, source: str) -> None:
|
||||
"""Select input source."""
|
||||
self._receiver.zone_b_source = source
|
||||
await self._zone_b.set_source(source)
|
||||
|
||||
|
||||
class LyngdorfMainDevice(LyngdorfDevice):
|
||||
@@ -233,7 +238,7 @@ class LyngdorfMainDevice(LyngdorfDevice):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
receiver: Receiver,
|
||||
receiver: LyngdorfReceiver,
|
||||
config_entry: LyngdorfConfigEntry,
|
||||
device_info: DeviceInfo,
|
||||
) -> None:
|
||||
@@ -253,10 +258,8 @@ class LyngdorfMainDevice(LyngdorfDevice):
|
||||
# drift, rather than once a second, which is all Home Assistant
|
||||
# needs: it stores a position and a timestamp and extrapolates.
|
||||
await super().async_added_to_hass()
|
||||
if self._has_streamer:
|
||||
self.async_on_remove(
|
||||
self._receiver.register_position_jump_callback(self._handle_position)
|
||||
)
|
||||
if (player := self._receiver.player) is not None:
|
||||
self.async_on_remove(player.on_position_jump(self._handle_position))
|
||||
|
||||
@callback
|
||||
def _handle_position(self, _position_ms: int | None) -> None:
|
||||
@@ -264,31 +267,35 @@ class LyngdorfMainDevice(LyngdorfDevice):
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def _has_streamer(self) -> bool:
|
||||
"""Return whether this model has a streaming module at all."""
|
||||
return self._receiver.model.has_streaming_feature()
|
||||
def _player(self) -> Player:
|
||||
"""Return the streamer; transport is only offered when it exists."""
|
||||
player = self._receiver.player
|
||||
if TYPE_CHECKING:
|
||||
assert player is not None
|
||||
return player
|
||||
|
||||
@property
|
||||
def _now_playing(self) -> NowPlaying | None:
|
||||
"""Return the current track, or None if this model has no streamer."""
|
||||
if not self._has_streamer:
|
||||
if (player := self._receiver.player) is None:
|
||||
return None
|
||||
return self._receiver.now_playing
|
||||
return player.now_playing
|
||||
|
||||
@override
|
||||
@property
|
||||
def supported_features(self) -> MediaPlayerEntityFeature:
|
||||
"""Return the features the device currently offers."""
|
||||
features = FEATURES_MAIN
|
||||
if (now_playing := self._now_playing) is None:
|
||||
player = self._receiver.player
|
||||
if player is None or (now_playing := player.now_playing) is None:
|
||||
return features
|
||||
|
||||
for control, feature in CONTROL_FEATURES:
|
||||
if control in now_playing.controls:
|
||||
features |= feature
|
||||
if self._receiver.can_shuffle:
|
||||
if player.can_shuffle:
|
||||
features |= MediaPlayerEntityFeature.SHUFFLE_SET
|
||||
if self._receiver.available_repeat_modes:
|
||||
if player.repeat_modes:
|
||||
features |= MediaPlayerEntityFeature.REPEAT_SET
|
||||
return features
|
||||
|
||||
@@ -314,25 +321,33 @@ class LyngdorfMainDevice(LyngdorfDevice):
|
||||
@property
|
||||
def media_title(self) -> str | None:
|
||||
"""Return the title of the current track."""
|
||||
return now_playing.title if (now_playing := self._now_playing) else None
|
||||
if (now_playing := self._now_playing) is None:
|
||||
return None
|
||||
return now_playing.title
|
||||
|
||||
@override
|
||||
@property
|
||||
def media_artist(self) -> str | None:
|
||||
"""Return the artist of the current track."""
|
||||
return now_playing.artist if (now_playing := self._now_playing) else None
|
||||
if (now_playing := self._now_playing) is None:
|
||||
return None
|
||||
return now_playing.artist
|
||||
|
||||
@override
|
||||
@property
|
||||
def media_album_name(self) -> str | None:
|
||||
"""Return the album of the current track."""
|
||||
return now_playing.album if (now_playing := self._now_playing) else None
|
||||
if (now_playing := self._now_playing) is None:
|
||||
return None
|
||||
return now_playing.album
|
||||
|
||||
@override
|
||||
@property
|
||||
def media_image_url(self) -> str | None:
|
||||
"""Return the album art of the current track."""
|
||||
return now_playing.art_url if (now_playing := self._now_playing) else None
|
||||
if (now_playing := self._now_playing) is None:
|
||||
return None
|
||||
return now_playing.art_url
|
||||
|
||||
@override
|
||||
@property
|
||||
@@ -348,10 +363,8 @@ class LyngdorfMainDevice(LyngdorfDevice):
|
||||
@property
|
||||
def media_position(self) -> int | None:
|
||||
"""Return the position of the current track, in seconds."""
|
||||
if (
|
||||
not self._has_streamer
|
||||
or (position_ms := self._receiver.position_ms) is None
|
||||
):
|
||||
player = self._receiver.player
|
||||
if player is None or (position_ms := player.position_ms) is None:
|
||||
return None
|
||||
return round(position_ms / 1000)
|
||||
|
||||
@@ -359,21 +372,27 @@ class LyngdorfMainDevice(LyngdorfDevice):
|
||||
@property
|
||||
def media_position_updated_at(self) -> datetime | None:
|
||||
"""Return when the position was last valid."""
|
||||
if not self._has_streamer or not self._receiver.has_position:
|
||||
# The timestamp advances on every poll, including ones that report no
|
||||
# position, so it is only meaningful alongside a position.
|
||||
player = self._receiver.player
|
||||
if player is None or player.position_ms is None:
|
||||
return None
|
||||
return self._receiver.position_updated_at
|
||||
return player.position_updated_at
|
||||
|
||||
@override
|
||||
@property
|
||||
def shuffle(self) -> bool | None:
|
||||
"""Return whether shuffle is enabled."""
|
||||
return self._receiver.shuffle if self._has_streamer else None
|
||||
if (player := self._receiver.player) is None:
|
||||
return None
|
||||
return player.shuffle
|
||||
|
||||
@override
|
||||
@property
|
||||
def repeat(self) -> RepeatMode | None:
|
||||
"""Return the current repeat mode."""
|
||||
if not self._has_streamer or (repeat := self._receiver.repeat) is None:
|
||||
player = self._receiver.player
|
||||
if player is None or (repeat := player.repeat) is None:
|
||||
return None
|
||||
return REPEAT_MODES.get(repeat)
|
||||
|
||||
@@ -383,67 +402,59 @@ class LyngdorfMainDevice(LyngdorfDevice):
|
||||
# On a controller-driven source such as AirPlay the device ends the
|
||||
# session rather than pausing, and only the controlling app can
|
||||
# start it again.
|
||||
await self._receiver.async_pause()
|
||||
await self._player.pause()
|
||||
|
||||
@override
|
||||
async def async_media_next_track(self) -> None:
|
||||
"""Skip to the next track."""
|
||||
await self._receiver.async_next()
|
||||
await self._player.next_track()
|
||||
|
||||
@override
|
||||
async def async_media_previous_track(self) -> None:
|
||||
"""Skip to the previous track."""
|
||||
await self._receiver.async_previous()
|
||||
await self._player.previous_track()
|
||||
|
||||
@override
|
||||
async def async_media_seek(self, position: float) -> None:
|
||||
"""Seek to a position, given in seconds."""
|
||||
await self._receiver.async_seek(round(position * 1000))
|
||||
await self._player.seek(round(position * 1000))
|
||||
|
||||
@override
|
||||
async def async_set_shuffle(self, shuffle: bool) -> None:
|
||||
"""Enable or disable shuffle, leaving the repeat mode alone."""
|
||||
await self._receiver.async_set_shuffle(shuffle)
|
||||
await self._player.set_shuffle(shuffle)
|
||||
|
||||
@override
|
||||
async def async_set_repeat(self, repeat: RepeatMode) -> None:
|
||||
"""Set the repeat mode, leaving shuffle alone."""
|
||||
await self._receiver.async_set_repeat(LYNGDORF_REPEATS[repeat])
|
||||
await self._player.set_repeat(LYNGDORF_REPEATS[repeat])
|
||||
|
||||
@override
|
||||
@property
|
||||
def source_list(self) -> list[str] | None:
|
||||
"""Return a list of available input sources."""
|
||||
return self._receiver.available_sources
|
||||
return self._receiver.sources
|
||||
|
||||
@override
|
||||
@property
|
||||
def sound_mode_list(self) -> list[str] | None:
|
||||
"""Return a list of available sound modes."""
|
||||
return self._receiver.available_sound_modes
|
||||
return self._receiver.sound_modes
|
||||
|
||||
@override
|
||||
@property
|
||||
def is_volume_muted(self) -> bool | None:
|
||||
"""Return boolean if volume is currently muted."""
|
||||
return self._receiver.mute_enabled
|
||||
|
||||
@property
|
||||
def _volume_range(self) -> NumericRange:
|
||||
"""Return the model's documented main-zone volume range."""
|
||||
volume_range = self._receiver.volume_range
|
||||
# Every supported model documents a main-zone volume range.
|
||||
if TYPE_CHECKING:
|
||||
assert volume_range is not None
|
||||
return volume_range
|
||||
return self._receiver.muted
|
||||
|
||||
@override
|
||||
@property
|
||||
def volume_level(self) -> float | None:
|
||||
"""Volume level of the media player (0..1)."""
|
||||
if (volume := self._receiver.volume) is None:
|
||||
volume = self._receiver.volume
|
||||
if volume is None or volume.value is None:
|
||||
return None
|
||||
return _to_ha_volume(volume, self._volume_range)
|
||||
return _to_ha_volume(volume.value, volume.range)
|
||||
|
||||
@override
|
||||
@property
|
||||
@@ -460,39 +471,42 @@ class LyngdorfMainDevice(LyngdorfDevice):
|
||||
@override
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn on media player."""
|
||||
self._receiver.power_on = True
|
||||
await self._receiver.set_power(True)
|
||||
|
||||
@override
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn off media player."""
|
||||
self._receiver.power_on = False
|
||||
await self._receiver.set_power(False)
|
||||
|
||||
@override
|
||||
async def async_volume_up(self) -> None:
|
||||
"""Volume up the media player."""
|
||||
self._receiver.volume_up()
|
||||
if (volume := self._receiver.volume) is not None:
|
||||
await volume.up()
|
||||
|
||||
@override
|
||||
async def async_volume_down(self) -> None:
|
||||
"""Volume down the media player."""
|
||||
self._receiver.volume_down()
|
||||
if (volume := self._receiver.volume) is not None:
|
||||
await volume.down()
|
||||
|
||||
@override
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Set volume level, range 0..1."""
|
||||
self._receiver.set_volume(_to_lyngdorf_volume(volume, self._volume_range))
|
||||
if (control := self._receiver.volume) is not None:
|
||||
await control.set(_to_lyngdorf_volume(volume, control.range))
|
||||
|
||||
@override
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Send mute command."""
|
||||
self._receiver.mute_enabled = mute
|
||||
await self._receiver.set_muted(mute)
|
||||
|
||||
@override
|
||||
async def async_select_sound_mode(self, sound_mode: str) -> None:
|
||||
"""Select sound mode."""
|
||||
self._receiver.sound_mode = sound_mode
|
||||
await self._receiver.set_sound_mode(sound_mode)
|
||||
|
||||
@override
|
||||
async def async_select_source(self, source: str) -> None:
|
||||
"""Select input source."""
|
||||
self._receiver.source = source
|
||||
await self._receiver.set_source(source)
|
||||
|
||||
@@ -11,6 +11,7 @@ from lyngdorf import (
|
||||
LyngdorfReceiver,
|
||||
NumericControl,
|
||||
NumericRange,
|
||||
Player,
|
||||
Remote,
|
||||
RemoteKey,
|
||||
Trim,
|
||||
@@ -70,6 +71,8 @@ class _FloatControl(float):
|
||||
control = super().__new__(cls, value)
|
||||
control.value = value
|
||||
control.range = value_range
|
||||
control.up = AsyncMock()
|
||||
control.down = AsyncMock()
|
||||
control.set = AsyncMock()
|
||||
return control
|
||||
|
||||
@@ -135,7 +138,10 @@ def mock_receiver(mock_create_receiver: MagicMock) -> MagicMock:
|
||||
receiver.zone_b_volume_range = NumericRange(-99.9, 24.0, 0.1)
|
||||
|
||||
receiver.power_on = False
|
||||
receiver.volume = -40.0
|
||||
receiver.volume = _FloatControl(-40.0, NumericRange(-99.9, 24.0, 0.1))
|
||||
receiver.muted = False
|
||||
receiver.sources = []
|
||||
receiver.sound_modes = []
|
||||
receiver.mute_enabled = False
|
||||
receiver.source = None
|
||||
receiver.available_sources = []
|
||||
@@ -158,8 +164,6 @@ def mock_receiver(mock_create_receiver: MagicMock) -> MagicMock:
|
||||
receiver.has_position = False
|
||||
receiver.position_ms = None
|
||||
receiver.position_updated_at = None
|
||||
receiver.shuffle = None
|
||||
receiver.repeat = None
|
||||
receiver.can_shuffle = False
|
||||
receiver.available_repeat_modes = frozenset()
|
||||
|
||||
@@ -205,6 +209,15 @@ def mock_receiver(mock_create_receiver: MagicMock) -> MagicMock:
|
||||
receiver.stream_types = ["AirPlay", "DLNA"]
|
||||
receiver.room_perfect_positions = ["Global", "Focus 1"]
|
||||
receiver.voicings = ["Neutral", "Music", "Movie"]
|
||||
player = MagicMock(spec=Player)
|
||||
player.now_playing = None
|
||||
player.position_ms = None
|
||||
player.position_updated_at = None
|
||||
player.shuffle = None
|
||||
player.repeat = None
|
||||
player.can_shuffle = False
|
||||
player.repeat_modes = frozenset()
|
||||
receiver.player = player
|
||||
|
||||
zone_b = MagicMock(spec=ZoneB)
|
||||
zone_b.power_on = False
|
||||
@@ -212,6 +225,7 @@ def mock_receiver(mock_create_receiver: MagicMock) -> MagicMock:
|
||||
zone_b.source = None
|
||||
zone_b.audio_input = "aux"
|
||||
zone_b.streaming_source = "DLNA"
|
||||
zone_b.sources = []
|
||||
zone_b.volume = _FloatControl(-40.0, NumericRange(-99.9, 24.0, 0.1))
|
||||
receiver.zone_b = zone_b
|
||||
|
||||
@@ -253,7 +267,7 @@ def notify_receiver_update(receiver: MagicMock) -> None:
|
||||
|
||||
def notify_position_jump(receiver: MagicMock, position_ms: int | None) -> None:
|
||||
"""Fire every position jump callback the entities registered."""
|
||||
for call in receiver.register_position_jump_callback.call_args_list:
|
||||
for call in receiver.player.on_position_jump.call_args_list:
|
||||
call.args[0](position_ms)
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Tests for the Lyngdorf media player platform."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime
|
||||
from operator import attrgetter
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from lyngdorf.const import LyngdorfModel
|
||||
from lyngdorf import LyngdorfModel
|
||||
from lyngdorf.states import Control, PlaybackState, Repeat
|
||||
from lyngdorf.streaming import NowPlaying
|
||||
import pytest
|
||||
@@ -79,7 +81,7 @@ def media_proxy_token() -> Generator[None]:
|
||||
def playing_receiver(mock_receiver: MagicMock) -> MagicMock:
|
||||
"""Return a receiver that is streaming a track."""
|
||||
mock_receiver.power_on = True
|
||||
mock_receiver.now_playing = NowPlaying(
|
||||
mock_receiver.player.now_playing = NowPlaying(
|
||||
state=PlaybackState.PLAYING,
|
||||
title="The Killing Moon",
|
||||
artist="Echo & the Bunnymen",
|
||||
@@ -97,13 +99,12 @@ def playing_receiver(mock_receiver: MagicMock) -> MagicMock:
|
||||
),
|
||||
play_modes=frozenset(),
|
||||
)
|
||||
mock_receiver.has_position = True
|
||||
mock_receiver.position_ms = 318544
|
||||
mock_receiver.position_updated_at = POSITION_UPDATED_AT
|
||||
mock_receiver.shuffle = False
|
||||
mock_receiver.repeat = Repeat.OFF
|
||||
mock_receiver.can_shuffle = True
|
||||
mock_receiver.available_repeat_modes = frozenset({Repeat.OFF, Repeat.ALL})
|
||||
mock_receiver.player.position_ms = 318544
|
||||
mock_receiver.player.position_updated_at = POSITION_UPDATED_AT
|
||||
mock_receiver.player.shuffle = False
|
||||
mock_receiver.player.repeat = Repeat.OFF
|
||||
mock_receiver.player.can_shuffle = True
|
||||
mock_receiver.player.repeat_modes = frozenset({Repeat.OFF, Repeat.ALL})
|
||||
return mock_receiver
|
||||
|
||||
|
||||
@@ -142,10 +143,10 @@ async def test_no_zone_b_entity_for_model_without_zone_b(
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "service", "attr", "expected"),
|
||||
[
|
||||
(MAIN_ZONE, SERVICE_TURN_ON, "power_on", True),
|
||||
(MAIN_ZONE, SERVICE_TURN_OFF, "power_on", False),
|
||||
(ZONE_B, SERVICE_TURN_ON, "zone_b_power_on", True),
|
||||
(ZONE_B, SERVICE_TURN_OFF, "zone_b_power_on", False),
|
||||
(MAIN_ZONE, SERVICE_TURN_ON, "set_power", True),
|
||||
(MAIN_ZONE, SERVICE_TURN_OFF, "set_power", False),
|
||||
(ZONE_B, SERVICE_TURN_ON, "zone_b.set_power", True),
|
||||
(ZONE_B, SERVICE_TURN_OFF, "zone_b.set_power", False),
|
||||
],
|
||||
)
|
||||
async def test_power(
|
||||
@@ -164,16 +165,16 @@ async def test_power(
|
||||
{ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
assert getattr(mock_receiver, attr) is expected
|
||||
attrgetter(attr)(mock_receiver).assert_awaited_once_with(expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "service", "method"),
|
||||
[
|
||||
(MAIN_ZONE, SERVICE_VOLUME_UP, "volume_up"),
|
||||
(MAIN_ZONE, SERVICE_VOLUME_DOWN, "volume_down"),
|
||||
(ZONE_B, SERVICE_VOLUME_UP, "zone_b_volume_up"),
|
||||
(ZONE_B, SERVICE_VOLUME_DOWN, "zone_b_volume_down"),
|
||||
(MAIN_ZONE, SERVICE_VOLUME_UP, "volume.up"),
|
||||
(MAIN_ZONE, SERVICE_VOLUME_DOWN, "volume.down"),
|
||||
(ZONE_B, SERVICE_VOLUME_UP, "zone_b.volume.up"),
|
||||
(ZONE_B, SERVICE_VOLUME_DOWN, "zone_b.volume.down"),
|
||||
],
|
||||
)
|
||||
async def test_volume_step(
|
||||
@@ -191,15 +192,15 @@ async def test_volume_step(
|
||||
{ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
getattr(mock_receiver, method).assert_called_once()
|
||||
attrgetter(method)(mock_receiver).assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "level", "method", "expected_db"),
|
||||
[
|
||||
(MAIN_ZONE, 0.5, "set_volume", -37.95),
|
||||
(MAIN_ZONE, 1.0, "set_volume", 24.0),
|
||||
(ZONE_B, 0.3, "set_zone_b_volume", -62.73),
|
||||
(MAIN_ZONE, 0.5, "volume.set", -37.95),
|
||||
(MAIN_ZONE, 1.0, "volume.set", 24.0),
|
||||
(ZONE_B, 0.3, "zone_b.volume.set", -62.73),
|
||||
],
|
||||
)
|
||||
async def test_volume_set(
|
||||
@@ -218,14 +219,16 @@ async def test_volume_set(
|
||||
{ATTR_ENTITY_ID: entity_id, ATTR_MEDIA_VOLUME_LEVEL: level},
|
||||
blocking=True,
|
||||
)
|
||||
getattr(mock_receiver, method).assert_called_once_with(pytest.approx(expected_db))
|
||||
attrgetter(method)(mock_receiver).assert_awaited_once_with(
|
||||
pytest.approx(expected_db)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "attr"),
|
||||
[
|
||||
(MAIN_ZONE, "mute_enabled"),
|
||||
(ZONE_B, "zone_b_mute_enabled"),
|
||||
(MAIN_ZONE, "set_muted"),
|
||||
(ZONE_B, "zone_b.set_muted"),
|
||||
],
|
||||
)
|
||||
async def test_mute(
|
||||
@@ -242,14 +245,14 @@ async def test_mute(
|
||||
{ATTR_ENTITY_ID: entity_id, ATTR_MEDIA_VOLUME_MUTED: True},
|
||||
blocking=True,
|
||||
)
|
||||
assert getattr(mock_receiver, attr) is True
|
||||
attrgetter(attr)(mock_receiver).assert_awaited_once_with(True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "attr"),
|
||||
[
|
||||
(MAIN_ZONE, "source"),
|
||||
(ZONE_B, "zone_b_source"),
|
||||
(MAIN_ZONE, "set_source"),
|
||||
(ZONE_B, "zone_b.set_source"),
|
||||
],
|
||||
)
|
||||
async def test_select_source(
|
||||
@@ -266,7 +269,7 @@ async def test_select_source(
|
||||
{ATTR_ENTITY_ID: entity_id, ATTR_INPUT_SOURCE: "HDMI"},
|
||||
blocking=True,
|
||||
)
|
||||
assert getattr(mock_receiver, attr) == "HDMI"
|
||||
attrgetter(attr)(mock_receiver).assert_awaited_once_with("HDMI")
|
||||
|
||||
|
||||
async def test_select_sound_mode(
|
||||
@@ -281,7 +284,7 @@ async def test_select_sound_mode(
|
||||
{ATTR_ENTITY_ID: MAIN_ZONE, ATTR_SOUND_MODE: "Movie"},
|
||||
blocking=True,
|
||||
)
|
||||
assert mock_receiver.sound_mode == "Movie"
|
||||
mock_receiver.set_sound_mode.assert_awaited_once_with("Movie")
|
||||
|
||||
|
||||
async def test_availability(
|
||||
@@ -312,12 +315,12 @@ async def test_main_zone_state_properties(
|
||||
) -> None:
|
||||
"""Test main zone state properties are reported correctly."""
|
||||
mock_receiver.power_on = True
|
||||
mock_receiver.volume = -40.0
|
||||
mock_receiver.mute_enabled = False
|
||||
mock_receiver.volume.value = -40.0
|
||||
mock_receiver.muted = False
|
||||
mock_receiver.source = "HDMI"
|
||||
mock_receiver.sound_mode = "Movie"
|
||||
mock_receiver.available_sources = ["HDMI", "Optical"]
|
||||
mock_receiver.available_sound_modes = ["Movie", "Stereo"]
|
||||
mock_receiver.sources = ["HDMI", "Optical"]
|
||||
mock_receiver.sound_modes = ["Movie", "Stereo"]
|
||||
notify_receiver_update(mock_receiver)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -330,7 +333,7 @@ async def test_main_zone_state_properties(
|
||||
assert state.attributes[ATTR_INPUT_SOURCE_LIST] == ["HDMI", "Optical"]
|
||||
assert state.attributes[ATTR_SOUND_MODE_LIST] == ["Movie", "Stereo"]
|
||||
|
||||
mock_receiver.volume = None
|
||||
mock_receiver.volume.value = None
|
||||
notify_receiver_update(mock_receiver)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(MAIN_ZONE)
|
||||
@@ -349,11 +352,11 @@ async def test_zone_b_state_properties(
|
||||
mock_receiver: MagicMock,
|
||||
) -> None:
|
||||
"""Test zone B state properties are reported correctly."""
|
||||
mock_receiver.zone_b_power_on = True
|
||||
mock_receiver.zone_b_volume = -30.0
|
||||
mock_receiver.zone_b_mute_enabled = True
|
||||
mock_receiver.zone_b_source = "Optical"
|
||||
mock_receiver.zone_b_available_sources = ["HDMI", "Optical"]
|
||||
mock_receiver.zone_b.power_on = True
|
||||
mock_receiver.zone_b.volume.value = -30.0
|
||||
mock_receiver.zone_b.muted = True
|
||||
mock_receiver.zone_b.source = "Optical"
|
||||
mock_receiver.zone_b.sources = ["HDMI", "Optical"]
|
||||
notify_receiver_update(mock_receiver)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -364,6 +367,12 @@ async def test_zone_b_state_properties(
|
||||
assert state.attributes[ATTR_INPUT_SOURCE] == "Optical"
|
||||
assert state.attributes[ATTR_INPUT_SOURCE_LIST] == ["HDMI", "Optical"]
|
||||
|
||||
mock_receiver.zone_b.volume.value = None
|
||||
notify_receiver_update(mock_receiver)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(ZONE_B)
|
||||
assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is None
|
||||
|
||||
|
||||
async def test_now_playing(
|
||||
hass: HomeAssistant,
|
||||
@@ -393,9 +402,11 @@ async def test_transport_features_absent_when_idle(
|
||||
@pytest.mark.parametrize(
|
||||
("service", "method"),
|
||||
[
|
||||
pytest.param(SERVICE_MEDIA_PAUSE, "async_pause", id="pause"),
|
||||
pytest.param(SERVICE_MEDIA_NEXT_TRACK, "async_next", id="next"),
|
||||
pytest.param(SERVICE_MEDIA_PREVIOUS_TRACK, "async_previous", id="previous"),
|
||||
pytest.param(SERVICE_MEDIA_PAUSE, "player.pause", id="pause"),
|
||||
pytest.param(SERVICE_MEDIA_NEXT_TRACK, "player.next_track", id="next"),
|
||||
pytest.param(
|
||||
SERVICE_MEDIA_PREVIOUS_TRACK, "player.previous_track", id="previous"
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
@@ -412,7 +423,7 @@ async def test_transport_actions(
|
||||
{ATTR_ENTITY_ID: MAIN_ZONE},
|
||||
blocking=True,
|
||||
)
|
||||
getattr(playing_receiver, method).assert_awaited_once()
|
||||
attrgetter(method)(playing_receiver).assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
@@ -427,7 +438,7 @@ async def test_seek_converts_to_milliseconds(
|
||||
{ATTR_ENTITY_ID: MAIN_ZONE, ATTR_MEDIA_SEEK_POSITION: 42.5},
|
||||
blocking=True,
|
||||
)
|
||||
playing_receiver.async_seek.assert_awaited_once_with(42500)
|
||||
playing_receiver.player.seek.assert_awaited_once_with(42500)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
@@ -437,14 +448,14 @@ async def test_seek_converts_to_milliseconds(
|
||||
pytest.param(
|
||||
SERVICE_SHUFFLE_SET,
|
||||
{ATTR_MEDIA_SHUFFLE: True},
|
||||
"async_set_shuffle",
|
||||
"player.set_shuffle",
|
||||
True,
|
||||
id="shuffle",
|
||||
),
|
||||
pytest.param(
|
||||
SERVICE_REPEAT_SET,
|
||||
{ATTR_MEDIA_REPEAT: RepeatMode.ALL},
|
||||
"async_set_repeat",
|
||||
"player.set_repeat",
|
||||
Repeat.ALL,
|
||||
id="repeat",
|
||||
),
|
||||
@@ -466,7 +477,59 @@ async def test_set_play_mode(
|
||||
{ATTR_ENTITY_ID: MAIN_ZONE} | payload,
|
||||
blocking=True,
|
||||
)
|
||||
getattr(playing_receiver, method).assert_awaited_once_with(expected)
|
||||
attrgetter(method)(playing_receiver).assert_awaited_once_with(expected)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_volume_before_the_device_reports_one(
|
||||
hass: HomeAssistant,
|
||||
mock_receiver: MagicMock,
|
||||
) -> None:
|
||||
"""Test the volume control being absent until the device reports a level."""
|
||||
mock_receiver.power_on = True
|
||||
mock_receiver.volume = None
|
||||
# Changed alongside so the assertions below fail if building the state
|
||||
# raised rather than merely omitting the volume.
|
||||
mock_receiver.muted = True
|
||||
notify_receiver_update(mock_receiver)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(MAIN_ZONE)
|
||||
assert state.attributes[ATTR_MEDIA_VOLUME_MUTED] is True
|
||||
assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is None
|
||||
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_VOLUME_UP,
|
||||
{ATTR_ENTITY_ID: MAIN_ZONE},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_VOLUME_SET,
|
||||
{ATTR_ENTITY_ID: MAIN_ZONE, ATTR_MEDIA_VOLUME_LEVEL: 0.5},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_transport_features_follow_the_source(
|
||||
hass: HomeAssistant,
|
||||
playing_receiver: MagicMock,
|
||||
) -> None:
|
||||
"""Test only the controls the current source offers are advertised."""
|
||||
now_playing = playing_receiver.player.now_playing
|
||||
playing_receiver.player.now_playing = replace(
|
||||
now_playing, controls=frozenset({Control.PAUSE})
|
||||
)
|
||||
notify_receiver_update(playing_receiver)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
features = hass.states.get(MAIN_ZONE).attributes[ATTR_SUPPORTED_FEATURES]
|
||||
assert features & MediaPlayerEntityFeature.PAUSE
|
||||
assert not features & MediaPlayerEntityFeature.SEEK
|
||||
assert not features & MediaPlayerEntityFeature.NEXT_TRACK
|
||||
assert not features & MediaPlayerEntityFeature.PREVIOUS_TRACK
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
@@ -475,7 +538,7 @@ async def test_no_streaming_features_on_model_without_streamer(
|
||||
playing_receiver: MagicMock,
|
||||
) -> None:
|
||||
"""Test a model with no streaming module offers no transport."""
|
||||
playing_receiver.model = LyngdorfModel.TDAI_2170
|
||||
playing_receiver.player = None
|
||||
notify_receiver_update(playing_receiver)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -492,12 +555,14 @@ async def test_no_position_before_the_streamer_reports_one(
|
||||
playing_receiver: MagicMock,
|
||||
) -> None:
|
||||
"""Test an attached player that has not yet reported a position."""
|
||||
playing_receiver.position_ms = None
|
||||
playing_receiver.player.position_ms = None
|
||||
notify_receiver_update(playing_receiver)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(MAIN_ZONE)
|
||||
assert state.attributes.get(ATTR_MEDIA_POSITION) is None
|
||||
# The timestamp advances on every poll, so it must not be published alone.
|
||||
assert state.attributes.get(ATTR_MEDIA_POSITION_UPDATED_AT) is None
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
@@ -506,7 +571,7 @@ async def test_position_jump_updates_state(
|
||||
playing_receiver: MagicMock,
|
||||
) -> None:
|
||||
"""Test a position discontinuity refreshes the reported position."""
|
||||
playing_receiver.position_ms = 1000
|
||||
playing_receiver.player.position_ms = 1000
|
||||
notify_position_jump(playing_receiver, 1000)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user