mirror of
https://github.com/home-assistant/core.git
synced 2025-09-11 15:51:47 +02:00
Fix dialog enhancement switch for Sonos Arc Ultra (#150116)
This commit is contained in:
@@ -186,6 +186,9 @@ MODELS_TV_ONLY = (
|
|||||||
"ULTRA",
|
"ULTRA",
|
||||||
)
|
)
|
||||||
MODELS_LINEIN_AND_TV = ("AMP",)
|
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_CHECK_INTERVAL = datetime.timedelta(minutes=1)
|
||||||
AVAILABILITY_TIMEOUT = AVAILABILITY_CHECK_INTERVAL.total_seconds() * 4.5
|
AVAILABILITY_TIMEOUT = AVAILABILITY_CHECK_INTERVAL.total_seconds() * 4.5
|
||||||
|
@@ -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_SPEECH_ENHANCEMENT_ENABLED,
|
||||||
AVAILABILITY_TIMEOUT,
|
AVAILABILITY_TIMEOUT,
|
||||||
BATTERY_SCAN_INTERVAL,
|
BATTERY_SCAN_INTERVAL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@@ -157,6 +158,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.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
|
||||||
self.sub_crossover: int | None = None
|
self.sub_crossover: int | None = None
|
||||||
@@ -548,6 +550,11 @@ class SonosSpeaker:
|
|||||||
@callback
|
@callback
|
||||||
def async_update_volume(self, event: SonosEvent) -> None:
|
def async_update_volume(self, event: SonosEvent) -> None:
|
||||||
"""Update information about currently volume settings."""
|
"""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)
|
self.event_stats.process(event)
|
||||||
variables = event.variables
|
variables = event.variables
|
||||||
|
|
||||||
@@ -565,6 +572,7 @@ class SonosSpeaker:
|
|||||||
|
|
||||||
for bool_var in (
|
for bool_var in (
|
||||||
"dialog_level",
|
"dialog_level",
|
||||||
|
ATTR_SPEECH_ENHANCEMENT_ENABLED,
|
||||||
"night_mode",
|
"night_mode",
|
||||||
"sub_enabled",
|
"sub_enabled",
|
||||||
"surround_enabled",
|
"surround_enabled",
|
||||||
|
@@ -19,7 +19,9 @@ from homeassistant.helpers.event import async_track_time_change
|
|||||||
|
|
||||||
from .alarms import SonosAlarms
|
from .alarms import SonosAlarms
|
||||||
from .const import (
|
from .const import (
|
||||||
|
ATTR_SPEECH_ENHANCEMENT_ENABLED,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
MODEL_SONOS_ARC_ULTRA,
|
||||||
SONOS_ALARMS_UPDATED,
|
SONOS_ALARMS_UPDATED,
|
||||||
SONOS_CREATE_ALARM,
|
SONOS_CREATE_ALARM,
|
||||||
SONOS_CREATE_SWITCHES,
|
SONOS_CREATE_SWITCHES,
|
||||||
@@ -59,6 +61,7 @@ ALL_FEATURES = (
|
|||||||
ATTR_SURROUND_ENABLED,
|
ATTR_SURROUND_ENABLED,
|
||||||
ATTR_STATUS_LIGHT,
|
ATTR_STATUS_LIGHT,
|
||||||
)
|
)
|
||||||
|
ALL_SUBST_FEATURES = (ATTR_SPEECH_ENHANCEMENT_ENABLED,)
|
||||||
|
|
||||||
COORDINATOR_FEATURES = ATTR_CROSSFADE
|
COORDINATOR_FEATURES = ATTR_CROSSFADE
|
||||||
|
|
||||||
@@ -69,6 +72,14 @@ POLL_REQUIRED = (
|
|||||||
|
|
||||||
WEEKEND_DAYS = (0, 6)
|
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(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@@ -92,6 +103,13 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
def available_soco_attributes(speaker: SonosSpeaker) -> list[str]:
|
def available_soco_attributes(speaker: SonosSpeaker) -> list[str]:
|
||||||
features = []
|
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:
|
for feature_type in ALL_FEATURES:
|
||||||
try:
|
try:
|
||||||
if (state := getattr(speaker.soco, feature_type, None)) is not None:
|
if (state := getattr(speaker.soco, feature_type, None)) is not None:
|
||||||
@@ -107,12 +125,23 @@ async def async_setup_entry(
|
|||||||
available_soco_attributes, speaker
|
available_soco_attributes, speaker
|
||||||
)
|
)
|
||||||
for feature_type in available_features:
|
for feature_type in available_features:
|
||||||
|
attribute_key = MODEL_FEATURE_SUBSTITUTIONS.get(
|
||||||
|
speaker.model_name.upper(), {}
|
||||||
|
).get(feature_type, feature_type)
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Creating %s switch on %s",
|
"Creating %s switch on %s attribute %s",
|
||||||
feature_type,
|
feature_type,
|
||||||
speaker.zone_name,
|
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)
|
async_add_entities(entities)
|
||||||
|
|
||||||
config_entry.async_on_unload(
|
config_entry.async_on_unload(
|
||||||
@@ -127,11 +156,15 @@ class SonosSwitchEntity(SonosPollingEntity, SwitchEntity):
|
|||||||
"""Representation of a Sonos feature switch."""
|
"""Representation of a Sonos feature switch."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, feature_type: str, speaker: SonosSpeaker, config_entry: SonosConfigEntry
|
self,
|
||||||
|
feature_type: str,
|
||||||
|
attribute_key: str,
|
||||||
|
speaker: SonosSpeaker,
|
||||||
|
config_entry: SonosConfigEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the switch."""
|
"""Initialize the switch."""
|
||||||
super().__init__(speaker, config_entry)
|
super().__init__(speaker, config_entry)
|
||||||
self.feature_type = feature_type
|
self.attribute_key = attribute_key
|
||||||
self.needs_coordinator = feature_type in COORDINATOR_FEATURES
|
self.needs_coordinator = feature_type in COORDINATOR_FEATURES
|
||||||
self._attr_entity_category = EntityCategory.CONFIG
|
self._attr_entity_category = EntityCategory.CONFIG
|
||||||
self._attr_translation_key = feature_type
|
self._attr_translation_key = feature_type
|
||||||
@@ -149,15 +182,15 @@ class SonosSwitchEntity(SonosPollingEntity, SwitchEntity):
|
|||||||
@soco_error()
|
@soco_error()
|
||||||
def poll_state(self) -> None:
|
def poll_state(self) -> None:
|
||||||
"""Poll the current state of the switch."""
|
"""Poll the current state of the switch."""
|
||||||
state = getattr(self.soco, self.feature_type)
|
state = getattr(self.soco, self.attribute_key)
|
||||||
setattr(self.speaker, self.feature_type, state)
|
setattr(self.speaker, self.attribute_key, state)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_on(self) -> bool:
|
def is_on(self) -> bool:
|
||||||
"""Return True if entity is on."""
|
"""Return True if entity is on."""
|
||||||
if self.needs_coordinator and not self.speaker.is_coordinator:
|
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.coordinator, self.attribute_key))
|
||||||
return cast(bool, getattr(self.speaker, self.feature_type))
|
return cast(bool, getattr(self.speaker, self.attribute_key))
|
||||||
|
|
||||||
def turn_on(self, **kwargs: Any) -> None:
|
def turn_on(self, **kwargs: Any) -> None:
|
||||||
"""Turn the entity on."""
|
"""Turn the entity on."""
|
||||||
@@ -175,7 +208,7 @@ class SonosSwitchEntity(SonosPollingEntity, SwitchEntity):
|
|||||||
else:
|
else:
|
||||||
soco = self.soco
|
soco = self.soco
|
||||||
try:
|
try:
|
||||||
setattr(soco, self.feature_type, enable)
|
setattr(soco, self.attribute_key, enable)
|
||||||
except SoCoUPnPException as exc:
|
except SoCoUPnPException as exc:
|
||||||
_LOGGER.warning("Could not toggle %s: %s", self.entity_id, exc)
|
_LOGGER.warning("Could not toggle %s: %s", self.entity_id, exc)
|
||||||
|
|
||||||
|
@@ -882,3 +882,23 @@ def ungroup_speakers(coordinator: MockSoCo, group_member: MockSoCo) -> None:
|
|||||||
)
|
)
|
||||||
coordinator.zoneGroupTopology.subscribe.return_value._callback(event)
|
coordinator.zoneGroupTopology.subscribe.return_value._callback(event)
|
||||||
group_member.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)
|
||||||
|
@@ -6,13 +6,18 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
import pytest
|
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 (
|
from homeassistant.components.sonos.switch import (
|
||||||
ATTR_DURATION,
|
ATTR_DURATION,
|
||||||
ATTR_ID,
|
ATTR_ID,
|
||||||
ATTR_INCLUDE_LINKED_ZONES,
|
ATTR_INCLUDE_LINKED_ZONES,
|
||||||
ATTR_PLAY_MODE,
|
ATTR_PLAY_MODE,
|
||||||
ATTR_RECURRENCE,
|
ATTR_RECURRENCE,
|
||||||
|
ATTR_SPEECH_ENHANCEMENT,
|
||||||
|
ATTR_SPEECH_ENHANCEMENT_ENABLED,
|
||||||
ATTR_VOLUME,
|
ATTR_VOLUME,
|
||||||
)
|
)
|
||||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
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.helpers import entity_registry as er
|
||||||
from homeassistant.util import dt as dt_util
|
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
|
from tests.common import async_fire_time_changed
|
||||||
|
|
||||||
@@ -142,6 +147,49 @@ async def test_switch_attributes(
|
|||||||
assert touch_controls_state.state == STATE_ON
|
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(
|
@pytest.mark.parametrize(
|
||||||
("service", "expected_result"),
|
("service", "expected_result"),
|
||||||
[
|
[
|
||||||
|
Reference in New Issue
Block a user