Add support for custom configurations in ZHA (#48423)

* initial configuration options

* first crack at saving the data

* constants

* implement initial options

* make more dynamic

* fix unload and reload of the config entry

* update unload
This commit is contained in:
David F. Mulcahey 2021-04-12 07:08:42 -04:00 committed by GitHub
parent 9c11f6547a
commit fe80afdb86
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 142 additions and 12 deletions

View file

@ -29,6 +29,7 @@ from .core.const import (
DATA_ZHA_DISPATCHERS,
DATA_ZHA_GATEWAY,
DATA_ZHA_PLATFORM_LOADED,
DATA_ZHA_SHUTDOWN_TASK,
DOMAIN,
PLATFORMS,
SIGNAL_ADD_ENTITIES,
@ -121,7 +122,9 @@ async def async_setup_entry(hass, config_entry):
await zha_data[DATA_ZHA_GATEWAY].shutdown()
await zha_data[DATA_ZHA_GATEWAY].async_update_device_storage()
hass.bus.async_listen_once(ha_const.EVENT_HOMEASSISTANT_STOP, async_zha_shutdown)
zha_data[DATA_ZHA_SHUTDOWN_TASK] = hass.bus.async_listen_once(
ha_const.EVENT_HOMEASSISTANT_STOP, async_zha_shutdown
)
asyncio.create_task(async_load_entities(hass))
return True
@ -129,6 +132,7 @@ async def async_setup_entry(hass, config_entry):
async def async_unload_entry(hass, config_entry):
"""Unload ZHA config entry."""
await hass.data[DATA_ZHA][DATA_ZHA_GATEWAY].shutdown()
await hass.data[DATA_ZHA][DATA_ZHA_GATEWAY].async_update_device_storage()
GROUP_PROBE.cleanup()
api.async_unload_api(hass)
@ -137,8 +141,15 @@ async def async_unload_entry(hass, config_entry):
for unsub_dispatcher in dispatchers:
unsub_dispatcher()
for platform in PLATFORMS:
await hass.config_entries.async_forward_entry_unload(config_entry, platform)
# our components don't have unload methods so no need to look at return values
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(config_entry, platform)
for platform in PLATFORMS
]
)
hass.data[DATA_ZHA][DATA_ZHA_SHUTDOWN_TASK]()
return True

View file

