diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 440d9a3aea7..ac2e3f50f13 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -15,6 +15,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.MEDIA_PLAYER, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] @@ -154,6 +155,7 @@ SONOS_CREATE_AUDIO_FORMAT_SENSOR = "sonos_create_audio_format_sensor" SONOS_CREATE_BATTERY = "sonos_create_battery" SONOS_CREATE_FAVORITES_SENSOR = "sonos_create_favorites_sensor" SONOS_CREATE_MIC_SENSOR = "sonos_create_mic_sensor" +SONOS_CREATE_SELECTS = "sonos_create_selects" SONOS_CREATE_SWITCHES = "sonos_create_switches" SONOS_CREATE_LEVELS = "sonos_create_levels" SONOS_CREATE_MEDIA_PLAYER = "sonos_create_media_player" @@ -189,6 +191,9 @@ MODELS_LINEIN_AND_TV = ("AMP",) MODEL_SONOS_ARC_ULTRA = "SONOS ARC ULTRA" ATTR_SPEECH_ENHANCEMENT_ENABLED = "speech_enhance_enabled" +SPEECH_DIALOG_LEVEL = "speech_dialog_level" +ATTR_DIALOG_LEVEL = "dialog_level" +ATTR_DIALOG_LEVEL_ENUM = "dialog_level_enum" AVAILABILITY_CHECK_INTERVAL = datetime.timedelta(minutes=1) AVAILABILITY_TIMEOUT = AVAILABILITY_CHECK_INTERVAL.total_seconds() * 4.5 diff --git a/homeassistant/components/sonos/select.py b/homeassistant/components/sonos/select.py new file mode 100644 index 00000000000..052a1d87967 --- /dev/null +++ b/homeassistant/components/sonos/select.py @@ -0,0 +1,129 @@ +"""Select entities for Sonos.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ( + ATTR_DIALOG_LEVEL, + ATTR_DIALOG_LEVEL_ENUM, + MODEL_SONOS_ARC_ULTRA, + SONOS_CREATE_SELECTS, + SPEECH_DIALOG_LEVEL, +) +from .entity import SonosEntity +from .helpers import SonosConfigEntry, soco_error +from .speaker import SonosSpeaker + + +@dataclass(frozen=True, kw_only=True) +class SonosSelectEntityDescription(SelectEntityDescription): + """Describes AirGradient select entity.""" + + soco_attribute: str + speaker_attribute: str + speaker_model: str + + +SELECT_TYPES: list[SonosSelectEntityDescription] = [ + SonosSelectEntityDescription( + key=SPEECH_DIALOG_LEVEL, + translation_key=SPEECH_DIALOG_LEVEL, + soco_attribute=ATTR_DIALOG_LEVEL, + speaker_attribute=ATTR_DIALOG_LEVEL_ENUM, + speaker_model=MODEL_SONOS_ARC_ULTRA, + options=["off", "low", "medium", "high", "max"], + ), +] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SonosConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Sonos select platform from a config entry.""" + + def available_soco_attributes( + speaker: SonosSpeaker, + ) -> list[SonosSelectEntityDescription]: + features: list[SonosSelectEntityDescription] = [] + for select_data in SELECT_TYPES: + if select_data.speaker_model == speaker.model_name.upper(): + if ( + state := getattr(speaker.soco, select_data.soco_attribute, None) + ) is not None: + setattr(speaker, select_data.speaker_attribute, state) + features.append(select_data) + return features + + async def _async_create_entities(speaker: SonosSpeaker) -> None: + available_features = await hass.async_add_executor_job( + available_soco_attributes, speaker + ) + async_add_entities( + SonosSelectEntity(speaker, config_entry, select_data) + for select_data in available_features + ) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, SONOS_CREATE_SELECTS, _async_create_entities) + ) + + +class SonosSelectEntity(SonosEntity, SelectEntity): + """Representation of a Sonos select entity.""" + + def __init__( + self, + speaker: SonosSpeaker, + config_entry: SonosConfigEntry, + select_data: SonosSelectEntityDescription, + ) -> None: + """Initialize the select entity.""" + super().__init__(speaker, config_entry) + self._attr_unique_id = f"{self.soco.uid}-{select_data.key}" + self._attr_translation_key = select_data.translation_key + assert select_data.options is not None + self._attr_options = select_data.options + self.speaker_attribute = select_data.speaker_attribute + self.soco_attribute = select_data.soco_attribute + + async def _async_fallback_poll(self) -> None: + """Poll the value if subscriptions are not working.""" + await self.hass.async_add_executor_job(self.poll_state) + self.async_write_ha_state() + + @soco_error() + def poll_state(self) -> None: + """Poll the device for the current state.""" + state = getattr(self.soco, self.soco_attribute) + setattr(self.speaker, self.speaker_attribute, state) + + @property + def current_option(self) -> str | None: + """Return the current option for the entity.""" + option = getattr(self.speaker, self.speaker_attribute, None) + if not isinstance(option, int) or not (0 <= option < len(self._attr_options)): + _LOGGER.error( + "Invalid option %s for %s on %s", + option, + self.soco_attribute, + self.speaker.zone_name, + ) + return None + return self._attr_options[option] + + @soco_error() + def select_option(self, option: str) -> None: + """Set a new value.""" + dialog_level = self._attr_options.index(option) + setattr(self.soco, self.soco_attribute, dialog_level) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 894d32fcb97..427f02f0479 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -35,6 +35,7 @@ from homeassistant.util import dt as dt_util from .alarms import SonosAlarms from .const import ( + ATTR_DIALOG_LEVEL, ATTR_SPEECH_ENHANCEMENT_ENABLED, AVAILABILITY_TIMEOUT, BATTERY_SCAN_INTERVAL, @@ -47,6 +48,7 @@ from .const import ( SONOS_CREATE_LEVELS, SONOS_CREATE_MEDIA_PLAYER, SONOS_CREATE_MIC_SENSOR, + SONOS_CREATE_SELECTS, SONOS_CREATE_SWITCHES, SONOS_FALLBACK_POLL, SONOS_REBOOTED, @@ -158,6 +160,7 @@ class SonosSpeaker: # Home theater self.audio_delay: int | None = None self.dialog_level: bool | None = None + self.dialog_level_enum: int | None = None self.speech_enhance_enabled: bool | None = None self.night_mode: bool | None = None self.sub_enabled: bool | None = None @@ -253,6 +256,7 @@ class SonosSpeaker: ]: dispatches.append((SONOS_CREATE_ALARM, self, new_alarms)) + dispatches.append((SONOS_CREATE_SELECTS, self)) dispatches.append((SONOS_CREATE_SWITCHES, self)) dispatches.append((SONOS_CREATE_MEDIA_PLAYER, self)) dispatches.append((SONOS_SPEAKER_ADDED, self.soco.uid)) @@ -593,6 +597,10 @@ class SonosSpeaker: if int_var in variables: setattr(self, int_var, variables[int_var]) + for enum_var in (ATTR_DIALOG_LEVEL,): + if enum_var in variables: + setattr(self, f"{enum_var}_enum", variables[enum_var]) + self.async_write_entity_states() # diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index b2f20449beb..068290066b7 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -50,6 +50,18 @@ "name": "Music surround level" } }, + "select": { + "speech_dialog_level": { + "name": "Dialog level", + "state": { + "off": "[%key:common::state::off%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]", + "max": "Max" + } + } + }, "sensor": { "audio_input_format": { "name": "Audio input format" diff --git a/tests/components/sonos/test_select.py b/tests/components/sonos/test_select.py new file mode 100644 index 00000000000..e573db5275c --- /dev/null +++ b/tests/components/sonos/test_select.py @@ -0,0 +1,189 @@ +"""Tests for the Sonos select platform.""" + +import logging +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.components.sonos.const import ( + ATTR_DIALOG_LEVEL, + MODEL_SONOS_ARC_ULTRA, + SCAN_INTERVAL, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import create_rendering_control_event + +from tests.common import async_fire_time_changed + +SELECT_DIALOG_LEVEL_ENTITY = "select.zone_a_dialog_level" + + +@pytest.fixture(name="platform_select", autouse=True) +async def platform_binary_sensor_fixture(): + """Patch Sonos to only load select platform.""" + with patch("homeassistant.components.sonos.PLATFORMS", [Platform.SELECT]): + yield + + +@pytest.mark.parametrize( + ("level", "result"), + [ + (0, "off"), + (1, "low"), + (2, "medium"), + (3, "high"), + (4, "max"), + ], +) +async def test_select_dialog_level( + hass: HomeAssistant, + async_setup_sonos, + soco, + entity_registry: er.EntityRegistry, + speaker_info: dict[str, str], + level: int, + result: str, +) -> None: + """Test dialog level select entity.""" + + speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() + soco.get_speaker_info.return_value = speaker_info + soco.dialog_level = level + + await async_setup_sonos() + + dialog_level_select = entity_registry.entities[SELECT_DIALOG_LEVEL_ENTITY] + dialog_level_state = hass.states.get(dialog_level_select.entity_id) + assert dialog_level_state.state == result + + +async def test_select_dialog_invalid_level( + hass: HomeAssistant, + async_setup_sonos, + soco, + entity_registry: er.EntityRegistry, + speaker_info: dict[str, str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test receiving an invalid level from the speaker.""" + + speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() + soco.get_speaker_info.return_value = speaker_info + soco.dialog_level = 10 + + with caplog.at_level(logging.WARNING): + await async_setup_sonos() + assert "Invalid option 10 for dialog_level" in caplog.text + + dialog_level_select = entity_registry.entities[SELECT_DIALOG_LEVEL_ENTITY] + dialog_level_state = hass.states.get(dialog_level_select.entity_id) + assert dialog_level_state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("result", "option"), + [ + (0, "off"), + (1, "low"), + (2, "medium"), + (3, "high"), + (4, "max"), + ], +) +async def test_select_dialog_level_set( + hass: HomeAssistant, + async_setup_sonos, + soco, + speaker_info: dict[str, str], + result: int, + option: str, +) -> None: + """Test setting dialog level select entity.""" + + speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() + soco.get_speaker_info.return_value = speaker_info + soco.dialog_level = 0 + + await async_setup_sonos() + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: SELECT_DIALOG_LEVEL_ENTITY, ATTR_OPTION: option}, + blocking=True, + ) + + assert soco.dialog_level == result + + +async def test_select_dialog_level_only_arc_ultra( + hass: HomeAssistant, + async_setup_sonos, + entity_registry: er.EntityRegistry, + speaker_info: dict[str, str], +) -> None: + """Test the dialog level select is only created for Sonos Arc Ultra.""" + + speaker_info["model_name"] = "Sonos S1" + await async_setup_sonos() + + assert SELECT_DIALOG_LEVEL_ENTITY not in entity_registry.entities + + +async def test_select_dialog_level_event( + hass: HomeAssistant, + async_setup_sonos, + soco, + entity_registry: er.EntityRegistry, + speaker_info: dict[str, str], +) -> None: + """Test dialog level select entity updated by event.""" + + speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() + soco.get_speaker_info.return_value = speaker_info + soco.dialog_level = 0 + + await async_setup_sonos() + + event = create_rendering_control_event(soco) + event.variables[ATTR_DIALOG_LEVEL] = 3 + soco.renderingControl.subscribe.return_value._callback(event) + await hass.async_block_till_done(wait_background_tasks=True) + + dialog_level_select = entity_registry.entities[SELECT_DIALOG_LEVEL_ENTITY] + dialog_level_state = hass.states.get(dialog_level_select.entity_id) + assert dialog_level_state.state == "high" + + +async def test_select_dialog_level_poll( + hass: HomeAssistant, + async_setup_sonos, + soco, + entity_registry: er.EntityRegistry, + speaker_info: dict[str, str], + freezer: FrozenDateTimeFactory, +) -> None: + """Test entity updated by poll when subscription fails.""" + + speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() + soco.get_speaker_info.return_value = speaker_info + soco.dialog_level = 0 + + await async_setup_sonos() + + soco.dialog_level = 4 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + dialog_level_select = entity_registry.entities[SELECT_DIALOG_LEVEL_ENTITY] + dialog_level_state = hass.states.get(dialog_level_select.entity_id) + assert dialog_level_state.state == "max"