diff --git a/homeassistant/components/mpd/__init__.py b/homeassistant/components/mpd/__init__.py index bf917ff19aa..01ea159cf02 100644 --- a/homeassistant/components/mpd/__init__.py +++ b/homeassistant/components/mpd/__init__.py @@ -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) diff --git a/homeassistant/components/mpd/config_flow.py b/homeassistant/components/mpd/config_flow.py new file mode 100644 index 00000000000..619fb8936e2 --- /dev/null +++ b/homeassistant/components/mpd/config_flow.py @@ -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), + }, + ) diff --git a/homeassistant/components/mpd/const.py b/homeassistant/components/mpd/const.py new file mode 100644 index 00000000000..0aed3bb8106 --- /dev/null +++ b/homeassistant/components/mpd/const.py @@ -0,0 +1,7 @@ +"""Constants for the MPD integration.""" + +import logging + +DOMAIN = "mpd" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py index 7f69b7bf914..f0df2cdbbe2 100644 --- a/homeassistant/components/mpd/media_player.py +++ b/homeassistant/components/mpd/media_player.py @@ -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() diff --git a/homeassistant/components/mpd/strings.json b/homeassistant/components/mpd/strings.json new file mode 100644 index 00000000000..fc922ab128a --- /dev/null +++ b/homeassistant/components/mpd/strings.json @@ -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." + } + } +} diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a7db391fee0..2a04171e636 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/mpd/__init__.py b/tests/components/mpd/__init__.py new file mode 100644 index 00000000000..f5ad1301c14 --- /dev/null +++ b/tests/components/mpd/__init__.py @@ -0,0 +1 @@ +"""Tests for the Music Player Daemon integration.""" diff --git a/tests/components/mpd/conftest.py b/tests/components/mpd/conftest.py new file mode 100644 index 00000000000..818f085decc --- /dev/null +++ b/tests/components/mpd/conftest.py @@ -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 diff --git a/tests/components/mpd/test_config_flow.py b/tests/components/mpd/test_config_flow.py new file mode 100644 index 00000000000..d17bef60446 --- /dev/null +++ b/tests/components/mpd/test_config_flow.py @@ -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"