Keep LG soundbar modes the library cannot name (#180369)

This commit is contained in:
Franck Nijhof
2026-08-27 08:28:04 +02:00
committed by GitHub
parent c9ae88e3b9
commit a836b8d5e4
2 changed files with 169 additions and 17 deletions
@@ -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:
@@ -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")
)