From 75f237b587b2912e07af896ea114d59f5d403b55 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Wed, 22 Nov 2023 16:53:17 +0100 Subject: [PATCH] Add local API support to Overkiz integration (Somfy TaHoma Developer Mode) (#71644) * Add initial config flow implementation * Add initial config flow implementation * Add todos * Bugfixes * Add first zeroconf code * Fixes for new firmware * Bugfixes for local integration * Delete local token * Fix diagnostics * Update translations and improve code * Update translations and improve code * Add local integration updates * Add local integration updates * Small tweaks * Add comments * Bugfix * Small code improvements * Small code improvements * Small code improvements * Small code improvements * Small code improvements * Small code improvements * Bugfixes * Small code improvements * Small code improvements * Change Config Flow (breaking change) * Remove token when integration is unloaded * Remove print * Simplify * Bugfixes * Improve configflow * Clean up unnecessary things * Catch nosuchtoken exception * Add migration for Config Flow * Add version 2 migration * Revert change in Config Flow * Fix api type * Update strings * Improve migrate entry * Implement changes * add more comments * Extend diagnostics * Ruff fixes * Clean up code * Bugfixes * Set gateway id * Start writing tests * Add first local test * Code coverage to 64% * Fixes * Remove local token on remove entry * Add debug logging + change manifest * Add developer mode check * Fix not_such_token issue * Small text changes * Bugfix * Fix tests * Address feedback * DRY * Test coverage to 77% * Coverage to 78% * Remove token removal by UUID * Add better retry methods * Clean up * Remove old data * 87% coverage * 90% code coverage * 100% code coverage * Use patch.multiple * Improve tests * Apply pre-commit after rebase * Fix breaking changes in ZeroconfServiceInfo * Add verify_ssl * Fix test import * Fix tests * Catch SSL verify failed * Revert hub to server rename * Move Config Flow version back to 1 * Add diagnostics tests * Fix tests * Fix strings * Implement feedback * Add debug logging for local connection errors * Simplify Config Flow and fix tests * Simplify Config Flow * Fix verify_ssl * Fix rebase mistake * Address feedback * Apply suggestions from code review * Update tests/components/overkiz/test_config_flow.py --------- Co-authored-by: Erik Montnemery --- homeassistant/components/overkiz/__init__.py | 70 +- .../components/overkiz/config_flow.py | 305 +++++- homeassistant/components/overkiz/const.py | 12 +- .../components/overkiz/coordinator.py | 4 +- .../components/overkiz/diagnostics.py | 24 +- .../components/overkiz/manifest.json | 6 +- homeassistant/components/overkiz/strings.json | 26 +- homeassistant/generated/integrations.json | 2 +- homeassistant/generated/zeroconf.py | 6 + tests/components/overkiz/conftest.py | 4 +- .../overkiz/snapshots/test_diagnostics.ambr | 2 + tests/components/overkiz/test_config_flow.py | 898 ++++++++++++++---- tests/components/overkiz/test_init.py | 4 +- 13 files changed, 1130 insertions(+), 233 deletions(-) diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index 36713d972b16..ebc3f96a7f54 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -9,23 +9,32 @@ from typing import cast from aiohttp import ClientError from pyoverkiz.client import OverkizClient from pyoverkiz.const import SUPPORTED_SERVERS -from pyoverkiz.enums import OverkizState, UIClass, UIWidget +from pyoverkiz.enums import APIType, OverkizState, UIClass, UIWidget from pyoverkiz.exceptions import ( BadCredentialsException, MaintenanceException, NotSuchTokenException, TooManyRequestsException, ) -from pyoverkiz.models import Device, Scenario, Setup +from pyoverkiz.models import Device, OverkizServer, Scenario, Setup +from pyoverkiz.utils import generate_local_server from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, + CONF_VERIFY_SSL, + Platform, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import ( + CONF_API_TYPE, CONF_HUB, DOMAIN, LOGGER, @@ -48,15 +57,26 @@ class HomeAssistantOverkizData: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Overkiz from a config entry.""" - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - server = SUPPORTED_SERVERS[entry.data[CONF_HUB]] + client: OverkizClient | None = None + api_type = entry.data.get(CONF_API_TYPE, APIType.CLOUD) - # To allow users with multiple accounts/hubs, we create a new session so they have separate cookies - session = async_create_clientsession(hass) - client = OverkizClient( - username=username, password=password, session=session, server=server - ) + # Local API + if api_type == APIType.LOCAL: + client = create_local_client( + hass, + host=entry.data[CONF_HOST], + token=entry.data[CONF_TOKEN], + verify_ssl=entry.data[CONF_VERIFY_SSL], + ) + + # Overkiz Cloud API + else: + client = create_cloud_client( + hass, + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + server=SUPPORTED_SERVERS[entry.data[CONF_HUB]], + ) await _async_migrate_entries(hass, entry) @@ -211,3 +231,31 @@ async def _async_migrate_entries( await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) return True + + +def create_local_client( + hass: HomeAssistant, host: str, token: str, verify_ssl: bool +) -> OverkizClient: + """Create Overkiz local client.""" + session = async_create_clientsession(hass, verify_ssl=verify_ssl) + + return OverkizClient( + username="", + password="", + token=token, + session=session, + server=generate_local_server(host=host), + verify_ssl=verify_ssl, + ) + + +def create_cloud_client( + hass: HomeAssistant, username: str, password: str, server: OverkizServer +) -> OverkizClient: + """Create Overkiz cloud client.""" + # To allow users with multiple accounts/hubs, we create a new session so they have separate cookies + session = async_create_clientsession(hass) + + return OverkizClient( + username=username, password=password, session=session, server=server + ) diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py index eac749f1bc08..03720dce2a8e 100644 --- a/homeassistant/components/overkiz/config_flow.py +++ b/homeassistant/components/overkiz/config_flow.py @@ -1,31 +1,46 @@ -"""Config flow for Overkiz (by Somfy) integration.""" +"""Config flow for Overkiz integration.""" from __future__ import annotations from collections.abc import Mapping from typing import Any, cast -from aiohttp import ClientError +from aiohttp import ClientConnectorCertificateError, ClientError from pyoverkiz.client import OverkizClient -from pyoverkiz.const import SUPPORTED_SERVERS +from pyoverkiz.const import SERVERS_WITH_LOCAL_API, SUPPORTED_SERVERS +from pyoverkiz.enums import APIType, Server from pyoverkiz.exceptions import ( BadCredentialsException, CozyTouchBadCredentialsException, MaintenanceException, + NotSuchTokenException, TooManyAttemptsBannedException, TooManyRequestsException, UnknownUserException, ) -from pyoverkiz.models import obfuscate_id +from pyoverkiz.models import OverkizServer +from pyoverkiz.obfuscate import obfuscate_id +from pyoverkiz.utils import generate_local_server, is_overkiz_gateway import voluptuous as vol from homeassistant import config_entries from homeassistant.components import dhcp, zeroconf from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, + CONF_VERIFY_SSL, +) from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_create_clientsession -from .const import CONF_HUB, DEFAULT_HUB, DOMAIN, LOGGER +from .const import CONF_API_TYPE, CONF_HUB, DEFAULT_SERVER, DOMAIN, LOGGER + + +class DeveloperModeDisabled(HomeAssistantError): + """Error to indicate Somfy Developer Mode is disabled.""" class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -34,45 +49,112 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 _config_entry: ConfigEntry | None - _default_user: None | str - _default_hub: str + _api_type: APIType + _user: None | str + _server: str + _host: str def __init__(self) -> None: """Initialize Overkiz Config Flow.""" super().__init__() self._config_entry = None - self._default_user = None - self._default_hub = DEFAULT_HUB + self._api_type = APIType.CLOUD + self._user = None + self._server = DEFAULT_SERVER + self._host = "gateway-xxxx-xxxx-xxxx.local:8443" - async def async_validate_input(self, user_input: dict[str, Any]) -> None: + async def async_validate_input(self, user_input: dict[str, Any]) -> dict[str, Any]: """Validate user credentials.""" - username = user_input[CONF_USERNAME] - password = user_input[CONF_PASSWORD] - server = SUPPORTED_SERVERS[user_input[CONF_HUB]] - session = async_create_clientsession(self.hass) + user_input[CONF_API_TYPE] = self._api_type - client = OverkizClient( - username=username, password=password, server=server, session=session + client = self._create_cloud_client( + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + server=SUPPORTED_SERVERS[user_input[CONF_HUB]], ) - await client.login(register_event_listener=False) - # Set first gateway id as unique id + # For Local API, we create and activate a local token + if self._api_type == APIType.LOCAL: + user_input[CONF_TOKEN] = await self._create_local_api_token( + cloud_client=client, + host=user_input[CONF_HOST], + verify_ssl=user_input[CONF_VERIFY_SSL], + ) + + # Set main gateway id as unique id if gateways := await client.get_gateways(): - gateway_id = gateways[0].id - await self.async_set_unique_id(gateway_id) + for gateway in gateways: + if is_overkiz_gateway(gateway.id): + gateway_id = gateway.id + await self.async_set_unique_id(gateway_id) + + return user_input async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step via config flow.""" - errors = {} + if user_input: + self._server = user_input[CONF_HUB] + + # Some Overkiz hubs do support a local API + # Users can choose between local or cloud API. + if self._server in SERVERS_WITH_LOCAL_API: + return await self.async_step_local_or_cloud() + + return await self.async_step_cloud() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HUB, default=self._server): vol.In( + {key: hub.name for key, hub in SUPPORTED_SERVERS.items()} + ), + } + ), + ) + + async def async_step_local_or_cloud( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Users can choose between local API or cloud API via config flow.""" + if user_input: + self._api_type = user_input[CONF_API_TYPE] + + if self._api_type == APIType.LOCAL: + return await self.async_step_local() + + return await self.async_step_cloud() + + return self.async_show_form( + step_id="local_or_cloud", + data_schema=vol.Schema( + { + vol.Required(CONF_API_TYPE): vol.In( + { + APIType.LOCAL: "Local API", + APIType.CLOUD: "Cloud API", + } + ), + } + ), + ) + + async def async_step_cloud( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the cloud authentication step via config flow.""" + errors: dict[str, str] = {} description_placeholders = {} if user_input: - self._default_user = user_input[CONF_USERNAME] - self._default_hub = user_input[CONF_HUB] + self._user = user_input[CONF_USERNAME] + + # inherit the server from previous step + user_input[CONF_HUB] = self._server try: await self.async_validate_input(user_input) @@ -81,7 +163,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except BadCredentialsException as exception: # If authentication with CozyTouch auth server is valid, but token is invalid # for Overkiz API server, the hardware is not supported. - if user_input[CONF_HUB] == "atlantic_cozytouch" and not isinstance( + if user_input[CONF_HUB] == Server.ATLANTIC_COZYTOUCH and not isinstance( exception, CozyTouchBadCredentialsException ): description_placeholders["unsupported_device"] = "CozyTouch" @@ -99,9 +181,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # the Overkiz API server. Login will return unknown user. description_placeholders["unsupported_device"] = "Somfy Protect" errors["base"] = "unsupported_hardware" - except Exception as exception: # pylint: disable=broad-except + except Exception: # pylint: disable=broad-except errors["base"] = "unknown" - LOGGER.exception(exception) + LOGGER.exception("Unknown error") else: if self._config_entry: if self._config_entry.unique_id != self.unique_id: @@ -132,14 +214,96 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return self.async_show_form( - step_id="user", + step_id="cloud", data_schema=vol.Schema( { - vol.Required(CONF_USERNAME, default=self._default_user): str, + vol.Required(CONF_USERNAME, default=self._user): str, vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_HUB, default=self._default_hub): vol.In( - {key: hub.name for key, hub in SUPPORTED_SERVERS.items()} - ), + } + ), + description_placeholders=description_placeholders, + errors=errors, + ) + + async def async_step_local( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the local authentication step via config flow.""" + errors = {} + description_placeholders = {} + + if user_input: + self._host = user_input[CONF_HOST] + self._user = user_input[CONF_USERNAME] + + # inherit the server from previous step + user_input[CONF_HUB] = self._server + + try: + user_input = await self.async_validate_input(user_input) + except TooManyRequestsException: + errors["base"] = "too_many_requests" + except BadCredentialsException: + errors["base"] = "invalid_auth" + except ClientConnectorCertificateError as exception: + errors["base"] = "certificate_verify_failed" + LOGGER.debug(exception) + except (TimeoutError, ClientError) as exception: + errors["base"] = "cannot_connect" + LOGGER.debug(exception) + except MaintenanceException: + errors["base"] = "server_in_maintenance" + except TooManyAttemptsBannedException: + errors["base"] = "too_many_attempts" + except NotSuchTokenException: + errors["base"] = "no_such_token" + except DeveloperModeDisabled: + errors["base"] = "developer_mode_disabled" + except UnknownUserException: + # Somfy Protect accounts are not supported since they don't use + # the Overkiz API server. Login will return unknown user. + description_placeholders["unsupported_device"] = "Somfy Protect" + errors["base"] = "unsupported_hardware" + except Exception: # pylint: disable=broad-except + errors["base"] = "unknown" + LOGGER.exception("Unknown error") + else: + if self._config_entry: + if self._config_entry.unique_id != self.unique_id: + return self.async_abort(reason="reauth_wrong_account") + + # Update existing entry during reauth + self.hass.config_entries.async_update_entry( + self._config_entry, + data={ + **self._config_entry.data, + **user_input, + }, + ) + + self.hass.async_create_task( + self.hass.config_entries.async_reload( + self._config_entry.entry_id + ) + ) + + return self.async_abort(reason="reauth_successful") + + # Create new entry + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=user_input[CONF_HOST], data=user_input + ) + + return self.async_show_form( + step_id="local", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=self._host): str, + vol.Required(CONF_USERNAME, default=self._user): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_VERIFY_SSL, default=True): bool, } ), description_placeholders=description_placeholders, @@ -150,6 +314,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle DHCP discovery.""" hostname = discovery_info.hostname gateway_id = hostname[8:22] + self._host = f"gateway-{gateway_id}.local:8443" LOGGER.debug("DHCP discovery detected gateway %s", obfuscate_id(gateway_id)) return await self._process_discovery(gateway_id) @@ -160,8 +325,22 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle ZeroConf discovery.""" properties = discovery_info.properties gateway_id = properties["gateway_pin"] + hostname = discovery_info.hostname + + LOGGER.debug( + "ZeroConf discovery detected gateway %s on %s (%s)", + obfuscate_id(gateway_id), + hostname, + discovery_info.type, + ) + + if discovery_info.type == "_kizbox._tcp.local.": + self._host = f"gateway-{gateway_id}.local:8443" + + if discovery_info.type == "_kizboxdev._tcp.local.": + self._host = f"{discovery_info.hostname[:-1]}:{discovery_info.port}" + self._api_type = APIType.LOCAL - LOGGER.debug("ZeroConf discovery detected gateway %s", obfuscate_id(gateway_id)) return await self._process_discovery(gateway_id) async def _process_discovery(self, gateway_id: str) -> FlowResult: @@ -183,7 +362,63 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): "gateway_id": self._config_entry.unique_id } - self._default_user = self._config_entry.data[CONF_USERNAME] - self._default_hub = self._config_entry.data[CONF_HUB] + self._user = self._config_entry.data[CONF_USERNAME] + self._server = self._config_entry.data[CONF_HUB] + self._api_type = self._config_entry.data[CONF_API_TYPE] + + if self._config_entry.data[CONF_API_TYPE] == APIType.LOCAL: + self._host = self._config_entry.data[CONF_HOST] return await self.async_step_user(dict(entry_data)) + + def _create_cloud_client( + self, username: str, password: str, server: OverkizServer + ) -> OverkizClient: + session = async_create_clientsession(self.hass) + client = OverkizClient( + username=username, password=password, server=server, session=session + ) + + return client + + async def _create_local_api_token( + self, cloud_client: OverkizClient, host: str, verify_ssl: bool + ) -> str: + """Create local API token.""" + # Create session on Somfy cloud server to generate an access token for local API + gateways = await cloud_client.get_gateways() + + gateway_id = "" + for gateway in gateways: + # Overkiz can return multiple gateways, but we only can generate a token + # for the main gateway. + if is_overkiz_gateway(gateway.id): + gateway_id = gateway.id + + developer_mode = await cloud_client.get_setup_option( + f"developerMode-{gateway_id}" + ) + + if developer_mode is None: + raise DeveloperModeDisabled + + token = await cloud_client.generate_local_token(gateway_id) + await cloud_client.activate_local_token( + gateway_id=gateway_id, token=token, label="Home Assistant/local" + ) + + session = async_create_clientsession(self.hass, verify_ssl=verify_ssl) + + # Local API + local_client = OverkizClient( + username="", + password="", + token=token, + session=session, + server=generate_local_server(host=host), + verify_ssl=verify_ssl, + ) + + await local_client.login() + + return token diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index 91346b63ce0c..b242f6db8e28 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -5,7 +5,13 @@ from datetime import timedelta import logging from typing import Final -from pyoverkiz.enums import MeasuredValueType, OverkizCommandParam, UIClass, UIWidget +from pyoverkiz.enums import ( + MeasuredValueType, + OverkizCommandParam, + Server, + UIClass, + UIWidget, +) from homeassistant.const import ( CONCENTRATION_PARTS_PER_BILLION, @@ -31,8 +37,10 @@ from homeassistant.const import ( DOMAIN: Final = "overkiz" LOGGER: logging.Logger = logging.getLogger(__package__) +CONF_API_TYPE: Final = "api_type" CONF_HUB: Final = "hub" -DEFAULT_HUB: Final = "somfy_europe" +DEFAULT_SERVER: Final = Server.SOMFY_EUROPE +DEFAULT_HOST: Final = "gateway-xxxx-xxxx-xxxx.local:8443" UPDATE_INTERVAL: Final = timedelta(seconds=30) UPDATE_INTERVAL_ALL_ASSUMED_STATE: Final = timedelta(minutes=60) diff --git a/homeassistant/components/overkiz/coordinator.py b/homeassistant/components/overkiz/coordinator.py index e5079b3d3b80..4630af8bbf8c 100644 --- a/homeassistant/components/overkiz/coordinator.py +++ b/homeassistant/components/overkiz/coordinator.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging from typing import Any -from aiohttp import ServerDisconnectedError +from aiohttp import ClientConnectorError, ServerDisconnectedError from pyoverkiz.client import OverkizClient from pyoverkiz.enums import EventName, ExecutionState, Protocol from pyoverkiz.exceptions import ( @@ -79,7 +79,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): raise UpdateFailed("Server is down for maintenance.") from exception except InvalidEventListenerIdException as exception: raise UpdateFailed(exception) from exception - except TimeoutError as exception: + except (TimeoutError, ClientConnectorError) as exception: raise UpdateFailed("Failed to connect.") from exception except (ServerDisconnectedError, NotAuthenticatedException): self.executions = {} diff --git a/homeassistant/components/overkiz/diagnostics.py b/homeassistant/components/overkiz/diagnostics.py index 77ca02275792..cb8cf6eb22f4 100644 --- a/homeassistant/components/overkiz/diagnostics.py +++ b/homeassistant/components/overkiz/diagnostics.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +from pyoverkiz.enums import APIType from pyoverkiz.obfuscate import obfuscate_id from homeassistant.config_entries import ConfigEntry @@ -10,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry from . import HomeAssistantOverkizData -from .const import CONF_HUB, DOMAIN +from .const import CONF_API_TYPE, CONF_HUB, DOMAIN async def async_get_config_entry_diagnostics( @@ -23,11 +24,16 @@ async def async_get_config_entry_diagnostics( data = { "setup": await client.get_diagnostic_data(), "server": entry.data[CONF_HUB], - "execution_history": [ - repr(execution) for execution in await client.get_execution_history() - ], + "api_type": entry.data.get(CONF_API_TYPE, APIType.CLOUD), } + # Only Overkiz cloud servers expose an endpoint with execution history + if client.api_type == APIType.CLOUD: + execution_history = [ + repr(execution) for execution in await client.get_execution_history() + ] + data["execution_history"] = execution_history + return data @@ -49,11 +55,15 @@ async def async_get_device_diagnostics( }, "setup": await client.get_diagnostic_data(), "server": entry.data[CONF_HUB], - "execution_history": [ + "api_type": entry.data.get(CONF_API_TYPE, APIType.CLOUD), + } + + # Only Overkiz cloud servers expose an endpoint with execution history + if client.api_type == APIType.CLOUD: + data["execution_history"] = [ repr(execution) for execution in await client.get_execution_history() if any(command.device_url == device_url for command in execution.commands) - ], - } + ] return data diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index dd78ec78f00d..e5c1665b2e48 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -11,13 +11,17 @@ ], "documentation": "https://www.home-assistant.io/integrations/overkiz", "integration_type": "hub", - "iot_class": "cloud_polling", + "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], "requirements": ["pyoverkiz==1.13.3"], "zeroconf": [ { "type": "_kizbox._tcp.local.", "name": "gateway*" + }, + { + "type": "_kizboxdev._tcp.local.", + "name": "gateway*" } ] } diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index 82d29a7534a8..2a549f1c24d6 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -3,18 +3,40 @@ "flow_title": "Gateway: {gateway_id}", "step": { "user": { - "description": "The Overkiz platform is used by various vendors like Somfy (Connexoon / TaHoma), Hitachi (Hi Kumo), Rexel (Energeasy Connect) and Atlantic (Cozytouch). Enter your application credentials and select your hub.", + "description": "Select your server. The Overkiz platform is used by various vendors like Somfy (Connexoon / TaHoma), Hitachi (Hi Kumo) and Atlantic (Cozytouch).", + "data": { + "hub": "Server" + } + }, + "local_or_cloud": { + "description": "Choose between local or cloud API. Local API supports TaHoma Connexoon, TaHoma v2, and TaHoma Switch. Climate devices are not supported in local API.", + "data": { + "api_type": "API type" + } + }, + "cloud": { + "description": "Enter your application credentials.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "local": { + "description": "By activating the [Developer Mode of your TaHoma box](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started), you can authorize third-party software (like Home Assistant) to connect to it via your local network. \n\n After activation, enter your application credentials and change the host to include your gateway-pin or enter the IP address of your gateway.", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "hub": "Hub" + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "certificate_verify_failed": "Cannot connect to host, certificate verify failed.", + "developer_mode_disabled": "Developer Mode disabled. Activate the Developer Mode of your Somfy TaHoma box first.", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "no_such_token": "Cannot create a token for this gateway. Please confirm if the account is linked to this gateway.", "server_in_maintenance": "Server is down for maintenance", "too_many_attempts": "Too many attempts with an invalid token, temporarily banned", "too_many_requests": "Too many requests, try again later", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d3685e454326..50fb66c5f594 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4156,7 +4156,7 @@ "name": "Overkiz", "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling" + "iot_class": "local_polling" }, "ovo_energy": { "name": "OVO Energy", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index d97bef19eb47..3c828a54faf0 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -508,6 +508,12 @@ ZEROCONF = { "name": "gateway*", }, ], + "_kizboxdev._tcp.local.": [ + { + "domain": "overkiz", + "name": "gateway*", + }, + ], "_lookin._tcp.local.": [ { "domain": "lookin", diff --git a/tests/components/overkiz/conftest.py b/tests/components/overkiz/conftest.py index 990b88d84ed3..da6d3a608399 100644 --- a/tests/components/overkiz/conftest.py +++ b/tests/components/overkiz/conftest.py @@ -12,8 +12,8 @@ from tests.components.overkiz import load_setup_fixture from tests.components.overkiz.test_config_flow import ( TEST_EMAIL, TEST_GATEWAY_ID, - TEST_HUB, TEST_PASSWORD, + TEST_SERVER, ) MOCK_SETUP_RESPONSE = Mock(devices=[], gateways=[]) @@ -26,7 +26,7 @@ def mock_config_entry() -> MockConfigEntry: title="Somfy TaHoma Switch", domain=DOMAIN, unique_id=TEST_GATEWAY_ID, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_SERVER}, ) diff --git a/tests/components/overkiz/snapshots/test_diagnostics.ambr b/tests/components/overkiz/snapshots/test_diagnostics.ambr index 06a456f88af9..a4ba28ec935a 100644 --- a/tests/components/overkiz/snapshots/test_diagnostics.ambr +++ b/tests/components/overkiz/snapshots/test_diagnostics.ambr @@ -1,6 +1,7 @@ # serializer version: 1 # name: test_device_diagnostics dict({ + 'api_type': 'cloud', 'device': dict({ 'controllable_name': 'rts:RollerShutterRTSComponent', 'device_url': 'rts://****-****-6867/16756006', @@ -969,6 +970,7 @@ # --- # name: test_diagnostics dict({ + 'api_type': 'cloud', 'execution_history': list([ ]), 'server': 'somfy_europe', diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py index a9d950a3a662..146d54feb9c3 100644 --- a/tests/components/overkiz/test_config_flow.py +++ b/tests/components/overkiz/test_config_flow.py @@ -1,13 +1,14 @@ -"""Tests for Overkiz (by Somfy) config flow.""" +"""Tests for Overkiz config flow.""" from __future__ import annotations from ipaddress import ip_address from unittest.mock import AsyncMock, Mock, patch -from aiohttp import ClientError +from aiohttp import ClientConnectorCertificateError, ClientError from pyoverkiz.exceptions import ( BadCredentialsException, MaintenanceException, + NotSuchTokenException, TooManyAttemptsBannedException, TooManyRequestsException, UnknownUserException, @@ -28,14 +29,18 @@ TEST_EMAIL = "test@testdomain.com" TEST_EMAIL2 = "test@testdomain.nl" TEST_PASSWORD = "test-password" TEST_PASSWORD2 = "test-password2" -TEST_HUB = "somfy_europe" -TEST_HUB2 = "hi_kumo_europe" -TEST_HUB_COZYTOUCH = "atlantic_cozytouch" +TEST_SERVER = "somfy_europe" +TEST_SERVER2 = "hi_kumo_europe" +TEST_SERVER_COZYTOUCH = "atlantic_cozytouch" TEST_GATEWAY_ID = "1234-5678-9123" TEST_GATEWAY_ID2 = "4321-5678-9123" +TEST_GATEWAY_ID3 = "SOMFY_PROTECT-v0NT53occUBPyuJRzx59kalW1hFfzimN" + +TEST_HOST = "gateway-1234-5678-9123.local:8443" +TEST_HOST2 = "192.168.11.104:8443" MOCK_GATEWAY_RESPONSE = [Mock(id=TEST_GATEWAY_ID)] -MOCK_GATEWAY2_RESPONSE = [Mock(id=TEST_GATEWAY_ID2)] +MOCK_GATEWAY2_RESPONSE = [Mock(id=TEST_GATEWAY_ID3), Mock(id=TEST_GATEWAY_ID2)] FAKE_ZERO_CONF_INFO = ZeroconfServiceInfo( ip_address=ip_address("192.168.0.51"), @@ -51,31 +56,133 @@ FAKE_ZERO_CONF_INFO = ZeroconfServiceInfo( }, ) +FAKE_ZERO_CONF_INFO_LOCAL = ZeroconfServiceInfo( + ip_address=ip_address("192.168.0.51"), + ip_addresses=[ip_address("192.168.0.51")], + port=8443, + hostname=f"gateway-{TEST_GATEWAY_ID}.local.", + type="_kizboxdev._tcp.local.", + name=f"gateway-{TEST_GATEWAY_ID}._kizboxdev._tcp.local.", + properties={ + "api_version": "1", + "gateway_pin": TEST_GATEWAY_ID, + "fw_version": "2021.5.4-29", + }, +) -async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + +async def test_form_cloud(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == "form" - assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "cloud"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "cloud" with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( - "pyoverkiz.client.OverkizClient.get_gateways", return_value=None + "pyoverkiz.client.OverkizClient.get_gateways", + return_value=MOCK_GATEWAY_RESPONSE, ): - result2 = await hass.config_entries.flow.async_configure( + await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) - assert result2["type"] == "create_entry" - assert result2["title"] == TEST_EMAIL - assert result2["data"] == { - "username": TEST_EMAIL, - "password": TEST_PASSWORD, - "hub": TEST_HUB, - } + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_only_cloud_supported( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER2}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "cloud" + + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( + "pyoverkiz.client.OverkizClient.get_gateways", + return_value=MOCK_GATEWAY_RESPONSE, + ): + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": TEST_EMAIL, "password": TEST_PASSWORD}, + ) + + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_local_happy_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "local"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "local" + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + login=AsyncMock(return_value=True), + get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), + get_setup_option=AsyncMock(return_value=True), + generate_local_token=AsyncMock(return_value="1234123412341234"), + activate_local_token=AsyncMock(return_value=True), + ): + await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "host": "gateway-1234-5678-1234.local:8443", + }, + ) await hass.async_block_till_done() @@ -95,23 +202,149 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: (Exception, "unknown"), ], ) -async def test_form_invalid_auth( +async def test_form_invalid_auth_cloud( hass: HomeAssistant, side_effect: Exception, error: str ) -> None: - """Test we handle invalid auth.""" + """Test we handle invalid auth (cloud).""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "cloud"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "cloud" + with patch("pyoverkiz.client.OverkizClient.login", side_effect=side_effect): - result2 = await hass.config_entries.flow.async_configure( + result4 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) - assert result["step_id"] == config_entries.SOURCE_USER - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result2["errors"] == {"base": error} + await hass.async_block_till_done() + + assert result4["type"] == data_entry_flow.FlowResultType.FORM + assert result4["errors"] == {"base": error} + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (BadCredentialsException, "invalid_auth"), + (TooManyRequestsException, "too_many_requests"), + ( + ClientConnectorCertificateError(Mock(host=TEST_HOST), Exception), + "certificate_verify_failed", + ), + (TimeoutError, "cannot_connect"), + (ClientError, "cannot_connect"), + (MaintenanceException, "server_in_maintenance"), + (TooManyAttemptsBannedException, "too_many_attempts"), + (UnknownUserException, "unsupported_hardware"), + (NotSuchTokenException, "no_such_token"), + (Exception, "unknown"), + ], +) +async def test_form_invalid_auth_local( + hass: HomeAssistant, side_effect: Exception, error: str +) -> None: + """Test we handle invalid auth (local).""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "local"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "local" + + with patch("pyoverkiz.client.OverkizClient.login", side_effect=side_effect): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": TEST_HOST, + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "verify_ssl": True, + }, + ) + + await hass.async_block_till_done() + + assert result4["type"] == data_entry_flow.FlowResultType.FORM + assert result4["errors"] == {"base": error} + + +async def test_form_local_developer_mode_disabled( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "local"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "local" + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + login=AsyncMock(return_value=True), + get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), + get_setup_option=AsyncMock(return_value=None), + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "host": "gateway-1234-5678-1234.local:8443", + "verify_ssl": True, + }, + ) + + assert result4["type"] == data_entry_flow.FlowResultType.FORM + assert result4["errors"] == {"base": "developer_mode_disabled"} @pytest.mark.parametrize( @@ -123,79 +356,398 @@ async def test_form_invalid_auth( async def test_form_invalid_cozytouch_auth( hass: HomeAssistant, side_effect: Exception, error: str ) -> None: - """Test we handle invalid auth from CozyTouch.""" + """Test we handle invalid auth (cloud).""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER_COZYTOUCH}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "cloud" + with patch("pyoverkiz.client.OverkizClient.login", side_effect=side_effect): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": TEST_EMAIL, - "password": TEST_PASSWORD, - "hub": TEST_HUB_COZYTOUCH, - }, - ) - - assert result["step_id"] == config_entries.SOURCE_USER - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result2["errors"] == {"base": error} - - -async def test_abort_on_duplicate_entry(hass: HomeAssistant) -> None: - """Test config flow aborts Config Flow on duplicate entries.""" - MockConfigEntry( - domain=DOMAIN, - unique_id=TEST_GATEWAY_ID, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, - ).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( - "pyoverkiz.client.OverkizClient.get_gateways", - return_value=MOCK_GATEWAY_RESPONSE, - ): - result2 = await hass.config_entries.flow.async_configure( + result3 = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) - assert result2["type"] == data_entry_flow.FlowResultType.ABORT - assert result2["reason"] == "already_configured" + await hass.async_block_till_done() + + assert result3["type"] == data_entry_flow.FlowResultType.FORM + assert result3["errors"] == {"base": error} + assert result3["step_id"] == "cloud" -async def test_allow_multiple_unique_entries(hass: HomeAssistant) -> None: - """Test config flow allows Config Flow unique entries.""" +async def test_cloud_abort_on_duplicate_entry( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form.""" + MockConfigEntry( domain=DOMAIN, - unique_id=TEST_GATEWAY_ID2, - data={"username": "test2@testdomain.com", "password": TEST_PASSWORD}, + unique_id=TEST_GATEWAY_ID, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_SERVER}, ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "cloud"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "cloud" + + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( + "pyoverkiz.client.OverkizClient.get_gateways", + return_value=MOCK_GATEWAY_RESPONSE, + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": TEST_EMAIL, "password": TEST_PASSWORD}, + ) + + assert result4["type"] == data_entry_flow.FlowResultType.ABORT + assert result4["reason"] == "already_configured" + + +async def test_local_abort_on_duplicate_entry( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form.""" + + MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + data={ + "host": TEST_HOST, + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_SERVER, + }, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "local"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "local" + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + login=AsyncMock(return_value=True), + get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), + get_setup_option=AsyncMock(return_value=True), + generate_local_token=AsyncMock(return_value="1234123412341234"), + activate_local_token=AsyncMock(return_value=True), + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": TEST_HOST, + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "verify_ssl": True, + }, + ) + + assert result4["type"] == data_entry_flow.FlowResultType.ABORT + assert result4["reason"] == "already_configured" + + +async def test_cloud_allow_multiple_unique_entries( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form.""" + + MockConfigEntry( + version=1, + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID2, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_SERVER}, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "cloud"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "cloud" + + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( + "pyoverkiz.client.OverkizClient.get_gateways", + return_value=MOCK_GATEWAY_RESPONSE, + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": TEST_EMAIL, "password": TEST_PASSWORD}, + ) + + assert result4["type"] == "create_entry" + assert result4["title"] == TEST_EMAIL + assert result4["data"] == { + "api_type": "cloud", + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_SERVER, + } + + +async def test_cloud_reauth_success(hass: HomeAssistant) -> None: + """Test reauthentication flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + version=2, + data={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_SERVER2, + "api_type": "cloud", + }, + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "cloud" + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( "pyoverkiz.client.OverkizClient.get_gateways", return_value=MOCK_GATEWAY_RESPONSE, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + user_input={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD2, + }, ) - assert result2["type"] == "create_entry" - assert result2["title"] == TEST_EMAIL - assert result2["data"] == { - "username": TEST_EMAIL, - "password": TEST_PASSWORD, - "hub": TEST_HUB, - } + assert result2["type"] == data_entry_flow.FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert mock_entry.data["username"] == TEST_EMAIL + assert mock_entry.data["password"] == TEST_PASSWORD2 + + +async def test_cloud_reauth_wrong_account(hass: HomeAssistant) -> None: + """Test reauthentication flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + version=2, + data={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_SERVER2, + "api_type": "cloud", + }, + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "cloud" + + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( + "pyoverkiz.client.OverkizClient.get_gateways", + return_value=MOCK_GATEWAY2_RESPONSE, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD2, + }, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.ABORT + assert result2["reason"] == "reauth_wrong_account" + + +async def test_local_reauth_success(hass: HomeAssistant) -> None: + """Test reauthentication flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + version=2, + data={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_SERVER, + "host": TEST_HOST, + "api_type": "local", + }, + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "local"}, + ) + + assert result2["step_id"] == "local" + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + login=AsyncMock(return_value=True), + get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), + get_setup_option=AsyncMock(return_value=True), + generate_local_token=AsyncMock(return_value="1234123412341234"), + activate_local_token=AsyncMock(return_value=True), + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD2, + }, + ) + + assert result3["type"] == data_entry_flow.FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + assert mock_entry.data["username"] == TEST_EMAIL + assert mock_entry.data["password"] == TEST_PASSWORD2 + + +async def test_local_reauth_wrong_account(hass: HomeAssistant) -> None: + """Test reauthentication flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID2, + version=2, + data={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_SERVER, + "host": TEST_HOST, + "api_type": "local", + }, + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "local_or_cloud" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "local"}, + ) + + assert result2["step_id"] == "local" + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + login=AsyncMock(return_value=True), + get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), + get_setup_option=AsyncMock(return_value=True), + generate_local_token=AsyncMock(return_value="1234123412341234"), + activate_local_token=AsyncMock(return_value=True), + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD2, + }, + ) + + assert result3["type"] == data_entry_flow.FlowResultType.ABORT + assert result3["reason"] == "reauth_wrong_account" async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: @@ -213,20 +765,37 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "cloud"}, + ) + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( "pyoverkiz.client.OverkizClient.get_gateways", return_value=None ): - result2 = await hass.config_entries.flow.async_configure( + result4 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + { + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + }, ) - assert result2["type"] == "create_entry" - assert result2["title"] == TEST_EMAIL - assert result2["data"] == { + assert result4["type"] == "create_entry" + assert result4["title"] == TEST_EMAIL + assert result4["data"] == { "username": TEST_EMAIL, "password": TEST_PASSWORD, - "hub": TEST_HUB, + "hub": TEST_SERVER, + "api_type": "cloud", } assert len(mock_setup_entry.mock_calls) == 1 @@ -237,7 +806,7 @@ async def test_dhcp_flow_already_configured(hass: HomeAssistant) -> None: config_entry = MockConfigEntry( domain=DOMAIN, unique_id=TEST_GATEWAY_ID, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_SERVER}, ) config_entry.add_to_hass(hass) @@ -266,20 +835,95 @@ async def test_zeroconf_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) - assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "cloud"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "cloud" + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( - "pyoverkiz.client.OverkizClient.get_gateways", return_value=None + "pyoverkiz.client.OverkizClient.get_gateways", + return_value=MOCK_GATEWAY_RESPONSE, ): - result2 = await hass.config_entries.flow.async_configure( + result4 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) - assert result2["type"] == "create_entry" - assert result2["title"] == TEST_EMAIL - assert result2["data"] == { + assert result4["type"] == "create_entry" + assert result4["title"] == TEST_EMAIL + assert result4["data"] == { "username": TEST_EMAIL, "password": TEST_PASSWORD, - "hub": TEST_HUB, + "hub": TEST_SERVER, + "api_type": "cloud", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_local_zeroconf_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test that zeroconf discovery for new local bridge works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=FAKE_ZERO_CONF_INFO_LOCAL, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == config_entries.SOURCE_USER + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"hub": TEST_SERVER}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "local_or_cloud" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_type": "local"}, + ) + + assert result3["type"] == "form" + assert result3["step_id"] == "local" + + with patch.multiple( + "pyoverkiz.client.OverkizClient", + login=AsyncMock(return_value=True), + get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), + get_setup_option=AsyncMock(return_value=True), + generate_local_token=AsyncMock(return_value="1234123412341234"), + activate_local_token=AsyncMock(return_value=True), + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": TEST_EMAIL, "password": TEST_PASSWORD, "verify_ssl": False}, + ) + + assert result4["type"] == "create_entry" + assert result4["title"] == "gateway-1234-5678-9123.local:8443" + assert result4["data"] == { + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_SERVER, + "host": "gateway-1234-5678-9123.local:8443", + "api_type": "local", + "token": "1234123412341234", + "verify_ssl": False, } assert len(mock_setup_entry.mock_calls) == 1 @@ -290,7 +934,7 @@ async def test_zeroconf_flow_already_configured(hass: HomeAssistant) -> None: config_entry = MockConfigEntry( domain=DOMAIN, unique_id=TEST_GATEWAY_ID, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_SERVER}, ) config_entry.add_to_hass(hass) @@ -302,85 +946,3 @@ async def test_zeroconf_flow_already_configured(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "already_configured" - - -async def test_reauth_success(hass: HomeAssistant) -> None: - """Test reauthentication flow.""" - - mock_entry = MockConfigEntry( - domain=DOMAIN, - unique_id=TEST_GATEWAY_ID, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB2}, - ) - mock_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": config_entries.SOURCE_REAUTH, - "unique_id": mock_entry.unique_id, - "entry_id": mock_entry.entry_id, - }, - data=mock_entry.data, - ) - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" - - with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( - "pyoverkiz.client.OverkizClient.get_gateways", - return_value=MOCK_GATEWAY_RESPONSE, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - "username": TEST_EMAIL, - "password": TEST_PASSWORD2, - "hub": TEST_HUB2, - }, - ) - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - assert mock_entry.data["username"] == TEST_EMAIL - assert mock_entry.data["password"] == TEST_PASSWORD2 - - -async def test_reauth_wrong_account(hass: HomeAssistant) -> None: - """Test reauthentication flow.""" - - mock_entry = MockConfigEntry( - domain=DOMAIN, - unique_id=TEST_GATEWAY_ID, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB2}, - ) - mock_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": config_entries.SOURCE_REAUTH, - "unique_id": mock_entry.unique_id, - "entry_id": mock_entry.entry_id, - }, - data=mock_entry.data, - ) - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" - - with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( - "pyoverkiz.client.OverkizClient.get_gateways", - return_value=MOCK_GATEWAY2_RESPONSE, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - "username": TEST_EMAIL, - "password": TEST_PASSWORD2, - "hub": TEST_HUB2, - }, - ) - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "reauth_wrong_account" diff --git a/tests/components/overkiz/test_init.py b/tests/components/overkiz/test_init.py index 774f3c9a79a1..ddecee7c1670 100644 --- a/tests/components/overkiz/test_init.py +++ b/tests/components/overkiz/test_init.py @@ -4,7 +4,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .test_config_flow import TEST_EMAIL, TEST_GATEWAY_ID, TEST_HUB, TEST_PASSWORD +from .test_config_flow import TEST_EMAIL, TEST_GATEWAY_ID, TEST_PASSWORD, TEST_SERVER from tests.common import MockConfigEntry, mock_registry @@ -23,7 +23,7 @@ async def test_unique_id_migration(hass: HomeAssistant) -> None: mock_entry = MockConfigEntry( domain=DOMAIN, unique_id=TEST_GATEWAY_ID, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_SERVER}, ) mock_entry.add_to_hass(hass)