diff --git a/homeassistant/components/sonos/binary_sensor.py b/homeassistant/components/sonos/binary_sensor.py index 730a6367edd..9e35fc59616 100644 --- a/homeassistant/components/sonos/binary_sensor.py +++ b/homeassistant/components/sonos/binary_sensor.py @@ -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() diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index c5a630d73bd..abd04652936 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -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" diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index 1d4eb6e1cdd..0579c4f5c9b 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -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 diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 5cb6e225510..9f2bc829eac 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -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 diff --git a/homeassistant/components/sonos/sensor.py b/homeassistant/components/sonos/sensor.py index a71ac7cef21..f8e5142c123 100644 --- a/homeassistant/components/sonos/sensor.py +++ b/homeassistant/components/sonos/sensor.py @@ -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() diff --git a/homeassistant/components/sonos/services.yaml b/homeassistant/components/sonos/services.yaml index 9858eb7f8ed..af664f0b367 100644 --- a/homeassistant/components/sonos/services.yaml +++ b/homeassistant/components/sonos/services.yaml @@ -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. diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 549e4bacc9d..66a2b46eb12 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -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 diff --git a/homeassistant/components/sonos/switch.py b/homeassistant/components/sonos/switch.py index cee60cbbafa..3e9d5484784 100644 --- a/homeassistant/components/sonos/switch.py +++ b/homeassistant/components/sonos/switch.py @@ -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() diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 4de0f37d333..9fb1d7639eb 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -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 diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py index d71d403fd8a..906695bdbaf 100644 --- a/tests/components/sonos/test_switch.py +++ b/tests/components/sonos/test_switch.py @@ -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