From 586d27320ed9e1506c8cba164f5e70a383d4ef86 Mon Sep 17 00:00:00 2001 From: BestPig Date: Tue, 16 Apr 2024 17:45:48 +0200 Subject: [PATCH] Add Sound Mode selection in soundpal components (#106589) --- .../components/songpal/media_player.py | 70 +++++++++++++++++++ tests/components/songpal/__init__.py | 18 +++++ tests/components/songpal/test_media_player.py | 22 +++++- 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py index 33dc65d5eaa..d3ce934ec51 100644 --- a/homeassistant/components/songpal/media_player.py +++ b/homeassistant/components/songpal/media_player.py @@ -11,9 +11,11 @@ from songpal import ( ContentChange, Device, PowerChange, + SettingChange, SongpalException, VolumeChange, ) +from songpal.containers import Setting import voluptuous as vol from homeassistant.components.media_player import ( @@ -99,6 +101,7 @@ class SongpalEntity(MediaPlayerEntity): | MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.SELECT_SOUND_MODE | MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF ) @@ -124,6 +127,8 @@ class SongpalEntity(MediaPlayerEntity): self._active_source = None self._sources = {} + self._active_sound_mode = None + self._sound_modes = {} async def async_added_to_hass(self) -> None: """Run when entity is added to hass.""" @@ -133,6 +138,28 @@ class SongpalEntity(MediaPlayerEntity): """Run when entity will be removed from hass.""" await self._dev.stop_listen_notifications() + async def _get_sound_modes_info(self): + """Get available sound modes and the active one.""" + settings = await self._dev.get_sound_settings("soundField") + if isinstance(settings, Setting): + settings = [settings] + + sound_modes = {} + active_sound_mode = None + for setting in settings: + cur = setting.currentValue + for opt in setting.candidate: + if not opt.isAvailable: + continue + if opt.value == cur: + active_sound_mode = opt.value + sound_modes[opt.value] = opt + + _LOGGER.debug("Got sound modes: %s", sound_modes) + _LOGGER.debug("Active sound mode: %s", active_sound_mode) + + return active_sound_mode, sound_modes + async def async_activate_websocket(self): """Activate websocket for listening if wanted.""" _LOGGER.info("Activating websocket connection") @@ -152,6 +179,16 @@ class SongpalEntity(MediaPlayerEntity): else: _LOGGER.debug("Got non-handled content change: %s", content) + async def _setting_changed(setting: SettingChange): + _LOGGER.debug("Setting changed: %s", setting) + + if setting.target == "soundField": + self._active_sound_mode = setting.currentValue + _LOGGER.debug("New active sound mode: %s", self._active_sound_mode) + self.async_write_ha_state() + else: + _LOGGER.debug("Got non-handled setting change: %s", setting) + async def _power_changed(power: PowerChange): _LOGGER.debug("Power changed: %s", power) self._state = power.status @@ -192,6 +229,7 @@ class SongpalEntity(MediaPlayerEntity): self._dev.on_notification(VolumeChange, _volume_changed) self._dev.on_notification(ContentChange, _source_changed) self._dev.on_notification(PowerChange, _power_changed) + self._dev.on_notification(SettingChange, _setting_changed) self._dev.on_notification(ConnectChange, _try_reconnect) async def handle_stop(event): @@ -271,6 +309,11 @@ class SongpalEntity(MediaPlayerEntity): _LOGGER.debug("Active source: %s", self._active_source) + ( + self._active_sound_mode, + self._sound_modes, + ) = await self._get_sound_modes_info() + self._attr_available = True except SongpalException as ex: @@ -291,6 +334,27 @@ class SongpalEntity(MediaPlayerEntity): """Return list of available sources.""" return [src.title for src in self._sources.values()] + async def async_select_sound_mode(self, sound_mode: str) -> None: + """Select sound mode.""" + for mode in self._sound_modes.values(): + if mode.title == sound_mode: + await self._dev.set_sound_settings("soundField", mode.value) + return + + _LOGGER.error("Unable to find sound mode: %s", sound_mode) + + @property + def sound_mode_list(self) -> list[str] | None: + """Return list of available sound modes. + + When active mode is None it means that sound mode is unavailable on the sound bar. + Can be due to incompatible sound bar or the sound bar is in a mode that does not + support sound mode changes. + """ + if not self._active_sound_mode: + return None + return [sound_mode.title for sound_mode in self._sound_modes.values()] + @property def state(self) -> MediaPlayerState: """Return current state.""" @@ -304,6 +368,12 @@ class SongpalEntity(MediaPlayerEntity): # Avoid a KeyError when _active_source is not (yet) populated return getattr(self._active_source, "title", None) + @property + def sound_mode(self) -> str | None: + """Return currently active sound_mode.""" + active_sound_mode = self._sound_modes.get(self._active_sound_mode) + return active_sound_mode.title if active_sound_mode else None + @property def volume_level(self): """Return volume level.""" diff --git a/tests/components/songpal/__init__.py b/tests/components/songpal/__init__.py index 6ebc2ec5ef4..ab585c5a6d5 100644 --- a/tests/components/songpal/__init__.py +++ b/tests/components/songpal/__init__.py @@ -85,6 +85,24 @@ def _create_mocked_device(throw_exception=False, wired_mac=MAC, wireless_mac=Non input2.active = True type(mocked_device).get_inputs = AsyncMock(return_value=[input1, input2]) + sound_mode1 = MagicMock() + sound_mode1.title = "Sound Mode 1" + sound_mode1.value = "sound_mode1" + sound_mode1.isAvailable = True + sound_mode2 = MagicMock() + sound_mode2.title = "Sound Mode 2" + sound_mode2.value = "sound_mode2" + sound_mode2.isAvailable = True + sound_mode3 = MagicMock() + sound_mode3.title = "Sound Mode 3" + sound_mode3.value = "sound_mode3" + sound_mode3.isAvailable = False + + soundField = MagicMock() + soundField.currentValue = "sound_mode2" + soundField.candidate = [sound_mode1, sound_mode2, sound_mode3] + type(mocked_device).get_sound_settings = AsyncMock(return_value=[soundField]) + type(mocked_device).set_power = AsyncMock() type(mocked_device).set_sound_settings = AsyncMock() type(mocked_device).listen_notifications = AsyncMock() diff --git a/tests/components/songpal/test_media_player.py b/tests/components/songpal/test_media_player.py index 4b1abf8709e..88443bf58b9 100644 --- a/tests/components/songpal/test_media_player.py +++ b/tests/components/songpal/test_media_player.py @@ -12,6 +12,7 @@ from songpal import ( SongpalException, VolumeChange, ) +from songpal.notification import SettingChange from homeassistant.components import media_player, songpal from homeassistant.components.media_player import MediaPlayerEntityFeature @@ -47,6 +48,7 @@ SUPPORT_SONGPAL = ( | MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.SELECT_SOUND_MODE | MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF ) @@ -138,6 +140,8 @@ async def test_state(hass: HomeAssistant) -> None: assert attributes["is_volume_muted"] is False assert attributes["source_list"] == ["title1", "title2"] assert attributes["source"] == "title2" + assert attributes["sound_mode_list"] == ["Sound Mode 1", "Sound Mode 2"] + assert attributes["sound_mode"] == "Sound Mode 2" assert attributes["supported_features"] == SUPPORT_SONGPAL device_registry = dr.async_get(hass) @@ -171,6 +175,8 @@ async def test_state_wireless(hass: HomeAssistant) -> None: assert attributes["is_volume_muted"] is False assert attributes["source_list"] == ["title1", "title2"] assert attributes["source"] == "title2" + assert attributes["sound_mode_list"] == ["Sound Mode 1", "Sound Mode 2"] + assert attributes["sound_mode"] == "Sound Mode 2" assert attributes["supported_features"] == SUPPORT_SONGPAL device_registry = dr.async_get(hass) @@ -206,6 +212,8 @@ async def test_state_both(hass: HomeAssistant) -> None: assert attributes["is_volume_muted"] is False assert attributes["source_list"] == ["title1", "title2"] assert attributes["source"] == "title2" + assert attributes["sound_mode_list"] == ["Sound Mode 1", "Sound Mode 2"] + assert attributes["sound_mode"] == "Sound Mode 2" assert attributes["supported_features"] == SUPPORT_SONGPAL device_registry = dr.async_get(hass) @@ -303,6 +311,9 @@ async def test_services(hass: HomeAssistant) -> None: mocked_device2.set_sound_settings.assert_called_once_with("name", "value") mocked_device3.set_sound_settings.assert_called_once_with("name", "value") + await _call(hass, media_player.SERVICE_SELECT_SOUND_MODE, sound_mode="Sound Mode 1") + mocked_device.set_sound_settings.assert_called_with("soundField", "sound_mode1") + async def test_websocket_events(hass: HomeAssistant) -> None: """Test websocket events.""" @@ -315,7 +326,7 @@ async def test_websocket_events(hass: HomeAssistant) -> None: await hass.async_block_till_done() mocked_device.listen_notifications.assert_called_once() - assert mocked_device.on_notification.call_count == 4 + assert mocked_device.on_notification.call_count == 5 notification_callbacks = mocked_device.notification_callbacks @@ -336,6 +347,15 @@ async def test_websocket_events(hass: HomeAssistant) -> None: await notification_callbacks[ContentChange](content_change) assert _get_attributes(hass)["source"] == "title1" + sound_mode_change = MagicMock() + sound_mode_change.target = "soundField" + sound_mode_change.currentValue = "sound_mode1" + await notification_callbacks[SettingChange](sound_mode_change) + assert _get_attributes(hass)["sound_mode"] == "Sound Mode 1" + sound_mode_change.currentValue = "sound_mode2" + await notification_callbacks[SettingChange](sound_mode_change) + assert _get_attributes(hass)["sound_mode"] == "Sound Mode 2" + power_change = MagicMock() power_change.status = False await notification_callbacks[PowerChange](power_change)