diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index 47df6a221dd..59fd81e650e 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -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 diff --git a/homeassistant/components/lutron_caseta/binary_sensor.py b/homeassistant/components/lutron_caseta/binary_sensor.py index 15c3d19008a..4295e3bb367 100644 --- a/homeassistant/components/lutron_caseta/binary_sensor.py +++ b/homeassistant/components/lutron_caseta/binary_sensor.py @@ -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) diff --git a/homeassistant/components/lutron_caseta/config_flow.py b/homeassistant/components/lutron_caseta/config_flow.py new file mode 100644 index 00000000000..45a7f10fbf0 --- /dev/null +++ b/homeassistant/components/lutron_caseta/config_flow.py @@ -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 diff --git a/homeassistant/components/lutron_caseta/const.py b/homeassistant/components/lutron_caseta/const.py new file mode 100644 index 00000000000..11bc8bcd6fb --- /dev/null +++ b/homeassistant/components/lutron_caseta/const.py @@ -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" diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py index 64c0aeac744..81a65786900 100644 --- a/homeassistant/components/lutron_caseta/cover.py +++ b/homeassistant/components/lutron_caseta/cover.py @@ -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) diff --git a/homeassistant/components/lutron_caseta/fan.py b/homeassistant/components/lutron_caseta/fan.py index 1227371ac07..aa6ab1c7144 100644 --- a/homeassistant/components/lutron_caseta/fan.py +++ b/homeassistant/components/lutron_caseta/fan.py @@ -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: diff --git a/homeassistant/components/lutron_caseta/light.py b/homeassistant/components/lutron_caseta/light.py index f6ec0369509..471be51219b 100644 --- a/homeassistant/components/lutron_caseta/light.py +++ b/homeassistant/components/lutron_caseta/light.py @@ -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) diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index e3b74d8157b..7b55dfd9c87 100644 --- a/homeassistant/components/lutron_caseta/manifest.json +++ b/homeassistant/components/lutron_caseta/manifest.json @@ -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 +} \ No newline at end of file diff --git a/homeassistant/components/lutron_caseta/scene.py b/homeassistant/components/lutron_caseta/scene.py index 8b4dc466c90..c74f60bc88c 100644 --- a/homeassistant/components/lutron_caseta/scene.py +++ b/homeassistant/components/lutron_caseta/scene.py @@ -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) diff --git a/homeassistant/components/lutron_caseta/strings.json b/homeassistant/components/lutron_caseta/strings.json new file mode 100644 index 00000000000..082497b1bf2 --- /dev/null +++ b/homeassistant/components/lutron_caseta/strings.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "import_failed": { + "title": "Failed to import Caséta bridge configuration.", + "description": "Couldn’t 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." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lutron_caseta/switch.py b/homeassistant/components/lutron_caseta/switch.py index 01e61cc9002..d7f9246feeb 100644 --- a/homeassistant/components/lutron_caseta/switch.py +++ b/homeassistant/components/lutron_caseta/switch.py @@ -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: diff --git a/homeassistant/components/lutron_caseta/translations/en.json b/homeassistant/components/lutron_caseta/translations/en.json index 970d722fe4c..5ae238b9d6a 100644 --- a/homeassistant/components/lutron_caseta/translations/en.json +++ b/homeassistant/components/lutron_caseta/translations/en.json @@ -1,3 +1,17 @@ { - "title": "Lutron Cas\u00e9ta" + "config": { + "step": { + "import_failed": { + "title": "Failed to import Caséta bridge configuration.", + "description": "Couldn’t 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." + } + } } \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d288af9c91f..c35c0384c4b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -81,6 +81,7 @@ FLOWS = [ "locative", "logi_circle", "luftdaten", + "lutron_caseta", "mailgun", "melcloud", "met", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1d6fb577c65..3960a703459 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/lutron_caseta/__init__.py b/tests/components/lutron_caseta/__init__.py new file mode 100644 index 00000000000..0e0ca8686ef --- /dev/null +++ b/tests/components/lutron_caseta/__init__.py @@ -0,0 +1 @@ +"""Tests for the Lutron Caseta integration.""" diff --git a/tests/components/lutron_caseta/test_config_flow.py b/tests/components/lutron_caseta/test_config_flow.py new file mode 100644 index 00000000000..a528e223e44 --- /dev/null +++ b/tests/components/lutron_caseta/test_config_flow.py @@ -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