From 92202709484abd555b785a9c245cfda9c3a86b5a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 2 Jun 2019 07:13:14 +0200 Subject: [PATCH] Adds AdGuard Home integration (#24219) * Adds AdGuard Home integration * :shirt: Addresses linting warnings * :ambulance: Fixes typehint in async_setup_entry * :shirt: Take advantage of Python's coalescing operators * :shirt: Use adguard instance from outer scope directly in service calls * :shirt: Use more sensible scan_interval default for sensors * :shirt: Adds specific files to .coveragerc * :umbrella: Added tests and small changes to improve coverage * :hammer: Import adguardhome dependencies at the top * :ambulance: Converted service handlers to be async * :fire: Removed init step from config flow --- .coveragerc | 4 + CODEOWNERS | 1 + .../components/adguard/.translations/en.json | 29 +++ homeassistant/components/adguard/__init__.py | 180 ++++++++++++++ .../components/adguard/config_flow.py | 147 +++++++++++ homeassistant/components/adguard/const.py | 14 ++ .../components/adguard/manifest.json | 13 + homeassistant/components/adguard/sensor.py | 232 +++++++++++++++++ .../components/adguard/services.yaml | 37 +++ homeassistant/components/adguard/strings.json | 29 +++ homeassistant/components/adguard/switch.py | 233 ++++++++++++++++++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/adguard/__init__.py | 1 + tests/components/adguard/test_config_flow.py | 167 +++++++++++++ 17 files changed, 1095 insertions(+) create mode 100644 homeassistant/components/adguard/.translations/en.json create mode 100644 homeassistant/components/adguard/__init__.py create mode 100644 homeassistant/components/adguard/config_flow.py create mode 100644 homeassistant/components/adguard/const.py create mode 100644 homeassistant/components/adguard/manifest.json create mode 100644 homeassistant/components/adguard/sensor.py create mode 100644 homeassistant/components/adguard/services.yaml create mode 100644 homeassistant/components/adguard/strings.json create mode 100644 homeassistant/components/adguard/switch.py create mode 100644 tests/components/adguard/__init__.py create mode 100644 tests/components/adguard/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 967c560198c6b4..5480e0f1766180 100644 --- a/.coveragerc +++ b/.coveragerc @@ -13,6 +13,10 @@ omit = homeassistant/components/abode/* homeassistant/components/acer_projector/switch.py homeassistant/components/actiontec/device_tracker.py + homeassistant/components/adguard/__init__.py + homeassistant/components/adguard/const.py + homeassistant/components/adguard/sensor.py + homeassistant/components/adguard/switch.py homeassistant/components/ads/* homeassistant/components/aftership/sensor.py homeassistant/components/airvisual/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index e472d4058b3d28..0fa8e54acc0925 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -17,6 +17,7 @@ virtualization/Docker/* @home-assistant/docker homeassistant/scripts/check_config.py @kellerza # Integrations +homeassistant/components/adguard/* @frenck homeassistant/components/airvisual/* @bachya homeassistant/components/alarm_control_panel/* @colinodell homeassistant/components/alpha_vantage/* @fabaff diff --git a/homeassistant/components/adguard/.translations/en.json b/homeassistant/components/adguard/.translations/en.json new file mode 100644 index 00000000000000..c88f7085e341c0 --- /dev/null +++ b/homeassistant/components/adguard/.translations/en.json @@ -0,0 +1,29 @@ +{ + "config": { + "title": "AdGuard Home", + "step": { + "user": { + "title": "Link your AdGuard Home.", + "description": "Set up your AdGuard Home instance to allow monitoring and control.", + "data": { + "host": "Host", + "password": "Password", + "port": "Port", + "username": "Username", + "ssl": "AdGuard Home uses a SSL certificate", + "verify_ssl": "AdGuard Home uses a proper certificate" + } + }, + "hassio_confirm": { + "title": "AdGuard Home via Hass.io add-on", + "description": "Do you want to configure Home Assistant to connect to the AdGuard Home provided by the Hass.io add-on: {addon}?" + } + }, + "error": { + "connection_error": "Failed to connect." + }, + "abort": { + "single_instance_allowed": "Only a single configuration of AdGuard Home is allowed." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py new file mode 100644 index 00000000000000..15b8b9978f6db9 --- /dev/null +++ b/homeassistant/components/adguard/__init__.py @@ -0,0 +1,180 @@ +"""Support for AdGuard Home.""" +import logging +from typing import Any, Dict + +from adguardhome import AdGuardHome, AdGuardHomeError +import voluptuous as vol + +from homeassistant.components.adguard.const import ( + CONF_FORCE, DATA_ADGUARD_CLIENT, DATA_ADGUARD_VERION, DOMAIN, + SERVICE_ADD_URL, SERVICE_DISABLE_URL, SERVICE_ENABLE_URL, SERVICE_REFRESH, + SERVICE_REMOVE_URL) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_SSL, CONF_URL, + CONF_USERNAME, CONF_VERIFY_SSL) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +_LOGGER = logging.getLogger(__name__) + +SERVICE_URL_SCHEMA = vol.Schema({vol.Required(CONF_URL): cv.url}) +SERVICE_ADD_URL_SCHEMA = vol.Schema( + {vol.Required(CONF_NAME): cv.string, vol.Required(CONF_URL): cv.url} +) +SERVICE_REFRESH_SCHEMA = vol.Schema( + {vol.Optional(CONF_FORCE, default=False): cv.boolean} +) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Set up the AdGuard Home components.""" + return True + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry +) -> bool: + """Set up AdGuard Home from a config entry.""" + session = async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]) + adguard = AdGuardHome( + entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + tls=entry.data[CONF_SSL], + verify_ssl=entry.data[CONF_VERIFY_SSL], + loop=hass.loop, + session=session, + ) + + hass.data.setdefault(DOMAIN, {})[DATA_ADGUARD_CLIENT] = adguard + + for component in 'sensor', 'switch': + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + async def add_url(call) -> None: + """Service call to add a new filter subscription to AdGuard Home.""" + await adguard.filtering.add_url( + call.data.get(CONF_NAME), call.data.get(CONF_URL) + ) + + async def remove_url(call) -> None: + """Service call to remove a filter subscription from AdGuard Home.""" + await adguard.filtering.remove_url(call.data.get(CONF_URL)) + + async def enable_url(call) -> None: + """Service call to enable a filter subscription in AdGuard Home.""" + await adguard.filtering.enable_url(call.data.get(CONF_URL)) + + async def disable_url(call) -> None: + """Service call to disable a filter subscription in AdGuard Home.""" + await adguard.filtering.disable_url(call.data.get(CONF_URL)) + + async def refresh(call) -> None: + """Service call to refresh the filter subscriptions in AdGuard Home.""" + await adguard.filtering.refresh(call.data.get(CONF_FORCE)) + + hass.services.async_register( + DOMAIN, SERVICE_ADD_URL, add_url, schema=SERVICE_ADD_URL_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_REMOVE_URL, remove_url, schema=SERVICE_URL_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_ENABLE_URL, enable_url, schema=SERVICE_URL_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_DISABLE_URL, disable_url, schema=SERVICE_URL_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_REFRESH, refresh, schema=SERVICE_REFRESH_SCHEMA + ) + + return True + + +async def async_unload_entry( + hass: HomeAssistantType, entry: ConfigType +) -> bool: + """Unload AdGuard Home config entry.""" + hass.services.async_remove(DOMAIN, SERVICE_ADD_URL) + hass.services.async_remove(DOMAIN, SERVICE_REMOVE_URL) + hass.services.async_remove(DOMAIN, SERVICE_ENABLE_URL) + hass.services.async_remove(DOMAIN, SERVICE_DISABLE_URL) + hass.services.async_remove(DOMAIN, SERVICE_REFRESH) + + for component in 'sensor', 'switch': + await hass.config_entries.async_forward_entry_unload(entry, component) + + del hass.data[DOMAIN] + + return True + + +class AdGuardHomeEntity(Entity): + """Defines a base AdGuard Home entity.""" + + def __init__(self, adguard, name: str, icon: str) -> None: + """Initialize the AdGuard Home entity.""" + self._name = name + self._icon = icon + self._available = True + self.adguard = adguard + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def icon(self) -> str: + """Return the mdi icon of the entity.""" + return self._icon + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + async def async_update(self) -> None: + """Update AdGuard Home entity.""" + try: + await self._adguard_update() + self._available = True + except AdGuardHomeError: + if self._available: + _LOGGER.debug( + "An error occurred while updating AdGuard Home sensor.", + exc_info=True, + ) + self._available = False + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + raise NotImplementedError() + + +class AdGuardHomeDeviceEntity(AdGuardHomeEntity): + """Defines a AdGuard Home device entity.""" + + @property + def device_info(self) -> Dict[str, Any]: + """Return device information about this AdGuard Home instance.""" + return { + 'identifiers': { + ( + DOMAIN, + self.adguard.host, + self.adguard.port, + self.adguard.base_path, + ) + }, + 'name': 'AdGuard Home', + 'manufacturer': 'AdGuard Team', + 'sw_version': self.hass.data[DOMAIN].get(DATA_ADGUARD_VERION), + } diff --git a/homeassistant/components/adguard/config_flow.py b/homeassistant/components/adguard/config_flow.py new file mode 100644 index 00000000000000..7e144a76e222e0 --- /dev/null +++ b/homeassistant/components/adguard/config_flow.py @@ -0,0 +1,147 @@ +"""Config flow to configure the AdGuard Home integration.""" +import logging + +from adguardhome import AdGuardHome, AdGuardHomeConnectionError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.adguard.const import DOMAIN +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SSL, CONF_USERNAME, + CONF_VERIFY_SSL) +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +_LOGGER = logging.getLogger(__name__) + + +@config_entries.HANDLERS.register(DOMAIN) +class AdGuardHomeFlowHandler(ConfigFlow): + """Handle a AdGuard Home config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + _hassio_discovery = None + + def __init__(self): + """Initialize AgGuard Home flow.""" + pass + + async def _show_setup_form(self, errors=None): + """Show the setup form to the user.""" + return self.async_show_form( + step_id='user', + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=3000): vol.Coerce(int), + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + vol.Required(CONF_SSL, default=True): bool, + vol.Required(CONF_VERIFY_SSL, default=True): bool, + } + ), + errors=errors or {}, + ) + + async def _show_hassio_form(self, errors=None): + """Show the Hass.io confirmation form to the user.""" + return self.async_show_form( + step_id='hassio_confirm', + description_placeholders={ + 'addon': self._hassio_discovery['addon'] + }, + data_schema=vol.Schema({}), + errors=errors or {}, + ) + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + if self._async_current_entries(): + return self.async_abort(reason='single_instance_allowed') + + if user_input is None: + return await self._show_setup_form(user_input) + + errors = {} + + session = async_get_clientsession( + self.hass, user_input[CONF_VERIFY_SSL] + ) + + adguard = AdGuardHome( + user_input[CONF_HOST], + port=user_input[CONF_PORT], + username=user_input.get(CONF_USERNAME), + password=user_input.get(CONF_PASSWORD), + tls=user_input[CONF_SSL], + verify_ssl=user_input[CONF_VERIFY_SSL], + loop=self.hass.loop, + session=session, + ) + + try: + await adguard.version() + except AdGuardHomeConnectionError: + errors['base'] = 'connection_error' + return await self._show_setup_form(errors) + + return self.async_create_entry( + title=user_input[CONF_HOST], + data={ + CONF_HOST: user_input[CONF_HOST], + CONF_PASSWORD: user_input.get(CONF_PASSWORD), + CONF_PORT: user_input[CONF_PORT], + CONF_SSL: user_input[CONF_SSL], + CONF_USERNAME: user_input.get(CONF_USERNAME), + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + }, + ) + + async def async_step_hassio(self, user_input=None): + """Prepare configuration for a Hass.io AdGuard Home add-on. + + This flow is triggered by the discovery component. + """ + if self._async_current_entries(): + return self.async_abort(reason='single_instance_allowed') + + self._hassio_discovery = user_input + + return await self.async_step_hassio_confirm() + + async def async_step_hassio_confirm(self, user_input=None): + """Confirm Hass.io discovery.""" + if user_input is None: + return await self._show_hassio_form() + + errors = {} + + session = async_get_clientsession(self.hass, False) + + adguard = AdGuardHome( + self._hassio_discovery[CONF_HOST], + port=self._hassio_discovery[CONF_PORT], + tls=False, + loop=self.hass.loop, + session=session, + ) + + try: + await adguard.version() + except AdGuardHomeConnectionError: + errors['base'] = 'connection_error' + return await self._show_hassio_form(errors) + + return self.async_create_entry( + title=self._hassio_discovery['addon'], + data={ + CONF_HOST: self._hassio_discovery[CONF_HOST], + CONF_PORT: self._hassio_discovery[CONF_PORT], + CONF_PASSWORD: None, + CONF_SSL: False, + CONF_USERNAME: None, + CONF_VERIFY_SSL: True, + }, + ) diff --git a/homeassistant/components/adguard/const.py b/homeassistant/components/adguard/const.py new file mode 100644 index 00000000000000..6bbabdafaf17c5 --- /dev/null +++ b/homeassistant/components/adguard/const.py @@ -0,0 +1,14 @@ +"""Constants for the AdGuard Home integration.""" + +DOMAIN = 'adguard' + +DATA_ADGUARD_CLIENT = 'adguard_client' +DATA_ADGUARD_VERION = 'adguard_version' + +CONF_FORCE = 'force' + +SERVICE_ADD_URL = 'add_url' +SERVICE_DISABLE_URL = 'disable_url' +SERVICE_ENABLE_URL = 'enable_url' +SERVICE_REFRESH = 'refresh' +SERVICE_REMOVE_URL = 'remove_url' diff --git a/homeassistant/components/adguard/manifest.json b/homeassistant/components/adguard/manifest.json new file mode 100644 index 00000000000000..281a384e21fe94 --- /dev/null +++ b/homeassistant/components/adguard/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "adguard", + "name": "AdGuard Home", + "config_flow": true, + "documentation": "https://www.home-assistant.io/components/adguard", + "requirements": [ + "adguardhome==0.2.0" + ], + "dependencies": [], + "codeowners": [ + "@frenck" + ] +} \ No newline at end of file diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py new file mode 100644 index 00000000000000..abb5309b449b8d --- /dev/null +++ b/homeassistant/components/adguard/sensor.py @@ -0,0 +1,232 @@ +"""Support for AdGuard Home sensors.""" +from datetime import timedelta +import logging + +from adguardhome import AdGuardHomeConnectionError + +from homeassistant.components.adguard import AdGuardHomeDeviceEntity +from homeassistant.components.adguard.const import ( + DATA_ADGUARD_CLIENT, DATA_ADGUARD_VERION, DOMAIN) +from homeassistant.config_entries import ConfigEntry +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.typing import HomeAssistantType + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=300) +PARALLEL_UPDATES = 4 + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up AdGuard Home sensor based on a config entry.""" + adguard = hass.data[DOMAIN][DATA_ADGUARD_CLIENT] + + try: + version = await adguard.version() + except AdGuardHomeConnectionError as exception: + raise PlatformNotReady from exception + + hass.data[DOMAIN][DATA_ADGUARD_VERION] = version + + sensors = [ + AdGuardHomeDNSQueriesSensor(adguard), + AdGuardHomeBlockedFilteringSensor(adguard), + AdGuardHomePercentageBlockedSensor(adguard), + AdGuardHomeReplacedParentalSensor(adguard), + AdGuardHomeReplacedSafeBrowsingSensor(adguard), + AdGuardHomeReplacedSafeSearchSensor(adguard), + AdGuardHomeAverageProcessingTimeSensor(adguard), + AdGuardHomeRulesCountSensor(adguard), + ] + + async_add_entities(sensors, True) + + +class AdGuardHomeSensor(AdGuardHomeDeviceEntity): + """Defines a AdGuard Home sensor.""" + + def __init__( + self, + adguard, + name: str, + icon: str, + measurement: str, + unit_of_measurement: str, + ) -> None: + """Initialize AdGuard Home sensor.""" + self._state = None + self._unit_of_measurement = unit_of_measurement + self.measurement = measurement + + super().__init__(adguard, name, icon) + + @property + def unique_id(self) -> str: + """Return the unique ID for this sensor.""" + return '_'.join( + [ + DOMAIN, + self.adguard.host, + str(self.adguard.port), + 'sensor', + self.measurement, + ] + ) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return self._unit_of_measurement + + +class AdGuardHomeDNSQueriesSensor(AdGuardHomeSensor): + """Defines a AdGuard Home DNS Queries sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, + 'AdGuard DNS Queries', + 'mdi:magnify', + 'dns_queries', + 'queries', + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.stats.dns_queries() + + +class AdGuardHomeBlockedFilteringSensor(AdGuardHomeSensor): + """Defines a AdGuard Home blocked by filtering sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, + 'AdGuard DNS Queries Blocked', + 'mdi:magnify-close', + 'blocked_filtering', + 'queries', + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.stats.blocked_filtering() + + +class AdGuardHomePercentageBlockedSensor(AdGuardHomeSensor): + """Defines a AdGuard Home blocked percentage sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, + 'AdGuard DNS Queries Blocked Ratio', + 'mdi:magnify-close', + 'blocked_percentage', + '%', + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + percentage = await self.adguard.stats.blocked_percentage() + self._state = "{:.2f}".format(percentage) + + +class AdGuardHomeReplacedParentalSensor(AdGuardHomeSensor): + """Defines a AdGuard Home replaced by parental control sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, + 'AdGuard Parental Control Blocked', + 'mdi:human-male-girl', + 'blocked_parental', + 'requests', + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.stats.replaced_parental() + + +class AdGuardHomeReplacedSafeBrowsingSensor(AdGuardHomeSensor): + """Defines a AdGuard Home replaced by safe browsing sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, + 'AdGuard Safe Browsing Blocked', + 'mdi:shield-half-full', + 'blocked_safebrowsing', + 'requests', + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.stats.replaced_safebrowsing() + + +class AdGuardHomeReplacedSafeSearchSensor(AdGuardHomeSensor): + """Defines a AdGuard Home replaced by safe search sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, + 'Searches Safe Search Enforced', + 'mdi:shield-search', + 'enforced_safesearch', + 'requests', + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.stats.replaced_safesearch() + + +class AdGuardHomeAverageProcessingTimeSensor(AdGuardHomeSensor): + """Defines a AdGuard Home average processing time sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, + 'AdGuard Average Processing Speed', + 'mdi:speedometer', + 'average_speed', + 'ms', + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + average = await self.adguard.stats.avg_processing_time() + self._state = "{:.2f}".format(average) + + +class AdGuardHomeRulesCountSensor(AdGuardHomeSensor): + """Defines a AdGuard Home rules count sensor.""" + + def __init__(self, adguard): + """Initialize AdGuard Home sensor.""" + super().__init__( + adguard, + 'AdGuard Rules Count', + 'mdi:counter', + 'rules_count', + 'rules', + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.filtering.rules_count() diff --git a/homeassistant/components/adguard/services.yaml b/homeassistant/components/adguard/services.yaml new file mode 100644 index 00000000000000..736acdd923c853 --- /dev/null +++ b/homeassistant/components/adguard/services.yaml @@ -0,0 +1,37 @@ +add_url: + description: Add a new filter subscription to AdGuard Home. + fields: + name: + description: The name of the filter subscription. + example: Example + url: + description: The filter URL to subscribe to, containing the filter rules. + example: https://www.example.com/filter/1.txt + +remove_url: + description: Removes a filter subscription from AdGuard Home. + fields: + url: + description: The filter subscription URL to remove. + example: https://www.example.com/filter/1.txt + +enable_url: + description: Enables a filter subscription in AdGuard Home. + fields: + url: + description: The filter subscription URL to enable. + example: https://www.example.com/filter/1.txt + +disable_url: + description: Disables a filter subscription in AdGuard Home. + fields: + url: + description: The filter subscription URL to disable. + example: https://www.example.com/filter/1.txt + +refresh: + description: Refresh all filter subscriptions in AdGuard Home. + fields: + force: + description: Force update (by passes AdGuard Home throttling). + example: '"true" to force, "false" or omit for a regular refresh.' diff --git a/homeassistant/components/adguard/strings.json b/homeassistant/components/adguard/strings.json new file mode 100644 index 00000000000000..c88f7085e341c0 --- /dev/null +++ b/homeassistant/components/adguard/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "title": "AdGuard Home", + "step": { + "user": { + "title": "Link your AdGuard Home.", + "description": "Set up your AdGuard Home instance to allow monitoring and control.", + "data": { + "host": "Host", + "password": "Password", + "port": "Port", + "username": "Username", + "ssl": "AdGuard Home uses a SSL certificate", + "verify_ssl": "AdGuard Home uses a proper certificate" + } + }, + "hassio_confirm": { + "title": "AdGuard Home via Hass.io add-on", + "description": "Do you want to configure Home Assistant to connect to the AdGuard Home provided by the Hass.io add-on: {addon}?" + } + }, + "error": { + "connection_error": "Failed to connect." + }, + "abort": { + "single_instance_allowed": "Only a single configuration of AdGuard Home is allowed." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/switch.py b/homeassistant/components/adguard/switch.py new file mode 100644 index 00000000000000..601bf25b5b06e4 --- /dev/null +++ b/homeassistant/components/adguard/switch.py @@ -0,0 +1,233 @@ +"""Support for AdGuard Home switches.""" +from datetime import timedelta +import logging + +from adguardhome import AdGuardHomeConnectionError, AdGuardHomeError + +from homeassistant.components.adguard import AdGuardHomeDeviceEntity +from homeassistant.components.adguard.const import ( + DATA_ADGUARD_CLIENT, DATA_ADGUARD_VERION, DOMAIN) +from homeassistant.config_entries import ConfigEntry +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.typing import HomeAssistantType + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=10) +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up AdGuard Home switch based on a config entry.""" + adguard = hass.data[DOMAIN][DATA_ADGUARD_CLIENT] + + try: + version = await adguard.version() + except AdGuardHomeConnectionError as exception: + raise PlatformNotReady from exception + + hass.data[DOMAIN][DATA_ADGUARD_VERION] = version + + switches = [ + AdGuardHomeProtectionSwitch(adguard), + AdGuardHomeFilteringSwitch(adguard), + AdGuardHomeParentalSwitch(adguard), + AdGuardHomeSafeBrowsingSwitch(adguard), + AdGuardHomeSafeSearchSwitch(adguard), + AdGuardHomeQueryLogSwitch(adguard), + ] + async_add_entities(switches, True) + + +class AdGuardHomeSwitch(ToggleEntity, AdGuardHomeDeviceEntity): + """Defines a AdGuard Home switch.""" + + def __init__(self, adguard, name: str, icon: str, key: str): + """Initialize AdGuard Home switch.""" + self._state = False + self._key = key + super().__init__(adguard, name, icon) + + @property + def unique_id(self) -> str: + """Return the unique ID for this sensor.""" + return '_'.join( + [ + DOMAIN, + self.adguard.host, + str(self.adguard.port), + 'switch', + self._key, + ] + ) + + @property + def is_on(self) -> bool: + """Return the state of the switch.""" + return self._state + + async def async_turn_off(self, **kwargs) -> None: + """Turn off the switch.""" + try: + await self._adguard_turn_off() + except AdGuardHomeError: + _LOGGER.error( + "An error occurred while turning off AdGuard Home switch." + ) + self._available = False + + async def _adguard_turn_off(self) -> None: + """Turn off the switch.""" + raise NotImplementedError() + + async def async_turn_on(self, **kwargs) -> None: + """Turn on the switch.""" + try: + await self._adguard_turn_on() + except AdGuardHomeError: + _LOGGER.error( + "An error occurred while turning on AdGuard Home switch." + ) + self._available = False + + async def _adguard_turn_on(self) -> None: + """Turn on the switch.""" + raise NotImplementedError() + + +class AdGuardHomeProtectionSwitch(AdGuardHomeSwitch): + """Defines a AdGuard Home protection switch.""" + + def __init__(self, adguard) -> None: + """Initialize AdGuard Home switch.""" + super().__init__( + adguard, "AdGuard Protection", 'mdi:shield-check', 'protection' + ) + + async def _adguard_turn_off(self) -> None: + """Turn off the switch.""" + await self.adguard.disable_protection() + + async def _adguard_turn_on(self) -> None: + """Turn on the switch.""" + await self.adguard.enable_protection() + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.protection_enabled() + + +class AdGuardHomeParentalSwitch(AdGuardHomeSwitch): + """Defines a AdGuard Home parental control switch.""" + + def __init__(self, adguard) -> None: + """Initialize AdGuard Home switch.""" + super().__init__( + adguard, "AdGuard Parental Control", 'mdi:shield-check', 'parental' + ) + + async def _adguard_turn_off(self) -> None: + """Turn off the switch.""" + await self.adguard.parental.disable() + + async def _adguard_turn_on(self) -> None: + """Turn on the switch.""" + await self.adguard.parental.enable() + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.parental.enabled() + + +class AdGuardHomeSafeSearchSwitch(AdGuardHomeSwitch): + """Defines a AdGuard Home safe search switch.""" + + def __init__(self, adguard) -> None: + """Initialize AdGuard Home switch.""" + super().__init__( + adguard, "AdGuard Safe Search", 'mdi:shield-check', 'safesearch' + ) + + async def _adguard_turn_off(self) -> None: + """Turn off the switch.""" + await self.adguard.safesearch.disable() + + async def _adguard_turn_on(self) -> None: + """Turn on the switch.""" + await self.adguard.safesearch.enable() + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.safesearch.enabled() + + +class AdGuardHomeSafeBrowsingSwitch(AdGuardHomeSwitch): + """Defines a AdGuard Home safe search switch.""" + + def __init__(self, adguard) -> None: + """Initialize AdGuard Home switch.""" + super().__init__( + adguard, + "AdGuard Safe Browsing", + 'mdi:shield-check', + 'safebrowsing', + ) + + async def _adguard_turn_off(self) -> None: + """Turn off the switch.""" + await self.adguard.safebrowsing.disable() + + async def _adguard_turn_on(self) -> None: + """Turn on the switch.""" + await self.adguard.safebrowsing.enable() + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.safebrowsing.enabled() + + +class AdGuardHomeFilteringSwitch(AdGuardHomeSwitch): + """Defines a AdGuard Home filtering switch.""" + + def __init__(self, adguard) -> None: + """Initialize AdGuard Home switch.""" + super().__init__( + adguard, "AdGuard Filtering", 'mdi:shield-check', 'filtering' + ) + + async def _adguard_turn_off(self) -> None: + """Turn off the switch.""" + await self.adguard.filtering.disable() + + async def _adguard_turn_on(self) -> None: + """Turn on the switch.""" + await self.adguard.filtering.enable() + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.filtering.enabled() + + +class AdGuardHomeQueryLogSwitch(AdGuardHomeSwitch): + """Defines a AdGuard Home query log switch.""" + + def __init__(self, adguard) -> None: + """Initialize AdGuard Home switch.""" + super().__init__( + adguard, "AdGuard Query Log", 'mdi:shield-check', 'querylog' + ) + + async def _adguard_turn_off(self) -> None: + """Turn off the switch.""" + await self.adguard.querylog.disable() + + async def _adguard_turn_on(self) -> None: + """Turn on the switch.""" + await self.adguard.querylog.enable() + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + self._state = await self.adguard.querylog.enabled() diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 32b41610acf02a..41b03264c4f5be 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -5,6 +5,7 @@ FLOWS = [ + "adguard", "ambiclimate", "ambient_station", "axis", diff --git a/requirements_all.txt b/requirements_all.txt index 4fb5dd4deab8de..6c4421ed2b338a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -107,6 +107,9 @@ adafruit-blinka==1.2.1 # homeassistant.components.mcp23017 adafruit-circuitpython-mcp230xx==1.1.2 +# homeassistant.components.adguard +adguardhome==0.2.0 + # homeassistant.components.frontier_silicon afsapi==0.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7ad6118362569e..4fde162bdd9741 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -35,6 +35,9 @@ PyTransportNSW==0.1.1 # homeassistant.components.yessssms YesssSMS==0.2.3 +# homeassistant.components.adguard +adguardhome==0.2.0 + # homeassistant.components.ambient_station aioambient==0.3.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index f85758e464ff02..33f27a6702188c 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -42,6 +42,7 @@ ) TEST_REQUIREMENTS = ( + 'adguardhome', 'ambiclimate', 'aioambient', 'aioautomatic', diff --git a/tests/components/adguard/__init__.py b/tests/components/adguard/__init__.py new file mode 100644 index 00000000000000..318e881ef2f90b --- /dev/null +++ b/tests/components/adguard/__init__.py @@ -0,0 +1 @@ +"""Tests for the AdGuard Home component.""" diff --git a/tests/components/adguard/test_config_flow.py b/tests/components/adguard/test_config_flow.py new file mode 100644 index 00000000000000..451fd1436d411d --- /dev/null +++ b/tests/components/adguard/test_config_flow.py @@ -0,0 +1,167 @@ +"""Tests for the AdGuard Home config flow.""" +import aiohttp + +from homeassistant import data_entry_flow +from homeassistant.components.adguard import config_flow +from homeassistant.components.adguard.const import DOMAIN +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SSL, CONF_USERNAME, + CONF_VERIFY_SSL) + +from tests.common import MockConfigEntry + +FIXTURE_USER_INPUT = { + CONF_HOST: '127.0.0.1', + CONF_PORT: 3000, + CONF_USERNAME: 'user', + CONF_PASSWORD: 'pass', + CONF_SSL: True, + CONF_VERIFY_SSL: True, +} + + +async def test_show_authenticate_form(hass): + """Test that the setup form is served.""" + flow = config_flow.AdGuardHomeFlowHandler() + flow.hass = hass + result = await flow.async_step_user(user_input=None) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + +async def test_connection_error(hass, aioclient_mock): + """Test we show user form on AdGuard Home connection error.""" + aioclient_mock.get( + "{}://{}:{}/control/status".format( + 'https' if FIXTURE_USER_INPUT[CONF_SSL] else 'http', + FIXTURE_USER_INPUT[CONF_HOST], + FIXTURE_USER_INPUT[CONF_PORT], + ), + exc=aiohttp.ClientError, + ) + + flow = config_flow.AdGuardHomeFlowHandler() + flow.hass = hass + result = await flow.async_step_user(user_input=FIXTURE_USER_INPUT) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + assert result['errors'] == {'base': 'connection_error'} + + +async def test_full_flow_implementation(hass, aioclient_mock): + """Test registering an integration and finishing flow works.""" + aioclient_mock.get( + "{}://{}:{}/control/status".format( + 'https' if FIXTURE_USER_INPUT[CONF_SSL] else 'http', + FIXTURE_USER_INPUT[CONF_HOST], + FIXTURE_USER_INPUT[CONF_PORT], + ), + json={'version': '1.0'}, + headers={'Content-Type': 'application/json'}, + ) + + flow = config_flow.AdGuardHomeFlowHandler() + flow.hass = hass + result = await flow.async_step_user(user_input=None) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + result = await flow.async_step_user(user_input=FIXTURE_USER_INPUT) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == FIXTURE_USER_INPUT[CONF_HOST] + assert result['data'][CONF_HOST] == FIXTURE_USER_INPUT[CONF_HOST] + assert result['data'][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] + assert result['data'][CONF_PORT] == FIXTURE_USER_INPUT[CONF_PORT] + assert result['data'][CONF_SSL] == FIXTURE_USER_INPUT[CONF_SSL] + assert result['data'][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] + assert ( + result['data'][CONF_VERIFY_SSL] == FIXTURE_USER_INPUT[CONF_VERIFY_SSL] + ) + + +async def test_integration_already_exists(hass): + """Test we only allow a single config flow.""" + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={'source': 'user'} + ) + assert result['type'] == 'abort' + assert result['reason'] == 'single_instance_allowed' + + +async def test_hassio_single_instance(hass): + """Test we only allow a single config flow.""" + MockConfigEntry(domain='adguard', data={'host': '1.2.3.4'}).add_to_hass( + hass + ) + + result = await hass.config_entries.flow.async_init( + 'adguard', context={'source': 'hassio'} + ) + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'single_instance_allowed' + + +async def test_hassio_confirm(hass, aioclient_mock): + """Test we can finish a config flow.""" + aioclient_mock.get( + "http://mock-adguard:3000/control/status", + json={'version': '1.0'}, + headers={'Content-Type': 'application/json'}, + ) + + result = await hass.config_entries.flow.async_init( + 'adguard', + data={ + 'addon': 'AdGuard Home Addon', + 'host': 'mock-adguard', + 'port': 3000, + }, + context={'source': 'hassio'}, + ) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'hassio_confirm' + assert result['description_placeholders'] == { + 'addon': 'AdGuard Home Addon' + } + + result = await hass.config_entries.flow.async_configure( + result['flow_id'], {} + ) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == 'AdGuard Home Addon' + assert result['data'][CONF_HOST] == 'mock-adguard' + assert result['data'][CONF_PASSWORD] is None + assert result['data'][CONF_PORT] == 3000 + assert result['data'][CONF_SSL] is False + assert result['data'][CONF_USERNAME] is None + assert result['data'][CONF_VERIFY_SSL] + + +async def test_hassio_connection_error(hass, aioclient_mock): + """Test we show hassio confirm form on AdGuard Home connection error.""" + aioclient_mock.get( + "http://mock-adguard:3000/control/status", + exc=aiohttp.ClientError, + ) + + result = await hass.config_entries.flow.async_init( + 'adguard', + data={ + 'addon': 'AdGuard Home Addon', + 'host': 'mock-adguard', + 'port': 3000, + }, + context={'source': 'hassio'}, + ) + + result = await hass.config_entries.flow.async_configure( + result['flow_id'], {} + ) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'hassio_confirm' + assert result['errors'] == {'base': 'connection_error'}