From d5c3d234ecbf47315bbaf037bd718a1a1abcf67b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 29 Sep 2021 17:43:51 +0200 Subject: [PATCH] Open garage, add config flow (#55290) --- .coveragerc | 1 + .../components/opengarage/__init__.py | 39 +++- .../components/opengarage/config_flow.py | 107 ++++++++++ homeassistant/components/opengarage/const.py | 11 + homeassistant/components/opengarage/cover.py | 66 +++--- .../components/opengarage/manifest.json | 13 +- .../components/opengarage/strings.json | 22 ++ homeassistant/generated/config_flows.py | 1 + requirements_test_all.txt | 3 + tests/components/opengarage/__init__.py | 1 + .../components/opengarage/test_config_flow.py | 202 ++++++++++++++++++ 11 files changed, 428 insertions(+), 38 deletions(-) create mode 100644 homeassistant/components/opengarage/config_flow.py create mode 100644 homeassistant/components/opengarage/const.py create mode 100644 homeassistant/components/opengarage/strings.json create mode 100644 tests/components/opengarage/__init__.py create mode 100644 tests/components/opengarage/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 4fc01dd17db..899c16e3920 100644 --- a/.coveragerc +++ b/.coveragerc @@ -764,6 +764,7 @@ omit = homeassistant/components/opencv/* homeassistant/components/openevse/sensor.py homeassistant/components/openexchangerates/sensor.py + homeassistant/components/opengarage/__init__.py homeassistant/components/opengarage/cover.py homeassistant/components/openhome/__init__.py homeassistant/components/openhome/media_player.py diff --git a/homeassistant/components/opengarage/__init__.py b/homeassistant/components/opengarage/__init__.py index 2f4d2e09cfb..5ea3af79ae4 100644 --- a/homeassistant/components/opengarage/__init__.py +++ b/homeassistant/components/opengarage/__init__.py @@ -1 +1,38 @@ -"""The opengarage component.""" +"""The OpenGarage integration.""" +from __future__ import annotations + +import opengarage + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_DEVICE_KEY, DOMAIN + +PLATFORMS = ["cover"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up OpenGarage from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + hass.data[DOMAIN][entry.entry_id] = opengarage.OpenGarage( + f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}", + entry.data[CONF_DEVICE_KEY], + entry.data[CONF_VERIFY_SSL], + async_get_clientsession(hass), + ) + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/opengarage/config_flow.py b/homeassistant/components/opengarage/config_flow.py new file mode 100644 index 00000000000..9121391b4e0 --- /dev/null +++ b/homeassistant/components/opengarage/config_flow.py @@ -0,0 +1,107 @@ +"""Config flow for OpenGarage integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import aiohttp +import opengarage +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac + +from .const import CONF_DEVICE_KEY, DEFAULT_PORT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_DEVICE_KEY): str, + vol.Required(CONF_HOST, default="http://"): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, + vol.Optional(CONF_VERIFY_SSL, default=False): bool, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + open_garage = opengarage.OpenGarage( + f"{data[CONF_HOST]}:{data[CONF_PORT]}", + data[CONF_DEVICE_KEY], + data[CONF_VERIFY_SSL], + async_get_clientsession(hass), + ) + + try: + status = await open_garage.update_state() + except aiohttp.ClientError as exp: + raise CannotConnect from exp + + if status is None: + raise InvalidAuth + + return {"title": status.get("name"), "unique_id": format_mac(status["mac"])} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for OpenGarage.""" + + VERSION = 1 + + async def async_step_import(self, import_info): + """Set the config entry up from yaml.""" + import_info[CONF_HOST] = ( + f"{'https' if import_info[CONF_SSL] else 'http'}://" + f"{import_info.get(CONF_HOST)}" + ) + + del import_info[CONF_SSL] + return await self.async_step_user(import_info) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(info["unique_id"]) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/opengarage/const.py b/homeassistant/components/opengarage/const.py new file mode 100644 index 00000000000..7cf9287e182 --- /dev/null +++ b/homeassistant/components/opengarage/const.py @@ -0,0 +1,11 @@ +"""Constants for the OpenGarage integration.""" + +ATTR_DISTANCE_SENSOR = "distance_sensor" +ATTR_DOOR_STATE = "door_state" +ATTR_SIGNAL_STRENGTH = "wifi_signal" + +CONF_DEVICE_KEY = "device_key" + +DEFAULT_NAME = "OpenGarage" +DEFAULT_PORT = 80 +DOMAIN = "opengarage" diff --git a/homeassistant/components/opengarage/cover.py b/homeassistant/components/opengarage/cover.py index 95743146a5f..5323ae7b0d3 100644 --- a/homeassistant/components/opengarage/cover.py +++ b/homeassistant/components/opengarage/cover.py @@ -1,9 +1,9 @@ """Platform for the opengarage.io cover component.""" import logging -import opengarage import voluptuous as vol +from homeassistant import config_entries from homeassistant.components.cover import ( DEVICE_CLASS_GARAGE, PLATFORM_SCHEMA, @@ -23,21 +23,19 @@ from homeassistant.const import ( STATE_OPEN, STATE_OPENING, ) -from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import format_mac + +from .const import ( + ATTR_DISTANCE_SENSOR, + ATTR_DOOR_STATE, + ATTR_SIGNAL_STRENGTH, + CONF_DEVICE_KEY, + DEFAULT_PORT, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) -ATTR_DISTANCE_SENSOR = "distance_sensor" -ATTR_DOOR_STATE = "door_state" -ATTR_SIGNAL_STRENGTH = "wifi_signal" - -CONF_DEVICE_KEY = "device_key" - -DEFAULT_NAME = "OpenGarage" -DEFAULT_PORT = 80 - STATES_MAP = {0: STATE_CLOSED, 1: STATE_OPEN} COVER_SCHEMA = vol.Schema( @@ -58,29 +56,22 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the OpenGarage covers.""" - covers = [] devices = config.get(CONF_COVERS) - for device_config in devices.values(): - opengarage_url = ( - f"{'https' if device_config[CONF_SSL] else 'http'}://" - f"{device_config.get(CONF_HOST)}:{device_config.get(CONF_PORT)}" - ) - - open_garage = opengarage.OpenGarage( - opengarage_url, - device_config[CONF_DEVICE_KEY], - device_config[CONF_VERIFY_SSL], - async_get_clientsession(hass), - ) - status = await open_garage.update_state() - covers.append( - OpenGarageCover( - device_config.get(CONF_NAME), open_garage, format_mac(status["mac"]) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=device_config, ) ) - async_add_entities(covers, True) + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the OpenGarage covers.""" + async_add_entities( + [OpenGarageCover(hass.data[DOMAIN][entry.entry_id], entry.unique_id)], True + ) class OpenGarageCover(CoverEntity): @@ -89,14 +80,13 @@ class OpenGarageCover(CoverEntity): _attr_device_class = DEVICE_CLASS_GARAGE _attr_supported_features = SUPPORT_OPEN | SUPPORT_CLOSE - def __init__(self, name, open_garage, device_id): + def __init__(self, open_garage, device_id): """Initialize the cover.""" - self._attr_name = name self._open_garage = open_garage self._state = None self._state_before_move = None self._extra_state_attributes = {} - self._attr_unique_id = device_id + self._attr_unique_id = self._device_id = device_id @property def extra_state_attributes(self): @@ -183,3 +173,13 @@ class OpenGarageCover(CoverEntity): self._state = self._state_before_move self._state_before_move = None + + @property + def device_info(self): + """Return the device_info of the device.""" + device_info = { + "identifiers": {(DOMAIN, self._device_id)}, + "name": self.name, + "manufacturer": "Open Garage", + } + return device_info diff --git a/homeassistant/components/opengarage/manifest.json b/homeassistant/components/opengarage/manifest.json index b6c617408b5..bf32b060f11 100644 --- a/homeassistant/components/opengarage/manifest.json +++ b/homeassistant/components/opengarage/manifest.json @@ -2,7 +2,12 @@ "domain": "opengarage", "name": "OpenGarage", "documentation": "https://www.home-assistant.io/integrations/opengarage", - "codeowners": ["@danielhiversen"], - "requirements": ["open-garage==0.1.5"], - "iot_class": "local_polling" -} + "codeowners": [ + "@danielhiversen" + ], + "requirements": [ + "open-garage==0.1.5" + ], + "iot_class": "local_polling", + "config_flow": true +} \ No newline at end of file diff --git a/homeassistant/components/opengarage/strings.json b/homeassistant/components/opengarage/strings.json new file mode 100644 index 00000000000..20e90386b45 --- /dev/null +++ b/homeassistant/components/opengarage/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "data": { + "device_key": "Device key", + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + } + } + }, + "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%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index da4079fe49f..0bd6edaf146 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -201,6 +201,7 @@ FLOWS = [ "ondilo_ico", "onewire", "onvif", + "opengarage", "opentherm_gw", "openuv", "openweathermap", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3f35b80347a..8464ee19383 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -660,6 +660,9 @@ ondilo==0.2.0 # homeassistant.components.onvif onvif-zeep-async==1.2.0 +# homeassistant.components.opengarage +open-garage==0.1.5 + # homeassistant.components.openerz openerz-api==0.1.0 diff --git a/tests/components/opengarage/__init__.py b/tests/components/opengarage/__init__.py new file mode 100644 index 00000000000..04c2572fde2 --- /dev/null +++ b/tests/components/opengarage/__init__.py @@ -0,0 +1 @@ +"""Tests for the OpenGarage integration.""" diff --git a/tests/components/opengarage/test_config_flow.py b/tests/components/opengarage/test_config_flow.py new file mode 100644 index 00000000000..ca89d07cedc --- /dev/null +++ b/tests/components/opengarage/test_config_flow.py @@ -0,0 +1,202 @@ +"""Test the OpenGarage config flow.""" +from unittest.mock import patch + +import aiohttp + +from homeassistant import config_entries, setup +from homeassistant.components.opengarage.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + 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"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "opengarage.OpenGarage.update_state", + return_value={"name": "Name of the device", "mac": "unique"}, + ), patch( + "homeassistant.components.opengarage.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "http://1.1.1.1", "device_key": "AfsasdnfkjDD"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Name of the device" + assert result2["data"] == { + "host": "http://1.1.1.1", + "device_key": "AfsasdnfkjDD", + "port": 80, + "verify_ssl": False, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "opengarage.OpenGarage.update_state", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "http://1.1.1.1", "device_key": "AfsasdnfkjDD"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "opengarage.OpenGarage.update_state", + side_effect=aiohttp.ClientError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "http://1.1.1.1", "device_key": "AfsasdnfkjDD"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_error(hass: HomeAssistant) -> None: + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "opengarage.OpenGarage.update_state", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "http://1.1.1.1", "device_key": "AfsasdnfkjDD"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: + """Test user input for config_entry that already exists.""" + first_entry = MockConfigEntry( + domain="opengarage", + data={ + "host": "http://1.1.1.1", + "device_key": "AfsasdnfkjDD", + }, + unique_id="unique", + ) + first_entry.add_to_hass(hass) + + with patch( + "opengarage.OpenGarage.update_state", + return_value={"name": "Name of the device", "mac": "unique"}, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + "host": "http://1.1.1.1", + "device_key": "AfsasdnfkjDD", + "port": 80, + "verify_ssl": False, + }, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_step_import(hass: HomeAssistant) -> None: + """Test when import configuring from yaml.""" + with patch( + "opengarage.OpenGarage.update_state", + return_value={"name": "Name of the device", "mac": "unique"}, + ), patch( + "homeassistant.components.opengarage.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "host": "1.1.1.1", + "device_key": "AfsasdnfkjDD", + "port": 1234, + "verify_ssl": False, + "ssl": False, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Name of the device" + assert result["data"] == { + "host": "http://1.1.1.1", + "device_key": "AfsasdnfkjDD", + "port": 1234, + "verify_ssl": False, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_step_import_ssl(hass: HomeAssistant) -> None: + """Test when import configuring from yaml.""" + with patch( + "opengarage.OpenGarage.update_state", + return_value={"name": "Name of the device", "mac": "unique"}, + ), patch( + "homeassistant.components.opengarage.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "host": "1.1.1.1", + "device_key": "AfsasdnfkjDD", + "port": 1234, + "verify_ssl": False, + "ssl": True, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Name of the device" + assert result["data"] == { + "host": "https://1.1.1.1", + "device_key": "AfsasdnfkjDD", + "port": 1234, + "verify_ssl": False, + } + assert len(mock_setup_entry.mock_calls) == 1