mirror of
https://github.com/home-assistant/core.git
synced 2026-09-26 09:23:17 -04:00
Add a maximum volume sensor to Lyngdorf (#181800)
This commit is contained in:
@@ -35,6 +35,9 @@
|
||||
"audio_input": {
|
||||
"default": "mdi:audio-input-stereo-minijack"
|
||||
},
|
||||
"maximum_volume": {
|
||||
"default": "mdi:volume-high"
|
||||
},
|
||||
"streaming_source": {
|
||||
"default": "mdi:cast-audio"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
"audio_input": {
|
||||
"name": "Audio input"
|
||||
},
|
||||
"maximum_volume": {
|
||||
"name": "Maximum volume"
|
||||
},
|
||||
"streaming_source": {
|
||||
"name": "Streaming source"
|
||||
},
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.mock_lyngdorf_maximum_volume',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'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': <UnitOfSoundPressure.DECIBEL: 'dB'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[sensor.mock_lyngdorf_maximum_volume-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Mock Lyngdorf Maximum volume',
|
||||
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfSoundPressure.DECIBEL: 'dB'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.mock_lyngdorf_maximum_volume',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[sensor.mock_lyngdorf_streaming_source-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user