Add config flow to frontier_silicon (#64365)

* Add config_flow to frontier_silicon

* Add missing translation file

* Delay unique_id validation until radio_id can be determined

* Fix tests

* Improve tests

* Use FlowResultType

* Bump afsapi to 0.2.6

* Fix requirements_test_all.txt

* Stash ssdp, reauth and unignore flows for now

* Re-introduce SSDP flow

* hassfest changes

* Address review comments

* Small style update

* Fix tests

* Update integrations.json

* fix order in manifest.json

* fix black errors

* Apply suggestions from code review

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>

* Address review comments

* fix black errors

* Use async_setup_platform instead of async_setup

* Address review comments on tests

* parameterize tests

* Remove discovery component changes from this PR

* Address review comments

* Apply suggestions from code review

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>

* Add extra asserts to tests

* Restructure _async_step_device_config_if_needed

* Add return statement

* Update homeassistant/components/frontier_silicon/media_player.py

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>

---------

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
This commit is contained in:
Thijs W
2023-03-10 10:26:03 +01:00
committed by GitHub
parent fde205c158
commit b8bda93d87
14 changed files with 636 additions and 20 deletions

View File

@ -395,7 +395,8 @@ omit =
homeassistant/components/fritzbox_callmonitor/__init__.py
homeassistant/components/fritzbox_callmonitor/base.py
homeassistant/components/fritzbox_callmonitor/sensor.py
homeassistant/components/frontier_silicon/const.py
homeassistant/components/frontier_silicon/__init__.py
homeassistant/components/frontier_silicon/browse_media.py
homeassistant/components/frontier_silicon/media_player.py
homeassistant/components/futurenow/light.py
homeassistant/components/garadget/cover.py

View File

@ -401,6 +401,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/frontend/ @home-assistant/frontend
/tests/components/frontend/ @home-assistant/frontend
/homeassistant/components/frontier_silicon/ @wlcrs
/tests/components/frontier_silicon/ @wlcrs
/homeassistant/components/fully_kiosk/ @cgarwood
/tests/components/fully_kiosk/ @cgarwood
/homeassistant/components/garages_amsterdam/ @klaasnicolaas

View File

@ -1 +1,45 @@
"""The frontier_silicon component."""
"""The Frontier Silicon integration."""
from __future__ import annotations
import logging
from afsapi import AFSAPI, ConnectionError as FSConnectionError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from .const import CONF_PIN, CONF_WEBFSAPI_URL, DOMAIN
PLATFORMS = [Platform.MEDIA_PLAYER]
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Frontier Silicon from a config entry."""
webfsapi_url = entry.data[CONF_WEBFSAPI_URL]
pin = entry.data[CONF_PIN]
afsapi = AFSAPI(webfsapi_url, pin)
try:
await afsapi.get_power()
except FSConnectionError as exception:
raise PlatformNotReady from exception
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = afsapi
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

@ -0,0 +1,178 @@
"""Config flow for Frontier Silicon Media Player integration."""
from __future__ import annotations
import logging
from typing import Any
from afsapi import AFSAPI, ConnectionError as FSConnectionError, InvalidPinException
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.data_entry_flow import FlowResult
from .const import CONF_PIN, CONF_WEBFSAPI_URL, DEFAULT_PIN, DEFAULT_PORT, DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
}
)
STEP_DEVICE_CONFIG_DATA_SCHEMA = vol.Schema(
{
vol.Required(
CONF_PIN,
default=DEFAULT_PIN,
): str,
}
)
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Frontier Silicon Media Player."""
VERSION = 1
def __init__(self) -> None:
"""Initialize flow."""
self._webfsapi_url: str | None = None
self._name: str | None = None
self._unique_id: str | None = None
async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult:
"""Handle the import of legacy configuration.yaml entries."""
device_url = f"http://{import_info[CONF_HOST]}:{import_info[CONF_PORT]}/device"
try:
self._webfsapi_url = await AFSAPI.get_webfsapi_endpoint(device_url)
except FSConnectionError:
return self.async_abort(reason="cannot_connect")
except Exception as exception: # pylint: disable=broad-except
_LOGGER.exception(exception)
return self.async_abort(reason="unknown")
try:
afsapi = AFSAPI(self._webfsapi_url, import_info[CONF_PIN])
self._unique_id = await afsapi.get_radio_id()
except FSConnectionError:
return self.async_abort(reason="cannot_connect")
except InvalidPinException:
return self.async_abort(reason="invalid_auth")
except Exception as exception: # pylint: disable=broad-except
_LOGGER.exception(exception)
return self.async_abort(reason="unknown")
await self.async_set_unique_id(self._unique_id, raise_on_progress=False)
self._abort_if_unique_id_configured()
self._name = import_info[CONF_NAME] or "Radio"
return await self._create_entry(pin=import_info[CONF_PIN])
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step of manual configuration."""
errors = {}
if user_input:
device_url = (
f"http://{user_input[CONF_HOST]}:{user_input[CONF_PORT]}/device"
)
try:
self._webfsapi_url = await AFSAPI.get_webfsapi_endpoint(device_url)
except FSConnectionError:
errors["base"] = "cannot_connect"
except Exception as exception: # pylint: disable=broad-except
_LOGGER.exception(exception)
errors["base"] = "unknown"
else:
return await self._async_step_device_config_if_needed()
data_schema = self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, user_input
)
return self.async_show_form(
step_id="user", data_schema=data_schema, errors=errors
)
async def _async_step_device_config_if_needed(self) -> FlowResult:
"""Most users will not have changed the default PIN on their radio.
We try to use this default PIN, and only if this fails ask for it via `async_step_device_config`
"""
try:
# try to login with default pin
afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN)
self._name = await afsapi.get_friendly_name()
except InvalidPinException:
# Ask for a PIN
return await self.async_step_device_config()
self.context["title_placeholders"] = {"name": self._name}
self._unique_id = await afsapi.get_radio_id()
await self.async_set_unique_id(self._unique_id)
self._abort_if_unique_id_configured()
return await self._create_entry()
async def async_step_device_config(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle device configuration step.
We ask for the PIN in this step.
"""
assert self._webfsapi_url is not None
if user_input is None:
return self.async_show_form(
step_id="device_config", data_schema=STEP_DEVICE_CONFIG_DATA_SCHEMA
)
errors = {}
try:
afsapi = AFSAPI(self._webfsapi_url, user_input[CONF_PIN])
self._name = await afsapi.get_friendly_name()
except FSConnectionError:
errors["base"] = "cannot_connect"
except InvalidPinException:
errors["base"] = "invalid_auth"
except Exception as exception: # pylint: disable=broad-except
_LOGGER.exception(exception)
errors["base"] = "unknown"
else:
self._unique_id = await afsapi.get_radio_id()
await self.async_set_unique_id(self._unique_id)
self._abort_if_unique_id_configured()
return await self._create_entry(pin=user_input[CONF_PIN])
data_schema = self.add_suggested_values_to_schema(
STEP_DEVICE_CONFIG_DATA_SCHEMA, user_input
)
return self.async_show_form(
step_id="device_config",
data_schema=data_schema,
errors=errors,
)
async def _create_entry(self, pin: str | None = None) -> FlowResult:
"""Create the entry."""
assert self._name is not None
assert self._webfsapi_url is not None
data = {CONF_WEBFSAPI_URL: self._webfsapi_url, CONF_PIN: pin or DEFAULT_PIN}
return self.async_create_entry(title=self._name, data=data)

View File

@ -1,6 +1,9 @@
"""Constants for the Frontier Silicon Media Player integration."""
DOMAIN = "frontier_silicon"
CONF_WEBFSAPI_URL = "webfsapi_url"
CONF_PIN = "pin"
DEFAULT_PIN = "1234"
DEFAULT_PORT = 80

View File

@ -2,6 +2,7 @@
"domain": "frontier_silicon",
"name": "Frontier Silicon",
"codeowners": ["@wlcrs"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/frontier_silicon",
"iot_class": "local_polling",
"requirements": ["afsapi==0.2.7"]

View File

@ -21,15 +21,17 @@ from homeassistant.components.media_player import (
MediaPlayerState,
MediaType,
)
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .browse_media import browse_node, browse_top_level
from .const import DEFAULT_PIN, DEFAULT_PORT, DOMAIN, MEDIA_CONTENT_ID_PRESET
from .const import CONF_PIN, DEFAULT_PIN, DEFAULT_PORT, DOMAIN, MEDIA_CONTENT_ID_PRESET
_LOGGER = logging.getLogger(__name__)
@ -49,7 +51,11 @@ async def async_setup_platform(
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Frontier Silicon platform."""
"""Set up the Frontier Silicon platform.
YAML is deprecated, and imported automatically.
SSDP discovery is temporarily retained - to be refactor subsequently.
"""
if discovery_info is not None:
webfsapi_url = await AFSAPI.get_webfsapi_endpoint(
discovery_info["ssdp_description"]
@ -61,24 +67,41 @@ async def async_setup_platform(
[AFSAPIDevice(name, afsapi)],
True,
)
return
host = config.get(CONF_HOST)
port = config.get(CONF_PORT)
password = config.get(CONF_PASSWORD)
name = config.get(CONF_NAME)
ir.async_create_issue(
hass,
DOMAIN,
"remove_yaml",
breaks_in_ha_version="2023.6.0",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="removed_yaml",
)
try:
webfsapi_url = await AFSAPI.get_webfsapi_endpoint(
f"http://{host}:{port}/device"
)
except FSConnectionError:
_LOGGER.error(
"Could not add the FSAPI device at %s:%s -> %s", host, port, password
)
return
afsapi = AFSAPI(webfsapi_url, password)
async_add_entities([AFSAPIDevice(name, afsapi)], True)
await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={
CONF_NAME: config.get(CONF_NAME),
CONF_HOST: config.get(CONF_HOST),
CONF_PORT: config.get(CONF_PORT, DEFAULT_PORT),
CONF_PIN: config.get(CONF_PASSWORD, DEFAULT_PIN),
},
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Frontier Silicon entity."""
afsapi: AFSAPI = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities([AFSAPIDevice(config_entry.title, afsapi)], True)
class AFSAPIDevice(MediaPlayerEntity):

View File

@ -0,0 +1,35 @@
{
"config": {
"flow_title": "{name}",
"step": {
"user": {
"title": "Frontier Silicon Setup",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
}
},
"device_config": {
"title": "Device Configuration",
"description": "The pin can be found via 'MENU button > Main Menu > System setting > Network > NetRemote PIN setup'",
"data": {
"pin": "[%key:common::config_flow::data::pin%]"
}
}
},
"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%]"
}
},
"issues": {
"removed_yaml": {
"title": "The Frontier Silicon YAML configuration has been removed",
"description": "Configuring Frontier Silicon using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
}
}
}

View File

@ -145,6 +145,7 @@ FLOWS = {
"fritzbox",
"fritzbox_callmonitor",
"fronius",
"frontier_silicon",
"fully_kiosk",
"garages_amsterdam",
"gdacs",

View File

@ -1818,7 +1818,7 @@
"frontier_silicon": {
"name": "Frontier Silicon",
"integration_type": "hub",
"config_flow": false,
"config_flow": true,
"iot_class": "local_polling"
},
"fully_kiosk": {

View File

@ -78,6 +78,9 @@ adguardhome==0.6.1
# homeassistant.components.advantage_air
advantage_air==0.4.1
# homeassistant.components.frontier_silicon
afsapi==0.2.7
# homeassistant.components.agent_dvr
agent-py==0.0.23

View File

@ -0,0 +1 @@
"""Tests for the Frontier Silicon integration."""

View File

@ -0,0 +1,59 @@
"""Configuration for frontier_silicon tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.frontier_silicon.const import (
CONF_PIN,
CONF_WEBFSAPI_URL,
DOMAIN,
)
from tests.common import MockConfigEntry
@pytest.fixture
def config_entry() -> MockConfigEntry:
"""Create a mock Frontier Silicon config entry."""
return MockConfigEntry(
domain=DOMAIN,
unique_id="mock_radio_id",
data={CONF_WEBFSAPI_URL: "http://1.1.1.1:80/webfsapi", CONF_PIN: "1234"},
)
@pytest.fixture(autouse=True)
def mock_valid_device_url() -> Generator[None, None, None]:
"""Return a valid webfsapi endpoint."""
with patch(
"afsapi.AFSAPI.get_webfsapi_endpoint",
return_value="http://1.1.1.1:80/webfsapi",
):
yield
@pytest.fixture(autouse=True)
def mock_valid_pin() -> Generator[None, None, None]:
"""Make get_friendly_name return a value, indicating a valid pin."""
with patch(
"afsapi.AFSAPI.get_friendly_name",
return_value="Name of the device",
):
yield
@pytest.fixture(autouse=True)
def mock_radio_id() -> Generator[None, None, None]:
"""Return a valid radio_id."""
with patch("afsapi.AFSAPI.get_radio_id", return_value="mock_radio_id"):
yield
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.frontier_silicon.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry

View File

@ -0,0 +1,266 @@
"""Test the Frontier Silicon config flow."""
from unittest.mock import AsyncMock, patch
from afsapi import ConnectionError, InvalidPinException
import pytest
from homeassistant import config_entries
from homeassistant.components.frontier_silicon.const import CONF_WEBFSAPI_URL, DOMAIN
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
async def test_import_success(hass: HomeAssistant) -> None:
"""Test successful import."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={
CONF_HOST: "1.1.1.1",
CONF_PORT: 80,
CONF_PIN: "1234",
CONF_NAME: "Test name",
},
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "Test name"
assert result["data"] == {
CONF_WEBFSAPI_URL: "http://1.1.1.1:80/webfsapi",
CONF_PIN: "1234",
}
@pytest.mark.parametrize(
("webfsapi_endpoint_error", "result_reason"),
[
(ConnectionError, "cannot_connect"),
(ValueError, "unknown"),
],
)
async def test_import_webfsapi_endpoint_failures(
hass: HomeAssistant, webfsapi_endpoint_error: Exception, result_reason: str
) -> None:
"""Test various failure of get_webfsapi_endpoint."""
with patch(
"afsapi.AFSAPI.get_webfsapi_endpoint",
side_effect=webfsapi_endpoint_error,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={
CONF_HOST: "1.1.1.1",
CONF_PORT: 80,
CONF_PIN: "1234",
CONF_NAME: "Test name",
},
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == result_reason
@pytest.mark.parametrize(
("radio_id_error", "result_reason"),
[
(ConnectionError, "cannot_connect"),
(InvalidPinException, "invalid_auth"),
(ValueError, "unknown"),
],
)
async def test_import_radio_id_failures(
hass: HomeAssistant, radio_id_error: Exception, result_reason: str
) -> None:
"""Test various failure of get_radio_id."""
with patch(
"afsapi.AFSAPI.get_radio_id",
side_effect=radio_id_error,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={
CONF_HOST: "1.1.1.1",
CONF_PORT: 80,
CONF_PIN: "1234",
CONF_NAME: "Test name",
},
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == result_reason
async def test_import_already_exists(
hass: HomeAssistant, config_entry: MockConfigEntry
) -> None:
"""Test import of device which already exists."""
config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={
CONF_HOST: "1.1.1.1",
CONF_PORT: 80,
CONF_PIN: "1234",
CONF_NAME: "Test name",
},
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_form_default_pin(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test manual device add with default pin."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "1.1.1.1", CONF_PORT: 80},
)
await hass.async_block_till_done()
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == "Name of the device"
assert result2["data"] == {
CONF_WEBFSAPI_URL: "http://1.1.1.1:80/webfsapi",
CONF_PIN: "1234",
}
mock_setup_entry.assert_called_once()
async def test_form_nondefault_pin(
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"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
with patch(
"afsapi.AFSAPI.get_friendly_name",
side_effect=InvalidPinException,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "1.1.1.1", CONF_PORT: 80},
)
await hass.async_block_till_done()
assert result2["type"] == FlowResultType.FORM
assert result2["step_id"] == "device_config"
assert result2["errors"] is None
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{CONF_PIN: "4321"},
)
await hass.async_block_till_done()
assert result3["type"] == FlowResultType.CREATE_ENTRY
assert result3["title"] == "Name of the device"
assert result3["data"] == {
"webfsapi_url": "http://1.1.1.1:80/webfsapi",
"pin": "4321",
}
mock_setup_entry.assert_called_once()
@pytest.mark.parametrize(
("friendly_name_error", "result_error"),
[
(ConnectionError, "cannot_connect"),
(InvalidPinException, "invalid_auth"),
(ValueError, "unknown"),
],
)
async def test_form_nondefault_pin_invalid(
hass: HomeAssistant, friendly_name_error: Exception, result_error: str
) -> None:
"""Test we get the proper errors when trying to validate an user-provided PIN."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
with patch(
"afsapi.AFSAPI.get_friendly_name",
side_effect=InvalidPinException,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "1.1.1.1", CONF_PORT: 80},
)
await hass.async_block_till_done()
assert result2["type"] == FlowResultType.FORM
assert result2["step_id"] == "device_config"
assert result2["errors"] is None
with patch(
"afsapi.AFSAPI.get_friendly_name",
side_effect=friendly_name_error,
):
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{CONF_PIN: "4321"},
)
await hass.async_block_till_done()
assert result3["type"] == FlowResultType.FORM
assert result2["step_id"] == "device_config"
assert result3["errors"] == {"base": result_error}
@pytest.mark.parametrize(
("webfsapi_endpoint_error", "result_error"),
[
(ConnectionError, "cannot_connect"),
(ValueError, "unknown"),
],
)
async def test_invalid_device_url(
hass: HomeAssistant, webfsapi_endpoint_error: Exception, result_error: str
) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
with patch(
"afsapi.AFSAPI.get_webfsapi_endpoint",
side_effect=webfsapi_endpoint_error,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "1.1.1.1", CONF_PORT: 80},
)
await hass.async_block_till_done()
assert result2["type"] == FlowResultType.FORM
assert result2["step_id"] == "user"
assert result2["errors"] == {"base": result_error}