Handle toggling of the 'expose_to_ha' setting in Music Assistant integration (#152779)

This commit is contained in:
Marcel van der Veldt
2025-09-23 13:22:16 +02:00
committed by GitHub
parent 86db60c442
commit 72e608918b
4 changed files with 136 additions and 21 deletions

View File

@@ -9,8 +9,10 @@ from typing import TYPE_CHECKING
from music_assistant_client import MusicAssistantClient
from music_assistant_client.exceptions import CannotConnect, InvalidServerVersion
from music_assistant_models.config_entries import PlayerConfig
from music_assistant_models.enums import EventType
from music_assistant_models.errors import ActionUnavailable, MusicAssistantError
from music_assistant_models.player import Player
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform
@@ -25,7 +27,7 @@ from homeassistant.helpers.issue_registry import (
)
from .actions import get_music_assistant_client, register_actions
from .const import DOMAIN, LOGGER
from .const import ATTR_CONF_EXPOSE_PLAYER_TO_HA, DOMAIN, LOGGER
if TYPE_CHECKING:
from music_assistant_models.event import MassEvent
@@ -59,7 +61,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
async def async_setup_entry(
async def async_setup_entry( # noqa: C901
hass: HomeAssistant, entry: MusicAssistantConfigEntry
) -> bool:
"""Set up Music Assistant from a config entry."""
@@ -126,8 +128,25 @@ async def async_setup_entry(
# initialize platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
def add_player(player: Player) -> None:
"""Handle adding Player from MA as HA device + entities."""
entry.runtime_data.discovered_players.add(player.player_id)
# run callback for each platform
for callback in entry.runtime_data.platform_handlers.values():
callback(player.player_id)
def remove_player(player_id: str) -> None:
"""Handle removing Player from MA as HA device + entities."""
if player_id in entry.runtime_data.discovered_players:
entry.runtime_data.discovered_players.remove(player_id)
dev_reg = dr.async_get(hass)
if hass_device := dev_reg.async_get_device({(DOMAIN, player_id)}):
dev_reg.async_update_device(
hass_device.id, remove_config_entry_id=entry.entry_id
)
# register listener for new players
async def handle_player_added(event: MassEvent) -> None:
def handle_player_added(event: MassEvent) -> None:
"""Handle Mass Player Added event."""
if TYPE_CHECKING:
assert event.object_id is not None
@@ -138,10 +157,7 @@ async def async_setup_entry(
assert player is not None
if not player.expose_to_ha:
return
entry.runtime_data.discovered_players.add(event.object_id)
# run callback for each platform
for callback in entry.runtime_data.platform_handlers.values():
callback(event.object_id)
add_player(player)
entry.async_on_unload(mass.subscribe(handle_player_added, EventType.PLAYER_ADDED))
@@ -149,25 +165,40 @@ async def async_setup_entry(
for player in mass.players:
if not player.expose_to_ha:
continue
entry.runtime_data.discovered_players.add(player.player_id)
for callback in entry.runtime_data.platform_handlers.values():
callback(player.player_id)
add_player(player)
# register listener for removed players
async def handle_player_removed(event: MassEvent) -> None:
def handle_player_removed(event: MassEvent) -> None:
"""Handle Mass Player Removed event."""
if event.object_id is None:
return
dev_reg = dr.async_get(hass)
if hass_device := dev_reg.async_get_device({(DOMAIN, event.object_id)}):
dev_reg.async_update_device(
hass_device.id, remove_config_entry_id=entry.entry_id
)
remove_player(event.object_id)
entry.async_on_unload(
mass.subscribe(handle_player_removed, EventType.PLAYER_REMOVED)
)
# register listener for player configs (to handle toggling of the 'expose_to_ha' setting)
def handle_player_config_updated(event: MassEvent) -> None:
"""Handle Mass Player Config Updated event."""
if event.object_id is None or not event.data:
return
player_id = event.object_id
player_config = PlayerConfig.from_dict(event.data)
expose_to_ha = player_config.get_value(ATTR_CONF_EXPOSE_PLAYER_TO_HA, True)
if not expose_to_ha and player_id in entry.runtime_data.discovered_players:
# player is no longer exposed to Home Assistant
remove_player(player_id)
elif expose_to_ha and player_id not in entry.runtime_data.discovered_players:
# player is now exposed to Home Assistant
if not (player := mass.players.get(player_id)):
return # guard
add_player(player)
entry.async_on_unload(
mass.subscribe(handle_player_config_updated, EventType.PLAYER_CONFIG_UPDATED)
)
# check if any playerconfigs have been removed while we were disconnected
all_player_configs = await mass.config.get_player_configs()
player_ids = {player.player_id for player in all_player_configs}

View File

@@ -65,5 +65,6 @@ ATTR_STREAM_TITLE = "stream_title"
ATTR_PROVIDER = "provider"
ATTR_ITEM_ID = "item_id"
ATTR_CONF_EXPOSE_PLAYER_TO_HA = "expose_player_to_ha"
LOGGER = logging.getLogger(__package__)

View File

@@ -186,15 +186,15 @@ async def trigger_subscription_callback(
):
continue
event = MassEvent(
mass_event = MassEvent(
event=event,
object_id=object_id,
data=data,
)
if inspect.iscoroutinefunction(cb_func):
await cb_func(event)
await cb_func(mass_event)
else:
cb_func(event)
cb_func(mass_event)
await hass.async_block_till_done()

View File

@@ -4,14 +4,18 @@ from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
from music_assistant_models.enums import EventType
from music_assistant_models.errors import ActionUnavailable
from homeassistant.components.music_assistant.const import DOMAIN
from homeassistant.components.music_assistant.const import (
ATTR_CONF_EXPOSE_PLAYER_TO_HA,
DOMAIN,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from .common import setup_integration_from_fixtures
from .common import setup_integration_from_fixtures, trigger_subscription_callback
from tests.typing import WebSocketGenerator
@@ -68,3 +72,82 @@ async def test_remove_config_entry_device(
response = await client.remove_device(device_entry.id, config_entry.entry_id)
assert music_assistant_client.config.remove_player_config.call_count == 0
assert response["success"] is True
async def test_player_config_expose_to_ha_toggle(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
music_assistant_client: MagicMock,
) -> None:
"""Test player exposure toggle via config update."""
await setup_integration_from_fixtures(hass, music_assistant_client)
await hass.async_block_till_done()
config_entry = hass.config_entries.async_entries(DOMAIN)[0]
# Initial state: player should be exposed (from fixture)
entity_id = "media_player.test_player_1"
player_id = "00:00:00:00:00:01"
assert hass.states.get(entity_id)
assert entity_registry.async_get(entity_id)
device_entry = device_registry.async_get_device({(DOMAIN, player_id)})
assert device_entry
assert player_id in config_entry.runtime_data.discovered_players
# Simulate player config update: expose_to_ha = False
# Trigger the subscription callback
event_data = {
"player_id": player_id,
"provider": "test",
"values": {
ATTR_CONF_EXPOSE_PLAYER_TO_HA: {
"key": ATTR_CONF_EXPOSE_PLAYER_TO_HA,
"type": "boolean",
"value": False,
"label": ATTR_CONF_EXPOSE_PLAYER_TO_HA,
"default_value": True,
}
},
}
await trigger_subscription_callback(
hass,
music_assistant_client,
EventType.PLAYER_CONFIG_UPDATED,
player_id,
event_data,
)
# Verify player was removed from HA
assert player_id not in config_entry.runtime_data.discovered_players
assert not hass.states.get(entity_id)
assert not entity_registry.async_get(entity_id)
device_entry = device_registry.async_get_device({(DOMAIN, player_id)})
assert not device_entry
# Now test re-adding the player: expose_to_ha = True
await trigger_subscription_callback(
hass,
music_assistant_client,
EventType.PLAYER_CONFIG_UPDATED,
player_id,
{
"player_id": player_id,
"provider": "test",
"values": {
ATTR_CONF_EXPOSE_PLAYER_TO_HA: {
"key": ATTR_CONF_EXPOSE_PLAYER_TO_HA,
"type": "boolean",
"value": True,
"label": ATTR_CONF_EXPOSE_PLAYER_TO_HA,
"default_value": True,
}
},
},
)
# Verify player was re-added to HA
assert player_id in config_entry.runtime_data.discovered_players
assert hass.states.get(entity_id)
assert entity_registry.async_get(entity_id)
device_entry = device_registry.async_get_device({(DOMAIN, player_id)})
assert device_entry