diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 707e0292c45..43b95a9c2f2 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -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 diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index 7e265d03c09..b5b29534ed9 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -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 diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 2c968a5f02d..f43d9febc55 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -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.""" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 65605b2f7a3..ab3c9b3b9e6 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -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 ) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 96e4a7c3eb8..4a9e6c28203 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -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, diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index cf3d040f020..f8fb12e1596 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -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() diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 72807458d26..6701a9bb3c7 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -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."""