Fix dialog enhancement switch for Sonos Arc Ultra (#150116)

This commit is contained in:
Pete Sage
2025-08-08 16:11:32 -04:00
committed by GitHub
parent dff4f79925
commit 981ae39182
5 changed files with 123 additions and 11 deletions

View File

@@ -186,6 +186,9 @@ MODELS_TV_ONLY = (
"ULTRA",
)
MODELS_LINEIN_AND_TV = ("AMP",)
MODEL_SONOS_ARC_ULTRA = "SONOS ARC ULTRA"
ATTR_SPEECH_ENHANCEMENT_ENABLED = "speech_enhance_enabled"
AVAILABILITY_CHECK_INTERVAL = datetime.timedelta(minutes=1)
AVAILABILITY_TIMEOUT = AVAILABILITY_CHECK_INTERVAL.total_seconds() * 4.5

View File

@@ -35,6 +35,7 @@ from homeassistant.util import dt as dt_util
from .alarms import SonosAlarms
from .const import (
ATTR_SPEECH_ENHANCEMENT_ENABLED,
AVAILABILITY_TIMEOUT,
BATTERY_SCAN_INTERVAL,
DOMAIN,
@@ -157,6 +158,7 @@ class SonosSpeaker:
# Home theater
self.audio_delay: int | None = None
self.dialog_level: bool | None = None
self.speech_enhance_enabled: bool | None = None
self.night_mode: bool | None = None
self.sub_enabled: bool | None = None
self.sub_crossover: int | None = None
@@ -548,6 +550,11 @@ class SonosSpeaker:
@callback
def async_update_volume(self, event: SonosEvent) -> None:
"""Update information about currently volume settings."""
_LOGGER.debug(
"Updating volume for %s with event variables: %s",
self.zone_name,
event.variables,
)
self.event_stats.process(event)
variables = event.variables
@@ -565,6 +572,7 @@ class SonosSpeaker:
for bool_var in (
"dialog_level",
ATTR_SPEECH_ENHANCEMENT_ENABLED,
"night_mode",
"sub_enabled",
"surround_enabled",

View File

@@ -19,7 +19,9 @@ from homeassistant.helpers.event import async_track_time_change
from .alarms import SonosAlarms
from .const import (
ATTR_SPEECH_ENHANCEMENT_ENABLED,
DOMAIN,
MODEL_SONOS_ARC_ULTRA,
SONOS_ALARMS_UPDATED,
SONOS_CREATE_ALARM,
SONOS_CREATE_SWITCHES,
@@ -59,6 +61,7 @@ ALL_FEATURES = (
ATTR_SURROUND_ENABLED,
ATTR_STATUS_LIGHT,
)
ALL_SUBST_FEATURES = (ATTR_SPEECH_ENHANCEMENT_ENABLED,)
COORDINATOR_FEATURES = ATTR_CROSSFADE
@@ -69,6 +72,14 @@ POLL_REQUIRED = (
WEEKEND_DAYS = (0, 6)
# Mapping of model names to feature attributes that need to be substituted.
# This is used to handle differences in attributes across Sonos models.
MODEL_FEATURE_SUBSTITUTIONS: dict[str, dict[str, str]] = {
MODEL_SONOS_ARC_ULTRA: {
ATTR_SPEECH_ENHANCEMENT: ATTR_SPEECH_ENHANCEMENT_ENABLED,
},
}
async def async_setup_entry(
hass: HomeAssistant,
@@ -92,6 +103,13 @@ async def async_setup_entry(
def available_soco_attributes(speaker: SonosSpeaker) -> list[str]:
features = []
for feature_type in ALL_SUBST_FEATURES:
try:
if (state := getattr(speaker.soco, feature_type, None)) is not None:
setattr(speaker, feature_type, state)
except SoCoSlaveException:
pass
for feature_type in ALL_FEATURES:
try:
if (state := getattr(speaker.soco, feature_type, None)) is not None:
@@ -107,12 +125,23 @@ async def async_setup_entry(
available_soco_attributes, speaker
)
for feature_type in available_features:
attribute_key = MODEL_FEATURE_SUBSTITUTIONS.get(
speaker.model_name.upper(), {}
).get(feature_type, feature_type)
_LOGGER.debug(
"Creating %s switch on %s",
"Creating %s switch on %s attribute %s",
feature_type,
speaker.zone_name,
attribute_key,
)
entities.append(
SonosSwitchEntity(
feature_type=feature_type,
attribute_key=attribute_key,
speaker=speaker,
config_entry=config_entry,
)
)
entities.append(SonosSwitchEntity(feature_type, speaker, config_entry))
async_add_entities(entities)
config_entry.async_on_unload(
@@ -127,11 +156,15 @@ class SonosSwitchEntity(SonosPollingEntity, SwitchEntity):
"""Representation of a Sonos feature switch."""
def __init__(
self, feature_type: str, speaker: SonosSpeaker, config_entry: SonosConfigEntry
self,
feature_type: str,
attribute_key: str,
speaker: SonosSpeaker,
config_entry: SonosConfigEntry,
) -> None:
"""Initialize the switch."""
super().__init__(speaker, config_entry)
self.feature_type = feature_type
self.attribute_key = attribute_key
self.needs_coordinator = feature_type in COORDINATOR_FEATURES
self._attr_entity_category = EntityCategory.CONFIG
self._attr_translation_key = feature_type
@@ -149,15 +182,15 @@ class SonosSwitchEntity(SonosPollingEntity, SwitchEntity):
@soco_error()
def poll_state(self) -> None:
"""Poll the current state of the switch."""
state = getattr(self.soco, self.feature_type)
setattr(self.speaker, self.feature_type, state)
state = getattr(self.soco, self.attribute_key)
setattr(self.speaker, self.attribute_key, state)
@property
def is_on(self) -> bool:
"""Return True if entity is on."""
if self.needs_coordinator and not self.speaker.is_coordinator:
return cast(bool, getattr(self.speaker.coordinator, self.feature_type))
return cast(bool, getattr(self.speaker, self.feature_type))
return cast(bool, getattr(self.speaker.coordinator, self.attribute_key))
return cast(bool, getattr(self.speaker, self.attribute_key))
def turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
@@ -175,7 +208,7 @@ class SonosSwitchEntity(SonosPollingEntity, SwitchEntity):
else:
soco = self.soco
try:
setattr(soco, self.feature_type, enable)
setattr(soco, self.attribute_key, enable)
except SoCoUPnPException as exc:
_LOGGER.warning("Could not toggle %s: %s", self.entity_id, exc)

View File

@@ -882,3 +882,23 @@ def ungroup_speakers(coordinator: MockSoCo, group_member: MockSoCo) -> None:
)
coordinator.zoneGroupTopology.subscribe.return_value._callback(event)
group_member.zoneGroupTopology.subscribe.return_value._callback(event)
def create_rendering_control_event(
soco: MockSoCo,
) -> SonosMockEvent:
"""Create a Sonos Event for speaker rendering control."""
variables = {
"dialog_level": 1,
"speech_enhance_enable": 1,
"surround_level": 6,
"music_surround_level": 4,
"audio_delay": 0,
"audio_delay_left_rear": 0,
"audio_delay_right_rear": 0,
"night_mode": 0,
"surround_enabled": 1,
"surround_mode": 1,
"height_channel_level": 1,
}
return SonosMockEvent(soco, soco.renderingControl, variables)

View File

@@ -6,13 +6,18 @@ from unittest.mock import patch
import pytest
from homeassistant.components.sonos.const import DATA_SONOS_DISCOVERY_MANAGER
from homeassistant.components.sonos.const import (
DATA_SONOS_DISCOVERY_MANAGER,
MODEL_SONOS_ARC_ULTRA,
)
from homeassistant.components.sonos.switch import (
ATTR_DURATION,
ATTR_ID,
ATTR_INCLUDE_LINKED_ZONES,
ATTR_PLAY_MODE,
ATTR_RECURRENCE,
ATTR_SPEECH_ENHANCEMENT,
ATTR_SPEECH_ENHANCEMENT_ENABLED,
ATTR_VOLUME,
)
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
@@ -29,7 +34,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util
from .conftest import MockSoCo, SonosMockEvent
from .conftest import MockSoCo, SonosMockEvent, create_rendering_control_event
from tests.common import async_fire_time_changed
@@ -142,6 +147,49 @@ async def test_switch_attributes(
assert touch_controls_state.state == STATE_ON
@pytest.mark.parametrize(
("model", "attribute"),
[
("Sonos One SL", ATTR_SPEECH_ENHANCEMENT),
(MODEL_SONOS_ARC_ULTRA.lower(), ATTR_SPEECH_ENHANCEMENT_ENABLED),
],
)
async def test_switch_speech_enhancement(
hass: HomeAssistant,
async_setup_sonos,
soco: MockSoCo,
speaker_info: dict[str, str],
entity_registry: er.EntityRegistry,
model: str,
attribute: str,
) -> None:
"""Tests the speech enhancement switch and attribute substitution for different models."""
entity_id = "switch.zone_a_speech_enhancement"
speaker_info["model_name"] = model
soco.get_speaker_info.return_value = speaker_info
setattr(soco, attribute, True)
await async_setup_sonos()
switch = entity_registry.entities[entity_id]
state = hass.states.get(switch.entity_id)
assert state.state == STATE_ON
event = create_rendering_control_event(soco)
event.variables[attribute] = False
soco.renderingControl.subscribe.return_value._callback(event)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get(switch.entity_id)
assert state.state == STATE_OFF
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert getattr(soco, attribute) is True
@pytest.mark.parametrize(
("service", "expected_result"),
[