forked from home-assistant/core
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add musicassistant integration (home-assistant#128919)
Co-authored-by: Marcel van der Veldt <[email protected]>
- Loading branch information
1 parent
2303521
commit 568bdef
Showing
19 changed files
with
1,319 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,164 @@ | ||
"""Music Assistant (music-assistant.io) integration.""" | ||
|
||
from __future__ import annotations | ||
|
||
import asyncio | ||
from dataclasses import dataclass | ||
from typing import TYPE_CHECKING | ||
|
||
from music_assistant_client import MusicAssistantClient | ||
from music_assistant_client.exceptions import CannotConnect, InvalidServerVersion | ||
from music_assistant_models.enums import EventType | ||
from music_assistant_models.errors import MusicAssistantError | ||
|
||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState | ||
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform | ||
from homeassistant.core import Event, HomeAssistant | ||
from homeassistant.exceptions import ConfigEntryNotReady | ||
from homeassistant.helpers import device_registry as dr | ||
from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||
from homeassistant.helpers.issue_registry import ( | ||
IssueSeverity, | ||
async_create_issue, | ||
async_delete_issue, | ||
) | ||
|
||
from .const import DOMAIN, LOGGER | ||
|
||
if TYPE_CHECKING: | ||
from music_assistant_models.event import MassEvent | ||
|
||
type MusicAssistantConfigEntry = ConfigEntry[MusicAssistantEntryData] | ||
|
||
PLATFORMS = [Platform.MEDIA_PLAYER] | ||
|
||
CONNECT_TIMEOUT = 10 | ||
LISTEN_READY_TIMEOUT = 30 | ||
|
||
|
||
@dataclass | ||
class MusicAssistantEntryData: | ||
"""Hold Mass data for the config entry.""" | ||
|
||
mass: MusicAssistantClient | ||
listen_task: asyncio.Task | ||
|
||
|
||
async def async_setup_entry( | ||
hass: HomeAssistant, entry: MusicAssistantConfigEntry | ||
) -> bool: | ||
"""Set up from a config entry.""" | ||
http_session = async_get_clientsession(hass, verify_ssl=False) | ||
mass_url = entry.data[CONF_URL] | ||
mass = MusicAssistantClient(mass_url, http_session) | ||
|
||
try: | ||
async with asyncio.timeout(CONNECT_TIMEOUT): | ||
await mass.connect() | ||
except (TimeoutError, CannotConnect) as err: | ||
raise ConfigEntryNotReady( | ||
f"Failed to connect to music assistant server {mass_url}" | ||
) from err | ||
except InvalidServerVersion as err: | ||
async_create_issue( | ||
hass, | ||
DOMAIN, | ||
"invalid_server_version", | ||
is_fixable=False, | ||
severity=IssueSeverity.ERROR, | ||
translation_key="invalid_server_version", | ||
) | ||
raise ConfigEntryNotReady(f"Invalid server version: {err}") from err | ||
except MusicAssistantError as err: | ||
LOGGER.exception("Failed to connect to music assistant server", exc_info=err) | ||
raise ConfigEntryNotReady( | ||
f"Unknown error connecting to the Music Assistant server {mass_url}" | ||
) from err | ||
|
||
async_delete_issue(hass, DOMAIN, "invalid_server_version") | ||
|
||
async def on_hass_stop(event: Event) -> None: | ||
"""Handle incoming stop event from Home Assistant.""" | ||
await mass.disconnect() | ||
|
||
entry.async_on_unload( | ||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) | ||
) | ||
|
||
# launch the music assistant client listen task in the background | ||
# use the init_ready event to wait until initialization is done | ||
init_ready = asyncio.Event() | ||
listen_task = asyncio.create_task(_client_listen(hass, entry, mass, init_ready)) | ||
|
||
try: | ||
async with asyncio.timeout(LISTEN_READY_TIMEOUT): | ||
await init_ready.wait() | ||
except TimeoutError as err: | ||
listen_task.cancel() | ||
raise ConfigEntryNotReady("Music Assistant client not ready") from err | ||
|
||
entry.runtime_data = MusicAssistantEntryData(mass, listen_task) | ||
|
||
# If the listen task is already failed, we need to raise ConfigEntryNotReady | ||
if listen_task.done() and (listen_error := listen_task.exception()) is not None: | ||
await hass.config_entries.async_unload_platforms(entry, PLATFORMS) | ||
try: | ||
await mass.disconnect() | ||
finally: | ||
raise ConfigEntryNotReady(listen_error) from listen_error | ||
|
||
# initialize platforms | ||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) | ||
|
||
# register listener for removed players | ||
async 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 | ||
) | ||
|
||
entry.async_on_unload( | ||
mass.subscribe(handle_player_removed, EventType.PLAYER_REMOVED) | ||
) | ||
|
||
return True | ||
|
||
|
||
async def _client_listen( | ||
hass: HomeAssistant, | ||
entry: ConfigEntry, | ||
mass: MusicAssistantClient, | ||
init_ready: asyncio.Event, | ||
) -> None: | ||
"""Listen with the client.""" | ||
try: | ||
await mass.start_listening(init_ready) | ||
except MusicAssistantError as err: | ||
if entry.state != ConfigEntryState.LOADED: | ||
raise | ||
LOGGER.error("Failed to listen: %s", err) | ||
except Exception as err: # pylint: disable=broad-except | ||
# We need to guard against unknown exceptions to not crash this task. | ||
if entry.state != ConfigEntryState.LOADED: | ||
raise | ||
LOGGER.exception("Unexpected exception: %s", err) | ||
|
||
if not hass.is_stopping: | ||
LOGGER.debug("Disconnected from server. Reloading integration") | ||
hass.async_create_task(hass.config_entries.async_reload(entry.entry_id)) | ||
|
||
|
||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
"""Unload a config entry.""" | ||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) | ||
|
||
if unload_ok: | ||
mass_entry_data: MusicAssistantEntryData = entry.runtime_data | ||
mass_entry_data.listen_task.cancel() | ||
await mass_entry_data.mass.disconnect() | ||
|
||
return unload_ok |
137 changes: 137 additions & 0 deletions
137
homeassistant/components/music_assistant/config_flow.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,137 @@ | ||
"""Config flow for MusicAssistant integration.""" | ||
|
||
from __future__ import annotations | ||
|
||
from typing import TYPE_CHECKING, Any | ||
|
||
from music_assistant_client import MusicAssistantClient | ||
from music_assistant_client.exceptions import ( | ||
CannotConnect, | ||
InvalidServerVersion, | ||
MusicAssistantClientException, | ||
) | ||
from music_assistant_models.api import ServerInfoMessage | ||
import voluptuous as vol | ||
|
||
from homeassistant.components import zeroconf | ||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult | ||
from homeassistant.const import CONF_URL | ||
from homeassistant.core import HomeAssistant | ||
from homeassistant.helpers import aiohttp_client | ||
|
||
from .const import DOMAIN, LOGGER | ||
|
||
DEFAULT_URL = "http://mass.local:8095" | ||
DEFAULT_TITLE = "Music Assistant" | ||
|
||
|
||
def get_manual_schema(user_input: dict[str, Any]) -> vol.Schema: | ||
"""Return a schema for the manual step.""" | ||
default_url = user_input.get(CONF_URL, DEFAULT_URL) | ||
return vol.Schema( | ||
{ | ||
vol.Required(CONF_URL, default=default_url): str, | ||
} | ||
) | ||
|
||
|
||
async def get_server_info(hass: HomeAssistant, url: str) -> ServerInfoMessage: | ||
"""Validate the user input allows us to connect.""" | ||
async with MusicAssistantClient( | ||
url, aiohttp_client.async_get_clientsession(hass) | ||
) as client: | ||
if TYPE_CHECKING: | ||
assert client.server_info is not None | ||
return client.server_info | ||
|
||
|
||
class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN): | ||
"""Handle a config flow for MusicAssistant.""" | ||
|
||
VERSION = 1 | ||
|
||
def __init__(self) -> None: | ||
"""Set up flow instance.""" | ||
self.server_info: ServerInfoMessage | None = None | ||
|
||
async def async_step_user( | ||
self, user_input: dict[str, Any] | None = None | ||
) -> ConfigFlowResult: | ||
"""Handle a manual configuration.""" | ||
errors: dict[str, str] = {} | ||
if user_input is not None: | ||
try: | ||
self.server_info = await get_server_info( | ||
self.hass, user_input[CONF_URL] | ||
) | ||
await self.async_set_unique_id( | ||
self.server_info.server_id, raise_on_progress=False | ||
) | ||
self._abort_if_unique_id_configured( | ||
updates={CONF_URL: self.server_info.base_url}, | ||
reload_on_update=True, | ||
) | ||
except CannotConnect: | ||
errors["base"] = "cannot_connect" | ||
except InvalidServerVersion: | ||
errors["base"] = "invalid_server_version" | ||
except MusicAssistantClientException: | ||
LOGGER.exception("Unexpected exception") | ||
errors["base"] = "unknown" | ||
else: | ||
return self.async_create_entry( | ||
title=DEFAULT_TITLE, | ||
data={ | ||
CONF_URL: self.server_info.base_url, | ||
}, | ||
) | ||
|
||
return self.async_show_form( | ||
step_id="user", data_schema=get_manual_schema(user_input), errors=errors | ||
) | ||
|
||
return self.async_show_form(step_id="user", data_schema=get_manual_schema({})) | ||
|
||
async def async_step_zeroconf( | ||
self, discovery_info: zeroconf.ZeroconfServiceInfo | ||
) -> ConfigFlowResult: | ||
"""Handle a discovered Mass server. | ||
This flow is triggered by the Zeroconf component. It will check if the | ||
host is already configured and delegate to the import step if not. | ||
""" | ||
# abort if discovery info is not what we expect | ||
if "server_id" not in discovery_info.properties: | ||
return self.async_abort(reason="missing_server_id") | ||
# abort if we already have exactly this server_id | ||
# reload the integration if the host got updated | ||
self.server_info = ServerInfoMessage.from_dict(discovery_info.properties) | ||
await self.async_set_unique_id(self.server_info.server_id) | ||
self._abort_if_unique_id_configured( | ||
updates={CONF_URL: self.server_info.base_url}, | ||
reload_on_update=True, | ||
) | ||
try: | ||
await get_server_info(self.hass, self.server_info.base_url) | ||
except CannotConnect: | ||
return self.async_abort(reason="cannot_connect") | ||
return await self.async_step_discovery_confirm() | ||
|
||
async def async_step_discovery_confirm( | ||
self, user_input: dict[str, Any] | None = None | ||
) -> ConfigFlowResult: | ||
"""Handle user-confirmation of discovered server.""" | ||
if TYPE_CHECKING: | ||
assert self.server_info is not None | ||
if user_input is not None: | ||
return self.async_create_entry( | ||
title=DEFAULT_TITLE, | ||
data={ | ||
CONF_URL: self.server_info.base_url, | ||
}, | ||
) | ||
self._set_confirm_only() | ||
return self.async_show_form( | ||
step_id="discovery_confirm", | ||
description_placeholders={"url": self.server_info.base_url}, | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
"""Constants for Music Assistant Component.""" | ||
|
||
import logging | ||
|
||
DOMAIN = "music_assistant" | ||
DOMAIN_EVENT = f"{DOMAIN}_event" | ||
|
||
DEFAULT_NAME = "Music Assistant" | ||
|
||
ATTR_IS_GROUP = "is_group" | ||
ATTR_GROUP_MEMBERS = "group_members" | ||
ATTR_GROUP_PARENTS = "group_parents" | ||
|
||
ATTR_MASS_PLAYER_TYPE = "mass_player_type" | ||
ATTR_ACTIVE_QUEUE = "active_queue" | ||
ATTR_STREAM_TITLE = "stream_title" | ||
|
||
LOGGER = logging.getLogger(__package__) |
Oops, something went wrong.