diff --git a/.coveragerc b/.coveragerc index 1f73828daa4..3daac135adb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -667,6 +667,9 @@ omit = homeassistant/components/led_ble/util.py homeassistant/components/lg_netcast/media_player.py homeassistant/components/lg_soundbar/media_player.py + homeassistant/components/lidarr/__init__.py + homeassistant/components/lidarr/coordinator.py + homeassistant/components/lidarr/sensor.py homeassistant/components/life360/__init__.py homeassistant/components/life360/const.py homeassistant/components/life360/coordinator.py diff --git a/CODEOWNERS b/CODEOWNERS index c087599baa4..8024c1dad17 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -609,6 +609,8 @@ build.json @home-assistant/supervisor /homeassistant/components/led_ble/ @bdraco /tests/components/led_ble/ @bdraco /homeassistant/components/lg_netcast/ @Drafteed +/homeassistant/components/lidarr/ @tkdrob +/tests/components/lidarr/ @tkdrob /homeassistant/components/life360/ @pnbruckner /tests/components/life360/ @pnbruckner /homeassistant/components/lifx/ @bdraco @Djelibeybi diff --git a/homeassistant/components/lidarr/__init__.py b/homeassistant/components/lidarr/__init__.py new file mode 100644 index 00000000000..6410e520b42 --- /dev/null +++ b/homeassistant/components/lidarr/__init__.py @@ -0,0 +1,85 @@ +"""The Lidarr component.""" +from __future__ import annotations + +from aiopyarr.lidarr_client import LidarrClient +from aiopyarr.models.host_configuration import PyArrHostConfiguration + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DEFAULT_NAME, DOMAIN +from .coordinator import ( + DiskSpaceDataUpdateCoordinator, + LidarrDataUpdateCoordinator, + QueueDataUpdateCoordinator, + StatusDataUpdateCoordinator, + WantedDataUpdateCoordinator, +) + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Lidarr from a config entry.""" + host_configuration = PyArrHostConfiguration( + api_token=entry.data[CONF_API_KEY], + verify_ssl=entry.data[CONF_VERIFY_SSL], + url=entry.data[CONF_URL], + ) + lidarr = LidarrClient( + host_configuration=host_configuration, + session=async_get_clientsession(hass, host_configuration.verify_ssl), + request_timeout=60, + ) + coordinators: dict[str, LidarrDataUpdateCoordinator] = { + "disk_space": DiskSpaceDataUpdateCoordinator(hass, host_configuration, lidarr), + "queue": QueueDataUpdateCoordinator(hass, host_configuration, lidarr), + "status": StatusDataUpdateCoordinator(hass, host_configuration, lidarr), + "wanted": WantedDataUpdateCoordinator(hass, host_configuration, lidarr), + } + # Temporary, until we add diagnostic entities + _version = None + for coordinator in coordinators.values(): + await coordinator.async_config_entry_first_refresh() + if isinstance(coordinator, StatusDataUpdateCoordinator): + _version = coordinator.data + coordinator.system_version = _version + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinators + 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.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class LidarrEntity(CoordinatorEntity[LidarrDataUpdateCoordinator]): + """Defines a base Lidarr entity.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: LidarrDataUpdateCoordinator, description: EntityDescription + ) -> None: + """Initialize the Lidarr entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + self._attr_device_info = DeviceInfo( + configuration_url=coordinator.host_configuration.base_url, + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + manufacturer=DEFAULT_NAME, + name=DEFAULT_NAME, + sw_version=coordinator.system_version, + ) diff --git a/homeassistant/components/lidarr/config_flow.py b/homeassistant/components/lidarr/config_flow.py new file mode 100644 index 00000000000..1b7f2b23c11 --- /dev/null +++ b/homeassistant/components/lidarr/config_flow.py @@ -0,0 +1,111 @@ +"""Config flow for Lidarr.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from aiohttp import ClientConnectorError +from aiopyarr import SystemStatus, exceptions +from aiopyarr.lidarr_client import LidarrClient +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DEFAULT_NAME, DOMAIN + + +class LidarrConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Lidarr.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the flow.""" + self.entry: ConfigEntry | None = None + + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Handle configuration by re-auth.""" + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Confirm reauth dialog.""" + if user_input is not None: + return await self.async_step_user() + + self._set_confirm_only() + return self.async_show_form(step_id="reauth_confirm") + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors = {} + + if user_input is None: + user_input = dict(self.entry.data) if self.entry else None + + else: + try: + result = await validate_input(self.hass, user_input) + if isinstance(result, tuple): + user_input[CONF_API_KEY] = result[1] + elif isinstance(result, str): + errors = {"base": result} + except exceptions.ArrAuthenticationException: + errors = {"base": "invalid_auth"} + except (ClientConnectorError, exceptions.ArrConnectionException): + errors = {"base": "cannot_connect"} + except exceptions.ArrException: + errors = {"base": "unknown"} + if not errors: + if self.entry: + self.hass.config_entries.async_update_entry( + self.entry, data=user_input + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + + return self.async_abort(reason="reauth_successful") + + return self.async_create_entry(title=DEFAULT_NAME, data=user_input) + + user_input = user_input or {} + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_URL, default=user_input.get(CONF_URL, "")): str, + vol.Optional(CONF_API_KEY): str, + vol.Optional( + CONF_VERIFY_SSL, + default=user_input.get(CONF_VERIFY_SSL, False), + ): bool, + } + ), + errors=errors, + ) + + +async def validate_input( + hass: HomeAssistant, data: dict[str, Any] +) -> tuple[str, str, str] | str | SystemStatus: + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + lidarr = LidarrClient( + api_token=data.get(CONF_API_KEY, ""), + url=data[CONF_URL], + session=async_get_clientsession(hass), + verify_ssl=data[CONF_VERIFY_SSL], + ) + if CONF_API_KEY not in data: + return await lidarr.async_try_zeroconf() + return await lidarr.async_get_system_status() diff --git a/homeassistant/components/lidarr/const.py b/homeassistant/components/lidarr/const.py new file mode 100644 index 00000000000..08e284b9b31 --- /dev/null +++ b/homeassistant/components/lidarr/const.py @@ -0,0 +1,38 @@ +"""Constants for Lidarr.""" +import logging +from typing import Final + +from homeassistant.const import ( + DATA_BYTES, + DATA_EXABYTES, + DATA_GIGABYTES, + DATA_KILOBYTES, + DATA_MEGABYTES, + DATA_PETABYTES, + DATA_TERABYTES, + DATA_YOTTABYTES, + DATA_ZETTABYTES, +) + +BYTE_SIZES = [ + DATA_BYTES, + DATA_KILOBYTES, + DATA_MEGABYTES, + DATA_GIGABYTES, + DATA_TERABYTES, + DATA_PETABYTES, + DATA_EXABYTES, + DATA_ZETTABYTES, + DATA_YOTTABYTES, +] + +# Defaults +DEFAULT_DAYS = "1" +DEFAULT_HOST = "localhost" +DEFAULT_NAME = "Lidarr" +DEFAULT_UNIT = DATA_GIGABYTES +DEFAULT_MAX_RECORDS = 20 + +DOMAIN: Final = "lidarr" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/lidarr/coordinator.py b/homeassistant/components/lidarr/coordinator.py new file mode 100644 index 00000000000..be789c6a32a --- /dev/null +++ b/homeassistant/components/lidarr/coordinator.py @@ -0,0 +1,94 @@ +"""Data update coordinator for the Lidarr integration.""" +from __future__ import annotations + +from abc import abstractmethod +from datetime import timedelta +from typing import Generic, TypeVar, cast + +from aiopyarr import LidarrAlbum, LidarrQueue, LidarrRootFolder, exceptions +from aiopyarr.lidarr_client import LidarrClient +from aiopyarr.models.host_configuration import PyArrHostConfiguration + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_MAX_RECORDS, DOMAIN, LOGGER + +T = TypeVar("T", list[LidarrRootFolder], LidarrQueue, str, LidarrAlbum) + + +class LidarrDataUpdateCoordinator(DataUpdateCoordinator, Generic[T]): + """Data update coordinator for the Lidarr integration.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + host_configuration: PyArrHostConfiguration, + api_client: LidarrClient, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass=hass, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + self.api_client = api_client + self.host_configuration = host_configuration + self.system_version: str | None = None + + async def _async_update_data(self) -> T: + """Get the latest data from Lidarr.""" + try: + return await self._fetch_data() + + except exceptions.ArrConnectionException as ex: + raise UpdateFailed(ex) from ex + except exceptions.ArrAuthenticationException as ex: + raise ConfigEntryAuthFailed( + "API Key is no longer valid. Please reauthenticate" + ) from ex + + @abstractmethod + async def _fetch_data(self) -> T: + """Fetch the actual data.""" + raise NotImplementedError + + +class DiskSpaceDataUpdateCoordinator(LidarrDataUpdateCoordinator): + """Disk space update coordinator for Lidarr.""" + + async def _fetch_data(self) -> list[LidarrRootFolder]: + """Fetch the data.""" + return cast(list, await self.api_client.async_get_root_folders()) + + +class QueueDataUpdateCoordinator(LidarrDataUpdateCoordinator): + """Queue update coordinator.""" + + async def _fetch_data(self) -> LidarrQueue: + """Fetch the album count in queue.""" + return await self.api_client.async_get_queue(page_size=DEFAULT_MAX_RECORDS) + + +class StatusDataUpdateCoordinator(LidarrDataUpdateCoordinator): + """Status update coordinator for Lidarr.""" + + async def _fetch_data(self) -> str: + """Fetch the data.""" + return (await self.api_client.async_get_system_status()).version + + +class WantedDataUpdateCoordinator(LidarrDataUpdateCoordinator): + """Wanted update coordinator.""" + + async def _fetch_data(self) -> LidarrAlbum: + """Fetch the wanted data.""" + return cast( + LidarrAlbum, + await self.api_client.async_get_wanted(page_size=DEFAULT_MAX_RECORDS), + ) diff --git a/homeassistant/components/lidarr/manifest.json b/homeassistant/components/lidarr/manifest.json new file mode 100644 index 00000000000..6f7ad875a46 --- /dev/null +++ b/homeassistant/components/lidarr/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "lidarr", + "name": "Lidarr", + "documentation": "https://www.home-assistant.io/integrations/lidarr", + "requirements": ["aiopyarr==22.7.0"], + "codeowners": ["@tkdrob"], + "config_flow": true, + "iot_class": "local_polling", + "loggers": ["aiopyarr"] +} diff --git a/homeassistant/components/lidarr/sensor.py b/homeassistant/components/lidarr/sensor.py new file mode 100644 index 00000000000..8529d9a6469 --- /dev/null +++ b/homeassistant/components/lidarr/sensor.py @@ -0,0 +1,162 @@ +"""Support for Lidarr.""" +from __future__ import annotations + +from collections.abc import Callable +from copy import deepcopy +from dataclasses import dataclass +from datetime import datetime +from typing import Generic + +from aiopyarr import LidarrQueueItem, LidarrRootFolder + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DATA_GIGABYTES +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import LidarrEntity +from .const import BYTE_SIZES, DOMAIN +from .coordinator import LidarrDataUpdateCoordinator, T + + +def get_space(data: list[LidarrRootFolder], name: str) -> str: + """Get space.""" + space = [] + for mount in data: + if name in mount.path: + mount.freeSpace = mount.freeSpace if mount.accessible else 0 + space.append(mount.freeSpace / 1024 ** BYTE_SIZES.index(DATA_GIGABYTES)) + return f"{space[0]:.2f}" + + +def get_modified_description( + description: LidarrSensorEntityDescription, mount: LidarrRootFolder +) -> tuple[LidarrSensorEntityDescription, str]: + """Return modified description and folder name.""" + desc = deepcopy(description) + name = mount.path.rsplit("/")[-1].rsplit("\\")[-1] + desc.key = f"{description.key}_{name}" + desc.name = f"{description.name} {name}".capitalize() + return desc, name + + +@dataclass +class LidarrSensorEntityDescriptionMixIn(Generic[T]): + """Mixin for required keys.""" + + value_fn: Callable[[T, str], str] + + +@dataclass +class LidarrSensorEntityDescription( + SensorEntityDescription, LidarrSensorEntityDescriptionMixIn, Generic[T] +): + """Class to describe a Lidarr sensor.""" + + attributes_fn: Callable[ + [T], dict[str, StateType | datetime] | None + ] = lambda _: None + description_fn: Callable[ + [LidarrSensorEntityDescription, LidarrRootFolder], + tuple[LidarrSensorEntityDescription, str] | None, + ] = lambda _, __: None + + +SENSOR_TYPES: dict[str, LidarrSensorEntityDescription] = { + "disk_space": LidarrSensorEntityDescription( + key="disk_space", + name="Disk space", + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:harddisk", + value_fn=get_space, + state_class=SensorStateClass.TOTAL, + description_fn=get_modified_description, + ), + "queue": LidarrSensorEntityDescription( + key="queue", + name="Queue", + native_unit_of_measurement="Albums", + icon="mdi:download", + value_fn=lambda data, _: data.totalRecords, + state_class=SensorStateClass.TOTAL, + attributes_fn=lambda data: {i.title: queue_str(i) for i in data.records}, + ), + "wanted": LidarrSensorEntityDescription( + key="wanted", + name="Wanted", + native_unit_of_measurement="Albums", + icon="mdi:music", + value_fn=lambda data, _: data.totalRecords, + state_class=SensorStateClass.TOTAL, + entity_registry_enabled_default=False, + attributes_fn=lambda data: { + album.title: album.artist.artistName for album in data.records + }, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Lidarr sensors based on a config entry.""" + coordinators: dict[str, LidarrDataUpdateCoordinator] = hass.data[DOMAIN][ + entry.entry_id + ] + entities = [] + for coordinator_type, description in SENSOR_TYPES.items(): + coordinator = coordinators[coordinator_type] + if coordinator_type != "disk_space": + entities.append(LidarrSensor(coordinator, description)) + else: + entities.extend( + LidarrSensor(coordinator, *get_modified_description(description, mount)) + for mount in coordinator.data + if description.description_fn + ) + async_add_entities(entities) + + +class LidarrSensor(LidarrEntity, SensorEntity): + """Implementation of the Lidarr sensor.""" + + entity_description: LidarrSensorEntityDescription + + def __init__( + self, + coordinator: LidarrDataUpdateCoordinator, + description: LidarrSensorEntityDescription, + folder_name: str = "", + ) -> None: + """Create Lidarr entity.""" + super().__init__(coordinator, description) + self.folder_name = folder_name + + @property + def extra_state_attributes(self) -> dict[str, StateType | datetime] | None: + """Return the state attributes of the sensor.""" + return self.entity_description.attributes_fn(self.coordinator.data) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data, self.folder_name) + + +def queue_str(item: LidarrQueueItem) -> str: + """Return string description of queue item.""" + if ( + item.sizeleft > 0 + and item.timeleft == "00:00:00" + or not hasattr(item, "trackedDownloadState") + ): + return "stopped" + return item.trackedDownloadState diff --git a/homeassistant/components/lidarr/strings.json b/homeassistant/components/lidarr/strings.json new file mode 100644 index 00000000000..662d930cbef --- /dev/null +++ b/homeassistant/components/lidarr/strings.json @@ -0,0 +1,42 @@ +{ + "config": { + "step": { + "user": { + "description": "API key can be retrieved automatically if login credentials were not set in application.\nYour API key can be found in Settings > General in the Lidarr Web UI.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Lidarr integration needs to be manually re-authenticated with the Lidarr API", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "zeroconf_failed": "API key not found. Please enter it manually", + "wrong_app": "Incorrect application reached. Please try again", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "upcoming_days": "Number of upcoming days to display on calendar", + "max_records": "Number of maximum records to display on wanted and queue" + } + } + } + } +} diff --git a/homeassistant/components/lidarr/translations/en.json b/homeassistant/components/lidarr/translations/en.json new file mode 100644 index 00000000000..03c7435eb36 --- /dev/null +++ b/homeassistant/components/lidarr/translations/en.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "Service is already configured", + "reauth_successful": "Re-authentication was successful" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "zeroconf_failed": "API key not found. Please enter it manually", + "wrong_app": "Incorrect application reached. Please try again", + "unknown": "Unexpected error" + }, + "step": { + "reauth_confirm": { + "description": "The Lidarr integration needs to be manually re-authenticated with the Lidarr API", + "title": "Reauthenticate Integration", + "data": { + "api_key": "API Key" + } + }, + "user": { + "description": "API key can be retrieved automatically if login credentials were not set in application.\nYour API key can be found in Settings > General in the Lidarr Web UI.", + "data": { + "api_key": "API Key", + "url": "URL", + "verify_ssl": "Verify SSL certificate" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "upcoming_days": "Number of upcoming days to display on calendar", + "max_records": "Number of maximum records to display on wanted and queue" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 208a7e02efc..038596d3e23 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -204,6 +204,7 @@ FLOWS = { "laundrify", "led_ble", "lg_soundbar", + "lidarr", "life360", "lifx", "litejet", diff --git a/requirements_all.txt b/requirements_all.txt index 6a12a38fdc5..74f93f267c8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -231,6 +231,7 @@ aiopvapi==2.0.1 # homeassistant.components.pvpc_hourly_pricing aiopvpc==3.0.0 +# homeassistant.components.lidarr # homeassistant.components.sonarr aiopyarr==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d60c9146079..36822d4cbae 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -206,6 +206,7 @@ aiopvapi==2.0.1 # homeassistant.components.pvpc_hourly_pricing aiopvpc==3.0.0 +# homeassistant.components.lidarr # homeassistant.components.sonarr aiopyarr==22.7.0 diff --git a/tests/components/lidarr/__init__.py b/tests/components/lidarr/__init__.py new file mode 100644 index 00000000000..8c1220e4c6c --- /dev/null +++ b/tests/components/lidarr/__init__.py @@ -0,0 +1,53 @@ +"""Tests for the Lidarr component.""" +from aiopyarr.lidarr_client import LidarrClient + +from homeassistant.components.lidarr.const import DOMAIN +from homeassistant.const import ( + CONF_API_KEY, + CONF_URL, + CONF_VERIFY_SSL, + CONTENT_TYPE_JSON, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + +BASE_PATH = "" +API_KEY = "1234567890abcdef1234567890abcdef" +URL = "http://127.0.0.1:8686" +client = LidarrClient(session=async_get_clientsession, api_token=API_KEY, url=URL) +API_URL = f"{URL}/api/{client._host.api_ver}" + +MOCK_REAUTH_INPUT = {CONF_API_KEY: "new_key"} + +MOCK_USER_INPUT = { + CONF_URL: URL, + CONF_VERIFY_SSL: False, +} + +CONF_DATA = MOCK_USER_INPUT | {CONF_API_KEY: API_KEY} + + +def mock_connection( + aioclient_mock: AiohttpClientMocker, + url: str = API_URL, +) -> None: + """Mock lidarr connection.""" + aioclient_mock.get( + f"{url}/system/status", + text=load_fixture("lidarr/system-status.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + +def create_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create Efergy entry in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CONF_DATA, + ) + + entry.add_to_hass(hass) + return entry diff --git a/tests/components/lidarr/fixtures/system-status.json b/tests/components/lidarr/fixtures/system-status.json new file mode 100644 index 00000000000..6baa9428ff6 --- /dev/null +++ b/tests/components/lidarr/fixtures/system-status.json @@ -0,0 +1,29 @@ +{ + "version": "10.0.0.34882", + "buildTime": "2020-09-01T23:23:23.9621974Z", + "isDebug": true, + "isProduction": false, + "isAdmin": false, + "isUserInteractive": true, + "startupPath": "C:\\ProgramData\\Radarr", + "appData": "C:\\ProgramData\\Radarr", + "osName": "Windows", + "osVersion": "10.0.18363.0", + "isNetCore": true, + "isMono": false, + "isMonoRuntime": false, + "isLinux": false, + "isOsx": false, + "isWindows": true, + "isDocker": false, + "mode": "console", + "branch": "nightly", + "authentication": "none", + "sqliteVersion": "3.32.1", + "migrationVersion": 180, + "urlBase": "", + "runtimeVersion": "3.1.10", + "runtimeName": "netCore", + "startTime": "2020-09-01T23:50:20.2415965Z", + "packageUpdateMechanism": "builtIn" +} diff --git a/tests/components/lidarr/test_config_flow.py b/tests/components/lidarr/test_config_flow.py new file mode 100644 index 00000000000..0ec48439012 --- /dev/null +++ b/tests/components/lidarr/test_config_flow.py @@ -0,0 +1,142 @@ +"""Test Lidarr config flow.""" +from unittest.mock import patch + +from aiopyarr import ArrAuthenticationException, ArrConnectionException, ArrException + +from homeassistant import data_entry_flow +from homeassistant.components.lidarr.const import DEFAULT_NAME, DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_SOURCE +from homeassistant.core import HomeAssistant + +from . import API_KEY, CONF_DATA, MOCK_USER_INPUT, create_entry, mock_connection + +from tests.test_util.aiohttp import AiohttpClientMocker + + +def _patch_client(): + return patch( + "homeassistant.components.lidarr.config_flow.LidarrClient.async_get_system_status" + ) + + +async def test_flow_user_form( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test that the user set up form is served.""" + mock_connection(aioclient_mock) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + with patch( + "homeassistant.components.lidarr.config_flow.LidarrClient.async_try_zeroconf", + return_value=("/api/v3", API_KEY, ""), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_INPUT, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["data"] == CONF_DATA + + +async def test_flow_user_invalid_auth(hass: HomeAssistant) -> None: + """Test invalid authentication.""" + with _patch_client() as client: + client.side_effect = ArrAuthenticationException + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == "invalid_auth" + + +async def test_flow_user_cannot_connect(hass: HomeAssistant) -> None: + """Test connection error.""" + with _patch_client() as client: + client.side_effect = ArrConnectionException + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == "cannot_connect" + + +async def test_flow_user_unknown_error(hass: HomeAssistant) -> None: + """Test unknown error.""" + with _patch_client() as client: + client.side_effect = ArrException + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == "unknown" + + +async def test_flow_user_failed_zeroconf(hass: HomeAssistant) -> None: + """Test zero configuration failed.""" + with _patch_client() as client: + client.return_value = "zeroconf_failed" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == "zeroconf_failed" + + +async def test_flow_reauth( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test reauth.""" + entry = create_entry(hass) + mock_connection(aioclient_mock) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + CONF_SOURCE: SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + data=CONF_DATA, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "abc123"}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + assert entry.data[CONF_API_KEY] == "abc123"