diff --git a/homeassistant/components/heos/const.py b/homeassistant/components/heos/const.py index 7f03fa11e79..0203def3885 100644 --- a/homeassistant/components/heos/const.py +++ b/homeassistant/components/heos/const.py @@ -3,5 +3,6 @@ ATTR_PASSWORD = "password" ATTR_USERNAME = "username" DOMAIN = "heos" +SERVICE_GROUP_VOLUME_SET = "group_volume_set" SERVICE_SIGN_IN = "sign_in" SERVICE_SIGN_OUT = "sign_out" diff --git a/homeassistant/components/heos/icons.json b/homeassistant/components/heos/icons.json index 23c2c8faeaf..a634701037c 100644 --- a/homeassistant/components/heos/icons.json +++ b/homeassistant/components/heos/icons.json @@ -1,5 +1,8 @@ { "services": { + "group_volume_set": { + "service": "mdi:volume-medium" + }, "sign_in": { "service": "mdi:login" }, diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index b9aa05810e5..a649740a933 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -17,10 +17,12 @@ from pyheos import ( RepeatType, const as heos_const, ) +import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.media_player import ( ATTR_MEDIA_ENQUEUE, + ATTR_MEDIA_VOLUME_LEVEL, BrowseMedia, MediaPlayerEnqueue, MediaPlayerEntity, @@ -33,13 +35,17 @@ from homeassistant.components.media_player import ( from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import ( + config_validation as cv, + entity_platform, + entity_registry as er, +) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow -from .const import DOMAIN as HEOS_DOMAIN +from .const import DOMAIN as HEOS_DOMAIN, SERVICE_GROUP_VOLUME_SET from .coordinator import HeosConfigEntry, HeosCoordinator PARALLEL_UPDATES = 0 @@ -93,6 +99,13 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add media players for a config entry.""" + # Register custom entity services + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_GROUP_VOLUME_SET, + {vol.Required(ATTR_MEDIA_VOLUME_LEVEL): cv.small_float}, + "async_set_group_volume_level", + ) def add_entities_callback(players: Sequence[HeosPlayer]) -> None: """Add entities for each player.""" @@ -346,6 +359,19 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): """Set volume level, range 0..1.""" await self._player.set_volume(int(volume * 100)) + @catch_action_error("set group volume level") + async def async_set_group_volume_level(self, volume_level: float) -> None: + """Set group volume level.""" + if self._player.group_id is None: + raise ServiceValidationError( + translation_domain=HEOS_DOMAIN, + translation_key="entity_not_grouped", + translation_placeholders={"entity_id": self.entity_id}, + ) + await self.coordinator.heos.set_group_volume( + self._player.group_id, int(volume_level * 100) + ) + @catch_action_error("join players") async def async_join_players(self, group_members: list[str]) -> None: """Join `group_members` as a player group with the current player.""" diff --git a/homeassistant/components/heos/services.yaml b/homeassistant/components/heos/services.yaml index 8dc222d65ba..948aeb919f4 100644 --- a/homeassistant/components/heos/services.yaml +++ b/homeassistant/components/heos/services.yaml @@ -1,3 +1,17 @@ +group_volume_set: + target: + entity: + integration: heos + domain: media_player + fields: + volume_level: + required: true + selector: + number: + min: 0 + max: 1 + step: 0.01 + sign_in: fields: username: diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index 53e20a032b5..af70c0c786e 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -71,6 +71,16 @@ } }, "services": { + "group_volume_set": { + "name": "Set group volume", + "description": "Sets the group's volume while preserving member volume ratios.", + "fields": { + "volume_level": { + "name": "Level", + "description": "The volume. 0 is inaudible, 1 is the maximum volume." + } + } + }, "sign_in": { "name": "Sign in", "description": "Signs in to a HEOS account.", @@ -94,6 +104,9 @@ "action_error": { "message": "Unable to {action}: {error}" }, + "entity_not_grouped": { + "message": "Entity {entity_id} is not joined to a group" + }, "entity_not_found": { "message": "Entity {entity_id} was not found" }, diff --git a/tests/components/heos/__init__.py b/tests/components/heos/__init__.py index cf0d10790b7..bc72981d805 100644 --- a/tests/components/heos/__init__.py +++ b/tests/components/heos/__init__.py @@ -34,6 +34,7 @@ class MockHeos(Heos): self.player_set_play_state: AsyncMock = AsyncMock() self.player_set_volume: AsyncMock = AsyncMock() self.set_group: AsyncMock = AsyncMock() + self.set_group_volume: AsyncMock = AsyncMock() self.sign_in: AsyncMock = AsyncMock() self.sign_out: AsyncMock = AsyncMock() diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 3768462eada..5a0ed0aa7c4 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -22,7 +22,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from syrupy.filters import props -from homeassistant.components.heos.const import DOMAIN +from homeassistant.components.heos.const import DOMAIN, SERVICE_GROUP_VOLUME_SET from homeassistant.components.media_player import ( ATTR_GROUP_MEMBERS, ATTR_INPUT_SOURCE, @@ -724,6 +724,62 @@ async def test_volume_set_error( controller.player_set_volume.assert_called_once_with(1, 100) +async def test_group_volume_set( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the group volume set service.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.services.async_call( + DOMAIN, + SERVICE_GROUP_VOLUME_SET, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_LEVEL: 1}, + blocking=True, + ) + controller.set_group_volume.assert_called_once_with(999, 100) + + +async def test_group_volume_set_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the group volume set service errors.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.set_group_volume.side_effect = CommandFailedError("", "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to set group volume level: Failure (1)"), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GROUP_VOLUME_SET, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_LEVEL: 1}, + blocking=True, + ) + controller.set_group_volume.assert_called_once_with(999, 100) + + +async def test_group_volume_set_not_grouped_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the group volume set service when not grouped raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + player.group_id = None + with pytest.raises( + ServiceValidationError, + match=re.escape("Entity media_player.test_player is not joined to a group"), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GROUP_VOLUME_SET, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_LEVEL: 1}, + blocking=True, + ) + controller.set_group_volume.assert_not_called() + + async def test_select_favorite( hass: HomeAssistant, config_entry: MockConfigEntry,