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_DISPATCHERS,
DATA_ZHA_GATEWAY, DATA_ZHA_GATEWAY,
DATA_ZHA_PLATFORM_LOADED, DATA_ZHA_PLATFORM_LOADED,
DATA_ZHA_SHUTDOWN_TASK,
DOMAIN, DOMAIN,
PLATFORMS, PLATFORMS,
SIGNAL_ADD_ENTITIES, 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].shutdown()
await zha_data[DATA_ZHA_GATEWAY].async_update_device_storage() 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)) asyncio.create_task(async_load_entities(hass))
return True return True
@ -129,6 +132,7 @@ async def async_setup_entry(hass, config_entry):
async def async_unload_entry(hass, config_entry): async def async_unload_entry(hass, config_entry):
"""Unload ZHA config entry.""" """Unload ZHA config entry."""
await hass.data[DATA_ZHA][DATA_ZHA_GATEWAY].shutdown() await hass.data[DATA_ZHA][DATA_ZHA_GATEWAY].shutdown()
await hass.data[DATA_ZHA][DATA_ZHA_GATEWAY].async_update_device_storage()
GROUP_PROBE.cleanup() GROUP_PROBE.cleanup()
api.async_unload_api(hass) api.async_unload_api(hass)
@ -137,8 +141,15 @@ async def async_unload_entry(hass, config_entry):
for unsub_dispatcher in dispatchers: for unsub_dispatcher in dispatchers:
unsub_dispatcher() unsub_dispatcher()
for platform in PLATFORMS: # our components don't have unload methods so no need to look at return values
await hass.config_entries.async_forward_entry_unload(config_entry, platform) 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 return True

View file

@ -7,6 +7,7 @@ import logging
from typing import Any from typing import Any
import voluptuous as vol import voluptuous as vol
from zigpy.config.validators import cv_boolean
from zigpy.types.named import EUI64 from zigpy.types.named import EUI64
import zigpy.zdo.types as zdo_types import zigpy.zdo.types as zdo_types
@ -40,6 +41,7 @@ from .core.const import (
CLUSTER_COMMANDS_SERVER, CLUSTER_COMMANDS_SERVER,
CLUSTER_TYPE_IN, CLUSTER_TYPE_IN,
CLUSTER_TYPE_OUT, CLUSTER_TYPE_OUT,
CUSTOM_CONFIGURATION,
DATA_ZHA, DATA_ZHA,
DATA_ZHA_GATEWAY, DATA_ZHA_GATEWAY,
DOMAIN, DOMAIN,
@ -52,6 +54,7 @@ from .core.const import (
WARNING_DEVICE_SQUAWK_MODE_ARMED, WARNING_DEVICE_SQUAWK_MODE_ARMED,
WARNING_DEVICE_STROBE_HIGH, WARNING_DEVICE_STROBE_HIGH,
WARNING_DEVICE_STROBE_YES, WARNING_DEVICE_STROBE_YES,
ZHA_CONFIG_SCHEMAS,
) )
from .core.group import GroupMember from .core.group import GroupMember
from .core.helpers import ( 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,))) 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 @callback
def async_load_api(hass): def async_load_api(hass):
"""Set up the web socket API.""" """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_bind_devices)
websocket_api.async_register_command(hass, websocket_unbind_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_update_topology)
websocket_api.async_register_command(hass, websocket_get_configuration)
websocket_api.async_register_command(hass, websocket_update_zha_configuration)
@callback @callback

View file

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

View file

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

View file

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

View file

@ -24,7 +24,14 @@ import zigpy.zdo.types as zdo_types
from homeassistant.core import State, callback 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 .registries import BINDABLE_CLUSTERS
from .typing import ZhaDeviceType, ZigpyClusterType from .typing import ZhaDeviceType, ZigpyClusterType
@ -122,6 +129,16 @@ def async_is_bindable_target(source_zha_device, target_zha_device):
return False 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): async def async_get_zha_device(hass, device_id):
"""Get a ZHA device for the given device registry id.""" """Get a ZHA device for the given device registry id."""
device_registry = await hass.helpers.device_registry.async_get_registry() device_registry = await hass.helpers.device_registry.async_get_registry()

View file

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