From 1a73cb4791980a4d31fff16b696c32a478384591 Mon Sep 17 00:00:00 2001 From: MeIchthys <10717998+meichthys@users.noreply.github.com> Date: Wed, 24 Feb 2021 06:04:38 -0500 Subject: [PATCH] Mullvad VPN (#44189) Co-authored-by: Franck Nijhof Co-authored-by: Franck Nijhof --- .coveragerc | 3 + CODEOWNERS | 1 + homeassistant/components/mullvad/__init__.py | 63 +++++++++++++++++++ .../components/mullvad/binary_sensor.py | 52 +++++++++++++++ .../components/mullvad/config_flow.py | 25 ++++++++ homeassistant/components/mullvad/const.py | 3 + .../components/mullvad/manifest.json | 12 ++++ homeassistant/components/mullvad/strings.json | 22 +++++++ .../components/mullvad/translations/en.json | 22 +++++++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/mullvad/__init__.py | 1 + tests/components/mullvad/test_config_flow.py | 46 ++++++++++++++ 14 files changed, 257 insertions(+) create mode 100644 homeassistant/components/mullvad/__init__.py create mode 100644 homeassistant/components/mullvad/binary_sensor.py create mode 100644 homeassistant/components/mullvad/config_flow.py create mode 100644 homeassistant/components/mullvad/const.py create mode 100644 homeassistant/components/mullvad/manifest.json create mode 100644 homeassistant/components/mullvad/strings.json create mode 100644 homeassistant/components/mullvad/translations/en.json create mode 100644 tests/components/mullvad/__init__.py create mode 100644 tests/components/mullvad/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index c0a28f70a4f..dfa742b490c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -586,6 +586,9 @@ omit = homeassistant/components/mpd/media_player.py homeassistant/components/mqtt_room/sensor.py homeassistant/components/msteams/notify.py + homeassistant/components/mullvad/__init__.py + homeassistant/components/mullvad/binary_sensor.py + homeassistant/components/nest/const.py homeassistant/components/mvglive/sensor.py homeassistant/components/mychevy/* homeassistant/components/mycroft/* diff --git a/CODEOWNERS b/CODEOWNERS index 2db89f09948..398e5b15f7f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -293,6 +293,7 @@ homeassistant/components/motion_blinds/* @starkillerOG homeassistant/components/mpd/* @fabaff homeassistant/components/mqtt/* @emontnemery homeassistant/components/msteams/* @peroyvind +homeassistant/components/mullvad/* @meichthys homeassistant/components/my/* @home-assistant/core homeassistant/components/myq/* @bdraco homeassistant/components/mysensors/* @MartinHjelmare @functionpointer diff --git a/homeassistant/components/mullvad/__init__.py b/homeassistant/components/mullvad/__init__.py new file mode 100644 index 00000000000..8d63ffd2221 --- /dev/null +++ b/homeassistant/components/mullvad/__init__.py @@ -0,0 +1,63 @@ +"""The Mullvad VPN integration.""" +import asyncio +from datetime import timedelta +import logging + +import async_timeout +from mullvad_api import MullvadAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import update_coordinator + +from .const import DOMAIN + +PLATFORMS = ["binary_sensor"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Mullvad VPN integration.""" + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: dict): + """Set up Mullvad VPN integration.""" + + async def async_get_mullvad_api_data(): + with async_timeout.timeout(10): + api = await hass.async_add_executor_job(MullvadAPI) + return api.data + + hass.data[DOMAIN] = update_coordinator.DataUpdateCoordinator( + hass, + logging.getLogger(__name__), + name=DOMAIN, + update_method=async_get_mullvad_api_data, + update_interval=timedelta(minutes=1), + ) + await hass.data[DOMAIN].async_refresh() + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + + if unload_ok: + del hass.data[DOMAIN] + + return unload_ok diff --git a/homeassistant/components/mullvad/binary_sensor.py b/homeassistant/components/mullvad/binary_sensor.py new file mode 100644 index 00000000000..40b9ae5b1a8 --- /dev/null +++ b/homeassistant/components/mullvad/binary_sensor.py @@ -0,0 +1,52 @@ +"""Setup Mullvad VPN Binary Sensors.""" +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, + BinarySensorEntity, +) +from homeassistant.const import CONF_DEVICE_CLASS, CONF_ID, CONF_NAME +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + +BINARY_SENSORS = ( + { + CONF_ID: "mullvad_exit_ip", + CONF_NAME: "Mullvad Exit IP", + CONF_DEVICE_CLASS: DEVICE_CLASS_CONNECTIVITY, + }, +) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Defer sensor setup to the shared sensor module.""" + coordinator = hass.data[DOMAIN] + + async_add_entities( + MullvadBinarySensor(coordinator, sensor) for sensor in BINARY_SENSORS + ) + + +class MullvadBinarySensor(CoordinatorEntity, BinarySensorEntity): + """Represents a Mullvad binary sensor.""" + + def __init__(self, coordinator, sensor): # pylint: disable=super-init-not-called + """Initialize the Mullvad binary sensor.""" + super().__init__(coordinator) + self.id = sensor[CONF_ID] + self._name = sensor[CONF_NAME] + self._device_class = sensor[CONF_DEVICE_CLASS] + + @property + def device_class(self): + """Return the device class for this binary sensor.""" + return self._device_class + + @property + def name(self): + """Return the name for this binary sensor.""" + return self._name + + @property + def state(self): + """Return the state for this binary sensor.""" + return self.coordinator.data[self.id] diff --git a/homeassistant/components/mullvad/config_flow.py b/homeassistant/components/mullvad/config_flow.py new file mode 100644 index 00000000000..d7b6f92c445 --- /dev/null +++ b/homeassistant/components/mullvad/config_flow.py @@ -0,0 +1,25 @@ +"""Config flow for Mullvad VPN integration.""" +import logging + +from homeassistant import config_entries + +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Mullvad VPN.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason="already_configured") + + if user_input is not None: + return self.async_create_entry(title="Mullvad VPN", data=user_input) + + return self.async_show_form(step_id="user") diff --git a/homeassistant/components/mullvad/const.py b/homeassistant/components/mullvad/const.py new file mode 100644 index 00000000000..4e3be28782c --- /dev/null +++ b/homeassistant/components/mullvad/const.py @@ -0,0 +1,3 @@ +"""Constants for the Mullvad VPN integration.""" + +DOMAIN = "mullvad" diff --git a/homeassistant/components/mullvad/manifest.json b/homeassistant/components/mullvad/manifest.json new file mode 100644 index 00000000000..1a440240d7e --- /dev/null +++ b/homeassistant/components/mullvad/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "mullvad", + "name": "Mullvad VPN", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/mullvad", + "requirements": [ + "mullvad-api==1.0.0" + ], + "codeowners": [ + "@meichthys" + ] +} diff --git a/homeassistant/components/mullvad/strings.json b/homeassistant/components/mullvad/strings.json new file mode 100644 index 00000000000..f522c12871f --- /dev/null +++ b/homeassistant/components/mullvad/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "description": "Set up the Mullvad VPN integration?", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + } + } + } + } +} diff --git a/homeassistant/components/mullvad/translations/en.json b/homeassistant/components/mullvad/translations/en.json new file mode 100644 index 00000000000..fcfa89ef082 --- /dev/null +++ b/homeassistant/components/mullvad/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Password", + "username": "Username" + }, + "description": "Set up the Mullvad VPN integration?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e0a4fd8cd57..da16d32d45b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -143,6 +143,7 @@ FLOWS = [ "monoprice", "motion_blinds", "mqtt", + "mullvad", "myq", "mysensors", "neato", diff --git a/requirements_all.txt b/requirements_all.txt index 60f7997a433..102b474f3bf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -955,6 +955,9 @@ mitemp_bt==0.0.3 # homeassistant.components.motion_blinds motionblinds==0.4.8 +# homeassistant.components.mullvad +mullvad-api==1.0.0 + # homeassistant.components.tts mutagen==1.45.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c21bec2a08f..7070b69162e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -500,6 +500,9 @@ minio==4.0.9 # homeassistant.components.motion_blinds motionblinds==0.4.8 +# homeassistant.components.mullvad +mullvad-api==1.0.0 + # homeassistant.components.tts mutagen==1.45.1 diff --git a/tests/components/mullvad/__init__.py b/tests/components/mullvad/__init__.py new file mode 100644 index 00000000000..dc940265eac --- /dev/null +++ b/tests/components/mullvad/__init__.py @@ -0,0 +1 @@ +"""Tests for the mullvad component.""" diff --git a/tests/components/mullvad/test_config_flow.py b/tests/components/mullvad/test_config_flow.py new file mode 100644 index 00000000000..01485da60a0 --- /dev/null +++ b/tests/components/mullvad/test_config_flow.py @@ -0,0 +1,46 @@ +"""Test the Mullvad config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries, setup +from homeassistant.components.mullvad.const import DOMAIN + +from tests.common import MockConfigEntry + + +async def test_form_user(hass): + """Test we can setup by the user.""" + await setup.async_setup_component(hass, DOMAIN, {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + + with patch( + "homeassistant.components.mullvad.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.mullvad.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "Mullvad VPN" + assert result2["data"] == {} + assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_user_only_once(hass): + """Test we can setup by the user only once.""" + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "abort" + assert result["reason"] == "already_configured"