Remove the lyngdorf 1.11 compatibility scaffolding (#180855)

This commit is contained in:
Alex Fishlock
2026-09-06 08:41:28 +02:00
committed by GitHub
parent c69b37d96e
commit dc5b9b6cbf
8 changed files with 33 additions and 127 deletions
@@ -106,11 +106,8 @@ async def async_get_config_entry_diagnostics(
for name, control in trims.items()
}
# Not lipsync.range: the control reads None until the device reports a
# value, while the range is known from the model as soon as it connects.
lipsync_range = receiver.lipsync_range
ranges: dict[str, Any] = {
"lipsync_range": asdict(lipsync_range) if lipsync_range is not None else None
"lipsync_range": asdict(lipsync.range) if lipsync is not None else None
}
ranges |= {
f"{name}_range": asdict(control.range) if control is not None else None
@@ -452,7 +452,7 @@ class LyngdorfMainDevice(LyngdorfDevice):
def volume_level(self) -> float | None:
"""Volume level of the media player (0..1)."""
volume = self._receiver.volume
if volume is None or volume.value is None:
if volume.value is None:
return None
return _to_ha_volume(volume.value, volume.range)
@@ -481,20 +481,18 @@ class LyngdorfMainDevice(LyngdorfDevice):
@override
async def async_volume_up(self) -> None:
"""Volume up the media player."""
if (volume := self._receiver.volume) is not None:
await volume.up()
await self._receiver.volume.up()
@override
async def async_volume_down(self) -> None:
"""Volume down the media player."""
if (volume := self._receiver.volume) is not None:
await volume.down()
await self._receiver.volume.down()
@override
async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1."""
if (control := self._receiver.volume) is not None:
await control.set(_to_lyngdorf_volume(volume, control.range))
control = self._receiver.volume
await control.set(_to_lyngdorf_volume(volume, control.range))
@override
async def async_mute_volume(self, mute: bool) -> None:
+4 -14
View File
@@ -26,9 +26,6 @@ PARALLEL_UPDATES = 1
class LyngdorfNumberEntityDescription(NumberEntityDescription):
"""Describe a Lyngdorf number entity."""
# Whether the model has this control at all. Must not depend on the device
# having reported a value, or the entity is dropped at startup.
range_fn: Callable[[LyngdorfReceiver], NumericRange | None]
control_fn: Callable[[LyngdorfReceiver], NumericControl | None]
set_value_fn: Callable[[NumericControl, float], Awaitable[None]]
@@ -40,7 +37,6 @@ NUMBER_ENTITIES: tuple[LyngdorfNumberEntityDescription, ...] = (
device_class=NumberDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.MILLISECONDS,
entity_category=EntityCategory.CONFIG,
range_fn=lambda r: r.lipsync_range,
control_fn=lambda r: r.lipsync,
# The device takes lip sync as whole milliseconds.
set_value_fn=lambda c, v: c.set(round(v)),
@@ -50,7 +46,6 @@ NUMBER_ENTITIES: tuple[LyngdorfNumberEntityDescription, ...] = (
translation_key="trim_bass",
native_unit_of_measurement=UnitOfSoundPressure.DECIBEL,
entity_category=EntityCategory.CONFIG,
range_fn=lambda r: c.range if (c := r.trims.get(Trim.BASS)) else None,
control_fn=lambda r: r.trims.get(Trim.BASS),
set_value_fn=lambda c, v: c.set(v),
),
@@ -59,7 +54,6 @@ NUMBER_ENTITIES: tuple[LyngdorfNumberEntityDescription, ...] = (
translation_key="trim_treble",
native_unit_of_measurement=UnitOfSoundPressure.DECIBEL,
entity_category=EntityCategory.CONFIG,
range_fn=lambda r: c.range if (c := r.trims.get(Trim.TREBLE)) else None,
control_fn=lambda r: r.trims.get(Trim.TREBLE),
set_value_fn=lambda c, v: c.set(v),
),
@@ -69,7 +63,6 @@ NUMBER_ENTITIES: tuple[LyngdorfNumberEntityDescription, ...] = (
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfSoundPressure.DECIBEL,
entity_category=EntityCategory.CONFIG,
range_fn=lambda r: c.range if (c := r.trims.get(Trim.CENTER)) else None,
control_fn=lambda r: r.trims.get(Trim.CENTER),
set_value_fn=lambda c, v: c.set(v),
),
@@ -79,7 +72,6 @@ NUMBER_ENTITIES: tuple[LyngdorfNumberEntityDescription, ...] = (
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfSoundPressure.DECIBEL,
entity_category=EntityCategory.CONFIG,
range_fn=lambda r: c.range if (c := r.trims.get(Trim.HEIGHT)) else None,
control_fn=lambda r: r.trims.get(Trim.HEIGHT),
set_value_fn=lambda c, v: c.set(v),
),
@@ -89,7 +81,6 @@ NUMBER_ENTITIES: tuple[LyngdorfNumberEntityDescription, ...] = (
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfSoundPressure.DECIBEL,
entity_category=EntityCategory.CONFIG,
range_fn=lambda r: c.range if (c := r.trims.get(Trim.LFE)) else None,
control_fn=lambda r: r.trims.get(Trim.LFE),
set_value_fn=lambda c, v: c.set(v),
),
@@ -99,7 +90,6 @@ NUMBER_ENTITIES: tuple[LyngdorfNumberEntityDescription, ...] = (
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfSoundPressure.DECIBEL,
entity_category=EntityCategory.CONFIG,
range_fn=lambda r: c.range if (c := r.trims.get(Trim.SURROUND)) else None,
control_fn=lambda r: r.trims.get(Trim.SURROUND),
set_value_fn=lambda c, v: c.set(v),
),
@@ -118,7 +108,7 @@ async def async_setup_entry(
async_add_entities(
LyngdorfNumber(receiver, config_entry, runtime_data.device_info, description)
for description in NUMBER_ENTITIES
if description.range_fn(receiver) is not None
if description.control_fn(receiver) is not None
)
@@ -144,11 +134,11 @@ class LyngdorfNumber(LyngdorfEntity, NumberEntity):
@property
def _range(self) -> NumericRange:
"""Return the device's range for this setting."""
device_range = self.entity_description.range_fn(self._receiver)
control = self.entity_description.control_fn(self._receiver)
# Entities are only created for controls the model actually has.
if TYPE_CHECKING:
assert device_range is not None
return device_range
assert control is not None
return control.range
@override
@property
+2 -5
View File
@@ -23,8 +23,7 @@ class LyngdorfSelectEntityDescription(SelectEntityDescription):
current_option_fn: Callable[[LyngdorfReceiver], str | None]
options_fn: Callable[[LyngdorfReceiver], list[str]]
# None on the pinned library, a coroutine on 2.x: await whichever it is.
select_option_fn: Callable[[LyngdorfReceiver, str], Awaitable[None] | None]
select_option_fn: Callable[[LyngdorfReceiver, str], Awaitable[None]]
SELECT_ENTITIES: tuple[LyngdorfSelectEntityDescription, ...] = (
@@ -95,6 +94,4 @@ class LyngdorfSelect(LyngdorfEntity, SelectEntity):
@override
async def async_select_option(self, option: str) -> None:
"""Set the selected option."""
result = self.entity_description.select_option_fn(self._receiver, option)
if result is not None:
await result
await self.entity_description.select_option_fn(self._receiver, option)
+11 -19
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
from collections.abc import Generator
from typing import Self
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from lyngdorf import (
@@ -14,6 +13,7 @@ from lyngdorf import (
Player,
Remote,
RemoteKey,
SteppableControl,
Trim,
ZoneB,
)
@@ -63,18 +63,12 @@ def mock_setup_entry() -> Generator[None]:
yield
class _FloatControl(float):
"""A float that is also a control, as the library's 1.x values are."""
def __new__(cls, value: float, value_range: NumericRange) -> Self:
"""Return a float carrying the control interface alongside it."""
control = super().__new__(cls, value)
control.value = value
control.range = value_range
control.up = AsyncMock()
control.down = AsyncMock()
control.set = AsyncMock()
return control
def _steppable(value: float | None, value_range: NumericRange) -> MagicMock:
"""Return a mocked volume-style control."""
control = MagicMock(spec=SteppableControl)
control.value = value
control.range = value_range
return control
def _control(value: float | None, value_range: NumericRange) -> MagicMock:
@@ -125,7 +119,6 @@ def mock_receiver(mock_create_receiver: MagicMock) -> MagicMock:
receiver.set_voicing.return_value = None
receiver.set_room_perfect_position.return_value = None
receiver.lipsync = None
receiver.lipsync_range = NumericRange(0, 500, 1)
for _t in ("bass", "treble"):
setattr(receiver, f"trim_{_t}", None)
setattr(receiver, f"trim_{_t}_range", NumericRange(-12.0, 12.0, 0.1))
@@ -137,7 +130,7 @@ 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 = _FloatControl(-40.0, NumericRange(-99.9, 24.0, 0.1))
receiver.volume = _steppable(-40.0, NumericRange(-99.9, 24.0, 0.1))
receiver.muted = False
receiver.sources = []
receiver.sound_modes = []
@@ -166,8 +159,7 @@ def mock_receiver(mock_create_receiver: MagicMock) -> MagicMock:
receiver.can_shuffle = False
receiver.available_repeat_modes = frozenset()
receiver.lipsync = _FloatControl(50.0, NumericRange(0, 500, 1))
receiver.lipsync_range = NumericRange(0, 500, 1)
receiver.lipsync = _control(50.0, NumericRange(0, 500, 1))
receiver.trims = {
Trim.BASS: _control(3.0, NumericRange(-12.0, 12.0, 0.1)),
Trim.TREBLE: _control(0.0, NumericRange(-12.0, 12.0, 0.1)),
@@ -199,7 +191,7 @@ def mock_receiver(mock_create_receiver: MagicMock) -> MagicMock:
receiver.zone_b = zone_b
receiver.zone_b_streaming_source = "DLNA"
receiver.volume = _FloatControl(-40.0, NumericRange(-99.9, 24.0, 0.1))
receiver.volume = _steppable(-40.0, NumericRange(-99.9, 24.0, 0.1))
receiver.muted = False
receiver.sources = []
receiver.sound_modes = []
@@ -225,7 +217,7 @@ def mock_receiver(mock_create_receiver: MagicMock) -> MagicMock:
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))
zone_b.volume = _steppable(-40.0, NumericRange(-99.9, 24.0, 0.1))
receiver.zone_b = zone_b
mock_create_receiver.return_value = receiver
+1 -18
View File
@@ -1,6 +1,6 @@
"""Tests for the Lyngdorf diagnostics."""
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -34,23 +34,6 @@ async def test_diagnostics(
) == snapshot(exclude=props("entry_id", "created_at", "modified_at"))
async def test_lipsync_range_reported_before_the_device_reports_a_value(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
init_integration: MockConfigEntry,
mock_receiver: MagicMock,
) -> None:
"""Test the lipsync range still reports before the first value arrives."""
mock_receiver.lipsync = None
diagnostics = await get_diagnostics_for_config_entry(
hass, hass_client, init_integration
)
assert diagnostics["state"]["lipsync"] is None
assert diagnostics["ranges"]["lipsync_range"] is not None
async def test_diagnostics_includes_ssdp_description(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
@@ -480,38 +480,6 @@ async def test_set_play_mode(
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,
+9 -28
View File
@@ -113,7 +113,7 @@ async def test_number_none_values(
mock_receiver: MagicMock,
) -> None:
"""Test a number shows unknown when the device reports nothing."""
mock_receiver.lipsync = None
mock_receiver.lipsync.value = None
mock_receiver.trims[Trim.BASS].value = None
notify_receiver_update(mock_receiver)
await hass.async_block_till_done()
@@ -122,37 +122,15 @@ async def test_number_none_values(
assert hass.states.get(TRIM_BASS_ENTITY_ID).state == STATE_UNKNOWN
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_entity_created_before_the_device_reports_a_value(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_receiver: MagicMock,
) -> None:
"""Test a control the model has still gets an entity before its first report."""
mock_receiver.lipsync = None
mock_config_entry.add_to_hass(hass)
with (
patch(
"homeassistant.components.lyngdorf.lookup_model",
return_value=LyngdorfModel.MP_60,
),
patch("homeassistant.components.lyngdorf.PLATFORMS", [Platform.NUMBER]),
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get(LIPSYNC_ENTITY_ID).state == STATE_UNKNOWN
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_receiver")
async def test_entities_absent_for_controls_the_model_lacks(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_receiver: MagicMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test no entity is created where the model has no such control."""
mock_receiver.lipsync_range = None
mock_receiver.lipsync = None
del mock_receiver.trims[Trim.SURROUND]
mock_config_entry.add_to_hass(hass)
@@ -166,9 +144,12 @@ async def test_entities_absent_for_controls_the_model_lacks(
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get(LIPSYNC_ENTITY_ID) is None
assert hass.states.get(TRIM_SURROUND_ENTITY_ID) is None
assert hass.states.get(TRIM_BASS_ENTITY_ID) is not None
# The registry, not the state machine: an entity that was created and then
# failed to render has no state either, so states alone cannot tell the two
# apart.
assert entity_registry.async_get(LIPSYNC_ENTITY_ID) is None
assert entity_registry.async_get(TRIM_SURROUND_ENTITY_ID) is None
assert entity_registry.async_get(TRIM_BASS_ENTITY_ID) is not None
@pytest.mark.usefixtures("init_integration")