Add lutron_caseta config entries (#34133)

* lutron_caseta: allow for multiple bridges; use config entries

Refactor to use config entries/flows, but only implemented import
(async_setup) flow handler for now.

* lutron_caseta: config_flow.py pylint hint

Co-Authored-By: Martin Hjelmare <marhje52@gmail.com>

* lutron_caseta: tweaks to __init__.py per PR feedback

* lutron_caseta: add config_flow tests

* lutron_caseta: verify connectivity to bridge

check connectivity before creating config entry; cleanup translation/strings

* lutron_caseta: allow for multiple bridges; use config entries

Refactor to use config entries/flows, but only implemented import
(async_setup) flow handler for now.

* lutron_caseta: config_flow.py pylint hint

Co-Authored-By: Martin Hjelmare <marhje52@gmail.com>

* lutron_caseta: tweaks to __init__.py per PR feedback

* lutron_caseta: add config_flow tests

* lutron_caseta: verify connectivity to bridge

check connectivity before creating config entry; cleanup translation/strings

* lutron_caseta: add error logging when exception is encountered checking connectivity

* lutron_caseta: tests mock bridge creation, not ha-side connectivity check

* lutron_caseta: catch more specific Error types while checking bridge conn.

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
pull/35497/head
Chris Aljoudi 2020-05-11 03:05:13 -06:00 committed by GitHub
parent 9d83059f14
commit d072091926
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 396 additions and 58 deletions

View File

@ -4,30 +4,30 @@ import logging
from pylutron_caseta.smartbridge import Smartbridge
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_HOST
from homeassistant.helpers import discovery
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
from .const import CONF_CA_CERTS, CONF_CERTFILE, CONF_KEYFILE
_LOGGER = logging.getLogger(__name__)
LUTRON_CASETA_SMARTBRIDGE = "lutron_smartbridge"
DOMAIN = "lutron_caseta"
CONF_KEYFILE = "keyfile"
CONF_CERTFILE = "certfile"
CONF_CA_CERTS = "ca_certs"
DATA_BRIDGE_CONFIG = "lutron_caseta_bridges"
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_KEYFILE): cv.string,
vol.Required(CONF_CERTFILE): cv.string,
vol.Required(CONF_CA_CERTS): cv.string,
}
DOMAIN: vol.All(
cv.ensure_list,
[
{
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_KEYFILE): cv.string,
vol.Required(CONF_CERTFILE): cv.string,
vol.Required(CONF_CA_CERTS): cv.string,
}
],
)
},
extra=vol.ALLOW_EXTRA,
@ -39,29 +39,57 @@ LUTRON_CASETA_COMPONENTS = ["light", "switch", "cover", "scene", "fan", "binary_
async def async_setup(hass, base_config):
"""Set up the Lutron component."""
config = base_config.get(DOMAIN)
keyfile = hass.config.path(config[CONF_KEYFILE])
certfile = hass.config.path(config[CONF_CERTFILE])
ca_certs = hass.config.path(config[CONF_CA_CERTS])
bridge = Smartbridge.create_tls(
hostname=config[CONF_HOST],
keyfile=keyfile,
certfile=certfile,
ca_certs=ca_certs,
)
hass.data[LUTRON_CASETA_SMARTBRIDGE] = bridge
await bridge.connect()
if not hass.data[LUTRON_CASETA_SMARTBRIDGE].is_connected():
_LOGGER.error(
"Unable to connect to Lutron smartbridge at %s", config[CONF_HOST]
bridge_configs = base_config.get(DOMAIN)
if not bridge_configs:
return True
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],
},
)
)
return True
async def async_setup_entry(hass, config_entry):
"""Set up a bridge from a config entry."""
host = config_entry.data[CONF_HOST]
keyfile = config_entry.data[CONF_KEYFILE]
certfile = config_entry.data[CONF_CERTFILE]
ca_certs = config_entry.data[CONF_CA_CERTS]
bridge = Smartbridge.create_tls(
hostname=host, keyfile=keyfile, certfile=certfile, ca_certs=ca_certs
)
await bridge.connect()
if not bridge.is_connected():
_LOGGER.error("Unable to connect to Lutron Caseta bridge at %s", host)
return False
_LOGGER.info("Connected to Lutron smartbridge at %s", config[CONF_HOST])
_LOGGER.debug("Connected to Lutron Caseta bridge at %s", host)
# Store this bridge (keyed by entry_id) so it can be retrieved by the
# components we're setting up.
hass.data[DOMAIN][config_entry.entry_id] = bridge
for component in LUTRON_CASETA_COMPONENTS:
hass.async_create_task(
discovery.async_load_platform(hass, component, DOMAIN, {}, config)
hass.config_entries.async_forward_entry_setup(config_entry, component)
)
return True

