diff --git a/homeassistant/components/lyngdorf/media_player.py b/homeassistant/components/lyngdorf/media_player.py index e0a69c4d4989..450c9150aab2 100644 --- a/homeassistant/components/lyngdorf/media_player.py +++ b/homeassistant/components/lyngdorf/media_player.py @@ -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) diff --git a/tests/components/lyngdorf/conftest.py b/tests/components/lyngdorf/conftest.py index 19d4c552b82d..c8114dd077aa 100644 --- a/tests/components/lyngdorf/conftest.py +++ b/tests/components/lyngdorf/conftest.py @@ -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) diff --git a/tests/components/lyngdorf/test_media_player.py b/tests/components/lyngdorf/test_media_player.py index 4a7ee8b634ed..732526904d96 100644 --- a/tests/components/lyngdorf/test_media_player.py +++ b/tests/components/lyngdorf/test_media_player.py @@ -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()