From e1ae940a34912836966b8a2f4361577c9640b709 Mon Sep 17 00:00:00 2001 From: Robert Hillis <tkdrob4390@yahoo.com> Date: Wed, 23 Mar 2022 00:01:24 -0400 Subject: [PATCH] Add config flow to deluge (#58789) --- .coveragerc | 2 + CODEOWNERS | 2 + homeassistant/components/deluge/__init__.py | 88 +++++++++- .../components/deluge/config_flow.py | 125 ++++++++++++++ homeassistant/components/deluge/const.py | 12 ++ .../components/deluge/coordinator.py | 68 ++++++++ homeassistant/components/deluge/manifest.json | 3 +- homeassistant/components/deluge/sensor.py | 153 +++++++---------- homeassistant/components/deluge/strings.json | 23 +++ homeassistant/components/deluge/switch.py | 120 +++++-------- .../components/deluge/translations/en.json | 23 +++ homeassistant/generated/config_flows.py | 1 + requirements_test_all.txt | 3 + tests/components/deluge/__init__.py | 32 ++++ tests/components/deluge/test_config_flow.py | 160 ++++++++++++++++++ 15 files changed, 651 insertions(+), 164 deletions(-) create mode 100644 homeassistant/components/deluge/config_flow.py create mode 100644 homeassistant/components/deluge/const.py create mode 100644 homeassistant/components/deluge/coordinator.py create mode 100644 homeassistant/components/deluge/strings.json create mode 100644 homeassistant/components/deluge/translations/en.json create mode 100644 tests/components/deluge/__init__.py create mode 100644 tests/components/deluge/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 1216a78370a..06d5265c9f2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -197,6 +197,8 @@ omit = homeassistant/components/decora/light.py homeassistant/components/decora_wifi/light.py homeassistant/components/delijn/* + homeassistant/components/deluge/__init__.py + homeassistant/components/deluge/coordinator.py homeassistant/components/deluge/sensor.py homeassistant/components/deluge/switch.py homeassistant/components/denon/media_player.py diff --git a/CODEOWNERS b/CODEOWNERS index 6ce57254f5a..bc801a5f8f0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -210,6 +210,8 @@ tests/components/deconz/* @Kane610 homeassistant/components/default_config/* @home-assistant/core tests/components/default_config/* @home-assistant/core homeassistant/components/delijn/* @bollewolle @Emilv2 +homeassistant/components/deluge/* @tkdrob +tests/components/deluge/* @tkdrob homeassistant/components/demo/* @home-assistant/core tests/components/demo/* @home-assistant/core homeassistant/components/denonavr/* @ol-iver @starkillerOG diff --git a/homeassistant/components/deluge/__init__.py b/homeassistant/components/deluge/__init__.py index ad40b688fcf..d8d6945be2a 100644 --- a/homeassistant/components/deluge/__init__.py +++ b/homeassistant/components/deluge/__init__.py @@ -1 +1,87 @@ -"""The deluge component.""" +"""The Deluge integration.""" +from __future__ import annotations + +import logging +import socket +from ssl import SSLError + +from deluge_client.client import DelugeRPCClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_WEB_PORT, DEFAULT_NAME, DOMAIN +from .coordinator import DelugeDataUpdateCoordinator + +PLATFORMS = [Platform.SENSOR, Platform.SWITCH] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Deluge from a config entry.""" + host = entry.data[CONF_HOST] + port = entry.data[CONF_PORT] + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + api = await hass.async_add_executor_job( + DelugeRPCClient, host, port, username, password + ) + api.web_port = entry.data[CONF_WEB_PORT] + try: + await hass.async_add_executor_job(api.connect) + except ( + ConnectionRefusedError, + socket.timeout, # pylint:disable=no-member + SSLError, + ) as ex: + raise ConfigEntryNotReady("Connection to Deluge Daemon failed") from ex + except Exception as ex: # pylint:disable=broad-except + if type(ex).__name__ == "BadLoginError": + raise ConfigEntryAuthFailed( + "Credentials for Deluge client are not valid" + ) from ex + _LOGGER.error("Unknown error connecting to Deluge: %s", ex) + + coordinator = DelugeDataUpdateCoordinator(hass, api, entry) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(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 DelugeEntity(CoordinatorEntity[DelugeDataUpdateCoordinator]): + """Representation of a Deluge entity.""" + + def __init__(self, coordinator: DelugeDataUpdateCoordinator) -> None: + """Initialize a Deluge entity.""" + super().__init__(coordinator) + self._server_unique_id = coordinator.config_entry.entry_id + self._attr_device_info = DeviceInfo( + configuration_url=f"http://{coordinator.api.host}:{coordinator.api.web_port}", + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + manufacturer=DEFAULT_NAME, + name=DEFAULT_NAME, + sw_version=coordinator.api.deluge_version, + ) diff --git a/homeassistant/components/deluge/config_flow.py b/homeassistant/components/deluge/config_flow.py new file mode 100644 index 00000000000..a15c0608029 --- /dev/null +++ b/homeassistant/components/deluge/config_flow.py @@ -0,0 +1,125 @@ +"""Config flow for the Deluge integration.""" +from __future__ import annotations + +import logging +import socket +from ssl import SSLError +from typing import Any + +from deluge_client.client import DelugeRPCClient +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow +from homeassistant.const import ( + CONF_HOST, + CONF_MONITORED_VARIABLES, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_SOURCE, + CONF_USERNAME, +) +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv + +from .const import ( + CONF_WEB_PORT, + DEFAULT_NAME, + DEFAULT_RPC_PORT, + DEFAULT_WEB_PORT, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +class DelugeFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Deluge.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors = {} + title = None + + if user_input is not None: + if CONF_NAME in user_input: + title = user_input.pop(CONF_NAME) + if (error := await self.validate_input(user_input)) is None: + for entry in self._async_current_entries(): + if ( + user_input[CONF_HOST] == entry.data[CONF_HOST] + and user_input[CONF_PORT] == entry.data[CONF_PORT] + ): + if self.context.get(CONF_SOURCE) == SOURCE_REAUTH: + self.hass.config_entries.async_update_entry( + entry, data=user_input + ) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + return self.async_abort(reason="already_configured") + return self.async_create_entry( + title=title or DEFAULT_NAME, + data=user_input, + ) + errors["base"] = error + user_input = user_input or {} + schema = vol.Schema( + { + vol.Required(CONF_HOST, default=user_input.get(CONF_HOST)): cv.string, + vol.Required( + CONF_USERNAME, default=user_input.get(CONF_USERNAME) + ): cv.string, + vol.Required(CONF_PASSWORD, default=""): cv.string, + vol.Optional( + CONF_PORT, default=user_input.get(CONF_PORT, DEFAULT_RPC_PORT) + ): int, + vol.Optional( + CONF_WEB_PORT, + default=user_input.get(CONF_WEB_PORT, DEFAULT_WEB_PORT), + ): int, + } + ) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + """Handle a reauthorization flow request.""" + return await self.async_step_user() + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + if CONF_MONITORED_VARIABLES in config: + config.pop(CONF_MONITORED_VARIABLES) + config[CONF_WEB_PORT] = DEFAULT_WEB_PORT + + for entry in self._async_current_entries(): + if entry.data[CONF_HOST] == config[CONF_HOST]: + _LOGGER.warning( + "Deluge yaml config has been imported. Please remove it" + ) + return self.async_abort(reason="already_configured") + return await self.async_step_user(config) + + async def validate_input(self, user_input: dict[str, Any]) -> str | None: + """Handle common flow input validation.""" + host = user_input[CONF_HOST] + port = user_input[CONF_PORT] + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + api = DelugeRPCClient( + host=host, port=port, username=username, password=password + ) + try: + await self.hass.async_add_executor_job(api.connect) + except ( + ConnectionRefusedError, + socket.timeout, # pylint:disable=no-member + SSLError, + ): + return "cannot_connect" + except Exception as ex: # pylint:disable=broad-except + if type(ex).__name__ == "BadLoginError": + return "invalid_auth" # pragma: no cover + return "unknown" + return None diff --git a/homeassistant/components/deluge/const.py b/homeassistant/components/deluge/const.py new file mode 100644 index 00000000000..505c20e860f --- /dev/null +++ b/homeassistant/components/deluge/const.py @@ -0,0 +1,12 @@ +"""Constants for the Deluge integration.""" +import logging + +CONF_WEB_PORT = "web_port" +DEFAULT_NAME = "Deluge" +DEFAULT_RPC_PORT = 58846 +DEFAULT_WEB_PORT = 8112 +DHT_UPLOAD = 1000 +DHT_DOWNLOAD = 1000 +DOMAIN = "deluge" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/deluge/coordinator.py b/homeassistant/components/deluge/coordinator.py new file mode 100644 index 00000000000..36c2d5d2240 --- /dev/null +++ b/homeassistant/components/deluge/coordinator.py @@ -0,0 +1,68 @@ +"""Data update coordinator for the Deluge integration.""" +from __future__ import annotations + +from datetime import timedelta +import socket +from ssl import SSLError + +from deluge_client.client import DelugeRPCClient, FailedToReconnectException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER + + +class DelugeDataUpdateCoordinator(DataUpdateCoordinator): + """Data update coordinator for the Deluge integration.""" + + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, api: DelugeRPCClient, entry: ConfigEntry + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass=hass, + logger=LOGGER, + name=entry.title, + update_interval=timedelta(seconds=30), + ) + self.api = api + self.config_entry = entry + + async def _async_update_data(self) -> dict[Platform, dict[str, int | str]]: + """Get the latest data from Deluge and updates the state.""" + data = {} + try: + data[Platform.SENSOR] = await self.hass.async_add_executor_job( + self.api.call, + "core.get_session_status", + [ + "upload_rate", + "download_rate", + "dht_upload_rate", + "dht_download_rate", + ], + ) + data[Platform.SWITCH] = await self.hass.async_add_executor_job( + self.api.call, "core.get_torrents_status", {}, ["paused"] + ) + except ( + ConnectionRefusedError, + socket.timeout, # pylint:disable=no-member + SSLError, + FailedToReconnectException, + ) as ex: + raise UpdateFailed(f"Connection to Deluge Daemon Lost: {ex}") from ex + except Exception as ex: # pylint:disable=broad-except + if type(ex).__name__ == "BadLoginError": + raise ConfigEntryAuthFailed( + "Credentials for Deluge client are not valid" + ) from ex + LOGGER.error("Unknown error connecting to Deluge: %s", ex) + raise ex + return data diff --git a/homeassistant/components/deluge/manifest.json b/homeassistant/components/deluge/manifest.json index 5bf4651096c..920e560b70f 100644 --- a/homeassistant/components/deluge/manifest.json +++ b/homeassistant/components/deluge/manifest.json @@ -3,7 +3,8 @@ "name": "Deluge", "documentation": "https://www.home-assistant.io/integrations/deluge", "requirements": ["deluge-client==1.7.1"], - "codeowners": [], + "codeowners": ["@tkdrob"], + "config_flow": true, "iot_class": "local_polling", "loggers": ["deluge_client"] } diff --git a/homeassistant/components/deluge/sensor.py b/homeassistant/components/deluge/sensor.py index 01241b0d120..99e63e6ef17 100644 --- a/homeassistant/components/deluge/sensor.py +++ b/homeassistant/components/deluge/sensor.py @@ -1,16 +1,15 @@ """Support for monitoring the Deluge BitTorrent client API.""" from __future__ import annotations -import logging - -from deluge_client import DelugeRPCClient, FailedToReconnectException import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA, SensorEntity, SensorEntityDescription, + SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_MONITORED_VARIABLES, @@ -20,20 +19,17 @@ from homeassistant.const import ( CONF_USERNAME, DATA_RATE_KILOBYTES_PER_SECOND, STATE_IDLE, + Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType -_LOGGER = logging.getLogger(__name__) -_THROTTLED_REFRESH = None +from . import DelugeEntity +from .const import DEFAULT_NAME, DEFAULT_RPC_PORT, DOMAIN +from .coordinator import DelugeDataUpdateCoordinator -DEFAULT_NAME = "Deluge" -DEFAULT_PORT = 58846 -DHT_UPLOAD = 1000 -DHT_DOWNLOAD = 1000 SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="current_status", @@ -43,21 +39,24 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="download_speed", name="Down Speed", native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="upload_speed", name="Up Speed", native_unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, ), ) SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] +# Deprecated in Home Assistant 2022.3 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_PORT, default=DEFAULT_RPC_PORT): cv.port, vol.Required(CONF_USERNAME): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_MONITORED_VARIABLES, default=[]): vol.All( @@ -67,92 +66,70 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: entity_platform.AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Deluge sensors.""" + """Set up the Deluge sensor component.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) - name = config[CONF_NAME] - host = config[CONF_HOST] - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - port = config[CONF_PORT] - deluge_api = DelugeRPCClient(host, port, username, password) - try: - deluge_api.connect() - except ConnectionRefusedError as err: - _LOGGER.error("Connection to Deluge Daemon failed") - raise PlatformNotReady from err - monitored_variables = config[CONF_MONITORED_VARIABLES] - entities = [ - DelugeSensor(deluge_api, name, description) +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: entity_platform.AddEntitiesCallback, +) -> None: + """Set up the Deluge sensor.""" + async_add_entities( + DelugeSensor(hass.data[DOMAIN][entry.entry_id], description) for description in SENSOR_TYPES - if description.key in monitored_variables - ] + ) - add_entities(entities) - -class DelugeSensor(SensorEntity): +class DelugeSensor(DelugeEntity, SensorEntity): """Representation of a Deluge sensor.""" def __init__( - self, deluge_client, client_name, description: SensorEntityDescription - ): + self, + coordinator: DelugeDataUpdateCoordinator, + description: SensorEntityDescription, + ) -> None: """Initialize the sensor.""" + super().__init__(coordinator) self.entity_description = description - self.client = deluge_client - self.data = None - - self._attr_available = False - self._attr_name = f"{client_name} {description.name}" - - def update(self): - """Get the latest data from Deluge and updates the state.""" - - try: - self.data = self.client.call( - "core.get_session_status", - [ - "upload_rate", - "download_rate", - "dht_upload_rate", - "dht_download_rate", - ], - ) - self._attr_available = True - except FailedToReconnectException: - _LOGGER.error("Connection to Deluge Daemon Lost") - self._attr_available = False - return - - upload = self.data[b"upload_rate"] - self.data[b"dht_upload_rate"] - download = self.data[b"download_rate"] - self.data[b"dht_download_rate"] - - sensor_type = self.entity_description.key - if sensor_type == "current_status": - if self.data: - if upload > 0 and download > 0: - self._attr_native_value = "Up/Down" - elif upload > 0 and download == 0: - self._attr_native_value = "Seeding" - elif upload == 0 and download > 0: - self._attr_native_value = "Downloading" - else: - self._attr_native_value = STATE_IDLE - else: - self._attr_native_value = None - - if self.data: - if sensor_type == "download_speed": - kb_spd = float(download) - kb_spd = kb_spd / 1024 - self._attr_native_value = round(kb_spd, 2 if kb_spd < 0.1 else 1) - elif sensor_type == "upload_speed": - kb_spd = float(upload) - kb_spd = kb_spd / 1024 - self._attr_native_value = round(kb_spd, 2 if kb_spd < 0.1 else 1) + self._attr_name = f"{coordinator.config_entry.title} {description.name}" + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + if self.coordinator.data: + data = self.coordinator.data[Platform.SENSOR] + upload = data[b"upload_rate"] - data[b"dht_upload_rate"] + download = data[b"download_rate"] - data[b"dht_download_rate"] + if self.entity_description.key == "current_status": + if data: + if upload > 0 and download > 0: + return "Up/Down" + if upload > 0 and download == 0: + return "Seeding" + if upload == 0 and download > 0: + return "Downloading" + return STATE_IDLE + + if data: + if self.entity_description.key == "download_speed": + kb_spd = float(download) + kb_spd = kb_spd / 1024 + return round(kb_spd, 2 if kb_spd < 0.1 else 1) + if self.entity_description.key == "upload_speed": + kb_spd = float(upload) + kb_spd = kb_spd / 1024 + return round(kb_spd, 2 if kb_spd < 0.1 else 1) + return None diff --git a/homeassistant/components/deluge/strings.json b/homeassistant/components/deluge/strings.json new file mode 100644 index 00000000000..9474e25a534 --- /dev/null +++ b/homeassistant/components/deluge/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "user": { + "description": "To be able to use this integration, you have to enable the following option in deluge settings: Daemon > Allow remote controls", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]", + "web_port": "Web port (for visiting service)" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/deluge/switch.py b/homeassistant/components/deluge/switch.py index 16a5f023052..d438d236e5c 100644 --- a/homeassistant/components/deluge/switch.py +++ b/homeassistant/components/deluge/switch.py @@ -1,118 +1,90 @@ """Support for setting the Deluge BitTorrent client in Pause.""" from __future__ import annotations -import logging +from typing import Any -from deluge_client import DelugeRPCClient, FailedToReconnectException import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, - STATE_OFF, - STATE_ON, + Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = "Deluge Switch" -DEFAULT_PORT = 58846 +from . import DelugeEntity +from .const import DEFAULT_RPC_PORT, DOMAIN +from .coordinator import DelugeDataUpdateCoordinator +# Deprecated in Home Assistant 2022.3 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_PORT, default=DEFAULT_RPC_PORT): cv.port, vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME, default="Deluge Switch"): cv.string, } ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: entity_platform.AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Deluge switch.""" + """Set up the Deluge sensor component.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) - name = config[CONF_NAME] - host = config[CONF_HOST] - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - port = config[CONF_PORT] - deluge_api = DelugeRPCClient(host, port, username, password) - try: - deluge_api.connect() - except ConnectionRefusedError as err: - _LOGGER.error("Connection to Deluge Daemon failed") - raise PlatformNotReady from err - - add_entities([DelugeSwitch(deluge_api, name)]) +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: entity_platform.AddEntitiesCallback, +) -> None: + """Set up the Deluge switch.""" + async_add_entities([DelugeSwitch(hass.data[DOMAIN][entry.entry_id])]) -class DelugeSwitch(SwitchEntity): +class DelugeSwitch(DelugeEntity, SwitchEntity): """Representation of a Deluge switch.""" - def __init__(self, deluge_client, name): + def __init__(self, coordinator: DelugeDataUpdateCoordinator) -> None: """Initialize the Deluge switch.""" - self._name = name - self.deluge_client = deluge_client - self._state = STATE_OFF - self._available = False - - @property - def name(self): - """Return the name of the switch.""" - return self._name + super().__init__(coordinator) + self._attr_name = coordinator.config_entry.title + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_enabled" - @property - def is_on(self): - """Return true if device is on.""" - return self._state == STATE_ON - - @property - def available(self): - """Return true if device is available.""" - return self._available - - def turn_on(self, **kwargs): + def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - torrent_ids = self.deluge_client.call("core.get_session_state") - self.deluge_client.call("core.resume_torrent", torrent_ids) + torrent_ids = self.coordinator.api.call("core.get_session_state") + self.coordinator.api.call("core.resume_torrent", torrent_ids) - def turn_off(self, **kwargs): + def turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - torrent_ids = self.deluge_client.call("core.get_session_state") - self.deluge_client.call("core.pause_torrent", torrent_ids) + torrent_ids = self.coordinator.api.call("core.get_session_state") + self.coordinator.api.call("core.pause_torrent", torrent_ids) - def update(self): - """Get the latest data from deluge and updates the state.""" - - try: - torrent_list = self.deluge_client.call( - "core.get_torrents_status", {}, ["paused"] - ) - self._available = True - except FailedToReconnectException: - _LOGGER.error("Connection to Deluge Daemon Lost") - self._available = False - return - for torrent in torrent_list.values(): - item = torrent.popitem() - if not item[1]: - self._state = STATE_ON - return - - self._state = STATE_OFF + @property + def is_on(self) -> bool: + """Return state of the switch.""" + if self.coordinator.data: + data: dict = self.coordinator.data[Platform.SWITCH] + for torrent in data.values(): + item = torrent.popitem() + if not item[1]: + return True + return False diff --git a/homeassistant/components/deluge/translations/en.json b/homeassistant/components/deluge/translations/en.json new file mode 100644 index 00000000000..a3a2f539126 --- /dev/null +++ b/homeassistant/components/deluge/translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "user": { + "description": "To be able to use this integration, you have to enable the following option in deluge settings: Daemon > Allow remote controls", + "data": { + "host": "Host", + "username": "Username", + "password": "Password", + "port": "Port", + "web_port": "Web port (for visiting service)" + } + } + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication" + }, + "abort": { + "already_configured": "Service is already configured" + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 25d6ec2c807..e1b6e95800d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -66,6 +66,7 @@ FLOWS = { "crownstone", "daikin", "deconz", + "deluge", "denonavr", "devolo_home_control", "devolo_home_network", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3bfd6769e08..357e9a10a9e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -373,6 +373,9 @@ debugpy==1.5.1 # homeassistant.components.ohmconnect defusedxml==0.7.1 +# homeassistant.components.deluge +deluge-client==1.7.1 + # homeassistant.components.denonavr denonavr==0.10.10 diff --git a/tests/components/deluge/__init__.py b/tests/components/deluge/__init__.py new file mode 100644 index 00000000000..47339f8dfd5 --- /dev/null +++ b/tests/components/deluge/__init__.py @@ -0,0 +1,32 @@ +"""Tests for the Deluge integration.""" + +from homeassistant.components.deluge.const import ( + CONF_WEB_PORT, + DEFAULT_RPC_PORT, + DEFAULT_WEB_PORT, +) +from homeassistant.const import ( + CONF_HOST, + CONF_MONITORED_VARIABLES, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) + +CONF_DATA = { + CONF_HOST: "1.2.3.4", + CONF_USERNAME: "user", + CONF_PASSWORD: "password", + CONF_PORT: DEFAULT_RPC_PORT, + CONF_WEB_PORT: DEFAULT_WEB_PORT, +} + +IMPORT_DATA = { + CONF_HOST: "1.2.3.4", + CONF_NAME: "Deluge Torrent", + CONF_MONITORED_VARIABLES: ["current_status", "download_speed", "upload_speed"], + CONF_USERNAME: "user", + CONF_PASSWORD: "password", + CONF_PORT: DEFAULT_RPC_PORT, +} diff --git a/tests/components/deluge/test_config_flow.py b/tests/components/deluge/test_config_flow.py new file mode 100644 index 00000000000..56dbac55674 --- /dev/null +++ b/tests/components/deluge/test_config_flow.py @@ -0,0 +1,160 @@ +"""Test Deluge config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.deluge.const import DEFAULT_NAME, DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_USER +from homeassistant.const import CONF_SOURCE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from . import CONF_DATA, IMPORT_DATA + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="api") +def mock_deluge_api(): + """Mock an api.""" + with patch("deluge_client.client.DelugeRPCClient.connect"), patch( + "deluge_client.client.DelugeRPCClient._create_socket" + ): + yield + + +@pytest.fixture(name="conn_error") +def mock_api_connection_error(): + """Mock an api.""" + with patch( + "deluge_client.client.DelugeRPCClient.connect", + side_effect=ConnectionRefusedError("111: Connection refused"), + ), patch("deluge_client.client.DelugeRPCClient._create_socket"): + yield + + +@pytest.fixture(name="unknown_error") +def mock_api_unknown_error(): + """Mock an api.""" + with patch( + "deluge_client.client.DelugeRPCClient.connect", side_effect=Exception + ), patch("deluge_client.client.DelugeRPCClient._create_socket"): + yield + + +@pytest.fixture(name="deluge_setup", autouse=True) +def deluge_setup_fixture(): + """Mock deluge entry setup.""" + with patch("homeassistant.components.deluge.async_setup_entry", return_value=True): + yield + + +async def test_flow_user(hass: HomeAssistant, api): + """Test user initialized flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=CONF_DATA, + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["data"] == CONF_DATA + + +async def test_flow_user_already_configured(hass: HomeAssistant, api): + """Test user initialized flow with duplicate server.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CONF_DATA, + ) + + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_user_cannot_connect(hass: HomeAssistant, conn_error): + """Test user initialized flow with unreachable server.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_flow_user_unknown_error(hass: HomeAssistant, unknown_error): + """Test user initialized flow with unreachable server.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "unknown"} + + +async def test_flow_import(hass: HomeAssistant, api): + """Test import step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_IMPORT}, data=IMPORT_DATA + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Deluge Torrent" + assert result["data"] == CONF_DATA + + +async def test_flow_import_already_configured(hass: HomeAssistant, api): + """Test import step already configured.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CONF_DATA, + ) + + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_IMPORT}, data=IMPORT_DATA + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_reauth(hass: HomeAssistant, api): + """Test reauth step.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CONF_DATA, + ) + + entry.add_to_hass(hass) + + 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"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + assert entry.data == CONF_DATA -- GitLab