Add group set volume

This commit is contained in:
Andrew Sayre 2025-01-30 02:55:00 +00:00
parent d1f0e0a70f
commit 13d9bbee52
7 changed files with 117 additions and 3 deletions

View File

@ -3,5 +3,6 @@
ATTR_PASSWORD = "password" ATTR_PASSWORD = "password"
ATTR_USERNAME = "username" ATTR_USERNAME = "username"
DOMAIN = "heos" DOMAIN = "heos"
SERVICE_GROUP_VOLUME_SET = "group_volume_set"
SERVICE_SIGN_IN = "sign_in" SERVICE_SIGN_IN = "sign_in"
SERVICE_SIGN_OUT = "sign_out" SERVICE_SIGN_OUT = "sign_out"

View File

@ -1,5 +1,8 @@
{ {
"services": { "services": {
"group_volume_set": {
"service": "mdi:volume-medium"
},
"sign_in": { "sign_in": {
"service": "mdi:login" "service": "mdi:login"
}, },

View File

@ -17,10 +17,12 @@ from pyheos import (
RepeatType, RepeatType,
const as heos_const, const as heos_const,
) )
import voluptuous as vol
from homeassistant.components import media_source from homeassistant.components import media_source
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_ENQUEUE,
ATTR_MEDIA_VOLUME_LEVEL,
BrowseMedia, BrowseMedia,
MediaPlayerEnqueue, MediaPlayerEnqueue,
MediaPlayerEntity, MediaPlayerEntity,
@ -33,13 +35,17 @@ from homeassistant.components.media_player import (
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError 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.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.dt import utcnow 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 from .coordinator import HeosConfigEntry, HeosCoordinator
PARALLEL_UPDATES = 0 PARALLEL_UPDATES = 0
@ -93,6 +99,13 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Add media players for a config entry.""" """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: def add_entities_callback(players: Sequence[HeosPlayer]) -> None:
"""Add entities for each player.""" """Add entities for each player."""
@ -346,6 +359,19 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity):
"""Set volume level, range 0..1.""" """Set volume level, range 0..1."""
await self._player.set_volume(int(volume * 100)) 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") @catch_action_error("join players")
async def async_join_players(self, group_members: list[str]) -> None: async def async_join_players(self, group_members: list[str]) -> None:
"""Join `group_members` as a player group with the current player.""" """Join `group_members` as a player group with the current player."""

View File

@ -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: sign_in:
fields: fields:
username: username:

View File

@ -71,6 +71,16 @@
} }
}, },
"services": { "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": { "sign_in": {
"name": "Sign in", "name": "Sign in",
"description": "Signs in to a HEOS account.", "description": "Signs in to a HEOS account.",
@ -94,6 +104,9 @@
"action_error": { "action_error": {
"message": "Unable to {action}: {error}" "message": "Unable to {action}: {error}"
}, },
"entity_not_grouped": {
"message": "Entity {entity_id} is not joined to a group"
},
"entity_not_found": { "entity_not_found": {
"message": "Entity {entity_id} was not found" "message": "Entity {entity_id} was not found"
}, },

View File

@ -34,6 +34,7 @@ class MockHeos(Heos):
self.player_set_play_state: AsyncMock = AsyncMock() self.player_set_play_state: AsyncMock = AsyncMock()
self.player_set_volume: AsyncMock = AsyncMock() self.player_set_volume: AsyncMock = AsyncMock()
self.set_group: AsyncMock = AsyncMock() self.set_group: AsyncMock = AsyncMock()
self.set_group_volume: AsyncMock = AsyncMock()
self.sign_in: AsyncMock = AsyncMock() self.sign_in: AsyncMock = AsyncMock()
self.sign_out: AsyncMock = AsyncMock() self.sign_out: AsyncMock = AsyncMock()

View File

@ -22,7 +22,7 @@ import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
from syrupy.filters import props 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 ( from homeassistant.components.media_player import (
ATTR_GROUP_MEMBERS, ATTR_GROUP_MEMBERS,
ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE,
@ -724,6 +724,62 @@ async def test_volume_set_error(
controller.player_set_volume.assert_called_once_with(1, 100) 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( async def test_select_favorite(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,