Add config flow to homeworks (#112042)

This commit is contained in:
Erik Montnemery 2024-03-04 19:09:39 +01:00 committed by GitHub
parent b5528de807
commit 7e7f25c859
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1343 additions and 61 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -224,6 +224,7 @@ FLOWS = {
"homekit_controller",
"homematicip_cloud",
"homewizard",
"homeworks",
"honeywell",
"huawei_lte",
"hue",

View File

@ -3370,7 +3370,7 @@
},
"homeworks": {
"integration_type": "hub",
"config_flow": false,
"config_flow": true,
"iot_class": "local_push",
"name": "Lutron Homeworks"
}

View File

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

View File

@ -0,0 +1 @@
"""Tests for the Lutron Homeworks Series 4 and 8 integration."""

View File

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

View File

@ -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 == {}