From 12aa537eb95873786aea179a90ddce62d9bde0a6 Mon Sep 17 00:00:00 2001 From: Chris Talkington <chris@talkingtontech.com> Date: Tue, 29 Dec 2020 20:43:02 -0600 Subject: [PATCH] Support homekit discovery for roku (#44625) * support homekit discovery for roku * Update config_flow.py * Update config_flow.py * Update test_config_flow.py * Update __init__.py * Update __init__.py * Update strings.json * Update manifest.json * Update __init__.py * Update test_config_flow.py * Update __init__.py * Update manifest.json * Update config_flow.py * Update config_flow.py * Update __init__.py * Update test_config_flow.py * Update __init__.py * Update manifest.json * Update __init__.py * Update zeroconf.py * Update config_flow.py * Update test_config_flow.py * Update config_flow.py * Update test_config_flow.py * Update __init__.py * Update config_flow.py * Update test_config_flow.py * Update manifest.json * Update zeroconf.py --- homeassistant/components/roku/config_flow.py | 45 +++++++++- homeassistant/components/roku/manifest.json | 8 ++ homeassistant/components/roku/strings.json | 2 +- homeassistant/generated/zeroconf.py | 4 + tests/components/roku/__init__.py | 16 +++- tests/components/roku/test_config_flow.py | 93 +++++++++++++++++++- 6 files changed, 160 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/roku/config_flow.py b/homeassistant/components/roku/config_flow.py index 6e494ce2692..f8e9034292c 100644 --- a/homeassistant/components/roku/config_flow.py +++ b/homeassistant/components/roku/config_flow.py @@ -85,6 +85,36 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=info["title"], data=user_input) + async def async_step_homekit(self, discovery_info): + """Handle a flow initialized by homekit discovery.""" + + # If we already have the host configured do + # not open connections to it if we can avoid it. + if self._host_already_configured(discovery_info[CONF_HOST]): + return self.async_abort(reason="already_configured") + + self.discovery_info.update({CONF_HOST: discovery_info[CONF_HOST]}) + + try: + info = await validate_input(self.hass, self.discovery_info) + except RokuError: + _LOGGER.debug("Roku Error", exc_info=True) + return self.async_abort(reason=ERROR_CANNOT_CONNECT) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error trying to connect") + return self.async_abort(reason=ERROR_UNKNOWN) + + await self.async_set_unique_id(info["serial_number"]) + self._abort_if_unique_id_configured( + updates={CONF_HOST: discovery_info[CONF_HOST]}, + ) + + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context.update({"title_placeholders": {"name": info["title"]}}) + self.discovery_info.update({CONF_NAME: info["title"]}) + + return await self.async_step_discovery_confirm() + async def async_step_ssdp( self, discovery_info: Optional[Dict] = None ) -> Dict[str, Any]: @@ -110,16 +140,16 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unknown error trying to connect") return self.async_abort(reason=ERROR_UNKNOWN) - return await self.async_step_ssdp_confirm() + return await self.async_step_discovery_confirm() - async def async_step_ssdp_confirm( + async def async_step_discovery_confirm( self, user_input: Optional[Dict] = None ) -> Dict[str, Any]: """Handle user-confirmation of discovered device.""" # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 if user_input is None: return self.async_show_form( - step_id="ssdp_confirm", + step_id="discovery_confirm", description_placeholders={"name": self.discovery_info[CONF_NAME]}, errors={}, ) @@ -128,3 +158,12 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): title=self.discovery_info[CONF_NAME], data=self.discovery_info, ) + + def _host_already_configured(self, host): + """See if we already have a hub with the host address configured.""" + existing_hosts = { + entry.data[CONF_HOST] + for entry in self._async_current_entries() + if CONF_HOST in entry.data + } + return host in existing_hosts diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index 39b48b91a84..682576b534a 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -3,6 +3,14 @@ "name": "Roku", "documentation": "https://www.home-assistant.io/integrations/roku", "requirements": ["rokuecp==0.6.0"], + "homekit": { + "models": [ + "3810X", + "4660X", + "7820X", + "C105X" + ] + }, "ssdp": [ { "st": "roku:ecp", diff --git a/homeassistant/components/roku/strings.json b/homeassistant/components/roku/strings.json index 6d9000b8669..55b533d4f1c 100644 --- a/homeassistant/components/roku/strings.json +++ b/homeassistant/components/roku/strings.json @@ -8,7 +8,7 @@ "host": "[%key:common::config_flow::data::host%]" } }, - "ssdp_confirm": { + "discovery_confirm": { "title": "Roku", "description": "Do you want to set up {name}?", "data": {} diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 6efa44e304f..57b6e6cb123 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -157,10 +157,14 @@ ZEROCONF = { } HOMEKIT = { + "3810X": "roku", + "4660X": "roku", + "7820X": "roku", "819LMB": "myq", "AC02": "tado", "Abode": "abode", "BSB002": "hue", + "C105X": "roku", "Healty Home Coach": "netatmo", "Iota": "abode", "LIFX": "lifx", diff --git a/tests/components/roku/__init__.py b/tests/components/roku/__init__.py index f2da007b5e0..4ab2991bd43 100644 --- a/tests/components/roku/__init__.py +++ b/tests/components/roku/__init__.py @@ -8,14 +8,16 @@ from homeassistant.components.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_SERIAL, ) -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_ID, CONF_NAME from homeassistant.helpers.typing import HomeAssistantType from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker -HOST = "192.168.1.160" NAME = "Roku 3" +NAME_ROKUTV = '58" Onn Roku TV' + +HOST = "192.168.1.160" SSDP_LOCATION = "http://192.168.1.160/" UPNP_FRIENDLY_NAME = "My Roku 3" UPNP_SERIAL = "1GU48T017973" @@ -26,6 +28,16 @@ MOCK_SSDP_DISCOVERY_INFO = { ATTR_UPNP_SERIAL: UPNP_SERIAL, } +HOMEKIT_HOST = "192.168.1.161" + +MOCK_HOMEKIT_DISCOVERY_INFO = { + CONF_NAME: "onn._hap._tcp.local.", + CONF_HOST: HOMEKIT_HOST, + "properties": { + CONF_ID: "2d:97:da:ee:dc:99", + }, +} + def mock_connection( aioclient_mock: AiohttpClientMocker, diff --git a/tests/components/roku/test_config_flow.py b/tests/components/roku/test_config_flow.py index a3cda6afa69..16e4a434dc3 100644 --- a/tests/components/roku/test_config_flow.py +++ b/tests/components/roku/test_config_flow.py @@ -1,6 +1,6 @@ """Test the Roku config flow.""" from homeassistant.components.roku.const import DOMAIN -from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER +from homeassistant.config_entries import SOURCE_HOMEKIT, SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SOURCE from homeassistant.data_entry_flow import ( RESULT_TYPE_ABORT, @@ -12,8 +12,11 @@ from homeassistant.setup import async_setup_component from tests.async_mock import patch from tests.components.roku import ( + HOMEKIT_HOST, HOST, + MOCK_HOMEKIT_DISCOVERY_INFO, MOCK_SSDP_DISCOVERY_INFO, + NAME_ROKUTV, UPNP_FRIENDLY_NAME, mock_connection, setup_integration, @@ -128,6 +131,92 @@ async def test_form_unknown_error(hass: HomeAssistantType) -> None: assert len(mock_validate_input.mock_calls) == 1 +async def test_homekit_cannot_connect( + hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we abort homekit flow on connection error.""" + mock_connection( + aioclient_mock, + host=HOMEKIT_HOST, + error=True, + ) + + discovery_info = MOCK_HOMEKIT_DISCOVERY_INFO.copy() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_HOMEKIT}, + data=discovery_info, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" + + +async def test_homekit_unknown_error( + hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we abort homekit flow on unknown error.""" + discovery_info = MOCK_HOMEKIT_DISCOVERY_INFO.copy() + with patch( + "homeassistant.components.roku.config_flow.Roku.update", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_HOMEKIT}, + data=discovery_info, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "unknown" + + +async def test_homekit_discovery( + hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the homekit discovery flow.""" + mock_connection(aioclient_mock, device="rokutv", host=HOMEKIT_HOST) + + discovery_info = MOCK_HOMEKIT_DISCOVERY_INFO.copy() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_HOMEKIT}, data=discovery_info + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "discovery_confirm" + assert result["description_placeholders"] == {CONF_NAME: NAME_ROKUTV} + + with patch( + "homeassistant.components.roku.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.roku.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == NAME_ROKUTV + + assert result["data"] + assert result["data"][CONF_HOST] == HOMEKIT_HOST + assert result["data"][CONF_NAME] == NAME_ROKUTV + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + # test abort on existing host + discovery_info = MOCK_HOMEKIT_DISCOVERY_INFO.copy() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_HOMEKIT}, data=discovery_info + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + async def test_ssdp_cannot_connect( hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker ) -> None: @@ -176,7 +265,7 @@ async def test_ssdp_discovery( ) assert result["type"] == RESULT_TYPE_FORM - assert result["step_id"] == "ssdp_confirm" + assert result["step_id"] == "discovery_confirm" assert result["description_placeholders"] == {CONF_NAME: UPNP_FRIENDLY_NAME} with patch(