diff --git a/CODEOWNERS b/CODEOWNERS index d128a6563ab..f6eeac33d89 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -257,7 +257,7 @@ homeassistant/components/luci/* @mzdrale homeassistant/components/luftdaten/* @fabaff homeassistant/components/lupusec/* @majuss homeassistant/components/lutron/* @JonGilmore -homeassistant/components/lutron_caseta/* @swails +homeassistant/components/lutron_caseta/* @swails @bdraco homeassistant/components/mastodon/* @fabaff homeassistant/components/matrix/* @tinloaf homeassistant/components/mcp23017/* @jardiamj diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index 612c6a26d1b..5ffe641546b 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -1,4 +1,5 @@ """Component for interacting with a Lutron Caseta system.""" +import asyncio import logging from pylutron_caseta.smartbridge import Smartbridge @@ -39,23 +40,24 @@ LUTRON_CASETA_COMPONENTS = ["light", "switch", "cover", "scene", "fan", "binary_ async def async_setup(hass, base_config): """Set up the Lutron component.""" - bridge_configs = base_config[DOMAIN] hass.data.setdefault(DOMAIN, {}) - for config in bridge_configs: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - # extract the config keys one-by-one just to be explicit - data={ - CONF_HOST: config[CONF_HOST], - CONF_KEYFILE: config[CONF_KEYFILE], - CONF_CERTFILE: config[CONF_CERTFILE], - CONF_CA_CERTS: config[CONF_CA_CERTS], - }, + if DOMAIN in base_config: + bridge_configs = base_config[DOMAIN] + for config in bridge_configs: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + # extract the config keys one-by-one just to be explicit + data={ + CONF_HOST: config[CONF_HOST], + CONF_KEYFILE: config[CONF_KEYFILE], + CONF_CERTFILE: config[CONF_CERTFILE], + CONF_CA_CERTS: config[CONF_CA_CERTS], + }, + ) ) - ) return True @@ -91,6 +93,26 @@ async def async_setup_entry(hass, config_entry): return True +async def async_unload_entry(hass, config_entry): + """Unload the bridge bridge from a config entry.""" + + hass.data[DOMAIN][config_entry.entry_id].close() + + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(config_entry, component) + for component in LUTRON_CASETA_COMPONENTS + ] + ) + ) + + if unload_ok: + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok + + class LutronCasetaDevice(Entity): """Common base class for all Lutron Caseta devices.""" diff --git a/homeassistant/components/lutron_caseta/config_flow.py b/homeassistant/components/lutron_caseta/config_flow.py index 1290d88b09c..bf252da0760 100644 --- a/homeassistant/components/lutron_caseta/config_flow.py +++ b/homeassistant/components/lutron_caseta/config_flow.py @@ -1,10 +1,16 @@ """Config flow for Lutron Caseta.""" +import asyncio import logging +import os +from pylutron_caseta.pairing import PAIR_CA, PAIR_CERT, PAIR_KEY, async_pair from pylutron_caseta.smartbridge import Smartbridge +import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_HOST +from homeassistant.components.zeroconf import ATTR_HOSTNAME +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.core import callback from . import DOMAIN # pylint: disable=unused-import from .const import ( @@ -17,10 +23,22 @@ from .const import ( STEP_IMPORT_FAILED, ) +HOSTNAME = "hostname" + + +FILE_MAPPING = { + PAIR_KEY: CONF_KEYFILE, + PAIR_CERT: CONF_CERTFILE, + PAIR_CA: CONF_CA_CERTS, +} + _LOGGER = logging.getLogger(__name__) ENTRY_DEFAULT_TITLE = "Caséta bridge" +DATA_SCHEMA_USER = vol.Schema({vol.Required(CONF_HOST): str}) +TLS_ASSET_TEMPLATE = "lutron_caseta-{}-{}.pem" + class LutronCasetaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle Lutron Caseta config flow.""" @@ -31,6 +49,111 @@ class LutronCasetaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize a Lutron Caseta flow.""" self.data = {} + self.lutron_id = None + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + if user_input is not None: + self.data[CONF_HOST] = user_input[CONF_HOST] + return await self.async_step_link() + + return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA_USER) + + async def async_step_zeroconf(self, discovery_info): + """Handle a flow initialized by zeroconf discovery.""" + hostname = discovery_info[ATTR_HOSTNAME] + if hostname is None or not hostname.startswith("lutron-"): + return self.async_abort(reason="not_lutron_device") + + self.lutron_id = hostname.split("-")[1].replace(".local.", "") + + await self.async_set_unique_id(self.lutron_id) + host = discovery_info[CONF_HOST] + self._abort_if_unique_id_configured({CONF_HOST: host}) + + self.data[CONF_HOST] = host + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context["title_placeholders"] = { + CONF_NAME: self.bridge_id, + CONF_HOST: host, + } + return await self.async_step_link() + + async_step_homekit = async_step_zeroconf + + async def async_step_link(self, user_input=None): + """Handle pairing with the hub.""" + errors = {} + # Abort if existing entry with matching host exists. + if self._async_data_host_is_already_configured(): + return self.async_abort(reason=ABORT_REASON_ALREADY_CONFIGURED) + + self._configure_tls_assets() + + if user_input is not None: + if ( + await self.hass.async_add_executor_job(self._tls_assets_exist) + and await self.async_validate_connectable_bridge_config() + ): + # If we previous paired and the tls assets already exist, + # we do not need to go though pairing again. + return self.async_create_entry(title=self.bridge_id, data=self.data) + + assets = None + try: + assets = await async_pair(self.data[CONF_HOST]) + except (asyncio.TimeoutError, OSError): + errors["base"] = "cannot_connect" + + if not errors: + await self.hass.async_add_executor_job(self._write_tls_assets, assets) + return self.async_create_entry(title=self.bridge_id, data=self.data) + + return self.async_show_form( + step_id="link", + errors=errors, + description_placeholders={ + CONF_NAME: self.bridge_id, + CONF_HOST: self.data[CONF_HOST], + }, + ) + + @property + def bridge_id(self): + """Return the best identifier for the bridge. + + If the bridge was not discovered via zeroconf, + we fallback to using the host. + """ + return self.lutron_id or self.data[CONF_HOST] + + def _write_tls_assets(self, assets): + """Write the tls assets to disk.""" + for asset_key, conf_key in FILE_MAPPING.items(): + with open(self.hass.config.path(self.data[conf_key]), "w") as file_handle: + file_handle.write(assets[asset_key]) + + def _tls_assets_exist(self): + """Check to see if tls assets are already on disk.""" + for conf_key in FILE_MAPPING.values(): + if not os.path.exists(self.hass.config.path(self.data[conf_key])): + return False + return True + + @callback + def _configure_tls_assets(self): + """Fill the tls asset locations in self.data.""" + for asset_key, conf_key in FILE_MAPPING.items(): + self.data[conf_key] = TLS_ASSET_TEMPLATE.format(self.bridge_id, asset_key) + + @callback + def _async_data_host_is_already_configured(self): + """Check to see if the host is already configured.""" + return any( + self.data[CONF_HOST] == entry.data[CONF_HOST] + for entry in self._async_current_entries() + if CONF_HOST in entry.data + ) async def async_step_import(self, import_info): """Import a new Caseta bridge as a config entry. @@ -38,15 +161,14 @@ class LutronCasetaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): This flow is triggered by `async_setup`. """ - # Abort if existing entry with matching host exists. host = import_info[CONF_HOST] - if any( - host == entry.data[CONF_HOST] for entry in self._async_current_entries() - ): - return self.async_abort(reason=ABORT_REASON_ALREADY_CONFIGURED) - # Store the imported config for other steps in this flow to access. self.data[CONF_HOST] = host + + # Abort if existing entry with matching host exists. + if self._async_data_host_is_already_configured(): + return self.async_abort(reason=ABORT_REASON_ALREADY_CONFIGURED) + self.data[CONF_KEYFILE] = import_info[CONF_KEYFILE] self.data[CONF_CERTFILE] = import_info[CONF_CERTFILE] self.data[CONF_CA_CERTS] = import_info[CONF_CA_CERTS] @@ -68,6 +190,9 @@ class LutronCasetaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_import_failed(self, user_input=None): """Make failed import surfaced to user.""" + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context["title_placeholders"] = {CONF_NAME: self.data[CONF_HOST]} + if user_input is None: return self.async_show_form( step_id=STEP_IMPORT_FAILED, diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index b21cfed30c2..95146667f41 100644 --- a/homeassistant/components/lutron_caseta/manifest.json +++ b/homeassistant/components/lutron_caseta/manifest.json @@ -3,9 +3,12 @@ "name": "Lutron Caséta", "documentation": "https://www.home-assistant.io/integrations/lutron_caseta", "requirements": [ - "pylutron-caseta==0.7.2" + "pylutron-caseta==0.8.0" ], - "codeowners": [ - "@swails" - ] -} \ No newline at end of file + "config_flow": true, + "zeroconf": ["_leap._tcp.local."], + "homekit": { + "models": ["Smart Bridge"] + }, + "codeowners": ["@swails", "@bdraco"] +} diff --git a/homeassistant/components/lutron_caseta/strings.json b/homeassistant/components/lutron_caseta/strings.json index a03cdd8c9d6..d72e544bcfa 100644 --- a/homeassistant/components/lutron_caseta/strings.json +++ b/homeassistant/components/lutron_caseta/strings.json @@ -1,15 +1,28 @@ { "config": { + "flow_title": "Lutron Caséta {name} ({host})", "step": { "import_failed": { "title": "Failed to import Caséta bridge configuration.", "description": "Couldn’t setup bridge (host: {host}) imported from configuration.yaml." + }, + "user": { + "title": "Automaticlly connect to the bridge", + "description": "Enter the ip address of the device.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "link": { + "title": "Pair with the bridge", + "description": "To pair with {name} ({host}), after submitting this form, press the black button on the back of the bridge." } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "abort": { + "not_lutron_device": "Discovered device is not a Lutron device", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } diff --git a/homeassistant/components/lutron_caseta/translations/en.json b/homeassistant/components/lutron_caseta/translations/en.json index 3797d476db3..38e55d72931 100644 --- a/homeassistant/components/lutron_caseta/translations/en.json +++ b/homeassistant/components/lutron_caseta/translations/en.json @@ -2,15 +2,28 @@ "config": { "abort": { "already_configured": "Device is already configured", - "cannot_connect": "Failed to connect" + "cannot_connect": "Failed to connect", + "not_lutron_device": "Discovered device is not a Lutron device" }, "error": { "cannot_connect": "Failed to connect" }, + "flow_title": "Lutron Cas\u00e9ta {name} ({host})", "step": { "import_failed": { "description": "Couldn\u2019t setup bridge (host: {host}) imported from configuration.yaml.", "title": "Failed to import Cas\u00e9ta bridge configuration." + }, + "link": { + "description": "To pair with {name} ({host}), after submitting this form, press the black button on the back of the bridge.", + "title": "Pair with the bridge" + }, + "user": { + "data": { + "host": "Host" + }, + "description": "Enter the ip address of the device.", + "title": "Automaticlly connect to the bridge" } } } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a1941d08f1f..2d5ab9d926b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -117,6 +117,7 @@ FLOWS = [ "locative", "logi_circle", "luftdaten", + "lutron_caseta", "mailgun", "melcloud", "met", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 4b31870ab06..5521ab9da8f 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -96,6 +96,11 @@ ZEROCONF = { "name": "gateway*" } ], + "_leap._tcp.local.": [ + { + "domain": "lutron_caseta" + } + ], "_mediaremotetv._tcp.local.": [ { "domain": "apple_tv" @@ -179,6 +184,7 @@ HOMEKIT = { "PowerView": "hunterdouglas_powerview", "Presence": "netatmo", "Rachio": "rachio", + "Smart Bridge": "lutron_caseta", "Socket": "wemo", "TRADFRI": "tradfri", "Welcome": "netatmo", diff --git a/requirements_all.txt b/requirements_all.txt index 36de313110d..e1a2d2ad0b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1500,7 +1500,7 @@ pylitejet==0.1 pyloopenergy==0.2.1 # homeassistant.components.lutron_caseta -pylutron-caseta==0.7.2 +pylutron-caseta==0.8.0 # homeassistant.components.lutron pylutron==0.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 22476640932..1d837bbee97 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -761,7 +761,7 @@ pylibrespot-java==0.1.0 pylitejet==0.1 # homeassistant.components.lutron_caseta -pylutron-caseta==0.7.2 +pylutron-caseta==0.8.0 # homeassistant.components.mailgun pymailgunner==1.4 diff --git a/tests/components/lutron_caseta/test_config_flow.py b/tests/components/lutron_caseta/test_config_flow.py index 69f96ca1133..8a33fab670b 100644 --- a/tests/components/lutron_caseta/test_config_flow.py +++ b/tests/components/lutron_caseta/test_config_flow.py @@ -1,9 +1,12 @@ """Test the Lutron Caseta config flow.""" +import asyncio from unittest.mock import AsyncMock, patch +from pylutron_caseta.pairing import PAIR_CA, PAIR_CERT, PAIR_KEY from pylutron_caseta.smartbridge import Smartbridge +import pytest -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components.lutron_caseta import DOMAIN import homeassistant.components.lutron_caseta.config_flow as CasetaConfigFlow from homeassistant.components.lutron_caseta.const import ( @@ -13,10 +16,17 @@ from homeassistant.components.lutron_caseta.const import ( ERROR_CANNOT_CONNECT, STEP_IMPORT_FAILED, ) +from homeassistant.components.zeroconf import ATTR_HOSTNAME from homeassistant.const import CONF_HOST from tests.common import MockConfigEntry +MOCK_ASYNC_PAIR_SUCCESS = { + PAIR_KEY: "mock_key", + PAIR_CERT: "mock_cert", + PAIR_CA: "mock_ca", +} + class MockBridge: """Mock Lutron bridge that emulates configured connected status.""" @@ -158,3 +168,335 @@ async def test_duplicate_bridge_import(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == CasetaConfigFlow.ABORT_REASON_ALREADY_CONFIGURED assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_already_configured_with_ignored(hass): + """Test ignored entries do not break checking for existing entries.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + config_entry = MockConfigEntry(domain=DOMAIN, data={}, source="ignore") + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_HOST: "1.1.1.1", + CONF_KEYFILE: "", + CONF_CERTFILE: "", + CONF_CA_CERTS: "", + }, + ) + assert result["type"] == "form" + + +async def test_form_user(hass, tmpdir): + """Test we get the form and can pair.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + hass.config.config_dir = await hass.async_add_executor_job( + tmpdir.mkdir, "tls_assets" + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + assert result["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + await hass.async_block_till_done() + assert result2["type"] == "form" + assert result2["step_id"] == "link" + + with patch( + "homeassistant.components.lutron_caseta.config_flow.async_pair", + return_value=MOCK_ASYNC_PAIR_SUCCESS, + ), patch( + "homeassistant.components.lutron_caseta.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.lutron_caseta.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result3["type"] == "create_entry" + assert result3["title"] == "1.1.1.1" + assert result3["data"] == { + CONF_HOST: "1.1.1.1", + CONF_KEYFILE: "lutron_caseta-1.1.1.1-key.pem", + CONF_CERTFILE: "lutron_caseta-1.1.1.1-cert.pem", + CONF_CA_CERTS: "lutron_caseta-1.1.1.1-ca.pem", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_user_pairing_fails(hass, tmpdir): + """Test we get the form and we handle pairing failure.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + hass.config.config_dir = await hass.async_add_executor_job( + tmpdir.mkdir, "tls_assets" + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + assert result["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + await hass.async_block_till_done() + assert result2["type"] == "form" + assert result2["step_id"] == "link" + + with patch( + "homeassistant.components.lutron_caseta.config_flow.async_pair", + side_effect=asyncio.TimeoutError, + ), patch( + "homeassistant.components.lutron_caseta.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.lutron_caseta.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result3["type"] == "form" + assert result3["errors"] == {"base": "cannot_connect"} + assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_form_user_reuses_existing_assets_when_pairing_again(hass, tmpdir): + """Test the tls assets saved on disk are reused when pairing again.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + hass.config.config_dir = await hass.async_add_executor_job( + tmpdir.mkdir, "tls_assets" + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + assert result["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + await hass.async_block_till_done() + assert result2["type"] == "form" + assert result2["step_id"] == "link" + + with patch( + "homeassistant.components.lutron_caseta.config_flow.async_pair", + return_value=MOCK_ASYNC_PAIR_SUCCESS, + ), patch( + "homeassistant.components.lutron_caseta.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.lutron_caseta.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result3["type"] == "create_entry" + assert result3["title"] == "1.1.1.1" + assert result3["data"] == { + CONF_HOST: "1.1.1.1", + CONF_KEYFILE: "lutron_caseta-1.1.1.1-key.pem", + CONF_CERTFILE: "lutron_caseta-1.1.1.1-cert.pem", + CONF_CA_CERTS: "lutron_caseta-1.1.1.1-ca.pem", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + with patch( + "homeassistant.components.lutron_caseta.async_unload_entry", return_value=True + ) as mock_unload: + await hass.config_entries.async_remove(result3["result"].entry_id) + await hass.async_block_till_done() + + assert len(mock_unload.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + assert result["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + await hass.async_block_till_done() + assert result2["type"] == "form" + assert result2["step_id"] == "link" + + with patch.object(Smartbridge, "create_tls") as create_tls, patch( + "homeassistant.components.lutron_caseta.async_setup", return_value=True + ), patch( + "homeassistant.components.lutron_caseta.async_setup_entry", + return_value=True, + ): + create_tls.return_value = MockBridge(can_connect=True) + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result3["type"] == "create_entry" + assert result3["title"] == "1.1.1.1" + assert result3["data"] == { + CONF_HOST: "1.1.1.1", + CONF_KEYFILE: "lutron_caseta-1.1.1.1-key.pem", + CONF_CERTFILE: "lutron_caseta-1.1.1.1-cert.pem", + CONF_CA_CERTS: "lutron_caseta-1.1.1.1-ca.pem", + } + + +async def test_zeroconf_host_already_configured(hass, tmpdir): + """Test starting a flow from discovery when the host is already configured.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + hass.config.config_dir = await hass.async_add_executor_job( + tmpdir.mkdir, "tls_assets" + ) + + config_entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: "1.1.1.1"}) + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data={ + CONF_HOST: "1.1.1.1", + ATTR_HOSTNAME: "lutron-abc.local.", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_zeroconf_lutron_id_already_configured(hass): + """Test starting a flow from discovery when lutron id already configured.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "4.5.6.7"}, unique_id="abc" + ) + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data={ + CONF_HOST: "1.1.1.1", + ATTR_HOSTNAME: "lutron-abc.local.", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + assert config_entry.data[CONF_HOST] == "1.1.1.1" + + +async def test_zeroconf_not_lutron_device(hass): + """Test starting a flow from discovery when it is not a lutron device.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data={ + CONF_HOST: "1.1.1.1", + ATTR_HOSTNAME: "notlutron-abc.local.", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "not_lutron_device" + + +@pytest.mark.parametrize( + "source", (config_entries.SOURCE_ZEROCONF, config_entries.SOURCE_HOMEKIT) +) +async def test_zeroconf(hass, source, tmpdir): + """Test starting a flow from discovery.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + hass.config.config_dir = await hass.async_add_executor_job( + tmpdir.mkdir, "tls_assets" + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": source}, + data={ + CONF_HOST: "1.1.1.1", + ATTR_HOSTNAME: "lutron-abc.local.", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == "form" + assert result["step_id"] == "link" + + with patch( + "homeassistant.components.lutron_caseta.config_flow.async_pair", + return_value=MOCK_ASYNC_PAIR_SUCCESS, + ), patch( + "homeassistant.components.lutron_caseta.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.lutron_caseta.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "abc" + assert result2["data"] == { + CONF_HOST: "1.1.1.1", + CONF_KEYFILE: "lutron_caseta-abc-key.pem", + CONF_CERTFILE: "lutron_caseta-abc-cert.pem", + CONF_CA_CERTS: "lutron_caseta-abc-ca.pem", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1