From 77cd7515437cb08734c6bd323eaebb5517501350 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Fri, 3 Dec 2021 18:29:15 +0100 Subject: [PATCH] DHCP discovery for Fronius integration (#60806) Co-authored-by: J. Nick Koston --- .../components/fronius/config_flow.py | 71 ++++++++++++++++--- .../components/fronius/manifest.json | 5 ++ homeassistant/components/fronius/strings.json | 7 +- .../components/fronius/translations/en.json | 7 +- homeassistant/generated/dhcp.py | 4 ++ tests/components/fronius/test_config_flow.py | 65 +++++++++++++++++ 6 files changed, 147 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/fronius/config_flow.py b/homeassistant/components/fronius/config_flow.py index fdcd5301830..86654f00c36 100644 --- a/homeassistant/components/fronius/config_flow.py +++ b/homeassistant/components/fronius/config_flow.py @@ -1,13 +1,15 @@ """Config flow for Fronius integration.""" from __future__ import annotations +import asyncio import logging -from typing import Any +from typing import Any, Final from pyfronius import Fronius, FroniusError import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.const import CONF_HOST, CONF_RESOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult @@ -18,6 +20,8 @@ from .const import DOMAIN, FroniusConfigEntryData _LOGGER = logging.getLogger(__name__) +DHCP_REQUEST_DELAY: Final = 60 + STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, @@ -25,14 +29,21 @@ STEP_USER_DATA_SCHEMA = vol.Schema( ) -async def validate_input( - hass: HomeAssistant, data: dict[str, Any] +def create_title(info: FroniusConfigEntryData) -> str: + """Return the title of the config flow.""" + return ( + f"SolarNet {'Datalogger' if info['is_logger'] else 'Inverter'}" + f" at {info['host']}" + ) + + +async def validate_host( + hass: HomeAssistant, host: str ) -> tuple[str, FroniusConfigEntryData]: """Validate the user input allows us to connect. Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - host = data[CONF_HOST] fronius = Fronius(async_get_clientsession(hass), host) try: @@ -67,6 +78,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + def __init__(self) -> None: + """Initialize flow.""" + self.info: FroniusConfigEntryData + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -79,7 +94,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors = {} try: - unique_id, info = await validate_input(self.hass, user_input) + unique_id, info = await validate_host(self.hass, user_input[CONF_HOST]) except CannotConnect: errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except @@ -90,11 +105,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured( updates=dict(info), reload_on_update=False ) - title = ( - f"SolarNet {'Datalogger' if info['is_logger'] else 'Inverter'}" - f" at {info['host']}" - ) - return self.async_create_entry(title=title, data=info) + return self.async_create_entry(title=create_title(info), data=info) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors @@ -104,6 +115,46 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Import a configuration from config.yaml.""" return await self.async_step_user(user_input={CONF_HOST: conf[CONF_RESOURCE]}) + async def async_step_dhcp(self, discovery_info: DhcpServiceInfo) -> FlowResult: + """Handle a flow initiated by the DHCP client.""" + for entry in self._async_current_entries(include_ignore=False): + if entry.data[CONF_HOST].lstrip("http://").rstrip("/").lower() in ( + discovery_info.ip, + discovery_info.hostname, + ): + return self.async_abort(reason="already_configured") + # Symo Datalogger devices need up to 1 minute at boot from DHCP request + # to respond to API requests (connection refused until then) + await asyncio.sleep(DHCP_REQUEST_DELAY) + try: + unique_id, self.info = await validate_host(self.hass, discovery_info.ip) + except CannotConnect: + return self.async_abort(reason="invalid_host") + + await self.async_set_unique_id(unique_id, raise_on_progress=False) + self._abort_if_unique_id_configured( + updates=dict(self.info), reload_on_update=False + ) + + return await self.async_step_confirm_discovery() + + async def async_step_confirm_discovery( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Attempt to confim.""" + title = create_title(self.info) + if user_input is not None: + return self.async_create_entry(title=title, data=self.info) + + self._set_confirm_only() + self.context.update({"title_placeholders": {"device": title}}) + return self.async_show_form( + step_id="confirm_discovery", + description_placeholders={ + "device": title, + }, + ) + class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json index 902a20b9b4e..edce9bab944 100644 --- a/homeassistant/components/fronius/manifest.json +++ b/homeassistant/components/fronius/manifest.json @@ -1,5 +1,10 @@ { "domain": "fronius", + "dhcp": [ + { + "macaddress": "0003AC*" + } + ], "name": "Fronius", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fronius", diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json index 7e411476559..711e363eeba 100644 --- a/homeassistant/components/fronius/strings.json +++ b/homeassistant/components/fronius/strings.json @@ -1,5 +1,6 @@ { "config": { + "flow_title": "{device}", "step": { "user": { "title": "Fronius SolarNet", @@ -7,6 +8,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]" } + }, + "confirm_discovery": { + "description": "Do you want to add {device} to Home Assistant?" } }, "error": { @@ -14,7 +18,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]" } } } diff --git a/homeassistant/components/fronius/translations/en.json b/homeassistant/components/fronius/translations/en.json index 75bbeede6e0..244949935e9 100644 --- a/homeassistant/components/fronius/translations/en.json +++ b/homeassistant/components/fronius/translations/en.json @@ -1,13 +1,18 @@ { "config": { "abort": { - "already_configured": "Device is already configured" + "already_configured": "Device is already configured", + "invalid_host": "Invalid hostname or IP address" }, "error": { "cannot_connect": "Failed to connect", "unknown": "Unexpected error" }, + "flow_title": "{device}", "step": { + "confirm_discovery": { + "description": "Do you want to add {device} to Home Assistant?" + }, "user": { "data": { "host": "Host" diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 17189705056..bdef30cf201 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -158,6 +158,10 @@ DHCP = [ "macaddress": "C82E47*", "hostname": "sta*" }, + { + "domain": "fronius", + "macaddress": "0003AC*" + }, { "domain": "goalzero", "hostname": "yeti*" diff --git a/tests/components/fronius/test_config_flow.py b/tests/components/fronius/test_config_flow.py index f0ff1e0ce48..427c8e4a163 100644 --- a/tests/components/fronius/test_config_flow.py +++ b/tests/components/fronius/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import patch from pyfronius import FroniusError from homeassistant import config_entries +from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.fronius.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import CONF_HOST, CONF_RESOURCE @@ -28,6 +29,11 @@ INVERTER_INFO_RETURN_VALUE = { ] } LOGGER_INFO_RETURN_VALUE = {"unique_identifier": {"value": "123.4567"}} +MOCK_DHCP_DATA = DhcpServiceInfo( + hostname="fronius", + ip="10.2.3.4", + macaddress="00:03:ac:11:22:33", +) async def test_form_with_logger(hass: HomeAssistant) -> None: @@ -261,3 +267,62 @@ async def test_import(hass, aioclient_mock): "host": MOCK_HOST, "is_logger": True, } + + +async def test_dhcp(hass, aioclient_mock): + """Test starting a flow from discovery.""" + with patch( + "homeassistant.components.fronius.config_flow.DHCP_REQUEST_DELAY", 0 + ), patch( + "pyfronius.Fronius.current_logger_info", + return_value=LOGGER_INFO_RETURN_VALUE, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=MOCK_DHCP_DATA + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "confirm_discovery" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == f"SolarNet Datalogger at {MOCK_DHCP_DATA.ip}" + assert result["data"] == { + "host": MOCK_DHCP_DATA.ip, + "is_logger": True, + } + + +async def test_dhcp_already_configured(hass, aioclient_mock): + """Test starting a flow from discovery.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="123.4567890", + data={ + CONF_HOST: f"http://{MOCK_DHCP_DATA.ip}/", + "is_logger": True, + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=MOCK_DHCP_DATA + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_invalid(hass, aioclient_mock): + """Test starting a flow from discovery.""" + with patch( + "homeassistant.components.fronius.config_flow.DHCP_REQUEST_DELAY", 0 + ), patch("pyfronius.Fronius.current_logger_info", side_effect=FroniusError,), patch( + "pyfronius.Fronius.inverter_info", + side_effect=FroniusError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=MOCK_DHCP_DATA + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "invalid_host"