Skip to content

Commit

Permalink
Add musicassistant integration (home-assistant#128919)
Browse files Browse the repository at this point in the history
Co-authored-by: Marcel van der Veldt <[email protected]>
  • Loading branch information
jozefKruszynski and marcelveldt authored Oct 30, 2024
1 parent 2303521 commit 568bdef
Show file tree
Hide file tree
Showing 19 changed files with 1,319 additions and 0 deletions.
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ homeassistant.components.moon.*
homeassistant.components.mopeka.*
homeassistant.components.motionmount.*
homeassistant.components.mqtt.*
homeassistant.components.music_assistant.*
homeassistant.components.my.*
homeassistant.components.mysensors.*
homeassistant.components.myuplink.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -954,6 +954,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/msteams/ @peroyvind
/homeassistant/components/mullvad/ @meichthys
/tests/components/mullvad/ @meichthys
/homeassistant/components/music_assistant/ @music-assistant
/tests/components/music_assistant/ @music-assistant
/homeassistant/components/mutesync/ @currentoor
/tests/components/mutesync/ @currentoor
/homeassistant/components/my/ @home-assistant/core
Expand Down
164 changes: 164 additions & 0 deletions homeassistant/components/music_assistant/__init__.py
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 homeassistant/components/music_assistant/config_flow.py
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},
)
18 changes: 18 additions & 0 deletions homeassistant/components/music_assistant/const.py
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__)
Loading

0 comments on commit 568bdef

Please sign in to comment.