Skip to content

Commit

Permalink
Add Home Assistant player provider (#1077)
Browse files Browse the repository at this point in the history
  • Loading branch information
marcelveldt authored Feb 13, 2024
1 parent e8105ee commit 6077af3
Show file tree
Hide file tree
Showing 10 changed files with 708 additions and 10 deletions.
5 changes: 5 additions & 0 deletions music_assistant/common/helpers/datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,8 @@ def from_utc_timestamp(timestamp: float) -> datetime.datetime:
def iso_from_utc_timestamp(timestamp: float) -> str:
"""Return ISO 8601 datetime string from UTC timestamp."""
return from_utc_timestamp(timestamp).isoformat()


def from_iso_string(iso_datetime: str) -> datetime.datetime:
"""Return datetime from ISO datetime string."""
return datetime.datetime.fromisoformat(iso_datetime)
14 changes: 5 additions & 9 deletions music_assistant/server/controllers/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,7 @@
from aiofiles.os import wrap
from cryptography.fernet import Fernet, InvalidToken

from music_assistant.common.helpers.json import (
JSON_DECODE_EXCEPTIONS,
json_dumps,
json_loads,
)
from music_assistant.common.helpers.json import JSON_DECODE_EXCEPTIONS, json_dumps, json_loads
from music_assistant.common.models import config_entries
from music_assistant.common.models.config_entries import (
DEFAULT_CORE_CONFIG_ENTRIES,
Expand All @@ -30,10 +26,7 @@
ProviderConfig,
)
from music_assistant.common.models.enums import EventType, PlayerState, ProviderType
from music_assistant.common.models.errors import (
InvalidDataError,
PlayerUnavailableError,
)
from music_assistant.common.models.errors import InvalidDataError, PlayerUnavailableError
from music_assistant.constants import (
CONF_CORE,
CONF_PLAYERS,
Expand Down Expand Up @@ -752,6 +745,9 @@ async def _add_provider_config(
else:
msg = f"Unknown provider domain: {provider_domain}"
raise KeyError(msg)
if prov.depends_on and not self.mass.get_provider(prov.depends_on):
msg = f"Provider {manifest.name} depends on {prov.depends_on}"
raise ValueError(msg)
# create new provider config with given values
existing = {
x.instance_id for x in await self.get_provider_configs(provider_domain=provider_domain)
Expand Down
6 changes: 5 additions & 1 deletion music_assistant/server/models/player_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,4 +242,8 @@ def on_child_power(self, player_id: str, child_player_id: str, new_power: bool)
def players(self) -> list[Player]:
"""Return all players belonging to this provider."""
# pylint: disable=no-member
return [player for player in self.mass.players if player.provider == self.domain]
return [
player
for player in self.mass.players
if player.provider in (self.instance_id, self.domain)
]
138 changes: 138 additions & 0 deletions music_assistant/server/providers/hass/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""
Home Assistant Plugin for Music Assistant.
The plugin is the core of all communication to/from Home Assistant and
responsible for maintaining the WebSocket API connection to HA.
Also, the Music Assistant integration within HA will relay its own api
communication over the HA api for more flexibility as well as security.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import shortuuid
from hass_client import HomeAssistantClient
from hass_client.utils import (
async_is_supervisor,
base_url,
get_auth_url,
get_long_lived_token,
get_token,
get_websocket_url,
)

from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType
from music_assistant.common.models.enums import ConfigEntryType
from music_assistant.common.models.errors import LoginFailed
from music_assistant.constants import MASS_LOGO_ONLINE
from music_assistant.server.helpers.auth import AuthenticationHelper
from music_assistant.server.models.plugin import PluginProvider

if TYPE_CHECKING:
from music_assistant.common.models.config_entries import ProviderConfig
from music_assistant.common.models.provider import ProviderManifest
from music_assistant.server import MusicAssistant
from music_assistant.server.models import ProviderInstanceType

DOMAIN = "hass"
CONF_URL = "url"
CONF_AUTH_TOKEN = "token"
CONF_ACTION_AUTH = "auth"


async def setup(
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
) -> ProviderInstanceType:
"""Initialize provider(instance) with given configuration."""
prov = HomeAssistant(mass, manifest, config)
await prov.handle_setup()
return prov


async def get_config_entries(
mass: MusicAssistant,
instance_id: str | None = None, # noqa: ARG001
action: str | None = None,
values: dict[str, ConfigValueType] | None = None,
) -> tuple[ConfigEntry, ...]:
"""
Return Config entries to setup this provider.
instance_id: id of an existing provider instance (None if new instance setup).
action: [optional] action key called from config entries UI.
values: the (intermediate) raw values for config entries sent with the action.
"""
# config flow auth action/step (authenticate button clicked)
if action == CONF_ACTION_AUTH:
hass_url = values[CONF_URL]
async with AuthenticationHelper(mass, values["session_id"]) as auth_helper:
client_id = base_url(auth_helper.callback_url)
auth_url = get_auth_url(
hass_url,
auth_helper.callback_url,
client_id=client_id,
state=values["session_id"],
)
result = await auth_helper.authenticate(auth_url)
if result["state"] != values["session_id"]:
msg = "session id mismatch"
raise LoginFailed(msg)
# get access token after auth was a success
token_details = await get_token(hass_url, result["code"], client_id=client_id)
# register for a long lived token
long_lived_token = await get_long_lived_token(
hass_url,
token_details["access_token"],
client_name=f"Music Assistant {shortuuid.random(6)}",
client_icon=MASS_LOGO_ONLINE,
lifespan=365 * 2,
)
# set the retrieved token on the values object to pass along
values[CONF_AUTH_TOKEN] = long_lived_token

entries = ()
if not await async_is_supervisor():
entries = (
ConfigEntry(
key=CONF_URL,
type=ConfigEntryType.STRING,
label="URL",
required=True,
description="URL to your Home Assistant instance (e.g. http://192.168.1.1:8123)",
value=values.get(CONF_URL) if values else None,
),
ConfigEntry(
key=CONF_AUTH_TOKEN,
type=ConfigEntryType.SECURE_STRING,
label="Authentication token for HomeAssistant",
description="You need to link Music Assistant to your Home Assistant instance.",
action=CONF_ACTION_AUTH,
action_label="Authenticate Home Assistant",
depends_on=CONF_URL,
value=values.get(CONF_AUTH_TOKEN) if values else None,
),
)

return entries


class HomeAssistant(PluginProvider):
"""Home Assistant Plugin for Music Assistant."""

hass: HomeAssistantClient

async def handle_setup(self) -> None:
"""Handle async initialization of the plugin."""
url = get_websocket_url(self.config.get_value(CONF_URL))
token = self.config.get_value(CONF_AUTH_TOKEN)
self.hass = HomeAssistantClient(url, token, self.mass.http_session)
await self.hass.connect()

async def unload(self) -> None:
"""
Handle unload/close of the provider.
Called when provider is deregistered (e.g. MA exiting or config reloading).
"""
await self.hass.disconnect()
5 changes: 5 additions & 0 deletions music_assistant/server/providers/hass/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions music_assistant/server/providers/hass/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"type": "plugin",
"domain": "hass",
"name": "Home Assistant",
"description": "Connect Music Assistant to Home Assistant.",
"codeowners": [
"@music-assistant"
],
"documentation": "",
"multi_instance": false,
"builtin": false,
"load_by_default": false,
"icon": "md:webhook",
"requirements": [
"hass-client==1.0.0"
]
}
Loading

0 comments on commit 6077af3

Please sign in to comment.