Migrate the Lyngdorf media player to the lyngdorf 2.0 API (#180618)

This commit is contained in:
Alex Fishlock
2026-08-30 17:25:22 +02:00
committed by GitHub
parent 59738d0455
commit cea0a35613
3 changed files with 231 additions and 138 deletions
@@ -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)
+18 -4
View File
@@ -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)
+116 -51
View File
@@ -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()