diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index b9f0ec1aaca773cb1ea52ff027b4c80ceeca1582..49f5d1df249d0d294bda1c12312311e2385181b5 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING, Any +from zigpy import types from zigpy.exceptions import ZigbeeException import zigpy.zcl @@ -126,12 +127,135 @@ class SmartThingsAcceleration(ZigbeeChannel): ) -@registries.CHANNEL_ONLY_CLUSTERS.register(0xFC31) @registries.CLIENT_CHANNELS_REGISTRY.register(0xFC31) -class InovelliCluster(ClientChannel): - """Inovelli Button Press Event channel.""" +class InovelliNotificationChannel(ClientChannel): + """Inovelli Notification channel.""" + + @callback + def attribute_updated(self, attrid, value): + """Handle an attribute updated on this cluster.""" + + @callback + def cluster_command(self, tsn, command_id, args): + """Handle a cluster command received on this cluster.""" + + +@registries.ZIGBEE_CHANNEL_REGISTRY.register(0xFC31) +class InovelliConfigEntityChannel(ZigbeeChannel): + """Inovelli Configuration Entity channel.""" + + class LEDEffectType(types.enum8): + """Effect type for Inovelli Blue Series switch.""" + + Off = 0x00 + Solid = 0x01 + Fast_Blink = 0x02 + Slow_Blink = 0x03 + Pulse = 0x04 + Chase = 0x05 + Open_Close = 0x06 + Small_To_Big = 0x07 + Clear = 0xFF REPORT_CONFIG = () + ZCL_INIT_ATTRS = { # pylint: disable=invalid-name + "dimming_speed_up_remote": False, + "dimming_speed_up_local": False, + "ramp_rate_off_to_on_local": False, + "ramp_rate_off_to_on_remote": False, + "dimming_speed_down_remote": False, + "dimming_speed_down_local": False, + "ramp_rate_on_to_off_local": False, + "ramp_rate_on_to_off_remote": False, + "minimum_level": False, + "maximum_level": False, + "invert_switch": False, + "auto_off_timer": False, + "default_level_local": False, + "default_level_remote": False, + "state_after_power_restored": False, + "load_level_indicator_timeout": False, + "active_power_reports": False, + "periodic_power_and_energy_reports": False, + "active_energy_reports": False, + "power_type": False, + "switch_type": False, + "button_delay": False, + "device_bind_number": False, + "smart_bulb_mode": False, + "double_tap_up_for_full_brightness": False, + "default_led1_strip_color_when_on": False, + "default_led1_strip_color_when_off": False, + "default_led1_strip_intensity_when_on": False, + "default_led1_strip_intensity_when_off": False, + "default_led2_strip_color_when_on": False, + "default_led2_strip_color_when_off": False, + "default_led2_strip_intensity_when_on": False, + "default_led2_strip_intensity_when_off": False, + "default_led3_strip_color_when_on": False, + "default_led3_strip_color_when_off": False, + "default_led3_strip_intensity_when_on": False, + "default_led3_strip_intensity_when_off": False, + "default_led4_strip_color_when_on": False, + "default_led4_strip_color_when_off": False, + "default_led4_strip_intensity_when_on": False, + "default_led4_strip_intensity_when_off": False, + "default_led5_strip_color_when_on": False, + "default_led5_strip_color_when_off": False, + "default_led5_strip_intensity_when_on": False, + "default_led5_strip_intensity_when_off": False, + "default_led6_strip_color_when_on": False, + "default_led6_strip_color_when_off": False, + "default_led6_strip_intensity_when_on": False, + "default_led6_strip_intensity_when_off": False, + "default_led7_strip_color_when_on": False, + "default_led7_strip_color_when_off": False, + "default_led7_strip_intensity_when_on": False, + "default_led7_strip_intensity_when_off": False, + "led_color_when_on": False, + "led_color_when_off": False, + "led_intensity_when_on": False, + "led_intensity_when_off": False, + "local_protection": False, + "remote_protection": False, + "output_mode": False, + "on_off_led_mode": False, + "firmware_progress_led": False, + "relay_click_in_on_off_mode": False, + } + + async def issue_all_led_effect( + self, + effect_type: LEDEffectType | int = LEDEffectType.Fast_Blink, + color: int = 200, + level: int = 100, + duration: int = 3, + **kwargs: Any, + ) -> None: + """Issue all LED effect command. + + This command is used to issue an LED effect to all LEDs on the device. + """ + + await self.led_effect(effect_type, color, level, duration, expect_reply=False) + + async def issue_individual_led_effect( + self, + led_number: int = 1, + effect_type: LEDEffectType | int = LEDEffectType.Fast_Blink, + color: int = 200, + level: int = 100, + duration: int = 3, + **kwargs: Any, + ) -> None: + """Issue individual LED effect command. + + This command is used to issue an LED effect to the specified LED on the device. + """ + + await self.individual_led_effect( + led_number, effect_type, color, level, duration, expect_reply=False + ) @registries.CHANNEL_ONLY_CLUSTERS.register(registries.IKEA_AIR_PURIFIER_CLUSTER) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 311d3d48b5b100636d7c98471c2d9a4efbf401c5..0204fb50bed5b5ec14a1f980043f8cc9aed03256 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -95,6 +95,7 @@ CHANNEL_TEMPERATURE = "temperature" CHANNEL_THERMOSTAT = "thermostat" CHANNEL_ZDO = "zdo" CHANNEL_ZONE = ZONE = "ias_zone" +CHANNEL_INOVELLI = "inovelli_vzm31sn_cluster" CLUSTER_COMMAND_SERVER = "server" CLUSTER_COMMANDS_CLIENT = "client_commands" diff --git a/homeassistant/components/zha/device_action.py b/homeassistant/components/zha/device_action.py index 3ee8694b09cbdca18cb6c5a0ef3b8e8f50fc21e1..1cb988b1c1513f57c8e262e9578b3d8eddd386d2 100644 --- a/homeassistant/components/zha/device_action.py +++ b/homeassistant/components/zha/device_action.py @@ -5,6 +5,7 @@ from typing import Any import voluptuous as vol +from homeassistant.components.device_automation import InvalidDeviceAutomationConfig from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import config_validation as cv @@ -12,7 +13,8 @@ from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN from .api import SERVICE_WARNING_DEVICE_SQUAWK, SERVICE_WARNING_DEVICE_WARN -from .core.const import CHANNEL_IAS_WD +from .core.channels.manufacturerspecific import InovelliConfigEntityChannel +from .core.const import CHANNEL_IAS_WD, CHANNEL_INOVELLI from .core.helpers import async_get_zha_device # mypy: disallow-any-generics @@ -23,21 +25,83 @@ ATTR_DATA = "data" ATTR_IEEE = "ieee" CONF_ZHA_ACTION_TYPE = "zha_action_type" ZHA_ACTION_TYPE_SERVICE_CALL = "service_call" +ZHA_ACTION_TYPE_CHANNEL_COMMAND = "channel_command" +INOVELLI_ALL_LED_EFFECT = "issue_all_led_effect" +INOVELLI_INDIVIDUAL_LED_EFFECT = "issue_individual_led_effect" + +DEFAULT_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_DOMAIN): DOMAIN, + vol.Required(CONF_TYPE): vol.In({ACTION_SQUAWK, ACTION_WARN}), + } +) + +INOVELLI_ALL_LED_EFFECT_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): INOVELLI_ALL_LED_EFFECT, + vol.Required(CONF_DOMAIN): DOMAIN, + vol.Required( + "effect_type" + ): InovelliConfigEntityChannel.LEDEffectType.__getitem__, + vol.Required("color"): vol.All(vol.Coerce(int), vol.Range(0, 255)), + vol.Required("level"): vol.All(vol.Coerce(int), vol.Range(0, 100)), + vol.Required("duration"): vol.All(vol.Coerce(int), vol.Range(1, 255)), + } +) + +INOVELLI_INDIVIDUAL_LED_EFFECT_SCHEMA = INOVELLI_ALL_LED_EFFECT_SCHEMA.extend( + { + vol.Required(CONF_TYPE): INOVELLI_INDIVIDUAL_LED_EFFECT, + vol.Required("led_number"): vol.All(vol.Coerce(int), vol.Range(1, 7)), + } +) -ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( - {vol.Required(CONF_DOMAIN): DOMAIN, vol.Required(CONF_TYPE): str} +ACTION_SCHEMA = vol.Any( + INOVELLI_ALL_LED_EFFECT_SCHEMA, + INOVELLI_INDIVIDUAL_LED_EFFECT_SCHEMA, + DEFAULT_ACTION_SCHEMA, ) DEVICE_ACTIONS = { CHANNEL_IAS_WD: [ {CONF_TYPE: ACTION_SQUAWK, CONF_DOMAIN: DOMAIN}, {CONF_TYPE: ACTION_WARN, CONF_DOMAIN: DOMAIN}, - ] + ], + CHANNEL_INOVELLI: [ + {CONF_TYPE: INOVELLI_ALL_LED_EFFECT, CONF_DOMAIN: DOMAIN}, + {CONF_TYPE: INOVELLI_INDIVIDUAL_LED_EFFECT, CONF_DOMAIN: DOMAIN}, + ], } DEVICE_ACTION_TYPES = { ACTION_SQUAWK: ZHA_ACTION_TYPE_SERVICE_CALL, ACTION_WARN: ZHA_ACTION_TYPE_SERVICE_CALL, + INOVELLI_ALL_LED_EFFECT: ZHA_ACTION_TYPE_CHANNEL_COMMAND, + INOVELLI_INDIVIDUAL_LED_EFFECT: ZHA_ACTION_TYPE_CHANNEL_COMMAND, +} + +DEVICE_ACTION_SCHEMAS = { + INOVELLI_ALL_LED_EFFECT: vol.Schema( + { + vol.Required("effect_type"): vol.In( + InovelliConfigEntityChannel.LEDEffectType.__members__.keys() + ), + vol.Required("color"): vol.All(vol.Coerce(int), vol.Range(0, 255)), + vol.Required("level"): vol.All(vol.Coerce(int), vol.Range(0, 100)), + vol.Required("duration"): vol.All(vol.Coerce(int), vol.Range(1, 255)), + } + ), + INOVELLI_INDIVIDUAL_LED_EFFECT: vol.Schema( + { + vol.Required("led_number"): vol.All(vol.Coerce(int), vol.Range(1, 7)), + vol.Required("effect_type"): vol.In( + InovelliConfigEntityChannel.LEDEffectType.__members__.keys() + ), + vol.Required("color"): vol.All(vol.Coerce(int), vol.Range(0, 255)), + vol.Required("level"): vol.All(vol.Coerce(int), vol.Range(0, 100)), + vol.Required("duration"): vol.All(vol.Coerce(int), vol.Range(1, 255)), + } + ), } SERVICE_NAMES = { @@ -45,6 +109,11 @@ SERVICE_NAMES = { ACTION_WARN: SERVICE_WARNING_DEVICE_WARN, } +CHANNEL_MAPPINGS = { + INOVELLI_ALL_LED_EFFECT: CHANNEL_INOVELLI, + INOVELLI_INDIVIDUAL_LED_EFFECT: CHANNEL_INOVELLI, +} + async def async_call_action_from_config( hass: HomeAssistant, @@ -82,6 +151,14 @@ async def async_get_actions( return actions +async def async_get_action_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: + """List action capabilities.""" + + return {"extra_fields": DEVICE_ACTION_SCHEMAS.get(config[CONF_TYPE], {})} + + async def _execute_service_based_action( hass: HomeAssistant, config: dict[str, Any], @@ -102,4 +179,40 @@ async def _execute_service_based_action( ) -ZHA_ACTION_TYPES = {ZHA_ACTION_TYPE_SERVICE_CALL: _execute_service_based_action} +async def _execute_channel_command_based_action( + hass: HomeAssistant, + config: dict[str, Any], + variables: TemplateVarsType, + context: Context | None, +) -> None: + action_type = config[CONF_TYPE] + channel_name = CHANNEL_MAPPINGS[action_type] + try: + zha_device = async_get_zha_device(hass, config[CONF_DEVICE_ID]) + except (KeyError, AttributeError): + return + + action_channel = None + for pool in zha_device.channels.pools: + for channel in pool.all_channels.values(): + if channel.name == channel_name: + action_channel = channel + break + + if action_channel is None: + raise InvalidDeviceAutomationConfig( + f"Unable to execute channel action - channel: {channel_name} action: {action_type}" + ) + + if not hasattr(action_channel, action_type): + raise InvalidDeviceAutomationConfig( + f"Unable to execute channel action - channel: {channel_name} action: {action_type}" + ) + + await getattr(action_channel, action_type)(**config) + + +ZHA_ACTION_TYPES = { + ZHA_ACTION_TYPE_SERVICE_CALL: _execute_service_based_action, + ZHA_ACTION_TYPE_CHANNEL_COMMAND: _execute_channel_command_based_action, +} diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index ae4bc7e5ea41517bf8d0703fc8f6060cf3dfecef..95a15778fe7ed459383c584e061e5c9e8e472960 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -66,6 +66,8 @@ class BaseZhaEntity(LogMixin, entity.Entity): @property def name(self) -> str: """Return Entity's default name.""" + if hasattr(self, "_attr_name") and self._attr_name is not None: + return self._attr_name return self._name @property diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 14967c1b91c2f0e5146b8d324b4459559664e275..6fe411abfb37b92e33ff3526edf25bf795b8c672 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -19,6 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( CHANNEL_ANALOG_OUTPUT, + CHANNEL_INOVELLI, CHANNEL_LEVEL, DATA_ZHA, SIGNAL_ADD_ENTITIES, @@ -251,6 +252,8 @@ ICONS = { 12: "mdi:counter", 13: "mdi:thermometer-lines", 14: "mdi:timer", + 15: "mdi:palette", + 16: "mdi:brightness-percent", } @@ -545,3 +548,252 @@ class FilterLifeTime(ZHANumberConfigurationEntity, id_suffix="filter_life_time") _attr_native_max_value: float = 0xFFFFFFFF _attr_native_unit_of_measurement: str | None = UNITS[72] _zcl_attribute: str = "filter_life_time" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +class InovelliRemoteDimmingUpSpeed( + ZHANumberConfigurationEntity, id_suffix="dimming_speed_up_remote" +): + """Inovelli remote dimming up speed configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[3] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 126 + _zcl_attribute: str = "dimming_speed_up_remote" + _attr_name: str = "Remote dimming up speed" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +class InovelliButtonDelay(ZHANumberConfigurationEntity, id_suffix="button_delay"): + """Inovelli button delay configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[3] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 9 + _zcl_attribute: str = "button_delay" + _attr_name: str = "Button delay" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +class InovelliDeviceBindNumber( + ZHANumberConfigurationEntity, id_suffix="device_bind_number" +): + """Inovelli device bind number configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 255 + _zcl_attribute: str = "device_bind_number" + _attr_name: str = "Device bind number" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +class InovelliLocalDimmingUpSpeed( + ZHANumberConfigurationEntity, id_suffix="dimming_speed_up_local" +): + """Inovelli local dimming up speed configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[3] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 127 + _zcl_attribute: str = "dimming_speed_up_local" + _attr_name: str = "Local dimming up speed" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +class InovelliLocalRampRateOffToOn( + ZHANumberConfigurationEntity, id_suffix="ramp_rate_off_to_on_local" +): + """Inovelli off to on local ramp rate configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[3] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 127 + _zcl_attribute: str = "ramp_rate_off_to_on_local" + _attr_name: str = "Local ramp rate off to on" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +class InovelliRemoteDimmingSpeedOffToOn( + ZHANumberConfigurationEntity, id_suffix="ramp_rate_off_to_on_remote" +): + """Inovelli off to on remote ramp rate configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[3] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 127 + _zcl_attribute: str = "ramp_rate_off_to_on_remote" + _attr_name: str = "Remote ramp rate off to on" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +class InovelliRemoteDimmingDownSpeed( + ZHANumberConfigurationEntity, id_suffix="dimming_speed_down_remote" +): + """Inovelli remote dimming down speed configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[3] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 127 + _zcl_attribute: str = "dimming_speed_down_remote" + _attr_name: str = "Remote dimming down speed" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +class InovelliLocalDimmingDownSpeed( + ZHANumberConfigurationEntity, id_suffix="dimming_speed_down_local" +): + """Inovelli local dimming down speed configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[3] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 127 + _zcl_attribute: str = "dimming_speed_down_local" + _attr_name: str = "Local dimming down speed" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +class InovelliLocalRampRateOnToOff( + ZHANumberConfigurationEntity, id_suffix="ramp_rate_on_to_off_local" +): + """Inovelli local on to off ramp rate configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[3] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 127 + _zcl_attribute: str = "ramp_rate_on_to_off_local" + _attr_name: str = "Local ramp rate on to off" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +class InovelliRemoteDimmingSpeedOnToOff( + ZHANumberConfigurationEntity, id_suffix="ramp_rate_on_to_off_remote" +): + """Inovelli remote on to off ramp rate configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[3] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 127 + _zcl_attribute: str = "ramp_rate_on_to_off_remote" + _attr_name: str = "Remote ramp rate on to off" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +class InovelliMinimumLoadDimmingLevel( + ZHANumberConfigurationEntity, id_suffix="minimum_level" +): + """Inovelli minimum load dimming level configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[16] + _attr_native_min_value: float = 1 + _attr_native_max_value: float = 254 + _zcl_attribute: str = "minimum_level" + _attr_name: str = "Minimum load dimming level" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +class InovelliMaximumLoadDimmingLevel( + ZHANumberConfigurationEntity, id_suffix="maximum_level" +): + """Inovelli maximum load dimming level configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[16] + _attr_native_min_value: float = 2 + _attr_native_max_value: float = 255 + _zcl_attribute: str = "maximum_level" + _attr_name: str = "Maximum load dimming level" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +class InovelliAutoShutoffTimer( + ZHANumberConfigurationEntity, id_suffix="auto_off_timer" +): + """Inovelli automatic switch shutoff timer configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[14] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 32767 + _zcl_attribute: str = "auto_off_timer" + _attr_name: str = "Automatic switch shutoff timer" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +class InovelliLoadLevelIndicatorTimeout( + ZHANumberConfigurationEntity, id_suffix="load_level_indicator_timeout" +): + """Inovelli load level indicator timeout configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[14] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 11 + _zcl_attribute: str = "load_level_indicator_timeout" + _attr_name: str = "Load level indicator timeout" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +class InovelliDefaultAllLEDOnColor( + ZHANumberConfigurationEntity, id_suffix="led_color_when_on" +): + """Inovelli default all led color when on configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[15] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 255 + _zcl_attribute: str = "led_color_when_on" + _attr_name: str = "Default all LED on color" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +class InovelliDefaultAllLEDOffColor( + ZHANumberConfigurationEntity, id_suffix="led_color_when_off" +): + """Inovelli default all led color when off configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[15] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 255 + _zcl_attribute: str = "led_color_when_off" + _attr_name: str = "Default all LED off color" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +class InovelliDefaultAllLEDOnIntensity( + ZHANumberConfigurationEntity, id_suffix="led_intensity_when_on" +): + """Inovelli default all led intensity when on configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[16] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 100 + _zcl_attribute: str = "led_intensity_when_on" + _attr_name: str = "Default all LED on intensity" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_INOVELLI) +class InovelliDefaultAllLEDOffIntensity( + ZHANumberConfigurationEntity, id_suffix="led_intensity_when_off" +): + """Inovelli default all led intensity when off configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[16] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 100 + _zcl_attribute: str = "led_intensity_when_off" + _attr_name: str = "Default all LED off intensity" diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 503c5a013a86984c7b40be0a459d6f85a0866385..8b2623b4de1e807c4000cf154a33f8b1731df9ac 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -21,6 +21,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( CHANNEL_IAS_WD, + CHANNEL_INOVELLI, CHANNEL_ON_OFF, DATA_ZHA, SIGNAL_ADD_ENTITIES, @@ -68,7 +69,7 @@ class ZHAEnumSelectEntity(ZhaEntity, SelectEntity): """Representation of a ZHA select entity.""" _attr_entity_category = EntityCategory.CONFIG - _attr_name: str + _attribute: str _enum: type[Enum] def __init__( @@ -79,7 +80,7 @@ class ZHAEnumSelectEntity(ZhaEntity, SelectEntity): **kwargs: Any, ) -> None: """Init this select entity.""" - self._attr_name = self._enum.__name__ + self._attribute = self._enum.__name__ self._attr_options = [entry.name.replace("_", " ") for entry in self._enum] self._channel: ZigbeeChannel = channels[0] super().__init__(unique_id, zha_device, channels, **kwargs) @@ -87,21 +88,21 @@ class ZHAEnumSelectEntity(ZhaEntity, SelectEntity): @property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" - option = self._channel.data_cache.get(self._attr_name) + option = self._channel.data_cache.get(self._attribute) if option is None: return None return option.name.replace("_", " ") async def async_select_option(self, option: str) -> None: """Change the selected option.""" - self._channel.data_cache[self._attr_name] = self._enum[option.replace(" ", "_")] + self._channel.data_cache[self._attribute] = self._enum[option.replace(" ", "_")] self.async_write_ha_state() @callback def async_restore_last_state(self, last_state) -> None: """Restore previous state.""" if last_state.state and last_state.state != STATE_UNKNOWN: - self._channel.data_cache[self._attr_name] = self._enum[ + self._channel.data_cache[self._attribute] = self._enum[ last_state.state.replace(" ", "_") ] @@ -285,3 +286,40 @@ class AqaraCurtainMode(ZCLEnumSelectEntity, id_suffix="window_covering_mode"): _select_attr = "window_covering_mode" _enum = AqaraE1ReverseDirection + + +class InovelliOutputMode(types.enum1): + """Inovelli output mode.""" + + Dimmer = 0x00 + OnOff = 0x01 + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names=CHANNEL_INOVELLI, +) +class InovelliOutputModeEntity(ZCLEnumSelectEntity, id_suffix="output_mode"): + """Inovelli output mode control.""" + + _select_attr = "output_mode" + _enum = InovelliOutputMode + _attr_name: str = "Output mode" + + +class InovelliSwitchType(types.enum8): + """Inovelli output mode.""" + + Load_Only = 0x00 + Three_Way_Dumb = 0x01 + Three_Way_AUX = 0x02 + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names=CHANNEL_INOVELLI, +) +class InovelliSwitchTypeEntity(ZCLEnumSelectEntity, id_suffix="switch_type"): + """Inovelli switch type control.""" + + _select_attr = "switch_type" + _enum = InovelliSwitchType + _attr_name: str = "Switch type" diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 1de5e164feeeff21801392bdfc005212ed8ff7b7..3901f9f94399b9b57bab6323b5cd31650a47fd32 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -160,7 +160,12 @@ } }, "device_automation": { - "action_type": { "squawk": "Squawk", "warn": "Warn" }, + "action_type": { + "squawk": "Squawk", + "warn": "Warn", + "issue_all_led_effect": "Issue effect for all LEDs", + "issue_individual_led_effect": "Issue effect for individual LED" + }, "trigger_type": { "remote_button_short_press": "\"{subtype}\" button pressed", "remote_button_short_release": "\"{subtype}\" button released", diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 752df6d568e7921d77705ee63c958fdca86597b9..47568648f2ba426aecb456a1895ee68c96df243a 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -19,6 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( + CHANNEL_INOVELLI, CHANNEL_ON_OFF, DATA_ZHA, SIGNAL_ADD_ENTITIES, @@ -309,3 +310,81 @@ class DisableLed(ZHASwitchConfigurationEntity, id_suffix="disable_led"): """ZHA BinarySensor.""" _zcl_attribute: str = "disable_led" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names=CHANNEL_INOVELLI, +) +class InovelliInvertSwitch(ZHASwitchConfigurationEntity, id_suffix="invert_switch"): + """Inovelli invert switch control.""" + + _zcl_attribute: str = "invert_switch" + _attr_name: str = "Invert switch" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names=CHANNEL_INOVELLI, +) +class InovelliSmartBulbMode(ZHASwitchConfigurationEntity, id_suffix="smart_bulb_mode"): + """Inovelli smart bulb mode control.""" + + _zcl_attribute: str = "smart_bulb_mode" + _attr_name: str = "Smart bulb mode" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names=CHANNEL_INOVELLI, +) +class InovelliDoubleTapForFullBrightness( + ZHASwitchConfigurationEntity, id_suffix="double_tap_up_for_full_brightness" +): + """Inovelli double tap for full brightness control.""" + + _zcl_attribute: str = "double_tap_up_for_full_brightness" + _attr_name: str = "Double tap full brightness" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names=CHANNEL_INOVELLI, +) +class InovelliLocalProtection( + ZHASwitchConfigurationEntity, id_suffix="local_protection" +): + """Inovelli local protection control.""" + + _zcl_attribute: str = "local_protection" + _attr_name: str = "Local protection" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names=CHANNEL_INOVELLI, +) +class InovelliOnOffLEDMode(ZHASwitchConfigurationEntity, id_suffix="on_off_led_mode"): + """Inovelli only 1 LED mode control.""" + + _zcl_attribute: str = "on_off_led_mode" + _attr_name: str = "Only 1 LED mode" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names=CHANNEL_INOVELLI, +) +class InovelliFirmwareProgressLED( + ZHASwitchConfigurationEntity, id_suffix="firmware_progress_led" +): + """Inovelli firmware progress LED control.""" + + _zcl_attribute: str = "firmware_progress_led" + _attr_name: str = "Firmware progress LED" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names=CHANNEL_INOVELLI, +) +class InovelliRelayClickInOnOffMode( + ZHASwitchConfigurationEntity, id_suffix="relay_click_in_on_off_mode" +): + """Inovelli relay click in on off mode control.""" + + _zcl_attribute: str = "relay_click_in_on_off_mode" + _attr_name: str = "Disable relay click in on off mode" diff --git a/homeassistant/components/zha/translations/en.json b/homeassistant/components/zha/translations/en.json index 1ba19bc0f9ebb87a885165298946fe5277537ab1..adf89983256695bf18b276d0e5efa56562abb7fd 100644 --- a/homeassistant/components/zha/translations/en.json +++ b/homeassistant/components/zha/translations/en.json @@ -117,7 +117,9 @@ "device_automation": { "action_type": { "squawk": "Squawk", - "warn": "Warn" + "warn": "Warn", + "issue_all_led_effect": "Issue effect for all LEDs", + "issue_individual_led_effect": "Issue effect for individual LED" }, "trigger_subtype": { "both_buttons": "Both buttons", diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index f24ec054c0b7fbd1c5ddce1ea9ed303c2653d135..e745856c34200a26590710c98fe9d891b2028216 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -1,7 +1,8 @@ """The test for zha device automation actions.""" -from unittest.mock import patch +from unittest.mock import call, patch import pytest +from zhaquirks.inovelli.VZM31SN import InovelliVZM31SNv11 import zigpy.profiles.zha import zigpy.zcl.clusters.general as general import zigpy.zcl.clusters.security as security @@ -16,7 +17,12 @@ from homeassistant.setup import async_setup_component from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE -from tests.common import async_get_device_automations, async_mock_service, mock_coro +from tests.common import ( + assert_lists_same, + async_get_device_automations, + async_mock_service, + mock_coro, +) from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 SHORT_PRESS = "remote_button_short_press" @@ -31,10 +37,13 @@ def required_platforms_only(): "homeassistant.components.zha.PLATFORMS", ( Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.DEVICE_TRACKER, + Platform.LIGHT, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SWITCH, Platform.SIREN, ), ): @@ -62,6 +71,36 @@ async def device_ias(hass, zigpy_device_mock, zha_device_joined_restored): return zigpy_device, zha_device +@pytest.fixture +async def device_inovelli(hass, zigpy_device_mock, zha_device_joined): + """Inovelli device fixture.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.Basic.cluster_id, + general.Identify.cluster_id, + general.OnOff.cluster_id, + general.LevelControl.cluster_id, + 0xFC31, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.DIMMABLE_LIGHT, + } + }, + ieee="00:1d:8f:08:0c:90:69:6b", + manufacturer="Inovelli", + model="VZM31-SN", + quirk=InovelliVZM31SNv11, + ) + + zha_device = await zha_device_joined(zigpy_device) + zha_device.update_available(True) + await hass.async_block_till_done() + return zigpy_device, zha_device + + async def test_get_actions(hass, device_ias): """Test we get the expected actions from a zha device.""" @@ -112,21 +151,108 @@ async def test_get_actions(hass, device_ias): }, ] - assert actions == expected_actions + assert_lists_same(actions, expected_actions) -async def test_action(hass, device_ias): +async def test_get_inovelli_actions(hass, device_inovelli): + """Test we get the expected actions from a zha device.""" + + inovelli_ieee_address = str(device_inovelli[0].ieee) + ha_device_registry = dr.async_get(hass) + inovelli_reg_device = ha_device_registry.async_get_device( + {(DOMAIN, inovelli_ieee_address)} + ) + + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, inovelli_reg_device.id + ) + + expected_actions = [ + { + "device_id": inovelli_reg_device.id, + "domain": DOMAIN, + "metadata": {}, + "type": "issue_all_led_effect", + }, + { + "device_id": inovelli_reg_device.id, + "domain": DOMAIN, + "metadata": {}, + "type": "issue_individual_led_effect", + }, + { + "device_id": inovelli_reg_device.id, + "domain": Platform.BUTTON, + "entity_id": "button.inovelli_vzm31_sn_identifybutton", + "metadata": {"secondary": True}, + "type": "press", + }, + { + "device_id": inovelli_reg_device.id, + "domain": Platform.LIGHT, + "entity_id": "light.inovelli_vzm31_sn_light", + "metadata": {"secondary": False}, + "type": "turn_off", + }, + { + "device_id": inovelli_reg_device.id, + "domain": Platform.LIGHT, + "entity_id": "light.inovelli_vzm31_sn_light", + "metadata": {"secondary": False}, + "type": "turn_on", + }, + { + "device_id": inovelli_reg_device.id, + "domain": Platform.LIGHT, + "entity_id": "light.inovelli_vzm31_sn_light", + "metadata": {"secondary": False}, + "type": "toggle", + }, + { + "device_id": inovelli_reg_device.id, + "domain": Platform.LIGHT, + "entity_id": "light.inovelli_vzm31_sn_light", + "metadata": {"secondary": False}, + "type": "brightness_increase", + }, + { + "device_id": inovelli_reg_device.id, + "domain": Platform.LIGHT, + "entity_id": "light.inovelli_vzm31_sn_light", + "metadata": {"secondary": False}, + "type": "brightness_decrease", + }, + { + "device_id": inovelli_reg_device.id, + "domain": Platform.LIGHT, + "entity_id": "light.inovelli_vzm31_sn_light", + "metadata": {"secondary": False}, + "type": "flash", + }, + ] + + assert_lists_same(actions, expected_actions) + + +async def test_action(hass, device_ias, device_inovelli): """Test for executing a zha device action.""" zigpy_device, zha_device = device_ias + inovelli_zigpy_device, inovelli_zha_device = device_inovelli zigpy_device.device_automation_triggers = { (SHORT_PRESS, SHORT_PRESS): {COMMAND: COMMAND_SINGLE} } ieee_address = str(zha_device.ieee) + inovelli_ieee_address = str(inovelli_zha_device.ieee) ha_device_registry = dr.async_get(hass) reg_device = ha_device_registry.async_get_device({(DOMAIN, ieee_address)}) + inovelli_reg_device = ha_device_registry.async_get_device( + {(DOMAIN, inovelli_ieee_address)} + ) + + cluster = inovelli_zigpy_device.endpoints[1].in_clusters[0xFC31] with patch( "zigpy.zcl.Cluster.request", @@ -145,11 +271,32 @@ async def test_action(hass, device_ias): "type": SHORT_PRESS, "subtype": SHORT_PRESS, }, - "action": { - "domain": DOMAIN, - "device_id": reg_device.id, - "type": "warn", - }, + "action": [ + { + "domain": DOMAIN, + "device_id": reg_device.id, + "type": "warn", + }, + { + "domain": DOMAIN, + "device_id": inovelli_reg_device.id, + "type": "issue_all_led_effect", + "effect_type": "Open_Close", + "duration": 5, + "level": 10, + "color": 41, + }, + { + "domain": DOMAIN, + "device_id": inovelli_reg_device.id, + "type": "issue_individual_led_effect", + "effect_type": "Open_Close", + "led_number": 1, + "duration": 5, + "level": 10, + "color": 41, + }, + ], } ] }, @@ -167,6 +314,41 @@ async def test_action(hass, device_ias): assert calls[0].service == "warning_device_warn" assert calls[0].data["ieee"] == ieee_address + assert len(cluster.request.mock_calls) == 2 + assert ( + call( + False, + cluster.commands_by_name["led_effect"].id, + cluster.commands_by_name["led_effect"].schema, + 6, + 41, + 10, + 5, + expect_reply=False, + manufacturer=4151, + tries=1, + tsn=None, + ) + in cluster.request.call_args_list + ) + assert ( + call( + False, + cluster.commands_by_name["individual_led_effect"].id, + cluster.commands_by_name["individual_led_effect"].schema, + 1, + 6, + 41, + 10, + 5, + expect_reply=False, + manufacturer=4151, + tries=1, + tsn=None, + ) + in cluster.request.call_args_list + ) + async def test_invalid_zha_event_type(hass, device_ias): """Test that unexpected types are not passed to `zha_send_event`."""