From 72e608918bfc52ea4c003e82d05cb8e18aefcd7c Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 23 Sep 2025 13:22:16 +0200 Subject: [PATCH] Handle toggling of the 'expose_to_ha' setting in Music Assistant integration (#152779) --- .../components/music_assistant/__init__.py | 63 ++++++++++---- .../components/music_assistant/const.py | 1 + tests/components/music_assistant/common.py | 6 +- tests/components/music_assistant/test_init.py | 87 ++++++++++++++++++- 4 files changed, 136 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index 32024c5ad13..993d1023996 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -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} diff --git a/homeassistant/components/music_assistant/const.py b/homeassistant/components/music_assistant/const.py index 8c1701b4afd..d1a97382193 100644 --- a/homeassistant/components/music_assistant/const.py +++ b/homeassistant/components/music_assistant/const.py @@ -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__) diff --git a/tests/components/music_assistant/common.py b/tests/components/music_assistant/common.py index 072b1ece1a1..620a85ed893 100644 --- a/tests/components/music_assistant/common.py +++ b/tests/components/music_assistant/common.py @@ -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() diff --git a/tests/components/music_assistant/test_init.py b/tests/components/music_assistant/test_init.py index 4cfefb50bd2..e088fd202bc 100644 --- a/tests/components/music_assistant/test_init.py +++ b/tests/components/music_assistant/test_init.py @@ -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