Add initial support for PlayerOptions: Number entities to Music Assistant (#162669)

Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
This commit is contained in:
Fabian Munkes
2026-04-07 20:51:26 +02:00
committed by GitHub
parent f7b2f5e8f1
commit 09ee76c265
8 changed files with 604 additions and 2 deletions

View File

@@ -49,7 +49,11 @@ if TYPE_CHECKING:
from homeassistant.helpers.typing import ConfigType
PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER]
PLATFORMS = [
Platform.BUTTON,
Platform.MEDIA_PLAYER,
Platform.NUMBER,
]
CONNECT_TIMEOUT = 10
LISTEN_READY_TIMEOUT = 30

View File

@@ -80,3 +80,5 @@ ATTR_FANART_IMAGE = "fanart_image"
ATTR_CONF_EXPOSE_PLAYER_TO_HA = "expose_player_to_ha"
LOGGER = logging.getLogger(__package__)
PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX = "player_options."

View File

@@ -6,8 +6,9 @@ from typing import TYPE_CHECKING
from music_assistant_models.enums import EventType
from music_assistant_models.event import MassEvent
from music_assistant_models.player import Player
from music_assistant_models.player import Player, PlayerOption
from homeassistant.const import EntityCategory
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
@@ -84,3 +85,45 @@ class MusicAssistantEntity(Entity):
async def async_on_update(self) -> None:
"""Handle player updates."""
class MusicAssistantPlayerOptionEntity(MusicAssistantEntity):
"""Base entity for Music Assistant Player Options."""
_attr_entity_category = EntityCategory.CONFIG
def __init__(
self, mass: MusicAssistantClient, player_id: str, player_option: PlayerOption
) -> None:
"""Initialize MusicAssistantPlayerOptionEntity."""
super().__init__(mass, player_id)
self.mass_option_key = player_option.key
self.mass_type = player_option.type
self.on_player_option_update(player_option)
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
# need callbacks of parent to catch availability
await super().async_added_to_hass()
# main callback for player options
self.async_on_remove(
self.mass.subscribe(
self.__on_mass_player_options_update,
EventType.PLAYER_OPTIONS_UPDATED,
self.player_id,
)
)
def __on_mass_player_options_update(self, event: MassEvent) -> None:
"""Call when we receive an event from MusicAssistant."""
for option in self.player.options:
if option.key == self.mass_option_key:
self.on_player_option_update(option)
self.async_write_ha_state()
break
def on_player_option_update(self, player_option: PlayerOption) -> None:
"""Callback for player option updates."""

View File

