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_USERNAME = "username"
DOMAIN = "heos"
SERVICE_GROUP_VOLUME_SET = "group_volume_set"
SERVICE_SIGN_IN = "sign_in"
SERVICE_SIGN_OUT = "sign_out"

View File

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

View File

@ -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."""

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

View File

@ -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"
},

View File

@ -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()

View File

@ -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,