Add dialog mode select for Sonos Arc Ultra soundbar (#150637)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Pete Sage
2025-08-17 11:15:29 -04:00
committed by GitHub
parent b222cc5889
commit ff418f513a
5 changed files with 343 additions and 0 deletions

View File

@@ -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

View 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)

View File

@@ -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()
#

View File

@@ -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"

View 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"