Add a one touch pairing config flow for lutron caseta (#45136)

Co-authored-by: Robert Svensson <Kane610@users.noreply.github.com>
This commit is contained in:
J. Nick Koston 2021-01-22 17:48:06 -06:00 committed by GitHub
parent 03fb73c0ae
commit 431b143eec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 556 additions and 31 deletions

View File

@ -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

View File

@ -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."""

View File

@ -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,

View File

@ -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"
]
}
"config_flow": true,
"zeroconf": ["_leap._tcp.local."],
"homekit": {
"models": ["Smart Bridge"]
},
"codeowners": ["@swails", "@bdraco"]
}

View File

@ -1,15 +1,28 @@
{
"config": {
"flow_title": "Lutron Caséta {name} ({host})",
"step": {
"import_failed": {
"title": "Failed to import Caséta bridge configuration.",
"description": "Couldnt 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%]"
}

View File

@ -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"
}
}
}

View File

@ -117,6 +117,7 @@ FLOWS = [
"locative",
"logi_circle",
"luftdaten",
"lutron_caseta",
"mailgun",
"melcloud",
"met",

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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