@ -7,6 +7,7 @@ import logging
from typing import Any
import voluptuous as vol
from zigpy.config.validators import cv_boolean
from zigpy.types.named import EUI64
import zigpy.zdo.types as zdo_types
@ -40,6 +41,7 @@ from .core.const import (
CLUSTER_COMMANDS_SERVER,
CLUSTER_TYPE_IN,
CLUSTER_TYPE_OUT,
CUSTOM_CONFIGURATION,
DATA_ZHA,
DATA_ZHA_GATEWAY,
DOMAIN,
@ -52,6 +54,7 @@ from .core.const import (
WARNING_DEVICE_SQUAWK_MODE_ARMED,
WARNING_DEVICE_STROBE_HIGH,
WARNING_DEVICE_STROBE_YES,
ZHA_CONFIG_SCHEMAS,
)
from .core.group import GroupMember
from .core.helpers import (
@ -882,6 +885,63 @@ async def async_binding_operation(zha_gateway, source_ieee, target_ieee, operati
zdo.debug(fmt, *(log_msg[2] + (outcome,)))
@websocket_api.require_admin
@websocket_api.async_response
@websocket_api.websocket_command({vol.Required(TYPE): "zha/configuration"})
async def websocket_get_configuration(hass, connection, msg):
"""Get ZHA configuration."""
zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
import voluptuous_serialize # pylint: disable=import-outside-toplevel
def custom_serializer(schema: Any) -> Any:
"""Serialize additional types for voluptuous_serialize."""
if schema is cv_boolean:
return {"type": "bool"}
if schema is vol.Schema:
return voluptuous_serialize.convert(
schema, custom_serializer=custom_serializer
)
return cv.custom_serializer(schema)
data = {"schemas": {}, "data": {}}
for section, schema in ZHA_CONFIG_SCHEMAS.items():
data["schemas"][section] = voluptuous_serialize.convert(
schema, custom_serializer=custom_serializer
)
data["data"][section] = zha_gateway.config_entry.options.get(
CUSTOM_CONFIGURATION, {}
).get(section, {})
connection.send_result(msg[ID], data)
@websocket_api.require_admin
@websocket_api.async_response
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zha/configuration/update",
vol.Required("data"): ZHA_CONFIG_SCHEMAS,
}
)
async def websocket_update_zha_configuration(hass, connection, msg):
"""Update the ZHA configuration."""
zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
options = zha_gateway.config_entry.options
data_to_save = {**options, **{CUSTOM_CONFIGURATION: msg["data"]}}
_LOGGER.info(
"Updating ZHA custom configuration options from %s to %s",
options,
data_to_save,
)
hass.config_entries.async_update_entry(
zha_gateway.config_entry, options=data_to_save
)
status = await hass.config_entries.async_reload(zha_gateway.config_entry.entry_id)
connection.send_result(msg[ID], status)
@callback
def async_load_api(hass):
"""Set up the web socket API."""
@ -1189,6 +1249,8 @@ def async_load_api(hass):
websocket_api.async_register_command(hass, websocket_bind_devices)
websocket_api.async_register_command(hass, websocket_unbind_devices)
websocket_api.async_register_command(hass, websocket_update_topology)
websocket_api.async_register_command(hass, websocket_get_configuration)
websocket_api.async_register_command(hass, websocket_update_zha_configuration)
@callback

View file

@ -5,6 +5,7 @@ import enum
import logging
import bellows.zigbee.application
import voluptuous as vol
from zigpy.config import CONF_DEVICE_PATH # noqa: F401 # pylint: disable=unused-import
import zigpy_cc.zigbee.application
import zigpy_deconz.zigbee.application
@ -22,6 +23,7 @@ from homeassistant.components.lock import DOMAIN as LOCK
from homeassistant.components.number import DOMAIN as NUMBER
from homeassistant.components.sensor import DOMAIN as SENSOR
from homeassistant.components.switch import DOMAIN as SWITCH
import homeassistant.helpers.config_validation as cv
from .typing import CALLABLE_T
@ -118,13 +120,24 @@ PLATFORMS = (
CONF_BAUDRATE = "baudrate"
CONF_DATABASE = "database_path"
CONF_DEFAULT_LIGHT_TRANSITION = "default_light_transition"
CONF_DEVICE_CONFIG = "device_config"
CONF_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join"
CONF_ENABLE_QUIRKS = "enable_quirks"
CONF_FLOWCONTROL = "flow_control"
CONF_RADIO_TYPE = "radio_type"
CONF_USB_PATH = "usb_path"
CONF_ZIGPY = "zigpy_config"
CONF_ZHA_OPTIONS_SCHEMA = vol.Schema(
{
vol.Optional(CONF_DEFAULT_LIGHT_TRANSITION): cv.positive_int,
vol.Required(CONF_ENABLE_IDENTIFY_ON_JOIN, default=True): cv.boolean,
}
)
CUSTOM_CONFIGURATION = "custom_configuration"
DATA_DEVICE_CONFIG = "zha_device_config"
DATA_ZHA = "zha"
DATA_ZHA_CONFIG = "config"
@ -133,6 +146,7 @@ DATA_ZHA_CORE_EVENTS = "zha_core_events"
DATA_ZHA_DISPATCHERS = "zha_dispatchers"
DATA_ZHA_GATEWAY = "zha_gateway"
DATA_ZHA_PLATFORM_LOADED = "platform_loaded"
DATA_ZHA_SHUTDOWN_TASK = "zha_shutdown_task"
DEBUG_COMP_BELLOWS = "bellows"
DEBUG_COMP_ZHA = "homeassistant.components.zha"
@ -176,6 +190,9 @@ POWER_BATTERY_OR_UNKNOWN = "Battery or Unknown"
PRESET_SCHEDULE = "schedule"
PRESET_COMPLEX = "complex"
ZHA_OPTIONS = "zha_options"
ZHA_CONFIG_SCHEMAS = {ZHA_OPTIONS: CONF_ZHA_OPTIONS_SCHEMA}
class RadioType(enum.Enum):
"""Possible options for radio type."""

View file

@ -56,6 +56,7 @@ from .const import (
CLUSTER_COMMANDS_SERVER,
CLUSTER_TYPE_IN,
CLUSTER_TYPE_OUT,
CONF_ENABLE_IDENTIFY_ON_JOIN,
EFFECT_DEFAULT_VARIANT,
EFFECT_OKAY,
POWER_BATTERY_OR_UNKNOWN,
@ -66,7 +67,7 @@ from .const import (
UNKNOWN_MANUFACTURER,
UNKNOWN_MODEL,
)
from .helpers import LogMixin
from .helpers import LogMixin, async_get_zha_config_value
_LOGGER = logging.getLogger(__name__)
CONSIDER_UNAVAILABLE_MAINS = 60 * 60 * 2 # 2 hours
@ -395,13 +396,20 @@ class ZHADevice(LogMixin):
async def async_configure(self):
"""Configure the device."""
should_identify = async_get_zha_config_value(
self._zha_gateway.config_entry, CONF_ENABLE_IDENTIFY_ON_JOIN, True
)
self.debug("started configuration")
await self._channels.async_configure()
self.debug("completed configuration")
entry = self.gateway.zha_storage.async_create_or_update_device(self)
self.debug("stored in registry: %s", entry)
if self._channels.identify_ch is not None and not self.skip_configuration:
if (
should_identify
and self._channels.identify_ch is not None
and not self.skip_configuration
):
await self._channels.identify_ch.trigger_effect(
EFFECT_OKAY, EFFECT_DEFAULT_VARIANT
)

View file

@ -127,7 +127,7 @@ class ZHAGateway:
}
self.debug_enabled = False
self._log_relay_handler = LogRelayHandler(hass, self)
self._config_entry = config_entry
self.config_entry = config_entry
self._unsubs = []
async def async_initialize(self):
@ -139,7 +139,7 @@ class ZHAGateway:
self.ha_device_registry = await get_dev_reg(self._hass)
self.ha_entity_registry = await get_ent_reg(self._hass)
radio_type = self._config_entry.data[CONF_RADIO_TYPE]
radio_type = self.config_entry.data[CONF_RADIO_TYPE]
app_controller_cls = RadioType[radio_type].controller
self.radio_description = RadioType[radio_type].description
@ -150,7 +150,7 @@ class ZHAGateway:
os.path.join(self._hass.config.config_dir, DEFAULT_DATABASE_NAME),
)
app_config[CONF_DATABASE] = database
app_config[CONF_DEVICE] = self._config_entry.data[CONF_DEVICE]
app_config[CONF_DEVICE] = self.config_entry.data[CONF_DEVICE]
app_config = app_controller_cls.SCHEMA(app_config)
try:
@ -506,7 +506,7 @@ class ZHAGateway:
zha_device = ZHADevice.new(self._hass, zigpy_device, self, restored)
self._devices[zigpy_device.ieee] = zha_device
device_registry_device = self.ha_device_registry.async_get_or_create(
config_entry_id=self._config_entry.entry_id,
config_entry_id=self.config_entry.entry_id,
connections={(CONNECTION_ZIGBEE, str(zha_device.ieee))},
identifiers={(DOMAIN, str(zha_device.ieee))},
name=zha_device.name,

View file

@ -24,7 +24,14 @@ import zigpy.zdo.types as zdo_types
from homeassistant.core import State, callback
from .const import CLUSTER_TYPE_IN, CLUSTER_TYPE_OUT, DATA_ZHA, DATA_ZHA_GATEWAY
from .const import (
CLUSTER_TYPE_IN,
CLUSTER_TYPE_OUT,
CUSTOM_CONFIGURATION,
DATA_ZHA,
DATA_ZHA_GATEWAY,
ZHA_OPTIONS,
)
from .registries import BINDABLE_CLUSTERS
from .typing import ZhaDeviceType, ZigpyClusterType
@ -122,6 +129,16 @@ def async_is_bindable_target(source_zha_device, target_zha_device):
return False
@callback
def async_get_zha_config_value(config_entry, config_key, default):
"""Get the value for the specified configuration from the zha config entry."""
return (
config_entry.options.get(CUSTOM_CONFIGURATION, {})
.get(ZHA_OPTIONS, {})
.get(config_key, default)
)
async def async_get_zha_device(hass, device_id):
"""Get a ZHA device for the given device registry id."""
device_registry = await hass.helpers.device_registry.async_get_registry()

View file

@ -47,6 +47,7 @@ from .core.const import (
CHANNEL_COLOR,
CHANNEL_LEVEL,
CHANNEL_ON_OFF,
CONF_DEFAULT_LIGHT_TRANSITION,
DATA_ZHA,
DATA_ZHA_DISPATCHERS,
EFFECT_BLINK,
@ -56,7 +57,7 @@ from .core.const import (
SIGNAL_ATTR_UPDATED,
SIGNAL_SET_LEVEL,
)
from .core.helpers import LogMixin
from .core.helpers import LogMixin, async_get_zha_config_value
from .core.registries import ZHA_ENTITIES
from .core.typing import ZhaDeviceType
from .entity import ZhaEntity, ZhaGroupEntity
@ -139,6 +140,7 @@ class BaseLight(LogMixin, light.LightEntity):
self._level_channel = None
self._color_channel = None
self._identify_channel = None
self._default_transition = None
@property
def extra_state_attributes(self) -> dict[str, Any]:
@ -207,7 +209,13 @@ class BaseLight(LogMixin, light.LightEntity):
async def async_turn_on(self, **kwargs):
"""Turn the entity on."""
transition = kwargs.get(light.ATTR_TRANSITION)
duration = transition * 10 if transition else DEFAULT_TRANSITION
duration = (
transition * 10
if transition
else self._default_transition * 10
if self._default_transition
else DEFAULT_TRANSITION
)
brightness = kwargs.get(light.ATTR_BRIGHTNESS)
effect = kwargs.get(light.ATTR_EFFECT)
flash = kwargs.get(light.ATTR_FLASH)
@ -389,6 +397,10 @@ class Light(BaseLight, ZhaEntity):
if effect_list:
self._effect_list = effect_list
self._default_transition = async_get_zha_config_value(
zha_device.gateway.config_entry, CONF_DEFAULT_LIGHT_TRANSITION, 0
)
@callback
def async_set_state(self, attr_id, attr_name, value):
"""Set the state."""
@ -544,6 +556,9 @@ class LightGroup(BaseLight, ZhaGroupEntity):
self._color_channel = group.endpoint[Color.cluster_id]
self._identify_channel = group.endpoint[Identify.cluster_id]
self._debounced_member_refresh = None
self._default_transition = async_get_zha_config_value(
zha_device.gateway.config_entry, CONF_DEFAULT_LIGHT_TRANSITION, 0
)
async def async_added_to_hass(self):
"""Run when about to be added to hass."""