Add config flow to MPD (#117907)

This commit is contained in:
Joost Lekkerkerker 2024-06-09 16:01:19 +02:00 committed by GitHub
parent c9911e4dd4
commit b26f613d06
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 469 additions and 16 deletions

View File

@ -1 +1,22 @@
"""The mpd component."""
"""The Music Player Daemon integration."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Music Player Daemon from a config entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -0,0 +1,101 @@
"""Music Player Daemon config flow."""
from asyncio import timeout
from contextlib import suppress
from socket import gaierror
from typing import Any
import mpd
from mpd.asyncio import MPDClient
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT
from .const import DOMAIN, LOGGER
SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Optional(CONF_PASSWORD): str,
vol.Optional(CONF_PORT, default=6600): int,
}
)
class MPDConfigFlow(ConfigFlow, domain=DOMAIN):
"""Music Player Daemon config flow."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initiated by the user."""
errors = {}
if user_input:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
client = MPDClient()
client.timeout = 30
client.idletimeout = 10
try:
async with timeout(35):
await client.connect(user_input[CONF_HOST], user_input[CONF_PORT])
if CONF_PASSWORD in user_input:
await client.password(user_input[CONF_PASSWORD])
with suppress(mpd.ConnectionError):
client.disconnect()
except (
TimeoutError,
gaierror,
mpd.ConnectionError,
OSError,
):
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unknown exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title="Music Player Daemon",
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=SCHEMA,
errors=errors,
)
async def async_step_import(
self, import_config: dict[str, Any]
) -> ConfigFlowResult:
"""Attempt to import the existing configuration."""
self._async_abort_entries_match({CONF_HOST: import_config[CONF_HOST]})
client = MPDClient()
client.timeout = 30
client.idletimeout = 10
try:
async with timeout(35):
await client.connect(import_config[CONF_HOST], import_config[CONF_PORT])
if CONF_PASSWORD in import_config:
await client.password(import_config[CONF_PASSWORD])
with suppress(mpd.ConnectionError):
client.disconnect()
except (
TimeoutError,
gaierror,
mpd.ConnectionError,
OSError,
):
return self.async_abort(reason="cannot_connect")
except Exception: # noqa: BLE001
LOGGER.exception("Unknown exception")
return self.async_abort(reason="unknown")
return self.async_create_entry(
title=import_config.get(CONF_NAME, "Music Player Daemon"),
data={
CONF_HOST: import_config[CONF_HOST],
CONF_PORT: import_config[CONF_PORT],
CONF_PASSWORD: import_config.get(CONF_PASSWORD),
},
)

View File

@ -0,0 +1,7 @@
"""Constants for the MPD integration."""
import logging
DOMAIN = "mpd"
LOGGER = logging.getLogger(__package__)

View File

