mirror of
https://github.com/home-assistant/core.git
synced 2025-09-01 02:41:46 +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.BINARY_SENSOR,
|
||||||
Platform.MEDIA_PLAYER,
|
Platform.MEDIA_PLAYER,
|
||||||
Platform.NUMBER,
|
Platform.NUMBER,
|
||||||
|
Platform.SELECT,
|
||||||
Platform.SENSOR,
|
Platform.SENSOR,
|
||||||
Platform.SWITCH,
|
Platform.SWITCH,
|
||||||
]
|
]
|
||||||
@@ -154,6 +155,7 @@ SONOS_CREATE_AUDIO_FORMAT_SENSOR = "sonos_create_audio_format_sensor"
|
|||||||
SONOS_CREATE_BATTERY = "sonos_create_battery"
|
SONOS_CREATE_BATTERY = "sonos_create_battery"
|
||||||
SONOS_CREATE_FAVORITES_SENSOR = "sonos_create_favorites_sensor"
|
SONOS_CREATE_FAVORITES_SENSOR = "sonos_create_favorites_sensor"
|
||||||
SONOS_CREATE_MIC_SENSOR = "sonos_create_mic_sensor"
|
SONOS_CREATE_MIC_SENSOR = "sonos_create_mic_sensor"
|
||||||
|
SONOS_CREATE_SELECTS = "sonos_create_selects"
|
||||||
SONOS_CREATE_SWITCHES = "sonos_create_switches"
|
SONOS_CREATE_SWITCHES = "sonos_create_switches"
|
||||||
SONOS_CREATE_LEVELS = "sonos_create_levels"
|
SONOS_CREATE_LEVELS = "sonos_create_levels"
|
||||||
SONOS_CREATE_MEDIA_PLAYER = "sonos_create_media_player"
|
SONOS_CREATE_MEDIA_PLAYER = "sonos_create_media_player"
|
||||||
@@ -189,6 +191,9 @@ MODELS_LINEIN_AND_TV = ("AMP",)
|
|||||||
MODEL_SONOS_ARC_ULTRA = "SONOS ARC ULTRA"
|
MODEL_SONOS_ARC_ULTRA = "SONOS ARC ULTRA"
|
||||||
|
|
||||||
ATTR_SPEECH_ENHANCEMENT_ENABLED = "speech_enhance_enabled"
|
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_CHECK_INTERVAL = datetime.timedelta(minutes=1)
|
||||||
AVAILABILITY_TIMEOUT = AVAILABILITY_CHECK_INTERVAL.total_seconds() * 4.5
|
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 .alarms import SonosAlarms
|
||||||
from .const import (
|
from .const import (
|
||||||
|
ATTR_DIALOG_LEVEL,
|
||||||
ATTR_SPEECH_ENHANCEMENT_ENABLED,
|
ATTR_SPEECH_ENHANCEMENT_ENABLED,
|
||||||
AVAILABILITY_TIMEOUT,
|
AVAILABILITY_TIMEOUT,
|
||||||
BATTERY_SCAN_INTERVAL,
|
BATTERY_SCAN_INTERVAL,
|
||||||
@@ -47,6 +48,7 @@ from .const import (
|
|||||||
SONOS_CREATE_LEVELS,
|
SONOS_CREATE_LEVELS,
|
||||||
SONOS_CREATE_MEDIA_PLAYER,
|
SONOS_CREATE_MEDIA_PLAYER,
|
||||||
SONOS_CREATE_MIC_SENSOR,
|
SONOS_CREATE_MIC_SENSOR,
|
||||||
|
SONOS_CREATE_SELECTS,
|
||||||
SONOS_CREATE_SWITCHES,
|
SONOS_CREATE_SWITCHES,
|
||||||
SONOS_FALLBACK_POLL,
|
SONOS_FALLBACK_POLL,
|
||||||
SONOS_REBOOTED,
|
SONOS_REBOOTED,
|
||||||
@@ -158,6 +160,7 @@ class SonosSpeaker:
|
|||||||
# Home theater
|
# Home theater
|
||||||
self.audio_delay: int | None = None
|
self.audio_delay: int | None = None
|
||||||
self.dialog_level: bool | None = None
|
self.dialog_level: bool | None = None
|
||||||
|
self.dialog_level_enum: int | None = None
|
||||||
self.speech_enhance_enabled: bool | None = None
|
self.speech_enhance_enabled: bool | None = None
|
||||||
self.night_mode: bool | None = None
|
self.night_mode: bool | None = None
|
||||||
self.sub_enabled: 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_ALARM, self, new_alarms))
|
||||||
|
|
||||||
|
dispatches.append((SONOS_CREATE_SELECTS, self))
|
||||||
dispatches.append((SONOS_CREATE_SWITCHES, self))
|
dispatches.append((SONOS_CREATE_SWITCHES, self))
|
||||||
dispatches.append((SONOS_CREATE_MEDIA_PLAYER, self))
|
dispatches.append((SONOS_CREATE_MEDIA_PLAYER, self))
|
||||||
dispatches.append((SONOS_SPEAKER_ADDED, self.soco.uid))
|
dispatches.append((SONOS_SPEAKER_ADDED, self.soco.uid))
|
||||||
@@ -593,6 +597,10 @@ class SonosSpeaker:
|
|||||||
if int_var in variables:
|
if int_var in variables:
|
||||||
setattr(self, int_var, variables[int_var])
|
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()
|
self.async_write_entity_states()
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@@ -50,6 +50,18 @@
|
|||||||
"name": "Music surround level"
|
"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": {
|
"sensor": {
|
||||||
"audio_input_format": {
|
"audio_input_format": {
|
||||||
"name": "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