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 the entity's device class."""
return DEVICE_CLASS_BATTERY_CHARGING return DEVICE_CLASS_BATTERY_CHARGING
async def async_update(self) -> None: async def _async_poll(self) -> None:
"""Poll the device for the current state.""" """Poll the device for the current state."""
await self.speaker.async_poll_battery() await self.speaker.async_poll_battery()

View file

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

View file

@ -1,6 +1,7 @@
"""Entity representing a Sonos player.""" """Entity representing a Sonos player."""
from __future__ import annotations from __future__ import annotations
from abc import abstractmethod
import datetime import datetime
import logging import logging
@ -30,6 +31,8 @@ _LOGGER = logging.getLogger(__name__)
class SonosEntity(Entity): class SonosEntity(Entity):
"""Representation of a Sonos entity.""" """Representation of a Sonos entity."""
_attr_should_poll = False
def __init__(self, speaker: SonosSpeaker) -> None: def __init__(self, speaker: SonosSpeaker) -> None:
"""Initialize a SonosEntity.""" """Initialize a SonosEntity."""
self.speaker = speaker self.speaker = speaker
@ -78,10 +81,14 @@ class SonosEntity(Entity):
self.speaker.subscriptions_failed = True self.speaker.subscriptions_failed = True
await self.speaker.async_unsubscribe() await self.speaker.async_unsubscribe()
try: try:
await self.async_update() # pylint: disable=no-member await self._async_poll()
except (OSError, SoCoException) as ex: except (OSError, SoCoException) as ex:
_LOGGER.debug("Error connecting to %s: %s", self.entity_id, 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 @property
def soco(self) -> SoCo: def soco(self) -> SoCo:
"""Return the speaker SoCo instance.""" """Return the speaker SoCo instance."""
@ -108,8 +115,3 @@ class SonosEntity(Entity):
def available(self) -> bool: def available(self) -> bool:
"""Return whether this device is available.""" """Return whether this device is available."""
return self.speaker.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_INCLUDE_LINKED_ZONES = "include_linked_zones"
ATTR_MASTER = "master" ATTR_MASTER = "master"
ATTR_WITH_GROUP = "with_group" 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_QUEUE_POSITION = "queue_position"
ATTR_STATUS_LIGHT = "status_light"
ATTR_EQ_BASS = "bass_level" ATTR_EQ_BASS = "bass_level"
ATTR_EQ_TREBLE = "treble_level" ATTR_EQ_TREBLE = "treble_level"
@ -233,11 +228,6 @@ async def async_setup_entry(
platform.async_register_entity_service( # type: ignore platform.async_register_entity_service( # type: ignore
SERVICE_SET_OPTION, 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.Optional(ATTR_EQ_BASS): vol.All(
vol.Coerce(int), vol.Range(min=-10, max=10) vol.Coerce(int), vol.Range(min=-10, max=10)
), ),
@ -302,7 +292,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
return STATE_PLAYING return STATE_PLAYING
return STATE_IDLE return STATE_IDLE
async def async_update(self) -> None: async def _async_poll(self) -> None:
"""Retrieve latest state by polling.""" """Retrieve latest state by polling."""
await self.hass.data[DATA_SONOS].favorites[ await self.hass.data[DATA_SONOS].favorites[
self.speaker.household_id self.speaker.household_id
@ -618,30 +608,10 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
@soco_error() @soco_error()
def set_option( def set_option(
self, 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, bass_level: int | None = None,
treble_level: int | None = None, treble_level: int | None = None,
) -> None: ) -> None:
"""Modify playback options.""" """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: if bass_level is not None:
self.soco.bass = bass_level self.soco.bass = bass_level
@ -671,12 +641,6 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
if self.speaker.treble_level is not None: if self.speaker.treble_level is not None:
attributes[ATTR_EQ_TREBLE] = self.speaker.treble_level 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: if self.media.queue_position is not None:
attributes[ATTR_QUEUE_POSITION] = self.media.queue_position attributes[ATTR_QUEUE_POSITION] = self.media.queue_position

View file

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

View file

@ -94,33 +94,6 @@ set_option:
device: device:
integration: sonos integration: sonos
fields: 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: bass_level:
name: Bass Level name: Bass Level
description: Bass level for EQ. 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.core import MUSIC_SRC_LINE_IN, MUSIC_SRC_RADIO, MUSIC_SRC_TV, SoCo
from soco.data_structures import DidlAudioBroadcast, DidlPlaylistContainer from soco.data_structures import DidlAudioBroadcast, DidlPlaylistContainer
from soco.events_base import Event as SonosEvent, SubscriptionBase 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.music_library import MusicLibrary
from soco.plugins.sharelink import ShareLinkPlugin from soco.plugins.sharelink import ShareLinkPlugin
from soco.snapshot import Snapshot from soco.snapshot import Snapshot
@ -46,6 +46,7 @@ from .const import (
SONOS_CREATE_ALARM, SONOS_CREATE_ALARM,
SONOS_CREATE_BATTERY, SONOS_CREATE_BATTERY,
SONOS_CREATE_MEDIA_PLAYER, SONOS_CREATE_MEDIA_PLAYER,
SONOS_CREATE_SWITCHES,
SONOS_ENTITY_CREATED, SONOS_ENTITY_CREATED,
SONOS_POLL_UPDATE, SONOS_POLL_UPDATE,
SONOS_REBOOTED, SONOS_REBOOTED,
@ -191,9 +192,14 @@ class SonosSpeaker:
self.muted: bool | None = None self.muted: bool | None = None
self.night_mode: bool | None = None self.night_mode: bool | None = None
self.dialog_mode: bool | None = None self.dialog_mode: bool | None = None
self.cross_fade: bool | None = None
self.bass_level: int | None = None self.bass_level: int | None = None
self.treble_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 # Grouping
self.coordinator: SonosSpeaker | None = None self.coordinator: SonosSpeaker | None = None
self.sonos_group: list[SonosSpeaker] = [self] self.sonos_group: list[SonosSpeaker] = [self]
@ -240,6 +246,8 @@ class SonosSpeaker:
else: else:
self._platforms_ready.add(SWITCH_DOMAIN) self._platforms_ready.add(SWITCH_DOMAIN)
dispatcher_send(self.hass, SONOS_CREATE_SWITCHES, self)
self._event_dispatchers = { self._event_dispatchers = {
"AlarmClock": self.async_dispatch_alarms, "AlarmClock": self.async_dispatch_alarms,
"AVTransport": self.async_dispatch_media_update, "AVTransport": self.async_dispatch_media_update,
@ -458,6 +466,9 @@ class SonosSpeaker:
@callback @callback
def async_dispatch_media_update(self, event: SonosEvent) -> None: def async_dispatch_media_update(self, event: SonosEvent) -> None:
"""Update information about currently playing media from an event.""" """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) self.hass.async_add_executor_job(self.update_media, event)
@callback @callback
@ -982,6 +993,11 @@ class SonosSpeaker:
self.bass_level = self.soco.bass self.bass_level = self.soco.bass
self.treble_level = self.soco.treble 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: def update_media(self, event: SonosEvent | None = None) -> None:
"""Update information about currently playing media.""" """Update information about currently playing media."""
variables = event and event.variables variables = event and event.variables

View file

@ -4,10 +4,10 @@ from __future__ import annotations
import datetime import datetime
import logging 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.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.core import callback
from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
@ -17,6 +17,7 @@ from .const import (
DOMAIN as SONOS_DOMAIN, DOMAIN as SONOS_DOMAIN,
SONOS_ALARMS_UPDATED, SONOS_ALARMS_UPDATED,
SONOS_CREATE_ALARM, SONOS_CREATE_ALARM,
SONOS_CREATE_SWITCHES,
) )
from .entity import SonosEntity from .entity import SonosEntity
from .speaker import SonosSpeaker from .speaker import SonosSpeaker
@ -31,11 +32,48 @@ ATTR_SCHEDULED_TODAY = "scheduled_today"
ATTR_VOLUME = "volume" ATTR_VOLUME = "volume"
ATTR_INCLUDE_LINKED_ZONES = "include_linked_zones" 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): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up Sonos from a config entry.""" """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 = [] entities = []
created_alarms = ( created_alarms = (
hass.data[DATA_SONOS].alarms[speaker.household_id].created_alarm_ids 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)) entities.append(SonosAlarmEntity(alarm_id, speaker))
async_add_entities(entities) 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( 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): class SonosAlarmEntity(SonosEntity, SwitchEntity):
@ -99,7 +221,7 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity):
str(self.alarm.start_time)[0:5], 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.""" """Call the central alarm polling method."""
await self.hass.data[DATA_SONOS].alarms[self.household_id].async_poll() 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 attributes = state.attributes
assert attributes["friendly_name"] == "Zone A" assert attributes["friendly_name"] == "Zone A"
assert attributes["is_volume_muted"] is False 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 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 "media_player.zone_a" in entity_registry.entities
assert "switch.sonos_alarm_14" 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): async def test_switch_attributes(hass, config_entry, config, soco):
"""Test for correct sonos alarm state.""" """Test for correct Sonos switch states."""
await setup_platform(hass, config_entry, config) await setup_platform(hass, config_entry, config)
entity_registry = await hass.helpers.entity_registry.async_get_registry() 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 alarm_state.attributes.get(ATTR_PLAY_MODE) == "SHUFFLE_NOREPEAT"
assert not alarm_state.attributes.get(ATTR_INCLUDE_LINKED_ZONES) 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( async def test_alarm_create_delete(
hass, config_entry, config, soco, alarm_clock, alarm_clock_extended, alarm_event hass, config_entry, config, soco, alarm_clock, alarm_clock_extended, alarm_event