diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 76e0a915060..440d9a3aea7 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -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 diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index f5cfb84ec36..894d32fcb97 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_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", diff --git a/homeassistant/components/sonos/switch.py b/homeassistant/components/sonos/switch.py index 582845d10a2..653be229b22 100644 --- a/homeassistant/components/sonos/switch.py +++ b/homeassistant/components/sonos/switch.py @@ -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) diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index d3de2a889d5..0cdc17c55a6 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -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) diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py index 04457ee95c7..c7df2062b0f 100644 --- a/tests/components/sonos/test_switch.py +++ b/tests/components/sonos/test_switch.py @@ -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"), [