Expose Sonos features as switch entities (#54502)

Co-authored-by: Tobias Sauerwein <cgtobi@users.noreply.github.com>
This commit is contained in:
jjlawren 2021-10-23 16:11:27 -05:00 committed by GitHub
parent 21daffe905
commit 084fd2d19f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 184 additions and 82 deletions

View File

@ -46,7 +46,7 @@ class SonosPowerEntity(SonosEntity, BinarySensorEntity):
"""Return the entity's device class."""
return DEVICE_CLASS_BATTERY_CHARGING
async def async_update(self) -> None:
async def _async_poll(self) -> None:
"""Poll the device for the current state."""
await self.speaker.async_poll_battery()

View File

@ -137,6 +137,7 @@ PLAYABLE_MEDIA_TYPES = [
SONOS_CREATE_ALARM = "sonos_create_alarm"
SONOS_CREATE_BATTERY = "sonos_create_battery"
SONOS_CREATE_SWITCHES = "sonos_create_switches"
SONOS_CREATE_MEDIA_PLAYER = "sonos_create_media_player"
SONOS_ENTITY_CREATED = "sonos_entity_created"
SONOS_POLL_UPDATE = "sonos_poll_update"

View File

@ -1,6 +1,7 @@
"""Entity representing a Sonos player."""
from __future__ import annotations
from abc import abstractmethod
import datetime
import logging
@ -30,6 +31,8 @@ _LOGGER = logging.getLogger(__name__)
class SonosEntity(Entity):
"""Representation of a Sonos entity."""
_attr_should_poll = False
def __init__(self, speaker: SonosSpeaker) -> None:
"""Initialize a SonosEntity."""
self.speaker = speaker
@ -78,10 +81,14 @@ class SonosEntity(Entity):
self.speaker.subscriptions_failed = True
await self.speaker.async_unsubscribe()
try:
await self.async_update() # pylint: disable=no-member
await self._async_poll()
except (OSError, SoCoException) as ex:
_LOGGER.debug("Error connecting to %s: %s", self.entity_id, ex)
@abstractmethod
async def _async_poll(self) -> None:
"""Poll the specific functionality. Should be implemented by platforms if needed."""
@property
def soco(self) -> SoCo:
"""Return the speaker SoCo instance."""
@ -108,8 +115,3 @@ class SonosEntity(Entity):
def available(self) -> bool:
"""Return whether this device is available."""
return self.speaker.available
@property
def should_poll(self) -> bool:
"""Return that we should not be polled (we handle that internally)."""
return False

View File

@ -119,12 +119,7 @@ ATTR_ENABLED = "enabled"
ATTR_INCLUDE_LINKED_ZONES = "include_linked_zones"
ATTR_MASTER = "master"
ATTR_WITH_GROUP = "with_group"
ATTR_BUTTONS_ENABLED = "buttons_enabled"
ATTR_CROSSFADE = "crossfade"
ATTR_NIGHT_SOUND = "night_sound"
ATTR_SPEECH_ENHANCE = "speech_enhance"
ATTR_QUEUE_POSITION = "queue_position"
ATTR_STATUS_LIGHT = "status_light"
ATTR_EQ_BASS = "bass_level"
ATTR_EQ_TREBLE = "treble_level"
@ -233,11 +228,6 @@ async def async_setup_entry(
platform.async_register_entity_service( # type: ignore
SERVICE_SET_OPTION,
{
vol.Optional(ATTR_BUTTONS_ENABLED): cv.boolean,
vol.Optional(ATTR_CROSSFADE): cv.boolean,
vol.Optional(ATTR_NIGHT_SOUND): cv.boolean,
vol.Optional(ATTR_SPEECH_ENHANCE): cv.boolean,
vol.Optional(ATTR_STATUS_LIGHT): cv.boolean,
vol.Optional(ATTR_EQ_BASS): vol.All(
vol.Coerce(int), vol.Range(min=-10, max=10)
),
@ -302,7 +292,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
return STATE_PLAYING
return STATE_IDLE
async def async_update(self) -> None:
async def _async_poll(self) -> None:
"""Retrieve latest state by polling."""
await self.hass.data[DATA_SONOS].favorites[
self.speaker.household_id
@ -618,30 +608,10 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
@soco_error()
def set_option(
self,
buttons_enabled: bool | None = None,
crossfade: bool | None = None,
night_sound: bool | None = None,
speech_enhance: bool | None = None,
status_light: bool | None = None,
bass_level: int | None = None,
treble_level: int | None = None,
) -> None:
"""Modify playback options."""
if buttons_enabled is not None:
self.soco.buttons_enabled = buttons_enabled
if crossfade is not None:
self.soco.cross_fade = crossfade
if night_sound is not None and self.speaker.night_mode is not None:
self.soco.night_mode = night_sound
if speech_enhance is not None and self.speaker.dialog_mode is not None:
self.soco.dialog_mode = speech_enhance
if status_light is not None:
self.soco.status_light = status_light
if bass_level is not None:
self.soco.bass = bass_level
@ -671,12 +641,6 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
if self.speaker.treble_level is not None:
attributes[ATTR_EQ_TREBLE] = self.speaker.treble_level
if self.speaker.night_mode is not None:
attributes[ATTR_NIGHT_SOUND] = self.speaker.night_mode
if self.speaker.dialog_mode is not None:
attributes[ATTR_SPEECH_ENHANCE] = self.speaker.dialog_mode
if self.media.queue_position is not None:
attributes[ATTR_QUEUE_POSITION] = self.media.queue_position

View File

@ -45,7 +45,7 @@ class SonosBatteryEntity(SonosEntity, SensorEntity):
"""Get the unit of measurement."""
return PERCENTAGE
async def async_update(self) -> None:
async def _async_poll(self) -> None:
"""Poll the device for the current state."""
await self.speaker.async_poll_battery()

View File

@ -94,33 +94,6 @@ set_option:
device:
integration: sonos
fields:
buttons_enabled:
name: Buttons enabled
description: Enable control buttons on the device
example: "true"
selector:
boolean:
crossfade:
name: Crossfade
description: Enable crossfade on the device
example: "true"
selector:
boolean:
night_sound:
name: Night sound
description: Enable Night Sound mode
selector:
boolean:
speech_enhance:
name: Speech enhance
description: Enable Speech Enhancement mode
selector:
boolean:
status_light:
name: Status light
description: Enable Status (LED) Light
selector:
boolean:
bass_level:
name: Bass Level
description: Bass level for EQ.

View File

@ -14,7 +14,7 @@ import async_timeout
from soco.core import MUSIC_SRC_LINE_IN, MUSIC_SRC_RADIO, MUSIC_SRC_TV, SoCo
from soco.data_structures import DidlAudioBroadcast, DidlPlaylistContainer
from soco.events_base import Event as SonosEvent, SubscriptionBase
from soco.exceptions import SoCoException, SoCoUPnPException
from soco.exceptions import SoCoException, SoCoSlaveException, SoCoUPnPException
from soco.music_library import MusicLibrary
from soco.plugins.sharelink import ShareLinkPlugin
from soco.snapshot import Snapshot
@ -46,6 +46,7 @@ from .const import (
SONOS_CREATE_ALARM,
SONOS_CREATE_BATTERY,
SONOS_CREATE_MEDIA_PLAYER,
SONOS_CREATE_SWITCHES,
SONOS_ENTITY_CREATED,
SONOS_POLL_UPDATE,
SONOS_REBOOTED,
@ -191,9 +192,14 @@ class SonosSpeaker:
self.muted: bool | None = None
self.night_mode: bool | None = None
self.dialog_mode: bool | None = None
self.cross_fade: bool | None = None
self.bass_level: int | None = None
self.treble_level: int | None = None
# Misc features
self.buttons_enabled: bool | None = None
self.status_light: bool | None = None
# Grouping
self.coordinator: SonosSpeaker | None = None
self.sonos_group: list[SonosSpeaker] = [self]
@ -240,6 +246,8 @@ class SonosSpeaker:
else:
self._platforms_ready.add(SWITCH_DOMAIN)
dispatcher_send(self.hass, SONOS_CREATE_SWITCHES, self)
self._event_dispatchers = {
"AlarmClock": self.async_dispatch_alarms,
"AVTransport": self.async_dispatch_media_update,
@ -458,6 +466,9 @@ class SonosSpeaker:
@callback
def async_dispatch_media_update(self, event: SonosEvent) -> None:
"""Update information about currently playing media from an event."""
if crossfade := event.variables.get("current_crossfade_mode"):
self.cross_fade = bool(int(crossfade))
self.hass.async_add_executor_job(self.update_media, event)
@callback
@ -982,6 +993,11 @@ class SonosSpeaker:
self.bass_level = self.soco.bass
self.treble_level = self.soco.treble
try:
self.cross_fade = self.soco.cross_fade
except SoCoSlaveException:
pass
def update_media(self, event: SonosEvent | None = None) -> None:
"""Update information about currently playing media."""
variables = event and event.variables

View File

@ -4,10 +4,10 @@ from __future__ import annotations
import datetime
import logging
from soco.exceptions import SoCoException, SoCoUPnPException
from soco.exceptions import SoCoException, SoCoSlaveException, SoCoUPnPException
from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity
from homeassistant.const import ATTR_TIME
from homeassistant.const import ATTR_TIME, ENTITY_CATEGORY_CONFIG
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@ -17,6 +17,7 @@ from .const import (
DOMAIN as SONOS_DOMAIN,
SONOS_ALARMS_UPDATED,
SONOS_CREATE_ALARM,
SONOS_CREATE_SWITCHES,
)
from .entity import SonosEntity
from .speaker import SonosSpeaker
@ -31,11 +32,48 @@ ATTR_SCHEDULED_TODAY = "scheduled_today"
ATTR_VOLUME = "volume"
ATTR_INCLUDE_LINKED_ZONES = "include_linked_zones"
ATTR_CROSSFADE = "cross_fade"
ATTR_NIGHT_SOUND = "night_mode"
ATTR_SPEECH_ENHANCEMENT = "dialog_mode"
ATTR_STATUS_LIGHT = "status_light"
ATTR_TOUCH_CONTROLS = "buttons_enabled"
ALL_FEATURES = (
ATTR_TOUCH_CONTROLS,
ATTR_CROSSFADE,
ATTR_NIGHT_SOUND,
ATTR_SPEECH_ENHANCEMENT,
ATTR_STATUS_LIGHT,
)
COORDINATOR_FEATURES = ATTR_CROSSFADE
POLL_REQUIRED = (
ATTR_TOUCH_CONTROLS,
ATTR_STATUS_LIGHT,
)
FRIENDLY_NAMES = {
ATTR_CROSSFADE: "Crossfade",
ATTR_NIGHT_SOUND: "Night Sound",
ATTR_SPEECH_ENHANCEMENT: "Speech Enhancement",
ATTR_STATUS_LIGHT: "Status Light",
ATTR_TOUCH_CONTROLS: "Touch Controls",
}
FEATURE_ICONS = {
ATTR_NIGHT_SOUND: "mdi:chat-sleep",
ATTR_SPEECH_ENHANCEMENT: "mdi:ear-hearing",
ATTR_CROSSFADE: "mdi:swap-horizontal",
ATTR_STATUS_LIGHT: "mdi:led-on",
ATTR_TOUCH_CONTROLS: "mdi:gesture-tap",
}
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up Sonos from a config entry."""
async def _async_create_entity(speaker: SonosSpeaker, alarm_ids: list[str]) -> None:
async def _async_create_alarms(speaker: SonosSpeaker, alarm_ids: list[str]) -> None:
entities = []
created_alarms = (
hass.data[DATA_SONOS].alarms[speaker.household_id].created_alarm_ids
@ -48,9 +86,93 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
entities.append(SonosAlarmEntity(alarm_id, speaker))
async_add_entities(entities)
def available_soco_attributes(speaker: SonosSpeaker) -> list[tuple[str, bool]]:
features = []
for feature_type in ALL_FEATURES:
try:
if (state := getattr(speaker.soco, feature_type, None)) is not None:
setattr(speaker, feature_type, state)
features.append(feature_type)
except SoCoSlaveException:
features.append(feature_type)
return features
async def _async_create_switches(speaker: SonosSpeaker) -> None:
entities = []
available_features = await hass.async_add_executor_job(
available_soco_attributes, speaker
)
for feature_type in available_features:
_LOGGER.debug(
"Creating %s switch on %s",
FRIENDLY_NAMES[feature_type],
speaker.zone_name,
)
entities.append(SonosSwitchEntity(feature_type, speaker))
async_add_entities(entities)
config_entry.async_on_unload(
async_dispatcher_connect(hass, SONOS_CREATE_ALARM, _async_create_entity)
async_dispatcher_connect(hass, SONOS_CREATE_ALARM, _async_create_alarms)
)
config_entry.async_on_unload(
async_dispatcher_connect(hass, SONOS_CREATE_SWITCHES, _async_create_switches)
)
class SonosSwitchEntity(SonosEntity, SwitchEntity):
"""Representation of a Sonos feature switch."""
def __init__(self, feature_type: str, speaker: SonosSpeaker) -> None:
"""Initialize the switch."""
super().__init__(speaker)
self.feature_type = feature_type
self.entity_id = ENTITY_ID_FORMAT.format(
f"sonos_{speaker.zone_name}_{FRIENDLY_NAMES[feature_type]}"
)
self.needs_coordinator = feature_type in COORDINATOR_FEATURES
self._attr_entity_category = ENTITY_CATEGORY_CONFIG
self._attr_name = f"{speaker.zone_name} {FRIENDLY_NAMES[feature_type]}"
self._attr_unique_id = f"{speaker.soco.uid}-{feature_type}"
self._attr_icon = FEATURE_ICONS.get(feature_type)
if feature_type in POLL_REQUIRED:
self._attr_should_poll = True
async def _async_poll(self) -> None:
"""Handle polling for subscription-based switches when subscription fails."""
if not self.should_poll:
await self.hass.async_add_executor_job(self.update)
def update(self) -> None:
"""Fetch switch state if necessary."""
state = getattr(self.soco, self.feature_type)
setattr(self.speaker, self.feature_type, state)
@property
def is_on(self) -> bool:
"""Return True if entity is on."""
if self.needs_coordinator and not self.speaker.is_coordinator:
return getattr(self.speaker.coordinator, self.feature_type)
return getattr(self.speaker, self.feature_type)
def turn_on(self, **kwargs) -> None:
"""Turn the entity on."""
self.send_command(True)
def turn_off(self, **kwargs) -> None:
"""Turn the entity off."""
self.send_command(False)
def send_command(self, enable: bool) -> None:
"""Enable or disable the feature on the device."""
if self.needs_coordinator:
soco = self.soco.group.coordinator
else:
soco = self.soco
try:
setattr(soco, self.feature_type, enable)
except SoCoUPnPException as exc:
_LOGGER.warning("Could not toggle %s: %s", self.entity_id, exc)
class SonosAlarmEntity(SonosEntity, SwitchEntity):
@ -99,7 +221,7 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity):
str(self.alarm.start_time)[0:5],
)
async def async_update(self) -> None:
async def _async_poll(self) -> None:
"""Call the central alarm polling method."""
await self.hass.data[DATA_SONOS].alarms[self.household_id].async_poll()

View File

@ -95,6 +95,4 @@ async def test_entity_basic(hass, config_entry, discover):
attributes = state.attributes
assert attributes["friendly_name"] == "Zone A"
assert attributes["is_volume_muted"] is False
assert attributes["night_sound"] is True
assert attributes["speech_enhance"] is True
assert attributes["volume_level"] == 0.19

View File

@ -30,10 +30,14 @@ async def test_entity_registry(hass, config_entry, config):
assert "media_player.zone_a" in entity_registry.entities
assert "switch.sonos_alarm_14" in entity_registry.entities
assert "switch.sonos_zone_a_status_light" in entity_registry.entities
assert "switch.sonos_zone_a_night_sound" in entity_registry.entities
assert "switch.sonos_zone_a_speech_enhancement" in entity_registry.entities
assert "switch.sonos_zone_a_touch_controls" in entity_registry.entities
async def test_alarm_attributes(hass, config_entry, config):
"""Test for correct sonos alarm state."""
async def test_switch_attributes(hass, config_entry, config, soco):
"""Test for correct Sonos switch states."""
await setup_platform(hass, config_entry, config)
entity_registry = await hass.helpers.entity_registry.async_get_registry()
@ -49,6 +53,28 @@ async def test_alarm_attributes(hass, config_entry, config):
assert alarm_state.attributes.get(ATTR_PLAY_MODE) == "SHUFFLE_NOREPEAT"
assert not alarm_state.attributes.get(ATTR_INCLUDE_LINKED_ZONES)
night_sound = entity_registry.entities["switch.sonos_zone_a_night_sound"]
night_sound_state = hass.states.get(night_sound.entity_id)
assert night_sound_state.state == STATE_ON
speech_enhancement = entity_registry.entities[
"switch.sonos_zone_a_speech_enhancement"
]
speech_enhancement_state = hass.states.get(speech_enhancement.entity_id)
assert speech_enhancement_state.state == STATE_ON
crossfade = entity_registry.entities["switch.sonos_zone_a_crossfade"]
crossfade_state = hass.states.get(crossfade.entity_id)
assert crossfade_state.state == STATE_ON
status_light = entity_registry.entities["switch.sonos_zone_a_status_light"]
status_light_state = hass.states.get(status_light.entity_id)
assert status_light_state.state == STATE_ON
touch_controls = entity_registry.entities["switch.sonos_zone_a_touch_controls"]
touch_controls_state = hass.states.get(touch_controls.entity_id)
assert touch_controls_state.state == STATE_ON
async def test_alarm_create_delete(
hass, config_entry, config, soco, alarm_clock, alarm_clock_extended, alarm_event