From 7e7f25c859b52797c9ffa997f98bbfe31e654b26 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Mar 2024 19:09:39 +0100 Subject: [PATCH] Add config flow to homeworks (#112042) --- .coveragerc | 3 +- .../components/homeworks/__init__.py | 164 +++-- .../components/homeworks/config_flow.py | 465 ++++++++++++++ homeassistant/components/homeworks/const.py | 15 + homeassistant/components/homeworks/light.py | 44 +- .../components/homeworks/manifest.json | 1 + .../components/homeworks/strings.json | 35 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/homeworks/__init__.py | 1 + tests/components/homeworks/conftest.py | 82 +++ .../components/homeworks/test_config_flow.py | 588 ++++++++++++++++++ 13 files changed, 1343 insertions(+), 61 deletions(-) create mode 100644 homeassistant/components/homeworks/config_flow.py create mode 100644 homeassistant/components/homeworks/const.py create mode 100644 homeassistant/components/homeworks/strings.json create mode 100644 tests/components/homeworks/__init__.py create mode 100644 tests/components/homeworks/conftest.py create mode 100644 tests/components/homeworks/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 626c2122d6f..b020819d3be 100644 --- a/.coveragerc +++ b/.coveragerc @@ -545,7 +545,8 @@ omit = homeassistant/components/homematic/notify.py homeassistant/components/homematic/sensor.py homeassistant/components/homematic/switch.py - homeassistant/components/homeworks/* + homeassistant/components/homeworks/__init__.py + homeassistant/components/homeworks/light.py homeassistant/components/horizon/media_player.py homeassistant/components/hp_ilo/sensor.py homeassistant/components/huawei_lte/__init__.py diff --git a/homeassistant/components/homeworks/__init__.py b/homeassistant/components/homeworks/__init__.py index 3d9023d16cb..05ba4d02454 100644 --- a/homeassistant/components/homeworks/__init__.py +++ b/homeassistant/components/homeworks/__init__.py @@ -1,9 +1,14 @@ """Support for Lutron Homeworks Series 4 and 8 systems.""" +from __future__ import annotations + +from dataclasses import dataclass import logging +from typing import Any from pyhomeworks.pyhomeworks import HW_BUTTON_PRESSED, HW_BUTTON_RELEASED, Homeworks import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_ID, @@ -13,27 +18,31 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify +from .const import ( + CONF_ADDR, + CONF_CONTROLLER_ID, + CONF_DIMMERS, + CONF_KEYPADS, + CONF_RATE, + DOMAIN, +) + _LOGGER = logging.getLogger(__name__) -DOMAIN = "homeworks" +PLATFORMS: list[Platform] = [Platform.LIGHT] -HOMEWORKS_CONTROLLER = "homeworks" EVENT_BUTTON_PRESS = "homeworks_button_press" EVENT_BUTTON_RELEASE = "homeworks_button_release" -CONF_DIMMERS = "dimmers" -CONF_KEYPADS = "keypads" -CONF_ADDR = "addr" -CONF_RATE = "rate" +DEFAULT_FADE_RATE = 1.0 -FADE_RATE = 1.0 CV_FADE_RATE = vol.All(vol.Coerce(float), vol.Range(min=0, max=20)) @@ -41,7 +50,7 @@ DIMMER_SCHEMA = vol.Schema( { vol.Required(CONF_ADDR): cv.string, vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_RATE, default=FADE_RATE): CV_FADE_RATE, + vol.Optional(CONF_RATE, default=DEFAULT_FADE_RATE): CV_FADE_RATE, } ) @@ -66,64 +75,137 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass: HomeAssistant, base_config: ConfigType) -> bool: +@dataclass +class HomeworksData: + """Container for config entry data.""" + + controller: Homeworks + controller_id: str + keypads: dict[str, HomeworksKeypad] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Start Homeworks controller.""" - def hw_callback(msg_type, values): - """Dispatch state changes.""" - _LOGGER.debug("callback: %s, %s", msg_type, values) - addr = values[0] - signal = f"homeworks_entity_{addr}" - dispatcher_send(hass, signal, msg_type, values) - - config = base_config[DOMAIN] - controller = Homeworks(config[CONF_HOST], config[CONF_PORT], hw_callback) - hass.data[HOMEWORKS_CONTROLLER] = controller - - def cleanup(event): - controller.close() - - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) - - dimmers = config[CONF_DIMMERS] - load_platform(hass, Platform.LIGHT, DOMAIN, {CONF_DIMMERS: dimmers}, base_config) - - for key_config in config[CONF_KEYPADS]: - addr = key_config[CONF_ADDR] - name = key_config[CONF_NAME] - HomeworksKeypadEvent(hass, addr, name) + if DOMAIN in config: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] + ) + ) return True -class HomeworksDevice(Entity): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Homeworks from a config entry.""" + + hass.data.setdefault(DOMAIN, {}) + controller_id = entry.options[CONF_CONTROLLER_ID] + + def hw_callback(msg_type: Any, values: Any) -> None: + """Dispatch state changes.""" + _LOGGER.debug("callback: %s, %s", msg_type, values) + addr = values[0] + signal = f"homeworks_entity_{controller_id}_{addr}" + dispatcher_send(hass, signal, msg_type, values) + + config = entry.options + try: + controller = await hass.async_add_executor_job( + Homeworks, config[CONF_HOST], config[CONF_PORT], hw_callback + ) + except (ConnectionError, OSError) as err: + raise ConfigEntryNotReady from err + + def cleanup(event): + controller.close() + + entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup)) + + keypads: dict[str, HomeworksKeypad] = {} + for key_config in config.get(CONF_KEYPADS, []): + addr = key_config[CONF_ADDR] + name = key_config[CONF_NAME] + keypads[addr] = HomeworksKeypad(hass, controller, controller_id, addr, name) + + hass.data[DOMAIN][entry.entry_id] = HomeworksData( + controller, controller_id, keypads + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + return False + + data: HomeworksData = hass.data[DOMAIN].pop(entry.entry_id) + for keypad in data.keypads.values(): + keypad.unsubscribe() + + await hass.async_add_executor_job(data.controller.close) + + return True + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +def calculate_unique_id(controller_id, addr, idx): + """Calculate entity unique id.""" + return f"homeworks.{controller_id}.{addr}.{idx}" + + +class HomeworksEntity(Entity): """Base class of a Homeworks device.""" _attr_should_poll = False - def __init__(self, controller, addr, name): + def __init__( + self, + controller: Homeworks, + controller_id: str, + addr: str, + idx: int, + name: str | None, + ) -> None: """Initialize Homeworks device.""" self._addr = addr + self._idx = idx + self._controller_id = controller_id self._attr_name = name - self._attr_unique_id = f"homeworks.{self._addr}" + self._attr_unique_id = calculate_unique_id( + self._controller_id, self._addr, self._idx + ) self._controller = controller -class HomeworksKeypadEvent: +class HomeworksKeypad: """When you want signals instead of entities. Stateless sensors such as keypads are expected to generate an event instead of a sensor entity in hass. """ - def __init__(self, hass, addr, name): + def __init__(self, hass, controller, controller_id, addr, name): """Register callback that will be used for signals.""" - self._hass = hass self._addr = addr + self._controller = controller + self._hass = hass self._name = name self._id = slugify(self._name) - signal = f"homeworks_entity_{self._addr}" - async_dispatcher_connect(self._hass, signal, self._update_callback) + signal = f"homeworks_entity_{controller_id}_{self._addr}" + _LOGGER.debug("connecting %s", signal) + self.unsubscribe = async_dispatcher_connect( + self._hass, signal, self._update_callback + ) @callback def _update_callback(self, msg_type, values): diff --git a/homeassistant/components/homeworks/config_flow.py b/homeassistant/components/homeworks/config_flow.py new file mode 100644 index 00000000000..e7095e4f57f --- /dev/null +++ b/homeassistant/components/homeworks/config_flow.py @@ -0,0 +1,465 @@ +"""Lutron Homeworks Series 4 and 8 config flow.""" +from __future__ import annotations + +from functools import partial +import logging +from typing import Any + +from pyhomeworks.pyhomeworks import Homeworks +import voluptuous as vol + +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + HomeAssistant, + async_get_hass, + callback, +) +from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers import ( + config_validation as cv, + entity_registry as er, + issue_registry as ir, + selector, +) +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaFlowError, + SchemaFlowFormStep, + SchemaFlowMenuStep, + SchemaOptionsFlowHandler, +) +from homeassistant.helpers.selector import TextSelector +from homeassistant.util import slugify + +from . import DEFAULT_FADE_RATE, calculate_unique_id +from .const import ( + CONF_ADDR, + CONF_CONTROLLER_ID, + CONF_DIMMERS, + CONF_INDEX, + CONF_KEYPADS, + CONF_RATE, + DEFAULT_KEYPAD_NAME, + DEFAULT_LIGHT_NAME, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +CONTROLLER_EDIT = { + vol.Required(CONF_HOST): selector.TextSelector(), + vol.Required(CONF_PORT): selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, + max=65535, + mode=selector.NumberSelectorMode.BOX, + ) + ), +} + +LIGHT_EDIT = { + vol.Optional(CONF_RATE, default=DEFAULT_FADE_RATE): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, + max=20, + mode=selector.NumberSelectorMode.SLIDER, + step=0.1, + ) + ), +} + + +validate_addr = cv.matches_regex(r"\[\d\d:\d\d:\d\d:\d\d\]") + + +async def validate_add_controller( + handler: ConfigFlow | SchemaOptionsFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + """Validate controller setup.""" + user_input[CONF_CONTROLLER_ID] = slugify(user_input[CONF_NAME]) + user_input[CONF_PORT] = int(user_input[CONF_PORT]) + try: + handler._async_abort_entries_match( # pylint: disable=protected-access + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) + except AbortFlow as err: + raise SchemaFlowError("duplicated_host_port") from err + + try: + handler._async_abort_entries_match( # pylint: disable=protected-access + {CONF_CONTROLLER_ID: user_input[CONF_CONTROLLER_ID]} + ) + except AbortFlow as err: + raise SchemaFlowError("duplicated_controller_id") from err + + await _try_connection(user_input) + + return user_input + + +async def _try_connection(user_input: dict[str, Any]) -> None: + """Try connecting to the controller.""" + + def _try_connect(host: str, port: int) -> None: + """Try connecting to the controller. + + Raises ConnectionError if the connection fails. + """ + _LOGGER.debug( + "Trying to connect to %s:%s", user_input[CONF_HOST], user_input[CONF_PORT] + ) + controller = Homeworks(host, port, lambda msg_types, values: None) + controller.close() + controller.join() + + hass = async_get_hass() + try: + await hass.async_add_executor_job( + _try_connect, user_input[CONF_HOST], user_input[CONF_PORT] + ) + except ConnectionError as err: + raise SchemaFlowError("connection_error") from err + except Exception as err: + _LOGGER.exception("Caught unexpected exception") + raise SchemaFlowError("unknown_error") from err + + +def _create_import_issue(hass: HomeAssistant) -> None: + """Create a repair issue asking the user to remove YAML.""" + ir.async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Lutron Homeworks", + }, + ) + + +def _validate_address(handler: SchemaCommonFlowHandler, addr: str) -> None: + """Validate address.""" + try: + validate_addr(addr) + except vol.Invalid as err: + raise SchemaFlowError("invalid_addr") from err + + for _key in (CONF_DIMMERS, CONF_KEYPADS): + items: list[dict[str, Any]] = handler.options[_key] + + for item in items: + if item[CONF_ADDR] == addr: + raise SchemaFlowError("duplicated_addr") + + +async def validate_add_keypad( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + """Validate keypad or light input.""" + _validate_address(handler, user_input[CONF_ADDR]) + + # Standard behavior is to merge the result with the options. + # In this case, we want to add a sub-item so we update the options directly. + items = handler.options[CONF_KEYPADS] + items.append(user_input) + return {} + + +async def validate_add_light( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + """Validate light input.""" + _validate_address(handler, user_input[CONF_ADDR]) + + # Standard behavior is to merge the result with the options. + # In this case, we want to add a sub-item so we update the options directly. + items = handler.options[CONF_DIMMERS] + items.append(user_input) + return {} + + +async def get_select_light_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: + """Return schema for selecting a light.""" + return vol.Schema( + { + vol.Required(CONF_INDEX): vol.In( + { + str(index): f"{config[CONF_NAME]} ({config[CONF_ADDR]})" + for index, config in enumerate(handler.options[CONF_DIMMERS]) + }, + ) + } + ) + + +async def validate_select_keypad_light( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + """Store keypad or light index in flow state.""" + handler.flow_state["_idx"] = int(user_input[CONF_INDEX]) + return {} + + +async def get_edit_light_suggested_values( + handler: SchemaCommonFlowHandler, +) -> dict[str, Any]: + """Return suggested values for light editing.""" + idx: int = handler.flow_state["_idx"] + return dict(handler.options[CONF_DIMMERS][idx]) + + +async def validate_light_edit( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + """Update edited keypad or light.""" + # Standard behavior is to merge the result with the options. + # In this case, we want to add a sub-item so we update the options directly. + idx: int = handler.flow_state["_idx"] + handler.options[CONF_DIMMERS][idx].update(user_input) + return {} + + +async def get_remove_keypad_light_schema( + handler: SchemaCommonFlowHandler, *, key: str +) -> vol.Schema: + """Return schema for keypad or light removal.""" + return vol.Schema( + { + vol.Required(CONF_INDEX): cv.multi_select( + { + str(index): f"{config[CONF_NAME]} ({config[CONF_ADDR]})" + for index, config in enumerate(handler.options[key]) + }, + ) + } + ) + + +async def validate_remove_keypad_light( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any], *, key: str +) -> dict[str, Any]: + """Validate remove keypad or light.""" + removed_indexes: set[str] = set(user_input[CONF_INDEX]) + + # Standard behavior is to merge the result with the options. + # In this case, we want to remove sub-items so we update the options directly. + entity_registry = er.async_get(handler.parent_handler.hass) + items: list[dict[str, Any]] = [] + item: dict[str, Any] + for index, item in enumerate(handler.options[key]): + if str(index) not in removed_indexes: + items.append(item) + elif key != CONF_DIMMERS: + continue + if entity_id := entity_registry.async_get_entity_id( + LIGHT_DOMAIN, + DOMAIN, + calculate_unique_id( + handler.options[CONF_CONTROLLER_ID], item[CONF_ADDR], 0 + ), + ): + entity_registry.async_remove(entity_id) + handler.options[key] = items + return {} + + +DATA_SCHEMA_ADD_CONTROLLER = vol.Schema( + { + vol.Required( + CONF_NAME, description={"suggested_value": "Lutron Homeworks"} + ): selector.TextSelector(), + **CONTROLLER_EDIT, + } +) +DATA_SCHEMA_ADD_LIGHT = vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_LIGHT_NAME): TextSelector(), + vol.Required(CONF_ADDR): TextSelector(), + **LIGHT_EDIT, + } +) +DATA_SCHEMA_ADD_KEYPAD = vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_KEYPAD_NAME): TextSelector(), + vol.Required(CONF_ADDR): TextSelector(), + } +) +DATA_SCHEMA_EDIT_LIGHT = vol.Schema(LIGHT_EDIT) + +OPTIONS_FLOW = { + "init": SchemaFlowMenuStep( + [ + "add_keypad", + "remove_keypad", + "add_light", + "select_edit_light", + "remove_light", + ] + ), + "add_keypad": SchemaFlowFormStep( + DATA_SCHEMA_ADD_KEYPAD, + suggested_values=None, + validate_user_input=validate_add_keypad, + ), + "remove_keypad": SchemaFlowFormStep( + partial(get_remove_keypad_light_schema, key=CONF_KEYPADS), + suggested_values=None, + validate_user_input=partial(validate_remove_keypad_light, key=CONF_KEYPADS), + ), + "add_light": SchemaFlowFormStep( + DATA_SCHEMA_ADD_LIGHT, + suggested_values=None, + validate_user_input=validate_add_light, + ), + "select_edit_light": SchemaFlowFormStep( + get_select_light_schema, + suggested_values=None, + validate_user_input=validate_select_keypad_light, + next_step="edit_light", + ), + "edit_light": SchemaFlowFormStep( + DATA_SCHEMA_EDIT_LIGHT, + suggested_values=get_edit_light_suggested_values, + validate_user_input=validate_light_edit, + ), + "remove_light": SchemaFlowFormStep( + partial(get_remove_keypad_light_schema, key=CONF_DIMMERS), + suggested_values=None, + validate_user_input=partial(validate_remove_keypad_light, key=CONF_DIMMERS), + ), +} + + +class HomeworksConfigFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow for Lutron Homeworks.""" + + import_config: dict[str, Any] + + async def async_step_import(self, config: dict[str, Any]) -> ConfigFlowResult: + """Start importing configuration from yaml.""" + self.import_config = { + CONF_HOST: config[CONF_HOST], + CONF_PORT: config[CONF_PORT], + CONF_DIMMERS: [ + { + CONF_ADDR: light[CONF_ADDR], + CONF_NAME: light[CONF_NAME], + CONF_RATE: light[CONF_RATE], + } + for light in config[CONF_DIMMERS] + ], + CONF_KEYPADS: [ + { + CONF_ADDR: keypad[CONF_ADDR], + CONF_NAME: keypad[CONF_NAME], + } + for keypad in config[CONF_KEYPADS] + ], + } + return await self.async_step_import_controller_name() + + async def async_step_import_controller_name( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Ask user to set a name of the controller.""" + errors = {} + try: + self._async_abort_entries_match( + { + CONF_HOST: self.import_config[CONF_HOST], + CONF_PORT: self.import_config[CONF_PORT], + } + ) + except AbortFlow: + _create_import_issue(self.hass) + raise + + if user_input: + try: + user_input[CONF_CONTROLLER_ID] = slugify(user_input[CONF_NAME]) + self._async_abort_entries_match( + {CONF_CONTROLLER_ID: user_input[CONF_CONTROLLER_ID]} + ) + except AbortFlow: + errors["base"] = "duplicated_controller_id" + else: + self.import_config |= user_input + return await self.async_step_import_finish() + + return self.async_show_form( + step_id="import_controller_name", + data_schema=vol.Schema( + { + vol.Required( + CONF_NAME, description={"suggested_value": "Lutron Homeworks"} + ): selector.TextSelector(), + } + ), + errors=errors, + ) + + async def async_step_import_finish( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Ask user to remove YAML configuration.""" + + if user_input is not None: + entity_registry = er.async_get(self.hass) + config = self.import_config + for light in config[CONF_DIMMERS]: + addr = light[CONF_ADDR] + if entity_id := entity_registry.async_get_entity_id( + LIGHT_DOMAIN, DOMAIN, f"homeworks.{addr}" + ): + entity_registry.async_update_entity( + entity_id, + new_unique_id=calculate_unique_id( + config[CONF_CONTROLLER_ID], addr, 0 + ), + ) + name = config.pop(CONF_NAME) + return self.async_create_entry( + title=name, + data={}, + options=config, + ) + + return self.async_show_form(step_id="import_finish", data_schema=vol.Schema({})) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + errors = {} + if user_input: + try: + await validate_add_controller(self, user_input) + except SchemaFlowError as err: + errors["base"] = str(err) + else: + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) + name = user_input.pop(CONF_NAME) + user_input |= {CONF_DIMMERS: [], CONF_KEYPADS: []} + return self.async_create_entry(title=name, data={}, options=user_input) + + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA_ADD_CONTROLLER, + errors=errors, + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> SchemaOptionsFlowHandler: + """Options flow handler for Lutron Homeworks.""" + return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) diff --git a/homeassistant/components/homeworks/const.py b/homeassistant/components/homeworks/const.py new file mode 100644 index 00000000000..df0e5294d4b --- /dev/null +++ b/homeassistant/components/homeworks/const.py @@ -0,0 +1,15 @@ +"""Constants for the Lutron Homeworks integration.""" +from __future__ import annotations + +DOMAIN = "homeworks" + +CONF_ADDR = "addr" +CONF_CONTROLLER_ID = "controller_id" +CONF_DIMMERS = "dimmers" +CONF_INDEX = "index" +CONF_KEYPADS = "keypads" +CONF_RATE = "rate" + +DEFAULT_BUTTON_NAME = "Homeworks button" +DEFAULT_KEYPAD_NAME = "Homeworks keypad" +DEFAULT_LIGHT_NAME = "Homeworks light" diff --git a/homeassistant/components/homeworks/light.py b/homeassistant/components/homeworks/light.py index 35a9e5665d1..98f327bdfce 100644 --- a/homeassistant/components/homeworks/light.py +++ b/homeassistant/components/homeworks/light.py @@ -7,53 +7,61 @@ from typing import Any from pyhomeworks.pyhomeworks import HW_LIGHT_CHANGED from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_ADDR, CONF_DIMMERS, CONF_RATE, HOMEWORKS_CONTROLLER, HomeworksDevice +from . import HomeworksData, HomeworksEntity +from .const import CONF_ADDR, CONF_CONTROLLER_ID, CONF_DIMMERS, CONF_RATE, DOMAIN _LOGGER = logging.getLogger(__name__) -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discover_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Homeworks lights.""" - if discover_info is None: - return - - controller = hass.data[HOMEWORKS_CONTROLLER] + data: HomeworksData = hass.data[DOMAIN][entry.entry_id] + controller = data.controller + controller_id = entry.options[CONF_CONTROLLER_ID] devs = [] - for dimmer in discover_info[CONF_DIMMERS]: + for dimmer in entry.options.get(CONF_DIMMERS, []): dev = HomeworksLight( - controller, dimmer[CONF_ADDR], dimmer[CONF_NAME], dimmer[CONF_RATE] + controller, + controller_id, + dimmer[CONF_ADDR], + dimmer[CONF_NAME], + dimmer[CONF_RATE], ) devs.append(dev) - add_entities(devs, True) + async_add_entities(devs, True) -class HomeworksLight(HomeworksDevice, LightEntity): +class HomeworksLight(HomeworksEntity, LightEntity): """Homeworks Light.""" _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} - def __init__(self, controller, addr, name, rate): + def __init__( + self, + controller, + controller_id, + addr, + name, + rate, + ): """Create device with Addr, name, and rate.""" - super().__init__(controller, addr, name) + super().__init__(controller, controller_id, addr, 0, name) self._rate = rate self._level = 0 self._prev_level = 0 async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" - signal = f"homeworks_entity_{self._addr}" + signal = f"homeworks_entity_{self._controller_id}_{self._addr}" _LOGGER.debug("connecting %s", signal) self.async_on_remove( async_dispatcher_connect(self.hass, signal, self._update_callback) diff --git a/homeassistant/components/homeworks/manifest.json b/homeassistant/components/homeworks/manifest.json index 4a3f132e14d..c2520b910d9 100644 --- a/homeassistant/components/homeworks/manifest.json +++ b/homeassistant/components/homeworks/manifest.json @@ -2,6 +2,7 @@ "domain": "homeworks", "name": "Lutron Homeworks", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homeworks", "iot_class": "local_push", "loggers": ["pyhomeworks"], diff --git a/homeassistant/components/homeworks/strings.json b/homeassistant/components/homeworks/strings.json new file mode 100644 index 00000000000..3154d3e145e --- /dev/null +++ b/homeassistant/components/homeworks/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "error": { + "connection_error": "Could not connect to the controller.", + "duplicated_controller_id": "The controller name is already in use.", + "duplicated_host_port": "The specified host and port is already configured.", + "unknown_error": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "import_finish": { + "description": "The existing YAML configuration has succesfully been imported.\n\nYou can now remove the `homeworks` configuration from your configuration.yaml file." + }, + "import_controller_name": { + "description": "Lutron Homeworks is no longer configured through configuration.yaml.\n\nPlease fill in the form to import the existing configuration to the UI.", + "data": { + "name": "[%key:component::homeworks::config::step::user::data::name%]" + }, + "data_description": { + "name": "[%key:component::homeworks::config::step::user::data_description::name%]" + } + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "name": "Controller name", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "name": "A unique name identifying the Lutron Homeworks controller" + }, + "description": "Add a Lutron Homeworks controller" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 55d77e26336..b657433b20b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -224,6 +224,7 @@ FLOWS = { "homekit_controller", "homematicip_cloud", "homewizard", + "homeworks", "honeywell", "huawei_lte", "hue", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6b6c41e412c..17115a55435 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3370,7 +3370,7 @@ }, "homeworks": { "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_push", "name": "Lutron Homeworks" } diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f17011f6244..a10f92e32d2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1437,6 +1437,9 @@ pyhiveapi==0.5.16 # homeassistant.components.homematic pyhomematic==0.1.77 +# homeassistant.components.homeworks +pyhomeworks==0.0.6 + # homeassistant.components.ialarm pyialarm==2.2.0 diff --git a/tests/components/homeworks/__init__.py b/tests/components/homeworks/__init__.py new file mode 100644 index 00000000000..6cb38e6ff81 --- /dev/null +++ b/tests/components/homeworks/__init__.py @@ -0,0 +1 @@ +"""Tests for the Lutron Homeworks Series 4 and 8 integration.""" diff --git a/tests/components/homeworks/conftest.py b/tests/components/homeworks/conftest.py new file mode 100644 index 00000000000..32b77781097 --- /dev/null +++ b/tests/components/homeworks/conftest.py @@ -0,0 +1,82 @@ +"""Common fixtures for the Lutron Homeworks Series 4 and 8 tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.homeworks.const import ( + CONF_ADDR, + CONF_CONTROLLER_ID, + CONF_DIMMERS, + CONF_KEYPADS, + CONF_RATE, + DOMAIN, +) +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Lutron Homeworks", + domain=DOMAIN, + data={}, + options={ + CONF_CONTROLLER_ID: "main_controller", + CONF_HOST: "192.168.0.1", + CONF_PORT: 1234, + CONF_DIMMERS: [ + { + CONF_ADDR: "[02:08:01:01]", + CONF_NAME: "Foyer Sconces", + CONF_RATE: 1.0, + } + ], + CONF_KEYPADS: [ + { + CONF_ADDR: "[02:08:02:01]", + CONF_NAME: "Foyer Keypad", + } + ], + }, + ) + + +@pytest.fixture +def mock_empty_config_entry() -> MockConfigEntry: + """Return a mocked config entry with no keypads or dimmers.""" + return MockConfigEntry( + title="Lutron Homeworks", + domain=DOMAIN, + data={}, + options={ + CONF_CONTROLLER_ID: "main_controller", + CONF_HOST: "192.168.0.1", + CONF_PORT: 1234, + CONF_DIMMERS: [], + CONF_KEYPADS: [], + }, + ) + + +@pytest.fixture +def mock_homeworks() -> Generator[None, MagicMock, None]: + """Return a mocked Homeworks client.""" + with patch( + "homeassistant.components.homeworks.Homeworks", autospec=True + ) as homeworks_mock, patch( + "homeassistant.components.homeworks.config_flow.Homeworks", new=homeworks_mock + ): + yield homeworks_mock + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.homeworks.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/homeworks/test_config_flow.py b/tests/components/homeworks/test_config_flow.py new file mode 100644 index 00000000000..0975abf1f82 --- /dev/null +++ b/tests/components/homeworks/test_config_flow.py @@ -0,0 +1,588 @@ +"""Test Lutron Homeworks Series 4 and 8 config flow.""" +from unittest.mock import ANY, MagicMock + +import pytest +from pytest_unordered import unordered + +from homeassistant.components.homeworks.const import ( + CONF_ADDR, + CONF_DIMMERS, + CONF_INDEX, + CONF_KEYPADS, + CONF_RATE, + DOMAIN, +) +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import entity_registry as er, issue_registry as ir + +from tests.common import MockConfigEntry + + +async def test_user_flow( + hass: HomeAssistant, mock_homeworks: MagicMock, mock_setup_entry +) -> None: + """Test the user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + mock_controller = MagicMock() + mock_homeworks.return_value = mock_controller + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "192.168.0.1", + CONF_NAME: "Main controller", + CONF_PORT: 1234, + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Main controller" + assert result["data"] == {} + assert result["options"] == { + "controller_id": "main_controller", + "dimmers": [], + "host": "192.168.0.1", + "keypads": [], + "port": 1234, + } + mock_homeworks.assert_called_once_with("192.168.0.1", 1234, ANY) + mock_controller.close.assert_called_once_with() + mock_controller.join.assert_called_once_with() + + +async def test_user_flow_already_exists( + hass: HomeAssistant, mock_empty_config_entry: MockConfigEntry, mock_setup_entry +) -> None: + """Test the user configuration flow.""" + mock_empty_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "192.168.0.1", + CONF_NAME: "Main controller", + CONF_PORT: 1234, + }, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "duplicated_host_port"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "192.168.0.2", + CONF_NAME: "Main controller", + CONF_PORT: 1234, + }, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "duplicated_controller_id"} + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [(ConnectionError, "connection_error"), (Exception, "unknown_error")], +) +async def test_user_flow_cannot_connect( + hass: HomeAssistant, + mock_homeworks: MagicMock, + mock_setup_entry, + side_effect: type[Exception], + error: str, +) -> None: + """Test handling invalid connection.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + mock_homeworks.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "192.168.0.1", + CONF_NAME: "Main controller", + CONF_PORT: 1234, + }, + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": error} + assert result["step_id"] == "user" + + +async def test_import_flow( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + mock_homeworks: MagicMock, + mock_setup_entry, +) -> None: + """Test importing yaml config.""" + entry = entity_registry.async_get_or_create( + LIGHT_DOMAIN, DOMAIN, "homeworks.[02:08:01:01]" + ) + + mock_controller = MagicMock() + mock_homeworks.return_value = mock_controller + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: "192.168.0.1", + CONF_PORT: 1234, + CONF_DIMMERS: [ + { + CONF_ADDR: "[02:08:01:01]", + CONF_NAME: "Foyer Sconces", + CONF_RATE: 1.0, + } + ], + CONF_KEYPADS: [ + { + CONF_ADDR: "[02:08:02:01]", + CONF_NAME: "Foyer Keypad", + } + ], + }, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "import_controller_name" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NAME: "Main controller"} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "import_finish" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Main controller" + assert result["data"] == {} + assert result["options"] == { + "controller_id": "main_controller", + "dimmers": [{"addr": "[02:08:01:01]", "name": "Foyer Sconces", "rate": 1.0}], + "host": "192.168.0.1", + "keypads": [ + { + "addr": "[02:08:02:01]", + "name": "Foyer Keypad", + } + ], + "port": 1234, + } + assert len(issue_registry.issues) == 0 + + # Check unique ID is updated in entity registry + entry = entity_registry.async_get(entry.id) + assert entry.unique_id == "homeworks.main_controller.[02:08:01:01].0" + + +async def test_import_flow_already_exists( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_empty_config_entry: MockConfigEntry, +) -> None: + """Test importing yaml config where entry already exists.""" + mock_empty_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={"host": "192.168.0.1", "port": 1234, CONF_DIMMERS: [], CONF_KEYPADS: []}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert len(issue_registry.issues) == 1 + + +async def test_import_flow_controller_id_exists( + hass: HomeAssistant, mock_empty_config_entry: MockConfigEntry +) -> None: + """Test importing yaml config where entry already exists.""" + mock_empty_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={"host": "192.168.0.2", "port": 1234, CONF_DIMMERS: [], CONF_KEYPADS: []}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "import_controller_name" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NAME: "Main controller"} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "import_controller_name" + assert result["errors"] == {"base": "duplicated_controller_id"} + + +async def test_options_add_light_flow( + hass: HomeAssistant, + mock_empty_config_entry: MockConfigEntry, + mock_homeworks: MagicMock, +) -> None: + """Test options flow to add a light.""" + mock_empty_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_empty_config_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.async_entity_ids("light") == unordered([]) + + result = await hass.config_entries.options.async_init( + mock_empty_config_entry.entry_id + ) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "add_light"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "add_light" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_ADDR: "[02:08:01:02]", + CONF_NAME: "Foyer Downlights", + CONF_RATE: 2.0, + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "controller_id": "main_controller", + "dimmers": [ + {"addr": "[02:08:01:02]", "name": "Foyer Downlights", "rate": 2.0}, + ], + "host": "192.168.0.1", + "keypads": [], + "port": 1234, + } + + await hass.async_block_till_done() + + # Check the entry was updated with the new entity + assert hass.states.async_entity_ids("light") == unordered( + ["light.foyer_downlights"] + ) + + +async def test_options_add_remove_light_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homeworks: MagicMock +) -> None: + """Test options flow to add and remove a light.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.async_entity_ids("light") == unordered(["light.foyer_sconces"]) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "add_light"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "add_light" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_ADDR: "[02:08:01:02]", + CONF_NAME: "Foyer Downlights", + CONF_RATE: 2.0, + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "controller_id": "main_controller", + "dimmers": [ + {"addr": "[02:08:01:01]", "name": "Foyer Sconces", "rate": 1.0}, + {"addr": "[02:08:01:02]", "name": "Foyer Downlights", "rate": 2.0}, + ], + "host": "192.168.0.1", + "keypads": [ + { + "addr": "[02:08:02:01]", + "name": "Foyer Keypad", + } + ], + "port": 1234, + } + + await hass.async_block_till_done() + + # Check the entry was updated with the new entity + assert hass.states.async_entity_ids("light") == unordered( + ["light.foyer_sconces", "light.foyer_downlights"] + ) + + # Now remove the original light + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "remove_light"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "remove_light" + assert result["data_schema"].schema["index"].options == { + "0": "Foyer Sconces ([02:08:01:01])", + "1": "Foyer Downlights ([02:08:01:02])", + } + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_INDEX: ["0"]} + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "controller_id": "main_controller", + "dimmers": [ + {"addr": "[02:08:01:02]", "name": "Foyer Downlights", "rate": 2.0}, + ], + "host": "192.168.0.1", + "keypads": [ + { + "addr": "[02:08:02:01]", + "name": "Foyer Keypad", + } + ], + "port": 1234, + } + + await hass.async_block_till_done() + + # Check the original entity was removed, with only the new entity left + assert hass.states.async_entity_ids("light") == unordered( + ["light.foyer_downlights"] + ) + + +async def test_options_add_remove_keypad_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homeworks: MagicMock +) -> None: + """Test options flow to add and remove a keypad.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "add_keypad"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "add_keypad" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_ADDR: "[02:08:03:01]", + CONF_NAME: "Hall Keypad", + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "controller_id": "main_controller", + "dimmers": [ + {"addr": "[02:08:01:01]", "name": "Foyer Sconces", "rate": 1.0}, + ], + "host": "192.168.0.1", + "keypads": [ + { + "addr": "[02:08:02:01]", + "name": "Foyer Keypad", + }, + {"addr": "[02:08:03:01]", "name": "Hall Keypad"}, + ], + "port": 1234, + } + + await hass.async_block_till_done() + + # Now remove the original keypad + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "remove_keypad"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "remove_keypad" + assert result["data_schema"].schema["index"].options == { + "0": "Foyer Keypad ([02:08:02:01])", + "1": "Hall Keypad ([02:08:03:01])", + } + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_INDEX: ["0"]} + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "controller_id": "main_controller", + "dimmers": [ + {"addr": "[02:08:01:01]", "name": "Foyer Sconces", "rate": 1.0}, + ], + "host": "192.168.0.1", + "keypads": [{"addr": "[02:08:03:01]", "name": "Hall Keypad"}], + "port": 1234, + } + await hass.async_block_till_done() + + +async def test_options_add_keypad_with_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homeworks: MagicMock +) -> None: + """Test options flow to add and remove a keypad.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "add_keypad"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "add_keypad" + + # Try an invalid address + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_ADDR: "[02:08:03:01", + CONF_NAME: "Hall Keypad", + }, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "add_keypad" + assert result["errors"] == {"base": "invalid_addr"} + + # Try an address claimed by another keypad + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_ADDR: "[02:08:02:01]", + CONF_NAME: "Hall Keypad", + }, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "add_keypad" + assert result["errors"] == {"base": "duplicated_addr"} + + # Try an address claimed by a light + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_ADDR: "[02:08:01:01]", + CONF_NAME: "Hall Keypad", + }, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "add_keypad" + assert result["errors"] == {"base": "duplicated_addr"} + + +async def test_options_edit_light_no_lights_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homeworks: MagicMock +) -> None: + """Test options flow to edit a light.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.async_entity_ids("light") == unordered(["light.foyer_sconces"]) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "select_edit_light"} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "select_edit_light" + assert result["data_schema"].schema["index"].container == { + "0": "Foyer Sconces ([02:08:01:01])" + } + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"index": "0"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "edit_light" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_RATE: 3.0} + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "controller_id": "main_controller", + "dimmers": [{"addr": "[02:08:01:01]", "name": "Foyer Sconces", "rate": 3.0}], + "host": "192.168.0.1", + "keypads": [ + { + "addr": "[02:08:02:01]", + "name": "Foyer Keypad", + } + ], + "port": 1234, + } + + await hass.async_block_till_done() + + # Check the entity was updated + assert len(hass.states.async_entity_ids("light")) == 1 + + +async def test_options_edit_light_flow_empty( + hass: HomeAssistant, + mock_empty_config_entry: MockConfigEntry, + mock_homeworks: MagicMock, +) -> None: + """Test options flow to edit a light.""" + mock_empty_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_empty_config_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.async_entity_ids("light") == unordered([]) + + result = await hass.config_entries.options.async_init( + mock_empty_config_entry.entry_id + ) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"next_step_id": "select_edit_light"} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "select_edit_light" + assert result["data_schema"].schema["index"].container == {}