View File

@ -6,14 +6,20 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
)
from . import LUTRON_CASETA_SMARTBRIDGE, LutronCasetaDevice
from . import DOMAIN as CASETA_DOMAIN, LutronCasetaDevice
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the Lutron Caseta lights."""
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Lutron Caseta binary_sensor platform.
Adds occupancy groups from the Caseta bridge associated with the
config_entry as binary_sensor entities.
"""
entities = []
bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE]
bridge = hass.data[CASETA_DOMAIN][config_entry.entry_id]
occupancy_groups = bridge.occupancy_groups
for occupancy_group in occupancy_groups.values():
entity = LutronOccupancySensor(occupancy_group, bridge)
entities.append(entity)

View File

@ -0,0 +1,107 @@
"""Config flow for Lutron Caseta."""
import logging
from pylutron_caseta.smartbridge import Smartbridge
from homeassistant import config_entries
from homeassistant.const import CONF_HOST
from . import DOMAIN # pylint: disable=unused-import
from .const import (
ABORT_REASON_ALREADY_CONFIGURED,
ABORT_REASON_CANNOT_CONNECT,
CONF_CA_CERTS,
CONF_CERTFILE,
CONF_KEYFILE,
ERROR_CANNOT_CONNECT,
STEP_IMPORT_FAILED,
)
_LOGGER = logging.getLogger(__name__)
ENTRY_DEFAULT_TITLE = "Caséta bridge"
class LutronCasetaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle Lutron Caseta config flow."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
def __init__(self):
"""Initialize a Lutron Caseta flow."""
self.data = {}
async def async_step_import(self, import_info):
"""Import a new Caseta bridge as a config entry.
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
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]
if not await self.async_validate_connectable_bridge_config():
# Ultimately we won't have a dedicated step for import failure, but
# in order to keep configuration.yaml-based configs transparently
# working without requiring further actions from the user, we don't
# display a form at all before creating a config entry in the
# default case, so we're only going to show a form in case the
# import fails.
# This will change in an upcoming release where UI-based config flow
# will become the default for the Lutron Caseta integration (which
# will require users to go through a confirmation flow for imports).
return await self.async_step_import_failed()
return self.async_create_entry(title=ENTRY_DEFAULT_TITLE, data=self.data)
async def async_step_import_failed(self, user_input=None):
"""Make failed import surfaced to user."""
if user_input is None:
return self.async_show_form(
step_id=STEP_IMPORT_FAILED,
description_placeholders={"host": self.data[CONF_HOST]},
errors={"base": ERROR_CANNOT_CONNECT},
)
return self.async_abort(reason=ABORT_REASON_CANNOT_CONNECT)
async def async_validate_connectable_bridge_config(self):
"""Check if we can connect to the bridge with the current config."""
try:
bridge = Smartbridge.create_tls(
hostname=self.data[CONF_HOST],
keyfile=self.data[CONF_KEYFILE],
certfile=self.data[CONF_CERTFILE],
ca_certs=self.data[CONF_CA_CERTS],
)
await bridge.connect()
if not bridge.is_connected():
return False
await bridge.close()
return True
except (KeyError, ValueError):
_LOGGER.error(
"Error while checking connectivity to bridge %s", self.data[CONF_HOST],
)
return False
except Exception: # pylint: disable=broad-except
_LOGGER.exception(
"Unknown exception while checking connectivity to bridge %s",
self.data[CONF_HOST],
)
return False

View File

@ -0,0 +1,10 @@
"""Lutron Caseta constants."""
CONF_KEYFILE = "keyfile"
CONF_CERTFILE = "certfile"
CONF_CA_CERTS = "ca_certs"
STEP_IMPORT_FAILED = "import_failed"
ERROR_CANNOT_CONNECT = "cannot_connect"
ABORT_REASON_CANNOT_CONNECT = "cannot_connect"
ABORT_REASON_ALREADY_CONFIGURED = "already_configured"

View File

@ -10,16 +10,22 @@ from homeassistant.components.cover import (
CoverEntity,
)
from . import LUTRON_CASETA_SMARTBRIDGE, LutronCasetaDevice
from . import DOMAIN as CASETA_DOMAIN, LutronCasetaDevice
_LOGGER = logging.getLogger(__name__)
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the Lutron Caseta shades as a cover device."""
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Lutron Caseta cover platform.
Adds shades from the Caseta bridge associated with the config_entry as
cover entities.
"""
entities = []
bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE]
bridge = hass.data[CASETA_DOMAIN][config_entry.entry_id]
cover_devices = bridge.get_devices_by_domain(DOMAIN)
for cover_device in cover_devices:
entity = LutronCasetaCover(cover_device, bridge)
entities.append(entity)

View File

@ -13,7 +13,7 @@ from homeassistant.components.fan import (
FanEntity,
)
from . import LUTRON_CASETA_SMARTBRIDGE, LutronCasetaDevice
from . import DOMAIN as CASETA_DOMAIN, LutronCasetaDevice
_LOGGER = logging.getLogger(__name__)
@ -36,10 +36,15 @@ SPEED_TO_VALUE = {
FAN_SPEEDS = [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH]
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up Lutron fan."""
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Lutron Caseta fan platform.
Adds fan controllers from the Caseta bridge associated with the config_entry
as fan entities.
"""
entities = []
bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE]
bridge = hass.data[CASETA_DOMAIN][config_entry.entry_id]
fan_devices = bridge.get_devices_by_domain(DOMAIN)
for fan_device in fan_devices:

View File

@ -8,7 +8,7 @@ from homeassistant.components.light import (
LightEntity,
)
from . import LUTRON_CASETA_SMARTBRIDGE, LutronCasetaDevice
from . import DOMAIN as CASETA_DOMAIN, LutronCasetaDevice
_LOGGER = logging.getLogger(__name__)
@ -23,11 +23,17 @@ def to_hass_level(level):
return int((level * 255) // 100)
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the Lutron Caseta lights."""
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Lutron Caseta light platform.
Adds dimmers from the Caseta bridge associated with the config_entry as
light entities.
"""
entities = []
bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE]
bridge = hass.data[CASETA_DOMAIN][config_entry.entry_id]
light_devices = bridge.get_devices_by_domain(DOMAIN)
for light_device in light_devices:
entity = LutronCasetaLight(light_device, bridge)
entities.append(entity)

View File

@ -3,5 +3,6 @@
"name": "Lutron Caséta",
"documentation": "https://www.home-assistant.io/integrations/lutron_caseta",
"requirements": ["pylutron-caseta==0.6.1"],
"codeowners": ["@swails"]
}
"codeowners": ["@swails"],
"config_flow": true
}

View File

@ -4,16 +4,22 @@ from typing import Any
from homeassistant.components.scene import Scene
from . import LUTRON_CASETA_SMARTBRIDGE
from . import DOMAIN as CASETA_DOMAIN
_LOGGER = logging.getLogger(__name__)
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the Lutron Caseta lights."""
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Lutron Caseta scene platform.
Adds scenes from the Caseta bridge associated with the config_entry as
scene entities.
"""
entities = []
bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE]
bridge = hass.data[CASETA_DOMAIN][config_entry.entry_id]
scenes = bridge.get_scenes()
for scene in scenes:
entity = LutronCasetaScene(scenes[scene], bridge)
entities.append(entity)

View File

@ -0,0 +1,17 @@
{
"config": {
"step": {
"import_failed": {
"title": "Failed to import Caséta bridge configuration.",
"description": "Couldnt setup bridge (host: {host}) imported from configuration.yaml."
}
},
"error": {
"cannot_connect": "Failed to connect to Caséta bridge; check your host and certificate configuration."
},
"abort": {
"already_configured": "Caséta bridge already configured.",
"cannot_connect": "Cancelled setup of Caséta bridge due to connection failure."
}
}
}

View File

@ -3,15 +3,20 @@ import logging
from homeassistant.components.switch import DOMAIN, SwitchEntity
from . import LUTRON_CASETA_SMARTBRIDGE, LutronCasetaDevice
from . import DOMAIN as CASETA_DOMAIN, LutronCasetaDevice
_LOGGER = logging.getLogger(__name__)
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up Lutron switch."""
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Lutron Caseta switch platform.
Adds switches from the Caseta bridge associated with the config_entry as
switch entities.
"""
entities = []
bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE]
bridge = hass.data[CASETA_DOMAIN][config_entry.entry_id]
switch_devices = bridge.get_devices_by_domain(DOMAIN)
for switch_device in switch_devices:

View File

@ -1,3 +1,17 @@
{
"title": "Lutron Cas\u00e9ta"
"config": {
"step": {
"import_failed": {
"title": "Failed to import Caséta bridge configuration.",
"description": "Couldnt import Caséta bridge (host: {host}) from configuration.yaml."
}
},
"error": {
"cannot_connect": "Failed to connect to Caséta bridge; check your host and certificate configuration."
},
"abort": {
"already_configured": "Caséta bridge already configured.",
"cannot_connect": "Cancelled setup of Caséta bridge due to connection failure."
}
}
}

View File

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

View File

@ -599,6 +599,9 @@ pylinky==0.4.0
# homeassistant.components.litejet
pylitejet==0.1
# homeassistant.components.lutron_caseta
pylutron-caseta==0.6.1
# homeassistant.components.mailgun
pymailgunner==1.4

View File

@ -0,0 +1 @@
"""Tests for the Lutron Caseta integration."""

View File

@ -0,0 +1,122 @@
"""Test the Lutron Caseta config flow."""
from asynctest import patch
from pylutron_caseta.smartbridge import Smartbridge
from homeassistant import config_entries, data_entry_flow
from homeassistant.components.lutron_caseta import DOMAIN
import homeassistant.components.lutron_caseta.config_flow as CasetaConfigFlow
from homeassistant.components.lutron_caseta.const import (
CONF_CA_CERTS,
CONF_CERTFILE,
CONF_KEYFILE,
ERROR_CANNOT_CONNECT,
STEP_IMPORT_FAILED,
)
from homeassistant.const import CONF_HOST
from tests.common import MockConfigEntry
class MockBridge:
"""Mock Lutron bridge that emulates configured connected status."""
def __init__(self, can_connect=True):
"""Initialize MockBridge instance with configured mock connectivity."""
self.can_connect = can_connect
self.is_currently_connected = False
async def connect(self):
"""Connect the mock bridge."""
if self.can_connect:
self.is_currently_connected = True
def is_connected(self):
"""Return whether the mock bridge is connected."""
return self.is_currently_connected
async def close(self):
"""Close the mock bridge connection."""
self.is_currently_connected = False
async def test_bridge_import_flow(hass):
"""Test a bridge entry gets created and set up during the import flow."""
entry_mock_data = {
CONF_HOST: "1.1.1.1",
CONF_KEYFILE: "",
CONF_CERTFILE: "",
CONF_CA_CERTS: "",
}
with patch(
"homeassistant.components.lutron_caseta.async_setup_entry", return_value=True,
) as mock_setup_entry, patch.object(Smartbridge, "create_tls") as create_tls:
create_tls.return_value = MockBridge(can_connect=True)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data=entry_mock_data,
)
assert result["type"] == "create_entry"
assert result["title"] == CasetaConfigFlow.ENTRY_DEFAULT_TITLE
assert result["data"] == entry_mock_data
await hass.async_block_till_done()
assert len(mock_setup_entry.mock_calls) == 1
async def test_bridge_cannot_connect(hass):
"""Test checking for connection and cannot_connect error."""
entry_mock_data = {
CONF_HOST: "not.a.valid.host",
CONF_KEYFILE: "",
CONF_CERTFILE: "",
CONF_CA_CERTS: "",
}
with patch(
"homeassistant.components.lutron_caseta.async_setup_entry", return_value=True,
) as mock_setup_entry, patch.object(Smartbridge, "create_tls") as create_tls:
create_tls.return_value = MockBridge(can_connect=False)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data=entry_mock_data,
)
assert result["type"] == "form"
assert result["step_id"] == STEP_IMPORT_FAILED
assert result["errors"] == {"base": ERROR_CANNOT_CONNECT}
# validate setup_entry was not called
assert len(mock_setup_entry.mock_calls) == 0
async def test_duplicate_bridge_import(hass):
"""Test that creating a bridge entry with a duplicate host errors."""
entry_mock_data = {
CONF_HOST: "1.1.1.1",
CONF_KEYFILE: "",
CONF_CERTFILE: "",
CONF_CA_CERTS: "",
}
mock_entry = MockConfigEntry(domain=DOMAIN, data=entry_mock_data)
mock_entry.add_to_hass(hass)
with patch(
"homeassistant.components.lutron_caseta.async_setup_entry", return_value=True,
) as mock_setup_entry:
# Mock entry added, try initializing flow with duplicate host
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data=entry_mock_data,
)
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