From 8a187190a15dd3585e9779dfc1b97a21338a1aa3 Mon Sep 17 00:00:00 2001 From: Alex Fishlock Date: Sun, 13 Sep 2026 10:41:33 +0100 Subject: [PATCH] Add a maximum volume sensor to Lyngdorf (#181800) --- homeassistant/components/lyngdorf/icons.json | 3 + .../components/lyngdorf/quality_scale.yaml | 6 +- homeassistant/components/lyngdorf/sensor.py | 34 +++++++++-- .../components/lyngdorf/strings.json | 3 + tests/components/lyngdorf/conftest.py | 14 ++++- .../lyngdorf/snapshots/test_sensor.ambr | 51 ++++++++++++++++ tests/components/lyngdorf/test_sensor.py | 60 ++++++++++++++++++- 7 files changed, 161 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/lyngdorf/icons.json b/homeassistant/components/lyngdorf/icons.json index d03976f606f3..70e49a753406 100644 --- a/homeassistant/components/lyngdorf/icons.json +++ b/homeassistant/components/lyngdorf/icons.json @@ -35,6 +35,9 @@ "audio_input": { "default": "mdi:audio-input-stereo-minijack" }, + "maximum_volume": { + "default": "mdi:volume-high" + }, "streaming_source": { "default": "mdi:cast-audio" }, diff --git a/homeassistant/components/lyngdorf/quality_scale.yaml b/homeassistant/components/lyngdorf/quality_scale.yaml index 1b67de847941..bdd129f4ae4c 100644 --- a/homeassistant/components/lyngdorf/quality_scale.yaml +++ b/homeassistant/components/lyngdorf/quality_scale.yaml @@ -69,8 +69,10 @@ rules: entity-disabled-by-default: status: done comment: >- - All entities are useful by default; the diagnostic sensors change only on - source or content changes. + The maximum volume sensor is disabled by default: most owners set no + ceiling and it would read the same value forever. Everything else is + useful by default; the diagnostic sensors change only on source or + content changes. entity-translations: done exception-translations: done icon-translations: done diff --git a/homeassistant/components/lyngdorf/sensor.py b/homeassistant/components/lyngdorf/sensor.py index 6a7b4b27d0ad..488e2aeb10c3 100644 --- a/homeassistant/components/lyngdorf/sensor.py +++ b/homeassistant/components/lyngdorf/sensor.py @@ -4,14 +4,14 @@ from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, override -from lyngdorf import LyngdorfReceiver +from lyngdorf import LyngdorfReceiver, VolumeControl from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, ) -from homeassistant.const import EntityCategory +from homeassistant.const import EntityCategory, UnitOfSoundPressure from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -26,7 +26,7 @@ PARALLEL_UPDATES = 0 class LyngdorfSensorEntityDescription(SensorEntityDescription): """Describe a Lyngdorf sensor entity.""" - value_fn: Callable[[LyngdorfReceiver], str | None] + value_fn: Callable[[LyngdorfReceiver], str | float | None] options_fn: Callable[[LyngdorfReceiver], list[str]] | None = None @@ -98,6 +98,22 @@ ZONE_B_SENSORS: tuple[LyngdorfSensorEntityDescription, ...] = ( ) +# Only the models that report `!MAXVOL` carry a VolumeControl, so the ceiling +# sensor is created from the control's type. Its value stays None until the +# device first reports one, which never means the model has no ceiling. +MAXIMUM_VOLUME_SENSOR = LyngdorfSensorEntityDescription( + key="maximum_volume", + translation_key="maximum_volume", + native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, + value_fn=lambda r: ( + volume.maximum_volume if isinstance(volume := r.volume, VolumeControl) else None + ), + entity_category=EntityCategory.DIAGNOSTIC, + # Most owners set no ceiling, so this would read the same value forever. + entity_registry_enabled_default=False, +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: LyngdorfConfigEntry, @@ -112,6 +128,16 @@ async def async_setup_entry( ) for description in MAIN_ZONE_SENSORS ] + if isinstance(runtime_data.receiver.volume, VolumeControl): + entities.append( + LyngdorfSensor( + runtime_data.receiver, + config_entry, + runtime_data.device_info, + MAXIMUM_VOLUME_SENSOR, + ) + ) + # Zone B sensors stay on the main device so they read "Zone B audio input" # rather than repeating the zone in the Zone B device's own name. if runtime_data.zone_b_device_info is not None: @@ -157,6 +183,6 @@ class LyngdorfSensor(LyngdorfEntity, SensorEntity): @override @property - def native_value(self) -> str | None: + def native_value(self) -> str | float | None: """Return the current sensor value.""" return self.entity_description.value_fn(self._receiver) diff --git a/homeassistant/components/lyngdorf/strings.json b/homeassistant/components/lyngdorf/strings.json index 7536d5487113..9f145ddbe173 100644 --- a/homeassistant/components/lyngdorf/strings.json +++ b/homeassistant/components/lyngdorf/strings.json @@ -89,6 +89,9 @@ "audio_input": { "name": "Audio input" }, + "maximum_volume": { + "name": "Maximum volume" + }, "streaming_source": { "name": "Streaming source" }, diff --git a/tests/components/lyngdorf/conftest.py b/tests/components/lyngdorf/conftest.py index 0f8311a45aa8..4fb83fce8c4a 100644 --- a/tests/components/lyngdorf/conftest.py +++ b/tests/components/lyngdorf/conftest.py @@ -15,6 +15,7 @@ from lyngdorf import ( RemoteKey, SteppableControl, Trim, + VolumeControl, ZoneB, ) import pytest @@ -71,6 +72,15 @@ def _steppable(value: float | None, value_range: NumericRange) -> MagicMock: return control +def _volume_control(value: float | None, value_range: NumericRange) -> MagicMock: + """Return a mocked volume control, as the MP and P models report.""" + control = MagicMock(spec=VolumeControl) + control.value = value + control.range = value_range + control.maximum_volume = None + return control + + def _control(value: float | None, value_range: NumericRange) -> MagicMock: """Return a mocked numeric control.""" control = MagicMock(spec=NumericControl) @@ -130,7 +140,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 = _steppable(-40.0, NumericRange(-99.9, 24.0, 0.1)) + receiver.volume = _volume_control(-40.0, NumericRange(-99.9, 24.0, 0.1)) receiver.muted = False receiver.sources = [] receiver.sound_modes = [] @@ -191,7 +201,7 @@ def mock_receiver(mock_create_receiver: MagicMock) -> MagicMock: receiver.zone_b = zone_b receiver.zone_b_streaming_source = "DLNA" - receiver.volume = _steppable(-40.0, NumericRange(-99.9, 24.0, 0.1)) + receiver.volume = _volume_control(-40.0, NumericRange(-99.9, 24.0, 0.1)) receiver.muted = False receiver.sources = [] receiver.sound_modes = [] diff --git a/tests/components/lyngdorf/snapshots/test_sensor.ambr b/tests/components/lyngdorf/snapshots/test_sensor.ambr index dd417716be64..d2dd76ad449e 100644 --- a/tests/components/lyngdorf/snapshots/test_sensor.ambr +++ b/tests/components/lyngdorf/snapshots/test_sensor.ambr @@ -109,6 +109,57 @@ 'state': 'optical', }) # --- +# name: test_entities[sensor.mock_lyngdorf_maximum_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_lyngdorf_maximum_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Maximum volume', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Maximum volume', + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'maximum_volume', + 'unique_id': '0050c27c76b2_maximum_volume', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.mock_lyngdorf_maximum_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mock Lyngdorf Maximum volume', + : , + }), + 'context': , + 'entity_id': 'sensor.mock_lyngdorf_maximum_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_entities[sensor.mock_lyngdorf_streaming_source-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/lyngdorf/test_sensor.py b/tests/components/lyngdorf/test_sensor.py index 23efd0814374..0937dbf961bc 100644 --- a/tests/components/lyngdorf/test_sensor.py +++ b/tests/components/lyngdorf/test_sensor.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock +from lyngdorf import NumericRange import pytest from syrupy.assertion import SnapshotAssertion @@ -9,7 +10,7 @@ from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import notify_receiver_update +from .conftest import _steppable, notify_receiver_update from tests.common import MockConfigEntry, snapshot_platform @@ -20,7 +21,7 @@ def platforms() -> list[Platform]: return [Platform.SENSOR] -@pytest.mark.usefixtures("mock_receiver") +@pytest.mark.usefixtures("mock_receiver", "entity_registry_enabled_by_default") async def test_entities( hass: HomeAssistant, init_integration: MockConfigEntry, @@ -131,3 +132,58 @@ async def test_enum_options_follow_the_device( state = hass.states.get("sensor.mock_lyngdorf_audio_input") assert state.attributes["options"] == ["HDMI", "optical", "ARC"] + + +async def test_maximum_volume_is_disabled_by_default( + hass: HomeAssistant, + init_integration: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the ceiling sensor exists but is off by default.""" + entry = entity_registry.async_get("sensor.mock_lyngdorf_maximum_volume") + + assert entry is not None + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get("sensor.mock_lyngdorf_maximum_volume") is None + + +async def test_maximum_volume_follows_the_device( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test the ceiling sensor reports what the device says, once enabled.""" + entity_id = "sensor.mock_lyngdorf_maximum_volume" + mock_receiver.volume.maximum_volume = 0.0 + + entity_registry.async_update_entity(entity_id, disabled_by=None) + await hass.config_entries.async_reload(init_integration.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "0.0" + + # The ceiling changes from the device's front panel, so it must not be + # read once and cached. + mock_receiver.volume.maximum_volume = -20.0 + notify_receiver_update(mock_receiver) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "-20.0" + + +async def test_no_maximum_volume_sensor_without_a_volume_control( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test a model that reports no ceiling does not get the sensor.""" + mock_config_entry.add_to_hass(hass) + mock_receiver.volume = _steppable(-40.0, NumericRange(-99.9, 24.0, 0.1)) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert entity_registry.async_get("sensor.mock_lyngdorf_maximum_volume") is None + assert hass.states.get("sensor.mock_lyngdorf_audio_information") is not None