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(