@@ -0,0 +1,127 @@
"""Music Assistant Number platform."""
from __future__ import annotations
from typing import Final
from music_assistant_client.client import MusicAssistantClient
from music_assistant_models.player import PlayerOption, PlayerOptionType
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import MusicAssistantConfigEntry
from .const import PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX
from .entity import MusicAssistantPlayerOptionEntity
from .helpers import catch_musicassistant_error
PLAYER_OPTIONS_TRANSLATION_KEYS_NUMBER: Final[list[str]] = [
"bass",
"dialogue_level",
"dialogue_lift",
"dts_dialogue_control",
"equalizer_high",
"equalizer_low",
"equalizer_mid",
"subwoofer_volume",
"treble",
]
async def async_setup_entry(
hass: HomeAssistant,
entry: MusicAssistantConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Music Assistant Number Entities (Player Options) from Config Entry."""
mass = entry.runtime_data.mass
def add_player(player_id: str) -> None:
"""Handle add player."""
player = mass.players.get(player_id)
if player is None:
return
entities: list[MusicAssistantPlayerConfigNumber] = []
for player_option in player.options:
if (
not player_option.read_only
and player_option.type
in (
PlayerOptionType.INTEGER,
PlayerOptionType.FLOAT,
)
and not player_option.options # these we map to select
):
# the MA translation key must have the format player_options.<translation key>
# we ignore entities with unknown translation keys.
if (
player_option.translation_key is None
or not player_option.translation_key.startswith(
PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX
)
):
continue
translation_key = player_option.translation_key[
len(PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX) :
]
if translation_key not in PLAYER_OPTIONS_TRANSLATION_KEYS_NUMBER:
continue
entities.append(
MusicAssistantPlayerConfigNumber(
mass,
player_id,
player_option=player_option,
entity_description=NumberEntityDescription(
key=player_option.key,
translation_key=translation_key,
),
)
)
async_add_entities(entities)
# register callback to add players when they are discovered
entry.runtime_data.platform_handlers.setdefault(Platform.NUMBER, add_player)
class MusicAssistantPlayerConfigNumber(MusicAssistantPlayerOptionEntity, NumberEntity):
"""Representation of a Number entity to control player provider dependent settings."""
def __init__(
self,
mass: MusicAssistantClient,
player_id: str,
player_option: PlayerOption,
entity_description: NumberEntityDescription,
) -> None:
"""Initialize MusicAssistantPlayerConfigNumber."""
super().__init__(mass, player_id, player_option)
self.entity_description = entity_description
@catch_musicassistant_error
async def async_set_native_value(self, value: float) -> None:
"""Set a new value."""
_value = round(value) if self.mass_type == PlayerOptionType.INTEGER else value
await self.mass.players.set_option(
self.player_id,
self.mass_option_key,
_value,
)
def on_player_option_update(self, player_option: PlayerOption) -> None:
"""Update on player option update."""
if player_option.min_value is not None:
self._attr_native_min_value = player_option.min_value
if player_option.max_value is not None:
self._attr_native_max_value = player_option.max_value
if player_option.step is not None:
self._attr_native_step = player_option.step
self._attr_native_value = (
player_option.value
if isinstance(player_option.value, (int, float))
else None
)

View File

@@ -53,6 +53,35 @@
"favorite_now_playing": {
"name": "Favorite current song"
}
},
"number": {
"bass": {
"name": "Bass"
},
"dialogue_level": {
"name": "Dialogue level"
},
"dialogue_lift": {
"name": "Dialogue lift"
},
"dts_dialogue_control": {
"name": "DTS dialogue control"
},
"equalizer_high": {
"name": "Equalizer high"
},
"equalizer_low": {
"name": "Equalizer low"
},
"equalizer_mid": {
"name": "Equalizer mid"
},
"subwoofer_volume": {
"name": "Subwoofer volume"
},
"treble": {
"name": "Treble"
}
}
},
"issues": {

View File

@@ -26,6 +26,131 @@
"volume_level": 20,
"volume_muted": false,
"group_members": [],
"options": [
{
"key": "treble",
"name": "Treble",
"type": "integer",
"translation_key": "player_options.treble",
"translation_params": null,
"value": -6,
"read_only": false,
"min_value": -10,
"max_value": 10,
"step": 1,
"options": null
},
{
"key": "bass",
"name": "Bass",
"type": "float",
"translation_key": "player_options.bass",
"translation_params": null,
"value": -6.0,
"read_only": false,
"min_value": -10.0,
"max_value": 10.0,
"step": 1.0,
"options": null
},
{
"key": "treble_ro",
"name": "Treble RO",
"type": "integer",
"translation_key": "player_options.treble",
"translation_params": null,
"value": -6,
"read_only": true,
"min_value": null,
"max_value": null,
"step": null,
"options": null
},
{
"key": "enhancer",
"name": "Enhancer",
"type": "boolean",
"translation_key": "player_options.enhancer",
"translation_params": null,
"value": false,
"read_only": false,
"min_value": null,
"max_value": null,
"step": null,
"options": null
},
{
"key": "enhancer_ro",
"name": "Enhancer RO",
"type": "boolean",
"translation_key": "player_options.enhancer",
"translation_params": null,
"value": false,
"read_only": true,
"min_value": null,
"max_value": null,
"step": null,
"options": null
},
{
"key": "network_name",
"name": "Network Name",
"type": "string",
"translation_key": "player_options.network_name",
"translation_params": null,
"value": "receiver",
"read_only": false,
"min_value": null,
"max_value": null,
"step": null,
"options": null
},
{
"key": "network_name_ro",
"name": "Network Name RO",
"type": "string",
"translation_key": "player_options.network_name",
"translation_params": null,
"value": "receiver ro",
"read_only": true,
"min_value": null,
"max_value": null,
"step": null,
"options": null
},
{
"key": "link_audio_delay",
"name": "Link Audio Delay",
"type": "string",
"translation_key": "player_options.link_audio_delay",
"translation_params": null,
"value": "lip_sync",
"read_only": false,
"min_value": null,
"max_value": null,
"step": null,
"options": [
{
"key": "audio_sync",
"name": "audio_sync",
"type": "string",
"value": "audio_sync"
},
{
"key": "balanced",
"name": "balanced",
"type": "string",
"value": "balanced"
},
{
"key": "lip_sync",
"name": "lip_sync",
"type": "string",
"value": "lip_sync"
}
]
}
],
"active_source": "00:00:00:00:00:01",
"active_group": null,
"current_media": null,

View File

@@ -0,0 +1,119 @@
# serializer version: 1
# name: test_number_entities[number.test_player_1_bass-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'max': 10.0,
'min': -10.0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1.0,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.test_player_1_bass',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Bass',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Bass',
'platform': 'music_assistant',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'bass',
'unique_id': '00:00:00:00:00:01_bass',
'unit_of_measurement': None,
})
# ---
# name: test_number_entities[number.test_player_1_bass-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test Player 1 Bass',
'max': 10.0,
'min': -10.0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1.0,
}),
'context': <ANY>,
'entity_id': 'number.test_player_1_bass',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '-6.0',
})
# ---
# name: test_number_entities[number.test_player_1_treble-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'max': 10,
'min': -10,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.test_player_1_treble',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Treble',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Treble',
'platform': 'music_assistant',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'treble',
'unique_id': '00:00:00:00:00:01_treble',
'unit_of_measurement': None,
})
# ---
# name: test_number_entities[number.test_player_1_treble-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test Player 1 Treble',
'max': 10,
'min': -10,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'context': <ANY>,
'entity_id': 'number.test_player_1_treble',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '-6',
})
# ---

View File

@@ -0,0 +1,153 @@
"""Test Music Assistant number entities."""
from unittest.mock import MagicMock, call
from music_assistant_models.enums import EventType
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.music_assistant.const import DOMAIN
from homeassistant.components.music_assistant.number import (
PLAYER_OPTIONS_TRANSLATION_KEYS_NUMBER,
)
from homeassistant.components.number import (
ATTR_VALUE,
DOMAIN as NUMBER_DOMAIN,
SERVICE_SET_VALUE,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.translation import LOCALE_EN, async_get_translations
from .common import (
setup_integration_from_fixtures,
snapshot_music_assistant_entities,
trigger_subscription_callback,
)
async def test_number_entities(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
music_assistant_client: MagicMock,
) -> None:
"""Test number entities."""
await setup_integration_from_fixtures(hass, music_assistant_client)
snapshot_music_assistant_entities(hass, entity_registry, snapshot, Platform.NUMBER)
async def test_number_set_action(
hass: HomeAssistant,
music_assistant_client: MagicMock,
) -> None:
"""Test number set action."""
mass_player_id = "00:00:00:00:00:01"
mass_option_key = "treble"
entity_id = "number.test_player_1_treble"
option_value = 3
await setup_integration_from_fixtures(hass, music_assistant_client)
state = hass.states.get(entity_id)
assert state
# test within range
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: entity_id,
ATTR_VALUE: option_value,
},
blocking=True,
)
assert music_assistant_client.send_command.call_count == 1
assert music_assistant_client.send_command.call_args == call(
"players/cmd/set_option",
player_id=mass_player_id,
option_key=mass_option_key,
option_value=option_value,
)
# test out of range
with pytest.raises(ServiceValidationError):
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: entity_id,
ATTR_VALUE: 20,
},
blocking=True,
)
async def test_external_update(
hass: HomeAssistant,
music_assistant_client: MagicMock,
) -> None:
"""Test external value update."""
mass_player_id = "00:00:00:00:00:01"
mass_option_key = "treble"
entity_id = "number.test_player_1_treble"
await setup_integration_from_fixtures(hass, music_assistant_client)
# get current option and remove it
number_option = next(
option
for option in music_assistant_client.players._players[mass_player_id].options
if option.key == mass_option_key
)
music_assistant_client.players._players[mass_player_id].options.remove(
number_option
)
# set new value different from previous one
previous_value = number_option.value
new_value = 5
number_option.value = new_value
assert previous_value != number_option.value
music_assistant_client.players._players[mass_player_id].options.append(
number_option
)
await trigger_subscription_callback(
hass, music_assistant_client, EventType.PLAYER_OPTIONS_UPDATED, mass_player_id
)
state = hass.states.get(entity_id)
assert state
assert int(float(state.state)) == new_value
async def test_ignored(
hass: HomeAssistant,
music_assistant_client: MagicMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test that non-compatible player options are ignored."""
config_entry = await setup_integration_from_fixtures(hass, music_assistant_client)
registry_entries = er.async_entries_for_config_entry(
entity_registry, config_entry_id=config_entry.entry_id
)
# we only have two non read-only player options, bass and treble
assert sum(1 for entry in registry_entries if entry.domain == NUMBER_DOMAIN) == 2
async def test_name_translation_availability(
hass: HomeAssistant,
) -> None:
"""Verify, that the list of available translation keys is reflected in strings.json."""
# verify, that PLAYER_OPTIONS_TRANSLATION_KEYS_NUMBER matches strings.json
translations = await async_get_translations(
hass, language=LOCALE_EN, category="entity", integrations=[DOMAIN]
)
prefix = f"component.{DOMAIN}.entity.{Platform.NUMBER.value}."
for translation_key in PLAYER_OPTIONS_TRANSLATION_KEYS_NUMBER:
assert translations.get(f"{prefix}{translation_key}.name") is not None, (
f"{translation_key} is missing in strings.json for platform number"
)