mirror of
https://github.com/home-assistant/core.git
synced 2025-08-31 02:11:32 +02:00
Add dialog mode select for Sonos Arc Ultra soundbar (#150637)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
@@ -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
|
||||
|
129
homeassistant/components/sonos/select.py
Normal file
129
homeassistant/components/sonos/select.py
Normal file
@@ -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)
|
@@ -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()
|
||||
|
||||
#
|
||||
|
@@ -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"
|
||||
|
189
tests/components/sonos/test_select.py
Normal file
189
tests/components/sonos/test_select.py
Normal file
@@ -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"
|
Reference in New Issue
Block a user