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."""
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."""

View File

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

View File

@ -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%]"
}
}
}

View File

@ -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"

View File

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

View File

@ -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"