DHCP discovery for Fronius integration (#60806)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Matthias Alphart 2021-12-03 18:29:15 +01:00 committed by GitHub
parent 75ec937359
commit 77cd751543
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 147 additions and 12 deletions

View File

@ -1,13 +1,15 @@
"""Config flow for Fronius integration.""" """Config flow for Fronius integration."""
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
from typing import Any from typing import Any, Final
from pyfronius import Fronius, FroniusError from pyfronius import Fronius, FroniusError
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.dhcp import DhcpServiceInfo
from homeassistant.const import CONF_HOST, CONF_RESOURCE from homeassistant.const import CONF_HOST, CONF_RESOURCE
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import FlowResult
@ -18,6 +20,8 @@ from .const import DOMAIN, FroniusConfigEntryData
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DHCP_REQUEST_DELAY: Final = 60
STEP_USER_DATA_SCHEMA = vol.Schema( STEP_USER_DATA_SCHEMA = vol.Schema(
{ {
vol.Required(CONF_HOST): str, vol.Required(CONF_HOST): str,
@ -25,14 +29,21 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
) )
async def validate_input( def create_title(info: FroniusConfigEntryData) -> str:
hass: HomeAssistant, data: dict[str, Any] """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]: ) -> tuple[str, FroniusConfigEntryData]:
"""Validate the user input allows us to connect. """Validate the user input allows us to connect.
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. 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) fronius = Fronius(async_get_clientsession(hass), host)
try: try:
@ -67,6 +78,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
def __init__(self) -> None:
"""Initialize flow."""
self.info: FroniusConfigEntryData
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> FlowResult: ) -> FlowResult:
@ -79,7 +94,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors = {} errors = {}
try: 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: except CannotConnect:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
@ -90,11 +105,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured( self._abort_if_unique_id_configured(
updates=dict(info), reload_on_update=False updates=dict(info), reload_on_update=False
) )
title = ( return self.async_create_entry(title=create_title(info), data=info)
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_show_form( return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors 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.""" """Import a configuration from config.yaml."""
return await self.async_step_user(user_input={CONF_HOST: conf[CONF_RESOURCE]}) 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): class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect.""" """Error to indicate we cannot connect."""

View File

@ -1,5 +1,10 @@
{ {
"domain": "fronius", "domain": "fronius",
"dhcp": [
{
"macaddress": "0003AC*"
}
],
"name": "Fronius", "name": "Fronius",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fronius", "documentation": "https://www.home-assistant.io/integrations/fronius",

View File

@ -1,5 +1,6 @@
{ {
"config": { "config": {
"flow_title": "{device}",
"step": { "step": {
"user": { "user": {
"title": "Fronius SolarNet", "title": "Fronius SolarNet",
@ -7,6 +8,9 @@
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]" "host": "[%key:common::config_flow::data::host%]"
} }
},
"confirm_discovery": {
"description": "Do you want to add {device} to Home Assistant?"
} }
}, },
"error": { "error": {
@ -14,7 +18,8 @@
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "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%]"
} }
} }
} }

View File

@ -1,13 +1,18 @@
{ {
"config": { "config": {
"abort": { "abort": {
"already_configured": "Device is already configured" "already_configured": "Device is already configured",
"invalid_host": "Invalid hostname or IP address"
}, },
"error": { "error": {
"cannot_connect": "Failed to connect", "cannot_connect": "Failed to connect",
"unknown": "Unexpected error" "unknown": "Unexpected error"
}, },
"flow_title": "{device}",
"step": { "step": {
"confirm_discovery": {
"description": "Do you want to add {device} to Home Assistant?"
},
"user": { "user": {
"data": { "data": {
"host": "Host" "host": "Host"

View File

@ -158,6 +158,10 @@ DHCP = [
"macaddress": "C82E47*", "macaddress": "C82E47*",
"hostname": "sta*" "hostname": "sta*"
}, },
{
"domain": "fronius",
"macaddress": "0003AC*"
},
{ {
"domain": "goalzero", "domain": "goalzero",
"hostname": "yeti*" "hostname": "yeti*"

View File

@ -4,6 +4,7 @@ from unittest.mock import patch
from pyfronius import FroniusError from pyfronius import FroniusError
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.dhcp import DhcpServiceInfo
from homeassistant.components.fronius.const import DOMAIN from homeassistant.components.fronius.const import DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import CONF_HOST, CONF_RESOURCE 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"}} 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: async def test_form_with_logger(hass: HomeAssistant) -> None:
@ -261,3 +267,62 @@ async def test_import(hass, aioclient_mock):
"host": MOCK_HOST, "host": MOCK_HOST,
"is_logger": True, "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"