mirror of
https://github.com/home-assistant/core.git
synced 2025-04-24 09:17:53 +00:00
Expose Sonos features as switch entities (#54502)
Co-authored-by: Tobias Sauerwein <cgtobi@users.noreply.github.com>
This commit is contained in:
parent
21daffe905
commit
084fd2d19f
@ -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()
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user