Migrate ssdp to config_flow for frontier_silicon (#89496)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Thijs W 2023-03-29 04:06:21 +02:00 committed by GitHub
parent 47a2598b66
commit e3cad8baac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 221 additions and 45 deletions

View File

@ -60,7 +60,6 @@ class ServiceDetails(NamedTuple):
SERVICE_HANDLERS = { SERVICE_HANDLERS = {
SERVICE_ENIGMA2: ServiceDetails("media_player", "enigma2"), SERVICE_ENIGMA2: ServiceDetails("media_player", "enigma2"),
"yamaha": ServiceDetails("media_player", "yamaha"), "yamaha": ServiceDetails("media_player", "yamaha"),
"frontier_silicon": ServiceDetails("media_player", "frontier_silicon"),
"openhome": ServiceDetails("media_player", "openhome"), "openhome": ServiceDetails("media_player", "openhome"),
"bluesound": ServiceDetails("media_player", "bluesound"), "bluesound": ServiceDetails("media_player", "bluesound"),
} }

View File

@ -3,15 +3,24 @@ from __future__ import annotations
import logging import logging
from typing import Any from typing import Any
from urllib.parse import urlparse
from afsapi import AFSAPI, ConnectionError as FSConnectionError, InvalidPinException from afsapi import AFSAPI, ConnectionError as FSConnectionError, InvalidPinException
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components import ssdp
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import FlowResult
from .const import CONF_PIN, CONF_WEBFSAPI_URL, DEFAULT_PIN, DEFAULT_PORT, DOMAIN from .const import (
CONF_PIN,
CONF_WEBFSAPI_URL,
DEFAULT_PIN,
DEFAULT_PORT,
DOMAIN,
SSDP_ATTR_SPEAKER_NAME,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -32,11 +41,17 @@ STEP_DEVICE_CONFIG_DATA_SCHEMA = vol.Schema(
) )
def hostname_from_url(url: str) -> str:
"""Return the hostname from a url."""
return str(urlparse(url).hostname)
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Frontier Silicon Media Player.""" """Handle a config flow for Frontier Silicon Media Player."""
VERSION = 1 VERSION = 1
_name: str
_webfsapi_url: str _webfsapi_url: str
async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult:
@ -101,6 +116,46 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
step_id="user", data_schema=data_schema, errors=errors step_id="user", data_schema=data_schema, errors=errors
) )
async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult:
"""Process entity discovered via SSDP."""
device_url = discovery_info.ssdp_location
if device_url is None:
return self.async_abort(reason="cannot_connect")
device_hostname = hostname_from_url(device_url)
for entry in self._async_current_entries(include_ignore=False):
if device_hostname == hostname_from_url(entry.data[CONF_WEBFSAPI_URL]):
return self.async_abort(reason="already_configured")
speaker_name = discovery_info.ssdp_headers.get(SSDP_ATTR_SPEAKER_NAME)
self.context["title_placeholders"] = {"name": speaker_name}
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.debug(exception)
return self.async_abort(reason="unknown")
try:
# try to login with default pin
afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN)
unique_id = await afsapi.get_radio_id()
except InvalidPinException:
return self.async_abort(reason="invalid_auth")
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured(
updates={CONF_WEBFSAPI_URL: self._webfsapi_url}, reload_on_update=True
)
self._name = await afsapi.get_friendly_name()
return await self.async_step_confirm()
async def _async_step_device_config_if_needed(self) -> FlowResult: async def _async_step_device_config_if_needed(self) -> FlowResult:
"""Most users will not have changed the default PIN on their radio. """Most users will not have changed the default PIN on their radio.
@ -111,21 +166,29 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
# try to login with default pin # try to login with default pin
afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN) afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN)
name = await afsapi.get_friendly_name() self._name = await afsapi.get_friendly_name()
except InvalidPinException: except InvalidPinException:
# Ask for a PIN # Ask for a PIN
return await self.async_step_device_config() return await self.async_step_device_config()
self.context["title_placeholders"] = {"name": name} self.context["title_placeholders"] = {"name": self._name}
unique_id = await afsapi.get_radio_id() unique_id = await afsapi.get_radio_id()
await self.async_set_unique_id(unique_id) await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
return self.async_create_entry( return await self._async_create_entry()
title=name,
data={CONF_WEBFSAPI_URL: self._webfsapi_url, CONF_PIN: DEFAULT_PIN}, async def async_step_confirm(
) self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Allow the user to confirm adding the device. Used when the default PIN could successfully be used."""
if user_input is not None:
return await self._async_create_entry()
self._set_confirm_only()
return self.async_show_form(step_id="confirm")
async def async_step_device_config( async def async_step_device_config(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@ -145,7 +208,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
try: try:
afsapi = AFSAPI(self._webfsapi_url, user_input[CONF_PIN]) afsapi = AFSAPI(self._webfsapi_url, user_input[CONF_PIN])
name = await afsapi.get_friendly_name() self._name = await afsapi.get_friendly_name()
except FSConnectionError: except FSConnectionError:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
@ -156,15 +219,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors["base"] = "unknown" errors["base"] = "unknown"
else: else:
unique_id = await afsapi.get_radio_id() unique_id = await afsapi.get_radio_id()
await self.async_set_unique_id(unique_id) await self.async_set_unique_id(unique_id, raise_on_progress=False)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
return self.async_create_entry( return await self._async_create_entry(user_input[CONF_PIN])
title=name,
data={
CONF_WEBFSAPI_URL: self._webfsapi_url,
CONF_PIN: user_input[CONF_PIN],
},
)
data_schema = self.add_suggested_values_to_schema( data_schema = self.add_suggested_values_to_schema(
STEP_DEVICE_CONFIG_DATA_SCHEMA, user_input STEP_DEVICE_CONFIG_DATA_SCHEMA, user_input
@ -174,3 +231,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
data_schema=data_schema, data_schema=data_schema,
errors=errors, errors=errors,
) )
async def _async_create_entry(self, pin: str | None = None):
"""Create the entry."""
return self.async_create_entry(
title=self._name,
data={CONF_WEBFSAPI_URL: self._webfsapi_url, CONF_PIN: pin or DEFAULT_PIN},
)

View File

@ -4,6 +4,9 @@ DOMAIN = "frontier_silicon"
CONF_WEBFSAPI_URL = "webfsapi_url" CONF_WEBFSAPI_URL = "webfsapi_url"
CONF_PIN = "pin" CONF_PIN = "pin"
SSDP_ST = "urn:schemas-frontier-silicon-com:undok:fsapi:1"
SSDP_ATTR_SPEAKER_NAME = "SPEAKER-NAME"
DEFAULT_PIN = "1234" DEFAULT_PIN = "1234"
DEFAULT_PORT = 80 DEFAULT_PORT = 80

View File

@ -5,5 +5,6 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/frontier_silicon", "documentation": "https://www.home-assistant.io/integrations/frontier_silicon",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["afsapi==0.2.7"] "requirements": ["afsapi==0.2.7"],
"ssdp": [{ "st": "urn:schemas-frontier-silicon-com:undok:fsapi:1" }]
} }

View File

@ -54,21 +54,7 @@ async def async_setup_platform(
"""Set up the Frontier Silicon platform. """Set up the Frontier Silicon platform.
YAML is deprecated, and imported automatically. 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"]
)
afsapi = AFSAPI(webfsapi_url, DEFAULT_PIN)
name = await afsapi.get_friendly_name()
async_add_entities(
[AFSAPIDevice(name, afsapi)],
True,
)
return
ir.async_create_issue( ir.async_create_issue(
hass, hass,

View File

@ -130,6 +130,11 @@ SSDP = {
"st": "urn:schemas-upnp-org:device:fritzbox:1", "st": "urn:schemas-upnp-org:device:fritzbox:1",
}, },
], ],
"frontier_silicon": [
{
"st": "urn:schemas-frontier-silicon-com:undok:fsapi:1",
},
],
"harmony": [ "harmony": [
{ {
"deviceType": "urn:myharmony-com:device:harmony:1", "deviceType": "urn:myharmony-com:device:harmony:1",

View File

@ -5,7 +5,12 @@ from afsapi import ConnectionError, InvalidPinException
import pytest import pytest
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.frontier_silicon.const import CONF_WEBFSAPI_URL, DOMAIN from homeassistant.components import ssdp
from homeassistant.components.frontier_silicon.const import (
CONF_WEBFSAPI_URL,
DEFAULT_PIN,
DOMAIN,
)
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, CONF_PORT from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, CONF_PORT
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
@ -15,6 +20,23 @@ from tests.common import MockConfigEntry
pytestmark = pytest.mark.usefixtures("mock_setup_entry") pytestmark = pytest.mark.usefixtures("mock_setup_entry")
MOCK_DISCOVERY = ssdp.SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_udn="uuid:3dcc7100-f76c-11dd-87af-00226124ca30",
ssdp_st="mock_st",
ssdp_location="http://1.1.1.1/device",
upnp={"SPEAKER-NAME": "Speaker Name"},
)
INVALID_MOCK_DISCOVERY = ssdp.SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_udn="uuid:3dcc7100-f76c-11dd-87af-00226124ca30",
ssdp_st="mock_st",
ssdp_location=None,
upnp={"SPEAKER-NAME": "Speaker Name"},
)
async def test_import_success(hass: HomeAssistant) -> None: async def test_import_success(hass: HomeAssistant) -> None:
"""Test successful import.""" """Test successful import."""
@ -49,7 +71,7 @@ async def test_import_webfsapi_endpoint_failures(
) -> None: ) -> None:
"""Test various failure of get_webfsapi_endpoint.""" """Test various failure of get_webfsapi_endpoint."""
with patch( with patch(
"afsapi.AFSAPI.get_webfsapi_endpoint", "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_webfsapi_endpoint",
side_effect=webfsapi_endpoint_error, side_effect=webfsapi_endpoint_error,
): ):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
@ -80,7 +102,7 @@ async def test_import_radio_id_failures(
) -> None: ) -> None:
"""Test various failure of get_radio_id.""" """Test various failure of get_radio_id."""
with patch( with patch(
"afsapi.AFSAPI.get_radio_id", "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_radio_id",
side_effect=radio_id_error, side_effect=radio_id_error,
): ):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
@ -157,7 +179,7 @@ async def test_form_nondefault_pin(
assert result["errors"] == {} assert result["errors"] == {}
with patch( with patch(
"afsapi.AFSAPI.get_friendly_name", "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_friendly_name",
side_effect=InvalidPinException, side_effect=InvalidPinException,
): ):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
@ -179,8 +201,8 @@ async def test_form_nondefault_pin(
assert result3["type"] == FlowResultType.CREATE_ENTRY assert result3["type"] == FlowResultType.CREATE_ENTRY
assert result3["title"] == "Name of the device" assert result3["title"] == "Name of the device"
assert result3["data"] == { assert result3["data"] == {
"webfsapi_url": "http://1.1.1.1:80/webfsapi", CONF_WEBFSAPI_URL: "http://1.1.1.1:80/webfsapi",
"pin": "4321", CONF_PIN: "4321",
} }
mock_setup_entry.assert_called_once() mock_setup_entry.assert_called_once()
@ -208,7 +230,7 @@ async def test_form_nondefault_pin_invalid(
assert result["errors"] == {} assert result["errors"] == {}
with patch( with patch(
"afsapi.AFSAPI.get_friendly_name", "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_friendly_name",
side_effect=InvalidPinException, side_effect=InvalidPinException,
): ):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
@ -222,7 +244,7 @@ async def test_form_nondefault_pin_invalid(
assert result2["errors"] is None assert result2["errors"] is None
with patch( with patch(
"afsapi.AFSAPI.get_friendly_name", "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_friendly_name",
side_effect=friendly_name_error, side_effect=friendly_name_error,
): ):
result3 = await hass.config_entries.flow.async_configure( result3 = await hass.config_entries.flow.async_configure(
@ -244,8 +266,8 @@ async def test_form_nondefault_pin_invalid(
assert result4["type"] == FlowResultType.CREATE_ENTRY assert result4["type"] == FlowResultType.CREATE_ENTRY
assert result4["title"] == "Name of the device" assert result4["title"] == "Name of the device"
assert result4["data"] == { assert result4["data"] == {
"webfsapi_url": "http://1.1.1.1:80/webfsapi", CONF_WEBFSAPI_URL: "http://1.1.1.1:80/webfsapi",
"pin": "4321", CONF_PIN: "4321",
} }
mock_setup_entry.assert_called_once() mock_setup_entry.assert_called_once()
@ -272,7 +294,7 @@ async def test_invalid_device_url(
assert result["errors"] == {} assert result["errors"] == {}
with patch( with patch(
"afsapi.AFSAPI.get_webfsapi_endpoint", "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_webfsapi_endpoint",
side_effect=webfsapi_endpoint_error, side_effect=webfsapi_endpoint_error,
): ):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
@ -294,7 +316,102 @@ async def test_invalid_device_url(
assert result3["type"] == FlowResultType.CREATE_ENTRY assert result3["type"] == FlowResultType.CREATE_ENTRY
assert result3["title"] == "Name of the device" assert result3["title"] == "Name of the device"
assert result3["data"] == { assert result3["data"] == {
"webfsapi_url": "http://1.1.1.1:80/webfsapi", CONF_WEBFSAPI_URL: "http://1.1.1.1:80/webfsapi",
"pin": "1234", CONF_PIN: "1234",
} }
mock_setup_entry.assert_called_once() mock_setup_entry.assert_called_once()
async def test_ssdp(hass: HomeAssistant, mock_setup_entry: MockConfigEntry) -> None:
"""Test a device being discovered."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_SSDP},
data=MOCK_DISCOVERY,
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "confirm"
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
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: DEFAULT_PIN,
}
mock_setup_entry.assert_called_once()
async def test_ssdp_invalid_location(hass: HomeAssistant) -> None:
"""Test a device being discovered."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_SSDP},
data=INVALID_MOCK_DISCOVERY,
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
async def test_ssdp_already_configured(
hass: HomeAssistant, config_entry: MockConfigEntry
) -> None:
"""Test an already known device being discovered."""
config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_SSDP},
data=MOCK_DISCOVERY,
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
("webfsapi_endpoint_error", "result_error"),
[(ValueError, "unknown"), (ConnectionError, "cannot_connect")],
)
async def test_ssdp_fail(
hass: HomeAssistant, webfsapi_endpoint_error: Exception, result_error: str
) -> None:
"""Test a device being discovered but failing to reply."""
with patch(
"homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_webfsapi_endpoint",
side_effect=webfsapi_endpoint_error,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_SSDP},
data=MOCK_DISCOVERY,
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == result_error
async def test_ssdp_nondefault_pin(hass: HomeAssistant) -> None:
"""Test a device being discovered."""
with patch(
"homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_radio_id",
side_effect=InvalidPinException,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_SSDP},
data=MOCK_DISCOVERY,
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "invalid_auth"