diff --git a/CODEOWNERS b/CODEOWNERS index 26096d2247a..d4fd1302e46 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -748,6 +748,7 @@ build.json @home-assistant/supervisor /tests/components/luftdaten/ @fabaff @frenck /homeassistant/components/lupusec/ @majuss /homeassistant/components/lutron/ @cdheiser +/tests/components/lutron/ @cdheiser /homeassistant/components/lutron_caseta/ @swails @bdraco @danaues /tests/components/lutron_caseta/ @swails @bdraco @danaues /homeassistant/components/lyric/ @timmo001 diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index c15f0ea075e..4d6dd795d75 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -4,6 +4,8 @@ import logging from pylutron import Button, Lutron import voluptuous as vol +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ID, CONF_HOST, @@ -11,14 +13,15 @@ from homeassistant.const import ( CONF_USERNAME, Platform, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify -DOMAIN = "lutron" +from .const import DOMAIN PLATFORMS = [ Platform.LIGHT, @@ -53,8 +56,59 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass: HomeAssistant, base_config: ConfigType) -> bool: +async def _async_import(hass: HomeAssistant, base_config: ConfigType) -> None: + """Import a config entry from configuration.yaml.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=base_config[DOMAIN], + ) + if ( + result["type"] == FlowResultType.CREATE_ENTRY + or result["reason"] == "single_instance_allowed" + ): + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Lutron", + }, + ) + return + async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_{result['reason']}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{result['reason']}", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Lutron", + }, + ) + + +async def async_setup(hass: HomeAssistant, base_config: ConfigType) -> bool: + """Set up the Lutron component.""" + if DOMAIN in base_config: + hass.async_create_task(_async_import(hass, base_config)) + return True + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Lutron integration.""" + hass.data.setdefault(DOMAIN, {}) hass.data[LUTRON_BUTTONS] = [] hass.data[LUTRON_CONTROLLER] = None hass.data[LUTRON_DEVICES] = { @@ -64,19 +118,25 @@ def setup(hass: HomeAssistant, base_config: ConfigType) -> bool: "scene": [], "binary_sensor": [], } + host = config_entry.data[CONF_HOST] + uid = config_entry.data[CONF_USERNAME] + pwd = config_entry.data[CONF_PASSWORD] - config = base_config[DOMAIN] - hass.data[LUTRON_CONTROLLER] = Lutron( - config[CONF_HOST], config[CONF_USERNAME], config[CONF_PASSWORD] - ) + def _load_db() -> bool: + hass.data[LUTRON_CONTROLLER].load_xml_db() + return True - hass.data[LUTRON_CONTROLLER].load_xml_db() + hass.data[LUTRON_CONTROLLER] = Lutron(host, uid, pwd) + await hass.async_add_executor_job(_load_db) hass.data[LUTRON_CONTROLLER].connect() - _LOGGER.info("Connected to main repeater at %s", config[CONF_HOST]) + _LOGGER.info("Connected to main repeater at %s", host) # Sort our devices into types + _LOGGER.debug("Start adding devices") for area in hass.data[LUTRON_CONTROLLER].areas: + _LOGGER.debug("Working on area %s", area.name) for output in area.outputs: + _LOGGER.debug("Working on output %s", output.type) if output.type == "SYSTEM_SHADE": hass.data[LUTRON_DEVICES]["cover"].append((area.name, output)) elif output.is_dimmable: @@ -108,18 +168,22 @@ def setup(hass: HomeAssistant, base_config: ConfigType) -> bool: hass.data[LUTRON_DEVICES]["binary_sensor"].append( (area.name, area.occupancy_group) ) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - for platform in PLATFORMS: - discovery.load_platform(hass, platform, DOMAIN, {}, base_config) return True +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Clean up resources and entities associated with the integration.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + class LutronDevice(Entity): """Representation of a Lutron device entity.""" _attr_should_poll = False - def __init__(self, area_name, lutron_device, controller): + def __init__(self, area_name, lutron_device, controller) -> None: """Initialize the device.""" self._lutron_device = lutron_device self._controller = controller @@ -155,7 +219,7 @@ class LutronButton: represented as an entity; it simply fires events. """ - def __init__(self, hass, area_name, keypad, button): + def __init__(self, hass: HomeAssistant, area_name, keypad, button) -> None: """Register callback for activity on the button.""" name = f"{keypad.name}: {button.name}" if button.name == "Unknown Button": diff --git a/homeassistant/components/lutron/binary_sensor.py b/homeassistant/components/lutron/binary_sensor.py index 9f9851fb484..1b32b009f01 100644 --- a/homeassistant/components/lutron/binary_sensor.py +++ b/homeassistant/components/lutron/binary_sensor.py @@ -1,34 +1,42 @@ """Support for Lutron Powr Savr occupancy sensors.""" from __future__ import annotations +from collections.abc import Mapping +import logging +from typing import Any + from pylutron import OccupancyGroup from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import DiscoveryInfoType from . import LUTRON_CONTROLLER, LUTRON_DEVICES, LutronDevice +_LOGGER = logging.getLogger(__name__) -def setup_platform( + +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Lutron occupancy sensors.""" - if discovery_info is None: - return - devs = [] - for area_name, device in hass.data[LUTRON_DEVICES]["binary_sensor"]: - dev = LutronOccupancySensor(area_name, device, hass.data[LUTRON_CONTROLLER]) - devs.append(dev) + """Set up the Lutron binary_sensor platform. - add_entities(devs) + Adds occupancy groups from the Main Repeater associated with the + config_entry as binary_sensor entities. + """ + entities = [] + for area_name, device in hass.data[LUTRON_DEVICES]["binary_sensor"]: + entity = LutronOccupancySensor(area_name, device, hass.data[LUTRON_CONTROLLER]) + entities.append(entity) + async_add_entities(entities, True) class LutronOccupancySensor(LutronDevice, BinarySensorEntity): @@ -42,13 +50,13 @@ class LutronOccupancySensor(LutronDevice, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.OCCUPANCY @property - def is_on(self): + def is_on(self) -> bool: """Return true if the binary sensor is on.""" # Error cases will end up treated as unoccupied. return self._lutron_device.state == OccupancyGroup.State.OCCUPIED @property - def name(self): + def name(self) -> str: """Return the name of the device.""" # The default LutronDevice naming would create 'Kitchen Occ Kitchen', # but since there can only be one OccupancyGroup per area we go @@ -56,6 +64,6 @@ class LutronOccupancySensor(LutronDevice, BinarySensorEntity): return f"{self._area_name} Occupancy" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes.""" return {"lutron_integration_id": self._lutron_device.id} diff --git a/homeassistant/components/lutron/config_flow.py b/homeassistant/components/lutron/config_flow.py new file mode 100644 index 00000000000..04628849230 --- /dev/null +++ b/homeassistant/components/lutron/config_flow.py @@ -0,0 +1,107 @@ +"""Config flow to configure the Lutron integration.""" +from __future__ import annotations + +import logging +from typing import Any +from urllib.error import HTTPError + +from pylutron import Lutron +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class LutronConfigFlow(ConfigFlow, domain=DOMAIN): + """User prompt for Main Repeater configuration information.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """First step in the config flow.""" + + # Check if a configuration entry already exists + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + errors = {} + + if user_input is not None: + ip_address = user_input[CONF_HOST] + + main_repeater = Lutron( + ip_address, + user_input.get(CONF_USERNAME), + user_input.get(CONF_PASSWORD), + ) + + try: + await self.hass.async_add_executor_job(main_repeater.load_xml_db) + except HTTPError: + _LOGGER.exception("Http error") + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error") + errors["base"] = "unknown" + else: + guid = main_repeater.guid + + if len(guid) <= 10: + errors["base"] = "cannot_connect" + + if not errors: + await self.async_set_unique_id(guid) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title="Lutron", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME, default="lutron"): str, + vol.Required(CONF_PASSWORD, default="integration"): str, + } + ), + errors=errors, + ) + + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Attempt to import the existing configuration.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + main_repeater = Lutron( + import_config[CONF_HOST], + import_config[CONF_USERNAME], + import_config[CONF_PASSWORD], + ) + + def _load_db() -> None: + main_repeater.load_xml_db() + + try: + await self.hass.async_add_executor_job(_load_db) + except HTTPError: + _LOGGER.exception("Http error") + return self.async_abort(reason="cannot_connect") + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error") + return self.async_abort(reason="unknown") + + guid = main_repeater.guid + + if len(guid) <= 10: + return self.async_abort(reason="cannot_connect") + _LOGGER.debug("Main Repeater GUID: %s", main_repeater.guid) + + await self.async_set_unique_id(guid) + self._abort_if_unique_id_configured() + return self.async_create_entry(title="Lutron", data=import_config) diff --git a/homeassistant/components/lutron/const.py b/homeassistant/components/lutron/const.py new file mode 100644 index 00000000000..3862f7eb1d8 --- /dev/null +++ b/homeassistant/components/lutron/const.py @@ -0,0 +1,3 @@ +"""Lutron constants.""" + +DOMAIN = "lutron" diff --git a/homeassistant/components/lutron/cover.py b/homeassistant/components/lutron/cover.py index 57fd8ac9d5b..a3b977b9bb3 100644 --- a/homeassistant/components/lutron/cover.py +++ b/homeassistant/components/lutron/cover.py @@ -1,6 +1,7 @@ """Support for Lutron shades.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -9,28 +10,30 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import LUTRON_CONTROLLER, LUTRON_DEVICES, LutronDevice _LOGGER = logging.getLogger(__name__) -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Lutron shades.""" - devs = [] - for area_name, device in hass.data[LUTRON_DEVICES]["cover"]: - dev = LutronCover(area_name, device, hass.data[LUTRON_CONTROLLER]) - devs.append(dev) + """Set up the Lutron cover platform. - add_entities(devs, True) + Adds shades from the Main Repeater associated with the config_entry as + cover entities. + """ + entities = [] + for area_name, device in hass.data[LUTRON_DEVICES]["cover"]: + entity = LutronCover(area_name, device, hass.data[LUTRON_CONTROLLER]) + entities.append(entity) + async_add_entities(entities, True) class LutronCover(LutronDevice, CoverEntity): @@ -73,6 +76,6 @@ class LutronCover(LutronDevice, CoverEntity): _LOGGER.debug("Lutron ID: %d updated to %f", self._lutron_device.id, level) @property - def extra_state_attributes(self) -> dict[str, Any]: + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes.""" return {"lutron_integration_id": self._lutron_device.id} diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py index 6bd556d36d1..c6e54675ffd 100644 --- a/homeassistant/components/lutron/light.py +++ b/homeassistant/components/lutron/light.py @@ -1,29 +1,32 @@ """Support for Lutron lights.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import LUTRON_CONTROLLER, LUTRON_DEVICES, LutronDevice -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Lutron lights.""" - devs = [] - for area_name, device in hass.data[LUTRON_DEVICES]["light"]: - dev = LutronLight(area_name, device, hass.data[LUTRON_CONTROLLER]) - devs.append(dev) + """Set up the Lutron light platform. - add_entities(devs, True) + Adds dimmers from the Main Repeater associated with the config_entry as + light entities. + """ + entities = [] + for area_name, device in hass.data[LUTRON_DEVICES]["light"]: + entity = LutronLight(area_name, device, hass.data[LUTRON_CONTROLLER]) + entities.append(entity) + async_add_entities(entities, True) def to_lutron_level(level): @@ -42,13 +45,13 @@ class LutronLight(LutronDevice, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} - def __init__(self, area_name, lutron_device, controller): + def __init__(self, area_name, lutron_device, controller) -> None: """Initialize the light.""" self._prev_brightness = None super().__init__(area_name, lutron_device, controller) @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of the light.""" new_brightness = to_hass_level(self._lutron_device.last_level()) if new_brightness != 0: @@ -71,12 +74,12 @@ class LutronLight(LutronDevice, LightEntity): self._lutron_device.level = 0 @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes.""" return {"lutron_integration_id": self._lutron_device.id} @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self._lutron_device.last_level() > 0 diff --git a/homeassistant/components/lutron/manifest.json b/homeassistant/components/lutron/manifest.json index 029e18d574a..83b391fa9b5 100644 --- a/homeassistant/components/lutron/manifest.json +++ b/homeassistant/components/lutron/manifest.json @@ -2,6 +2,7 @@ "domain": "lutron", "name": "Lutron", "codeowners": ["@cdheiser"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lutron", "iot_class": "local_polling", "loggers": ["pylutron"], diff --git a/homeassistant/components/lutron/scene.py b/homeassistant/components/lutron/scene.py index f2d008a1187..ed4e28d945a 100644 --- a/homeassistant/components/lutron/scene.py +++ b/homeassistant/components/lutron/scene.py @@ -4,29 +4,31 @@ from __future__ import annotations from typing import Any from homeassistant.components.scene import Scene +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import LUTRON_CONTROLLER, LUTRON_DEVICES, LutronDevice -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Lutron scenes.""" - devs = [] + """Set up the Lutron scene platform. + + Adds scenes from the Main Repeater associated with the config_entry as + scene entities. + """ + entities = [] for scene_data in hass.data[LUTRON_DEVICES]["scene"]: (area_name, keypad_name, device, led) = scene_data - dev = LutronScene( + entity = LutronScene( area_name, keypad_name, device, led, hass.data[LUTRON_CONTROLLER] ) - devs.append(dev) - - add_entities(devs, True) + entities.append(entity) + async_add_entities(entities, True) class LutronScene(LutronDevice, Scene): diff --git a/homeassistant/components/lutron/strings.json b/homeassistant/components/lutron/strings.json new file mode 100644 index 00000000000..20a8d9bf971 --- /dev/null +++ b/homeassistant/components/lutron/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of the Lutron main repeater." + }, + "description": "Please enter the main repeater login information", + "title": "Main repeater setup" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The Lutron YAML configuration import cannot connect to server", + "description": "Configuring Lutron using YAML is being removed but there was an connection error importing your YAML configuration.\n\nThings you can try:\nMake sure your home assistant can reach the main repeater.\nRestart the main repeater by unplugging it for 60 seconds.\nTry logging into the main repeater at the IP address you specified in a web browser and the same login information.\n\nThen restart Home Assistant to try importing this integration again.\n\nAlternatively, you may remove the Lutron configuration from your YAML configuration entirely, restart Home Assistant, and add the Lutron integration manually." + }, + "deprecated_yaml_import_issue_unknown": { + "title": "The Lutron YAML configuration import request failed due to an unknown error", + "description": "Configuring Lutron using YAML is being removed but there was an unknown error while importing your existing configuration.\nSetup will not proceed.\n\nThe specific error can be found in the logs. The most likely cause is a networking error or the Main Repeater is down or has an invalid configuration.\n\nVerify that your Lutron system is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the Lutron configuration from your YAML configuration entirely, restart Home Assistant, and add the Lutron integration manually." + } + } +} diff --git a/homeassistant/components/lutron/switch.py b/homeassistant/components/lutron/switch.py index 7d33a822087..572b599787a 100644 --- a/homeassistant/components/lutron/switch.py +++ b/homeassistant/components/lutron/switch.py @@ -1,29 +1,33 @@ """Support for Lutron switches.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import LUTRON_CONTROLLER, LUTRON_DEVICES, LutronDevice -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Lutron switches.""" - devs = [] + """Set up the Lutron switch platform. + + Adds switches from the Main Repeater associated with the config_entry as + switch entities. + """ + entities = [] # Add Lutron Switches for area_name, device in hass.data[LUTRON_DEVICES]["switch"]: - dev = LutronSwitch(area_name, device, hass.data[LUTRON_CONTROLLER]) - devs.append(dev) + entity = LutronSwitch(area_name, device, hass.data[LUTRON_CONTROLLER]) + entities.append(entity) # Add the indicator LEDs for scenes (keypad buttons) for scene_data in hass.data[LUTRON_DEVICES]["scene"]: @@ -32,15 +36,14 @@ def setup_platform( led = LutronLed( area_name, keypad_name, scene, led, hass.data[LUTRON_CONTROLLER] ) - devs.append(led) - - add_entities(devs, True) + entities.append(led) + async_add_entities(entities, True) class LutronSwitch(LutronDevice, SwitchEntity): """Representation of a Lutron Switch.""" - def __init__(self, area_name, lutron_device, controller): + def __init__(self, area_name, lutron_device, controller) -> None: """Initialize the switch.""" self._prev_state = None super().__init__(area_name, lutron_device, controller) @@ -54,12 +57,12 @@ class LutronSwitch(LutronDevice, SwitchEntity): self._lutron_device.level = 0 @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes.""" return {"lutron_integration_id": self._lutron_device.id} @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self._lutron_device.last_level() > 0 @@ -87,7 +90,7 @@ class LutronLed(LutronDevice, SwitchEntity): self._lutron_device.state = 0 @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes.""" return { "keypad": self._keypad_name, @@ -96,7 +99,7 @@ class LutronLed(LutronDevice, SwitchEntity): } @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self._lutron_device.last_state diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6a387da0d42..f04ec579f91 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -282,6 +282,7 @@ FLOWS = { "lookin", "loqed", "luftdaten", + "lutron", "lutron_caseta", "lyric", "mailgun", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a7cfea03be7..5a66e7e1f44 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3280,7 +3280,7 @@ "integrations": { "lutron": { "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling", "name": "Lutron" }, diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bd030a294bb..b625bd390e8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1450,6 +1450,9 @@ pylitterbot==2023.4.9 # homeassistant.components.lutron_caseta pylutron-caseta==0.18.3 +# homeassistant.components.lutron +pylutron==0.2.8 + # homeassistant.components.mailgun pymailgunner==1.4 diff --git a/tests/components/lutron/__init__.py b/tests/components/lutron/__init__.py new file mode 100644 index 00000000000..6ffe0ee7d94 --- /dev/null +++ b/tests/components/lutron/__init__.py @@ -0,0 +1 @@ +"""Test for the lutron integration.""" diff --git a/tests/components/lutron/conftest.py b/tests/components/lutron/conftest.py new file mode 100644 index 00000000000..e94e337ce1d --- /dev/null +++ b/tests/components/lutron/conftest.py @@ -0,0 +1,15 @@ +"""Provide common Lutron fixtures and mocks.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.lutron.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/lutron/test_config_flow.py b/tests/components/lutron/test_config_flow.py new file mode 100644 index 00000000000..feb5c77c9be --- /dev/null +++ b/tests/components/lutron/test_config_flow.py @@ -0,0 +1,214 @@ +"""Test the lutron config flow.""" +from unittest.mock import AsyncMock, patch +from urllib.error import HTTPError + +import pytest + +from homeassistant.components.lutron.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +MOCK_DATA_STEP = { + CONF_HOST: "127.0.0.1", + CONF_USERNAME: "lutron", + CONF_PASSWORD: "integration", +} + + +async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test success response.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("homeassistant.components.lutron.config_flow.Lutron.load_xml_db"), patch( + "homeassistant.components.lutron.config_flow.Lutron.guid", "12345678901" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["result"].title == "Lutron" + + assert result["data"] == MOCK_DATA_STEP + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (HTTPError("", 404, "", None, {}), "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_flow_failure( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test unknown errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.lutron.config_flow.Lutron.load_xml_db", + side_effect=raise_error, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + with patch("homeassistant.components.lutron.config_flow.Lutron.load_xml_db"), patch( + "homeassistant.components.lutron.config_flow.Lutron.guid", "12345678901" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["result"].title == "Lutron" + + assert result["data"] == MOCK_DATA_STEP + + +async def test_flow_incorrect_guid( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test configuring flow with incorrect guid.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("homeassistant.components.lutron.config_flow.Lutron.load_xml_db"), patch( + "homeassistant.components.lutron.config_flow.Lutron.guid", "12345" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + with patch("homeassistant.components.lutron.config_flow.Lutron.load_xml_db"), patch( + "homeassistant.components.lutron.config_flow.Lutron.guid", "12345678901" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + + +async def test_flow_single_instance_allowed(hass: HomeAssistant) -> None: + """Test we abort user data set when entry is already configured.""" + + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_DATA_STEP, unique_id="12345678901") + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +MOCK_DATA_IMPORT = { + CONF_HOST: "127.0.0.1", + CONF_USERNAME: "lutron", + CONF_PASSWORD: "integration", +} + + +async def test_import( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test import flow.""" + with patch("homeassistant.components.lutron.config_flow.Lutron.load_xml_db"), patch( + "homeassistant.components.lutron.config_flow.Lutron.guid", "12345678901" + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_DATA_IMPORT + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == MOCK_DATA_IMPORT + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "reason"), + [ + (HTTPError("", 404, "", None, {}), "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_import_flow_failure( + hass: HomeAssistant, raise_error: Exception, reason: str +) -> None: + """Test handling errors while importing.""" + + with patch( + "homeassistant.components.lutron.config_flow.Lutron.load_xml_db", + side_effect=raise_error, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_DATA_IMPORT + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == reason + + +async def test_import_flow_guid_failure(hass: HomeAssistant) -> None: + """Test handling errors while importing.""" + + with patch("homeassistant.components.lutron.config_flow.Lutron.load_xml_db"), patch( + "homeassistant.components.lutron.config_flow.Lutron.guid", "123" + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_DATA_IMPORT + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_import_already_configured(hass: HomeAssistant) -> None: + """Test we abort import when entry is already configured.""" + + entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_DATA_IMPORT, unique_id="12345678901" + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_DATA_IMPORT + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed"