diff --git a/homeassistant/components/lg_soundbar/media_player.py b/homeassistant/components/lg_soundbar/media_player.py index beb562d88c79..0785afd4d8d4 100644 --- a/homeassistant/components/lg_soundbar/media_player.py +++ b/homeassistant/components/lg_soundbar/media_player.py @@ -26,6 +26,37 @@ EQUIVALENT_FUNCTIONS = ( ) +def _display_name(names: list[str], index: int) -> str: + """Return the display name for an index. + + The temescal name tables stopped being maintained when the library was + archived in 2023, so newer models report indices past the end of them. + Naming those keeps them selectable instead of silently dropping them. + """ + if index < len(names): + return names[index] + + return f"Unknown ({index})" + + +def _offered_names(names: list[str], offered: list[int]) -> list[str]: + """Return the display names of the offered indices.""" + return sorted(_display_name(names, index) for index in offered) + + +def _index_for_name(names: list[str], offered: list[int], name: str) -> int: + """Return the index a display name refers to. + + Indices the library cannot name are only known through the offered list, so + they are resolved from there; anything else resolves against the library. + """ + for index in offered: + if _display_name(names, index) == name: + return index + + return names.index(name) + + def _offered_equivalent(function: str, offered: list[int]) -> str | None: """Return an offered function from the same group as the given one.""" group = next((names for names in EQUIVALENT_FUNCTIONS if function in names), ()) @@ -236,27 +267,23 @@ class LGDevice(MediaPlayerEntity): @override def sound_mode(self): """Return the current sound mode.""" - if self._equaliser == -1 or self._equaliser >= len(temescal.equalisers): + if self._equaliser == -1: return None - return temescal.equalisers[self._equaliser] + return _display_name(temescal.equalisers, self._equaliser) @property @override def sound_mode_list(self): """Return the available sound modes.""" - return sorted( - temescal.equalisers[equaliser] - for equaliser in self._equalisers - if equaliser < len(temescal.equalisers) - ) + return _offered_names(temescal.equalisers, self._equalisers) @property @override def source(self): """Return the current input source.""" - if self._function == -1 or self._function >= len(temescal.functions): + if self._function == -1: return None - function = temescal.functions[self._function] + function = _display_name(temescal.functions, self._function) if self._function in self._functions: return function return _offered_equivalent(function, self._functions) or function @@ -265,11 +292,7 @@ class LGDevice(MediaPlayerEntity): @override def source_list(self): """List of available input sources.""" - return sorted( - temescal.functions[function] - for function in self._functions - if function < len(temescal.functions) - ) + return _offered_names(temescal.functions, self._functions) @override def set_volume_level(self, volume: float) -> None: @@ -285,12 +308,16 @@ class LGDevice(MediaPlayerEntity): @override def select_source(self, source: str) -> None: """Select input source.""" - self._device.set_func(temescal.functions.index(source)) + self._device.set_func( + _index_for_name(temescal.functions, self._functions, source) + ) @override def select_sound_mode(self, sound_mode: str) -> None: """Set Sound Mode for Receiver..""" - self._device.set_eq(temescal.equalisers.index(sound_mode)) + self._device.set_eq( + _index_for_name(temescal.equalisers, self._equalisers, sound_mode) + ) @override def turn_on(self) -> None: diff --git a/tests/components/lg_soundbar/test_media_player.py b/tests/components/lg_soundbar/test_media_player.py index dce71d6727b7..c4b3e744a65e 100644 --- a/tests/components/lg_soundbar/test_media_player.py +++ b/tests/components/lg_soundbar/test_media_player.py @@ -4,7 +4,16 @@ from unittest.mock import MagicMock import temescal -from homeassistant.components.media_player import ATTR_INPUT_SOURCE_LIST +from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE, + ATTR_INPUT_SOURCE_LIST, + ATTR_SOUND_MODE, + ATTR_SOUND_MODE_LIST, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOUND_MODE, + SERVICE_SELECT_SOURCE, +) +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from . import find_update_callback, setup_integration @@ -122,3 +131,119 @@ async def test_source_unknown_before_any_response( await setup_integration(hass, mock_config_entry) assert "source" not in hass.states.get(ENTITY_ID).attributes + + +# An SG10TY reports these alongside indices temescal knows about. They sit past +# the end of the library's table, which has been read-only since 2023. +UNMAPPED_EQUALISERS = [23, 24, 25, 26] + +AVAILABLE_EQUALISERS = [ + temescal.equalisers.index("Standard"), + temescal.equalisers.index("Cinema"), + *UNMAPPED_EQUALISERS, +] + + +def send_eq_view_info(callback: MagicMock, current_equaliser: int) -> None: + """Report the given equaliser as the current one via the callback.""" + callback( + { + "msg": "EQ_VIEW_INFO", + "data": { + "i_curr_eq": current_equaliser, + "ai_eq_list": AVAILABLE_EQUALISERS, + }, + } + ) + + +async def test_sound_modes_beyond_the_library_table_are_offered( + hass: HomeAssistant, + mock_temescal: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that equalisers the library cannot name are still offered.""" + await setup_integration(hass, mock_config_entry) + + send_eq_view_info(find_update_callback(mock_temescal), 23) + await hass.async_block_till_done() + + assert (state := hass.states.get(ENTITY_ID)) + assert state.attributes[ATTR_SOUND_MODE_LIST] == [ + "Cinema", + "Standard", + "Unknown (23)", + "Unknown (24)", + "Unknown (25)", + "Unknown (26)", + ] + assert state.attributes[ATTR_SOUND_MODE] == "Unknown (23)" + + +async def test_selecting_a_sound_mode_beyond_the_library_table( + hass: HomeAssistant, + mock_temescal: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that an equaliser the library cannot name can be selected.""" + await setup_integration(hass, mock_config_entry) + + send_eq_view_info(find_update_callback(mock_temescal), 0) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOUND_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SOUND_MODE: "Unknown (25)"}, + blocking=True, + ) + + mock_temescal.return_value.set_eq.assert_called_once_with(25) + + +async def test_selecting_a_named_sound_mode( + hass: HomeAssistant, + mock_temescal: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that an equaliser the library can name is still selectable.""" + await setup_integration(hass, mock_config_entry) + + send_eq_view_info(find_update_callback(mock_temescal), 0) + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOUND_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SOUND_MODE: "Cinema"}, + blocking=True, + ) + + mock_temescal.return_value.set_eq.assert_called_once_with( + temescal.equalisers.index("Cinema") + ) + + +async def test_selecting_a_source_the_device_does_not_offer( + hass: HomeAssistant, + mock_temescal: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that a known function outside of ai_func_list stays selectable.""" + await setup_integration(hass, mock_config_entry) + + send_func_view_info(find_update_callback(mock_temescal), "Bluetooth") + await hass.async_block_till_done() + + assert "E-ARC" not in hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "E-ARC"}, + blocking=True, + ) + + mock_temescal.return_value.set_func.assert_called_once_with( + temescal.functions.index("E-ARC") + )