@ -26,15 +26,18 @@ from homeassistant.components.media_player import (
RepeatMode,
async_process_play_media_url,
)
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
import homeassistant.util.dt as dt_util
_LOGGER = logging.getLogger(__name__)
from .const import DOMAIN, LOGGER
DEFAULT_NAME = "MPD"
DEFAULT_PORT = 6600
@ -74,13 +77,63 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the MPD platform."""
host = config.get(CONF_HOST)
port = config.get(CONF_PORT)
name = config.get(CONF_NAME)
password = config.get(CONF_PASSWORD)
entity = MpdDevice(host, port, password, name)
async_add_entities([entity], True)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=config,
)
if (
result["type"] is FlowResultType.CREATE_ENTRY
or result["reason"] == "single_instance_allowed"
):
async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2024.12.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Music Player Daemon",
},
)
return
async_create_issue(
hass,
DOMAIN,
f"deprecated_yaml_import_issue_{result['reason']}",
breaks_in_ha_version="2024.12.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key=f"deprecated_yaml_import_issue_{result['reason']}",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Music Player Daemon",
},
)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up media player from config_entry."""
async_add_entities(
[
MpdDevice(
entry.data[CONF_HOST],
entry.data[CONF_PORT],
entry.data.get(CONF_PASSWORD),
entry.title,
)
],
True,
)
class MpdDevice(MediaPlayerEntity):
@ -148,7 +201,7 @@ class MpdDevice(MediaPlayerEntity):
log_level = logging.DEBUG
if self._is_available is not False:
log_level = logging.WARNING
_LOGGER.log(
LOGGER.log(
log_level, "Error connecting to '%s': %s", self.server, error
)
self._is_available = False
@ -181,7 +234,7 @@ class MpdDevice(MediaPlayerEntity):
await self._update_playlists()
except (mpd.ConnectionError, ValueError) as error:
_LOGGER.debug("Error updating status: %s", error)
LOGGER.debug("Error updating status: %s", error)
@property
def available(self) -> bool:
@ -340,7 +393,7 @@ class MpdDevice(MediaPlayerEntity):
response = await self._client.readpicture(file)
except mpd.CommandError as error:
if error.errno is not mpd.FailureResponseCode.NO_EXIST:
_LOGGER.warning(
LOGGER.warning(
"Retrieving artwork through `readpicture` command failed: %s",
error,
)
@ -352,7 +405,7 @@ class MpdDevice(MediaPlayerEntity):
response = await self._client.albumart(file)
except mpd.CommandError as error:
if error.errno is not mpd.FailureResponseCode.NO_EXIST:
_LOGGER.warning(
LOGGER.warning(
"Retrieving artwork through `albumart` command failed: %s",
error,
)
@ -412,7 +465,7 @@ class MpdDevice(MediaPlayerEntity):
self._playlists.append(playlist_data["playlist"])
except mpd.CommandError as error:
self._playlists = None
_LOGGER.warning("Playlists could not be updated: %s:", error)
LOGGER.warning("Playlists could not be updated: %s:", error)
async def async_set_volume_level(self, volume: float) -> None:
"""Set volume of media player."""
@ -489,12 +542,12 @@ class MpdDevice(MediaPlayerEntity):
media_id = async_process_play_media_url(self.hass, play_item.url)
if media_type == MediaType.PLAYLIST:
_LOGGER.debug("Playing playlist: %s", media_id)
LOGGER.debug("Playing playlist: %s", media_id)
if media_id in self._playlists:
self._currentplaylist = media_id
else:
self._currentplaylist = None
_LOGGER.warning("Unknown playlist name %s", media_id)
LOGGER.warning("Unknown playlist name %s", media_id)
await self._client.clear()
await self._client.load(media_id)
await self._client.play()

View File

@ -0,0 +1,33 @@
{
"config": {
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "The hostname or IP address of your Music Player Daemon instance."
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"issues": {
"deprecated_yaml_import_issue_cannot_connect": {
"title": "The {integration_title} YAML configuration import cannot connect to daemon",
"description": "Configuring {integration_title} using YAML is being removed but there was a connection error importing your YAML configuration.\n\nPlease make sure {integration_title} is turned on, and restart Home Assistant to try importing again. Otherwise, please remove the YAML from your configuration and add the integration manually."
},
"deprecated_yaml_import_issue_unknown": {
"title": "The {integration_title} YAML configuration could not be imported",
"description": "Configuring {integration_title} using YAML is being removed but there was an unknown error importing your YAML configuration.\n\nPlease make sure {integration_title} is turned on, and restart Home Assistant to try importing again. Otherwise, please remove the YAML from your configuration and add the integration manually."
}
}
}

View File

@ -1780,6 +1780,9 @@ python-matter-server==6.1.0
# homeassistant.components.xiaomi_miio
python-miio==0.5.12
# homeassistant.components.mpd
python-mpd2==3.1.1
# homeassistant.components.mystrom
python-mystrom==2.2.0

View File

@ -0,0 +1 @@
"""Tests for the Music Player Daemon integration."""

View File

@ -0,0 +1,43 @@
"""Fixtures for Music Player Daemon integration tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.mpd.const import DOMAIN
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
from tests.common import MockConfigEntry
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
title="Music Player Daemon",
domain=DOMAIN,
data={CONF_HOST: "192.168.0.1", CONF_PORT: 6600, CONF_PASSWORD: "test123"},
)
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
"""Mock setting up a config entry."""
with patch(
"homeassistant.components.mpd.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_mpd_client() -> Generator[AsyncMock, None, None]:
"""Return a mock for Music Player Daemon client."""
with patch(
"homeassistant.components.mpd.config_flow.MPDClient",
autospec=True,
) as mpd_client:
client = mpd_client.return_value
client.password = AsyncMock()
yield client

View File

@ -0,0 +1,191 @@
"""Tests for the Music Player Daemon config flow."""
from socket import gaierror
from unittest.mock import AsyncMock
import mpd
import pytest
from homeassistant.components.mpd.const import DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
async def test_full_flow(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_mpd_client: AsyncMock,
) -> None:
"""Test the happy flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert not result["errors"]
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "192.168.0.1", CONF_PORT: 6600, CONF_PASSWORD: "test123"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Music Player Daemon"
assert result["data"] == {
CONF_HOST: "192.168.0.1",
CONF_PORT: 6600,
CONF_PASSWORD: "test123",
}
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("exception", "error"),
[
(TimeoutError, "cannot_connect"),
(gaierror, "cannot_connect"),
(mpd.ConnectionError, "cannot_connect"),
(OSError, "cannot_connect"),
(Exception, "unknown"),
],
)
async def test_errors(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_mpd_client: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test we handle errors correctly."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
mock_mpd_client.password.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "192.168.0.1", CONF_PORT: 6600, CONF_PASSWORD: "test123"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
mock_mpd_client.password.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "192.168.0.1", CONF_PORT: 6600, CONF_PASSWORD: "test123"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_existing_entry(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test we abort if an entry already exists."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert not result["errors"]
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "192.168.0.1", CONF_PORT: 6600, CONF_PASSWORD: "test123"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_full_import_flow(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_mpd_client: AsyncMock,
) -> None:
"""Test the happy flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={
CONF_HOST: "192.168.0.1",
CONF_PORT: 6600,
CONF_PASSWORD: "test123",
CONF_NAME: "My PC",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "My PC"
assert result["data"] == {
CONF_HOST: "192.168.0.1",
CONF_PORT: 6600,
CONF_PASSWORD: "test123",
}
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("exception", "error"),
[
(TimeoutError, "cannot_connect"),
(gaierror, "cannot_connect"),
(mpd.ConnectionError, "cannot_connect"),
(OSError, "cannot_connect"),
(Exception, "unknown"),
],
)
async def test_import_errors(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_mpd_client: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test we handle errors correctly."""
mock_mpd_client.password.side_effect = exception
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={
CONF_HOST: "192.168.0.1",
CONF_PORT: 6600,
CONF_PASSWORD: "test123",
CONF_NAME: "My PC",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == error
async def test_existing_entry_import(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test we abort if an entry already exists."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={
CONF_HOST: "192.168.0.1",
CONF_PORT: 6600,
CONF_PASSWORD: "test123",
CONF_NAME: "My PC",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"