From 740a8c33eeeecf25cf3f8487f3a80c42fd43d15d Mon Sep 17 00:00:00 2001 From: jjlawren Date: Mon, 10 Jan 2022 09:04:40 -0600 Subject: [PATCH] Add `audio_delay` number entity to Sonos (#63566) --- homeassistant/components/sonos/number.py | 50 ++++++++++++++++++----- homeassistant/components/sonos/speaker.py | 13 +++--- homeassistant/components/sonos/switch.py | 2 +- tests/components/sonos/conftest.py | 1 + tests/components/sonos/test_number.py | 32 +++++++++++++++ tests/components/sonos/test_speaker.py | 33 +++++++++++++++ 6 files changed, 113 insertions(+), 18 deletions(-) create mode 100644 tests/components/sonos/test_number.py create mode 100644 tests/components/sonos/test_speaker.py diff --git a/homeassistant/components/sonos/number.py b/homeassistant/components/sonos/number.py index d62e09f0b49..2e6acd55a66 100644 --- a/homeassistant/components/sonos/number.py +++ b/homeassistant/components/sonos/number.py @@ -5,17 +5,22 @@ import logging from homeassistant.components.number import NumberEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import SONOS_CREATE_LEVELS from .entity import SonosEntity +from .exception import SpeakerUnavailable from .helpers import soco_error from .speaker import SonosSpeaker -LEVEL_TYPES = ("bass", "treble") +LEVEL_TYPES = { + "audio_delay": (0, 5), + "bass": (-10, 10), + "treble": (-10, 10), +} _LOGGER = logging.getLogger(__name__) @@ -27,14 +32,26 @@ async def async_setup_entry( ) -> None: """Set up the Sonos number platform from a config entry.""" - @callback - def _async_create_entities(speaker: SonosSpeaker) -> None: + def available_soco_attributes(speaker: SonosSpeaker) -> list[str]: + features = [] + for level_type, valid_range in LEVEL_TYPES.items(): + if (state := getattr(speaker.soco, level_type, None)) is not None: + setattr(speaker, level_type, state) + features.append((level_type, valid_range)) + return features + + async def _async_create_entities(speaker: SonosSpeaker) -> None: entities = [] - for level_type in LEVEL_TYPES: + + available_features = await hass.async_add_executor_job( + available_soco_attributes, speaker + ) + + for level_type, valid_range in available_features: _LOGGER.debug( "Creating %s number control on %s", level_type, speaker.zone_name ) - entities.append(SonosLevelEntity(speaker, level_type)) + entities.append(SonosLevelEntity(speaker, level_type, valid_range)) async_add_entities(entities) config_entry.async_on_unload( @@ -46,19 +63,30 @@ class SonosLevelEntity(SonosEntity, NumberEntity): """Representation of a Sonos level entity.""" _attr_entity_category = EntityCategory.CONFIG - _attr_min_value = -10 - _attr_max_value = 10 - def __init__(self, speaker: SonosSpeaker, level_type: str) -> None: + def __init__( + self, speaker: SonosSpeaker, level_type: str, valid_range: tuple[int] + ) -> None: """Initialize the level entity.""" super().__init__(speaker) self._attr_unique_id = f"{self.soco.uid}-{level_type}" - self._attr_name = f"{self.speaker.zone_name} {level_type.capitalize()}" + name_suffix = level_type.replace("_", " ").title() + self._attr_name = f"{self.speaker.zone_name} {name_suffix}" self.level_type = level_type + self._attr_min_value, self._attr_max_value = valid_range async def _async_poll(self) -> None: """Poll the value if subscriptions are not working.""" - # Handled by SonosSpeaker + await self.hass.async_add_executor_job(self.update) + + @soco_error(raise_on_err=False) + def update(self) -> None: + """Fetch number state if necessary.""" + if not self.available: + raise SpeakerUnavailable + + state = getattr(self.soco, self.level_type) + setattr(self.speaker, self.level_type, state) @soco_error() def set_value(self, value: float) -> None: diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index d0a16bd10e6..4c1f53f49d0 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -185,11 +185,14 @@ class SonosSpeaker: # Volume / Sound self.volume: int | None = None self.muted: bool | None = None - self.night_mode: bool | None = None - self.dialog_level: bool | None = None self.cross_fade: bool | None = None self.bass: int | None = None self.treble: int | None = None + + # Home theater + self.audio_delay: int | None = None + self.dialog_level: bool | None = None + self.night_mode: bool | None = None self.sub_enabled: bool | None = None self.surround_enabled: bool | None = None @@ -485,7 +488,7 @@ class SonosSpeaker: if bool_var in variables: setattr(self, bool_var, variables[bool_var] == "1") - for int_var in ("bass", "treble"): + for int_var in ("audio_delay", "bass", "treble"): if int_var in variables: setattr(self, int_var, variables[int_var]) @@ -969,15 +972,13 @@ class SonosSpeaker: # # Media and playback state handlers # - @soco_error() + @soco_error(raise_on_err=False) def update_volume(self) -> None: """Update information about current volume settings.""" self.volume = self.soco.volume self.muted = self.soco.mute self.night_mode = self.soco.night_mode self.dialog_level = self.soco.dialog_level - self.bass = self.soco.bass - self.treble = self.soco.treble try: self.cross_fade = self.soco.cross_fade diff --git a/homeassistant/components/sonos/switch.py b/homeassistant/components/sonos/switch.py index 2770b7461f8..4e3303db45d 100644 --- a/homeassistant/components/sonos/switch.py +++ b/homeassistant/components/sonos/switch.py @@ -106,7 +106,7 @@ async def async_setup_entry( entities.append(SonosAlarmEntity(alarm_id, speaker)) async_add_entities(entities) - def available_soco_attributes(speaker: SonosSpeaker) -> list[tuple[str, bool]]: + def available_soco_attributes(speaker: SonosSpeaker) -> list[str]: features = [] for feature_type in ALL_FEATURES: try: diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 01c7a8948af..a35eee1c1b3 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -101,6 +101,7 @@ def soco_fixture(music_library, speaker_info, battery_info, alarm_clock): mock_soco.night_mode = True mock_soco.dialog_level = True mock_soco.volume = 19 + mock_soco.audio_delay = 2 mock_soco.bass = 1 mock_soco.treble = -1 mock_soco.sub_enabled = False diff --git a/tests/components/sonos/test_number.py b/tests/components/sonos/test_number.py new file mode 100644 index 00000000000..91c00e05390 --- /dev/null +++ b/tests/components/sonos/test_number.py @@ -0,0 +1,32 @@ +"""Tests for the Sonos number platform.""" +from unittest.mock import patch + +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.helpers import entity_registry as ent_reg + + +async def test_audio_input_sensor(hass, async_autosetup_sonos, soco): + """Test audio input sensor.""" + entity_registry = ent_reg.async_get(hass) + + bass_number = entity_registry.entities["number.zone_a_bass"] + bass_state = hass.states.get(bass_number.entity_id) + assert bass_state.state == "1" + + treble_number = entity_registry.entities["number.zone_a_treble"] + treble_state = hass.states.get(treble_number.entity_id) + assert treble_state.state == "-1" + + audio_delay_number = entity_registry.entities["number.zone_a_audio_delay"] + audio_delay_state = hass.states.get(audio_delay_number.entity_id) + assert audio_delay_state.state == "2" + + with patch("soco.SoCo.audio_delay") as mock_audio_delay: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: audio_delay_number.entity_id, "value": 3}, + blocking=True, + ) + assert mock_audio_delay.called_with(3) diff --git a/tests/components/sonos/test_speaker.py b/tests/components/sonos/test_speaker.py new file mode 100644 index 00000000000..cb53fb43ed2 --- /dev/null +++ b/tests/components/sonos/test_speaker.py @@ -0,0 +1,33 @@ +"""Tests for common SonosSpeaker behavior.""" +from unittest.mock import patch + +from homeassistant.components.sonos.const import DATA_SONOS, SCAN_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_time_changed + + +async def test_fallback_to_polling( + hass: HomeAssistant, async_autosetup_sonos, soco, caplog +): + """Test that polling fallback works.""" + speaker = list(hass.data[DATA_SONOS].discovered.values())[0] + assert speaker.soco is soco + assert speaker._subscriptions + + caplog.clear() + + # Ensure subscriptions are cancelled and polling methods are called when subscriptions time out + with patch( + "homeassistant.components.sonos.speaker.SonosSpeaker.update_media" + ), patch( + "homeassistant.components.sonos.speaker.SonosSpeaker.subscription_address" + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + + assert not speaker._subscriptions + assert speaker.subscriptions_failed + assert "falling back to polling" in caplog.text + assert "Activity on Zone A from SonosSpeaker.update_volume" in caplog.text