diff --git a/zha/__init__.py b/zha/__init__.py index 280b81d0..68b75cc9 100644 --- a/zha/__init__.py +++ b/zha/__init__.py @@ -1 +1,292 @@ -"""zigbee home automation.""" +"""Support for Zigbee Home Automation devices.""" + +import asyncio +import contextlib +import copy +import logging +import re + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TYPE, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import ConfigType +import voluptuous as vol +from zhaquirks import setup as setup_quirks +from zigpy.config import CONF_DATABASE, CONF_DEVICE, CONF_DEVICE_PATH +from zigpy.exceptions import NetworkSettingsInconsistent, TransientConnectionError + +from . import repairs, websocket_api +from .core import ZHAGateway +from .core.const import ( + BAUD_RATES, + CONF_BAUDRATE, + CONF_CUSTOM_QUIRKS_PATH, + CONF_DEVICE_CONFIG, + CONF_ENABLE_QUIRKS, + CONF_FLOW_CONTROL, + CONF_RADIO_TYPE, + CONF_USB_PATH, + CONF_ZIGPY, + DATA_ZHA, + DOMAIN, + PLATFORMS, + SIGNAL_ADD_ENTITIES, + RadioType, +) +from .core.device import get_device_automation_triggers +from .core.discovery import GROUP_PROBE +from .core.helpers import ZHAData, get_zha_data +from .radio_manager import ZhaRadioManager +from .repairs.network_settings_inconsistent import warn_on_inconsistent_network_settings +from .repairs.wrong_silabs_firmware import ( + AlreadyRunningEZSP, + warn_on_wrong_silabs_firmware, +) + +DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({vol.Optional(CONF_TYPE): cv.string}) +ZHA_CONFIG_SCHEMA = { + vol.Optional(CONF_BAUDRATE): cv.positive_int, + vol.Optional(CONF_DATABASE): cv.string, + vol.Optional(CONF_DEVICE_CONFIG, default={}): vol.Schema( + {cv.string: DEVICE_CONFIG_SCHEMA_ENTRY} + ), + vol.Optional(CONF_ENABLE_QUIRKS, default=True): cv.boolean, + vol.Optional(CONF_ZIGPY): dict, + vol.Optional(CONF_RADIO_TYPE): cv.enum(RadioType), + vol.Optional(CONF_USB_PATH): cv.string, + vol.Optional(CONF_CUSTOM_QUIRKS_PATH): cv.isdir, +} +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + vol.All( + cv.deprecated(CONF_USB_PATH), + cv.deprecated(CONF_BAUDRATE), + cv.deprecated(CONF_RADIO_TYPE), + ZHA_CONFIG_SCHEMA, + ), + ), + }, + extra=vol.ALLOW_EXTRA, +) + +# Zigbee definitions +CENTICELSIUS = "C-100" + +# Internal definitions +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up ZHA from config.""" + zha_data = ZHAData() + zha_data.yaml_config = config.get(DOMAIN, {}) + hass.data[DATA_ZHA] = zha_data + + return True + + +def _clean_serial_port_path(path: str) -> str: + """Clean the serial port path, applying corrections where necessary.""" + + if path.startswith("socket://"): + path = path.strip() + + # Removes extraneous brackets from IP addresses (they don't parse in CPython 3.11.4) + if re.match(r"^socket://\[\d+\.\d+\.\d+\.\d+\]:\d+$", path): + path = path.replace("[", "").replace("]", "") + + return path + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up ZHA. + + Will automatically load components to support devices found on the network. + """ + + # Remove brackets around IP addresses, this no longer works in CPython 3.11.4 + # This will be removed in 2023.11.0 + path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] + cleaned_path = _clean_serial_port_path(path) + data = copy.deepcopy(dict(config_entry.data)) + + if path != cleaned_path: + _LOGGER.debug("Cleaned serial port path %r -> %r", path, cleaned_path) + data[CONF_DEVICE][CONF_DEVICE_PATH] = cleaned_path + hass.config_entries.async_update_entry(config_entry, data=data) + + zha_data = get_zha_data(hass) + + if zha_data.yaml_config.get(CONF_ENABLE_QUIRKS, True): + setup_quirks( + custom_quirks_path=zha_data.yaml_config.get(CONF_CUSTOM_QUIRKS_PATH) + ) + + # Load and cache device trigger information early + device_registry = dr.async_get(hass) + radio_mgr = ZhaRadioManager.from_config_entry(hass, config_entry) + + async with radio_mgr.connect_zigpy_app() as app: + for dev in app.devices.values(): + dev_entry = device_registry.async_get_device( + identifiers={(DOMAIN, str(dev.ieee))}, + connections={(dr.CONNECTION_ZIGBEE, str(dev.ieee))}, + ) + + if dev_entry is None: + continue + + zha_data.device_trigger_cache[dev_entry.id] = ( + str(dev.ieee), + get_device_automation_triggers(dev), + ) + + _LOGGER.debug("Trigger cache: %s", zha_data.device_trigger_cache) + + try: + zha_gateway = await ZHAGateway.async_from_config( + hass=hass, + config=zha_data.yaml_config, + config_entry=config_entry, + ) + except NetworkSettingsInconsistent as exc: + await warn_on_inconsistent_network_settings( + hass, + config_entry=config_entry, + old_state=exc.old_state, + new_state=exc.new_state, + ) + raise ConfigEntryError( + "Network settings do not match most recent backup" + ) from exc + except TransientConnectionError as exc: + raise ConfigEntryNotReady from exc + except Exception as exc: + _LOGGER.debug("Failed to set up ZHA", exc_info=exc) + device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] + + if ( + not device_path.startswith("socket://") + and RadioType[config_entry.data[CONF_RADIO_TYPE]] == RadioType.ezsp + ): + try: + # Ignore all exceptions during probing, they shouldn't halt setup + if await warn_on_wrong_silabs_firmware(hass, device_path): + raise ConfigEntryError("Incorrect firmware installed") from exc + except AlreadyRunningEZSP as ezsp_exc: + raise ConfigEntryNotReady from ezsp_exc + + raise ConfigEntryNotReady from exc + + repairs.async_delete_blocking_issues(hass) + + manufacturer = zha_gateway.state.node_info.manufacturer + model = zha_gateway.state.node_info.model + + if manufacturer is None and model is None: + manufacturer = "Unknown" + model = "Unknown" + + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_ZIGBEE, str(zha_gateway.state.node_info.ieee))}, + identifiers={(DOMAIN, str(zha_gateway.state.node_info.ieee))}, + name="Zigbee Coordinator", + manufacturer=manufacturer, + model=model, + sw_version=zha_gateway.state.node_info.version, + ) + + websocket_api.async_load_api(hass) + + async def async_shutdown(_: Event) -> None: + await zha_gateway.shutdown() + + config_entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_shutdown) + ) + + await zha_gateway.async_initialize_devices_and_entities() + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + async_dispatcher_send(hass, SIGNAL_ADD_ENTITIES) + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload ZHA config entry.""" + zha_data = get_zha_data(hass) + + if zha_data.gateway is not None: + await zha_data.gateway.shutdown() + zha_data.gateway = None + + # clean up any remaining entity metadata + # (entities that have been discovered but not yet added to HA) + # suppress KeyError because we don't know what state we may + # be in when we get here in failure cases + with contextlib.suppress(KeyError): + for platform in PLATFORMS: + del zha_data.platforms[platform] + + GROUP_PROBE.cleanup() + websocket_api.async_unload_api(hass) + + # 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 + ) + ) + + return True + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version == 1: + data = { + CONF_RADIO_TYPE: config_entry.data[CONF_RADIO_TYPE], + CONF_DEVICE: {CONF_DEVICE_PATH: config_entry.data[CONF_USB_PATH]}, + } + + baudrate = get_zha_data(hass).yaml_config.get(CONF_BAUDRATE) + if data[CONF_RADIO_TYPE] != RadioType.deconz and baudrate in BAUD_RATES: + data[CONF_DEVICE][CONF_BAUDRATE] = baudrate + + hass.config_entries.async_update_entry(config_entry, data=data, version=2) + + if config_entry.version == 2: + data = {**config_entry.data} + + if data[CONF_RADIO_TYPE] == "ti_cc": + data[CONF_RADIO_TYPE] = "znp" + + hass.config_entries.async_update_entry(config_entry, data=data, version=3) + + if config_entry.version == 3: + data = {**config_entry.data} + + if not data[CONF_DEVICE].get(CONF_BAUDRATE): + data[CONF_DEVICE][CONF_BAUDRATE] = { + "deconz": 38400, + "xbee": 57600, + "ezsp": 57600, + "znp": 115200, + "zigate": 115200, + }[data[CONF_RADIO_TYPE]] + + if not data[CONF_DEVICE].get(CONF_FLOW_CONTROL): + data[CONF_DEVICE][CONF_FLOW_CONTROL] = None + + hass.config_entries.async_update_entry(config_entry, data=data, version=4) + + _LOGGER.info("Migration to version %s successful", config_entry.version) + return True diff --git a/zha/alarm_control_panel.py b/zha/alarm_control_panel.py new file mode 100644 index 00000000..3ff5202a --- /dev/null +++ b/zha/alarm_control_panel.py @@ -0,0 +1,157 @@ +"""Alarm control panels on Zigbee Home Automation networks.""" + +from __future__ import annotations + +import functools +from typing import TYPE_CHECKING + +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, + CodeFormat, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, + Platform, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from zigpy.zcl.clusters.security import IasAce + +from .core import discovery +from .core.cluster_handlers.security import ( + SIGNAL_ALARM_TRIGGERED, + SIGNAL_ARMED_STATE_CHANGED, + IasAceClusterHandler, +) +from .core.const import ( + CLUSTER_HANDLER_IAS_ACE, + CONF_ALARM_ARM_REQUIRES_CODE, + CONF_ALARM_FAILED_TRIES, + CONF_ALARM_MASTER_CODE, + SIGNAL_ADD_ENTITIES, + ZHA_ALARM_OPTIONS, +) +from .core.helpers import async_get_zha_config_value, get_zha_data +from .core.registries import ZHA_ENTITIES +from .entity import ZhaEntity + +if TYPE_CHECKING: + from .core.device import ZHADevice + +STRICT_MATCH = functools.partial( + ZHA_ENTITIES.strict_match, Platform.ALARM_CONTROL_PANEL +) + +IAS_ACE_STATE_MAP = { + IasAce.PanelStatus.Panel_Disarmed: STATE_ALARM_DISARMED, + IasAce.PanelStatus.Armed_Stay: STATE_ALARM_ARMED_HOME, + IasAce.PanelStatus.Armed_Night: STATE_ALARM_ARMED_NIGHT, + IasAce.PanelStatus.Armed_Away: STATE_ALARM_ARMED_AWAY, + IasAce.PanelStatus.In_Alarm: STATE_ALARM_TRIGGERED, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Zigbee Home Automation alarm control panel from config entry.""" + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.ALARM_CONTROL_PANEL] + + unsub = async_dispatcher_connect( + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), + ) + config_entry.async_on_unload(unsub) + + +@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_ACE) +class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity): + """Entity for ZHA alarm control devices.""" + + _attr_translation_key: str = "alarm_control_panel" + _attr_code_format = CodeFormat.TEXT + _attr_supported_features = ( + AlarmControlPanelEntityFeature.ARM_HOME + | AlarmControlPanelEntityFeature.ARM_AWAY + | AlarmControlPanelEntityFeature.ARM_NIGHT + | AlarmControlPanelEntityFeature.TRIGGER + ) + + def __init__( + self, unique_id, zha_device: ZHADevice, cluster_handlers, **kwargs + ) -> None: + """Initialize the ZHA alarm control device.""" + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + cfg_entry = zha_device.gateway.config_entry + self._cluster_handler: IasAceClusterHandler = cluster_handlers[0] + self._cluster_handler.panel_code = async_get_zha_config_value( + cfg_entry, ZHA_ALARM_OPTIONS, CONF_ALARM_MASTER_CODE, "1234" + ) + self._cluster_handler.code_required_arm_actions = async_get_zha_config_value( + cfg_entry, ZHA_ALARM_OPTIONS, CONF_ALARM_ARM_REQUIRES_CODE, False + ) + self._cluster_handler.max_invalid_tries = async_get_zha_config_value( + cfg_entry, ZHA_ALARM_OPTIONS, CONF_ALARM_FAILED_TRIES, 3 + ) + + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + await super().async_added_to_hass() + self.async_accept_signal( + self._cluster_handler, SIGNAL_ARMED_STATE_CHANGED, self.async_set_armed_mode + ) + self.async_accept_signal( + self._cluster_handler, SIGNAL_ALARM_TRIGGERED, self.async_alarm_trigger + ) + + @callback + def async_set_armed_mode(self) -> None: + """Set the entity state.""" + self.async_write_ha_state() + + @property + def code_arm_required(self) -> bool: + """Whether the code is required for arm actions.""" + return self._cluster_handler.code_required_arm_actions + + async def async_alarm_disarm(self, code: str | None = None) -> None: + """Send disarm command.""" + self._cluster_handler.arm(IasAce.ArmMode.Disarm, code, 0) + self.async_write_ha_state() + + async def async_alarm_arm_home(self, code: str | None = None) -> None: + """Send arm home command.""" + self._cluster_handler.arm(IasAce.ArmMode.Arm_Day_Home_Only, code, 0) + self.async_write_ha_state() + + async def async_alarm_arm_away(self, code: str | None = None) -> None: + """Send arm away command.""" + self._cluster_handler.arm(IasAce.ArmMode.Arm_All_Zones, code, 0) + self.async_write_ha_state() + + async def async_alarm_arm_night(self, code: str | None = None) -> None: + """Send arm night command.""" + self._cluster_handler.arm(IasAce.ArmMode.Arm_Night_Sleep_Only, code, 0) + self.async_write_ha_state() + + async def async_alarm_trigger(self, code: str | None = None) -> None: + """Send alarm trigger command.""" + self.async_write_ha_state() + + @property + def state(self) -> str | None: + """Return the state of the entity.""" + return IAS_ACE_STATE_MAP.get(self._cluster_handler.armed_state) diff --git a/zha/api.py b/zha/api.py new file mode 100644 index 00000000..db0658eb --- /dev/null +++ b/zha/api.py @@ -0,0 +1,115 @@ +"""API for Zigbee Home Automation.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal + +from zigpy.backups import NetworkBackup +from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH +from zigpy.types import Channels +from zigpy.util import pick_optimal_channel + +from .core.const import CONF_RADIO_TYPE, DOMAIN, RadioType +from .core.helpers import get_zha_gateway +from .radio_manager import ZhaRadioManager + +if TYPE_CHECKING: + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import HomeAssistant + + +def _get_config_entry(hass: HomeAssistant) -> ConfigEntry: + """Find the singleton ZHA config entry, if one exists.""" + + # If ZHA is already running, use its config entry + try: + zha_gateway = get_zha_gateway(hass) + except ValueError: + pass + else: + return zha_gateway.config_entry + + # Otherwise, find one + entries = hass.config_entries.async_entries(DOMAIN) + + if len(entries) != 1: + raise ValueError(f"Invalid number of ZHA config entries: {entries!r}") + + return entries[0] + + +def async_get_active_network_settings(hass: HomeAssistant) -> NetworkBackup: + """Get the network settings for the currently active ZHA network.""" + app = get_zha_gateway(hass).application_controller + + return NetworkBackup( + node_info=app.state.node_info, + network_info=app.state.network_info, + ) + + +async def async_get_last_network_settings( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> NetworkBackup | None: + """Get the network settings for the last-active ZHA network.""" + if config_entry is None: + config_entry = _get_config_entry(hass) + + radio_mgr = ZhaRadioManager.from_config_entry(hass, config_entry) + + async with radio_mgr.connect_zigpy_app() as app: + try: + settings = max(app.backups, key=lambda b: b.backup_time) + except ValueError: + settings = None + + return settings + + +async def async_get_network_settings( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> NetworkBackup | None: + """Get ZHA network settings, preferring the active settings if ZHA is running.""" + + try: + return async_get_active_network_settings(hass) + except ValueError: + return await async_get_last_network_settings(hass, config_entry) + + +def async_get_radio_type( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> RadioType: + """Get ZHA radio type.""" + if config_entry is None: + config_entry = _get_config_entry(hass) + + return RadioType[config_entry.data[CONF_RADIO_TYPE]] + + +def async_get_radio_path( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> str: + """Get ZHA radio path.""" + if config_entry is None: + config_entry = _get_config_entry(hass) + + return config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] + + +async def async_change_channel( + hass: HomeAssistant, new_channel: int | Literal["auto"] +) -> None: + """Migrate the ZHA network to a new channel.""" + + app = get_zha_gateway(hass).application_controller + + if new_channel == "auto": + channel_energy = await app.energy_scan( + channels=Channels.ALL_CHANNELS, + duration_exp=4, + count=1, + ) + new_channel = pick_optimal_channel(channel_energy) + + await app.move_network_to_channel(new_channel) diff --git a/zha/backup.py b/zha/backup.py new file mode 100644 index 00000000..25d5a83b --- /dev/null +++ b/zha/backup.py @@ -0,0 +1,21 @@ +"""Backup platform for the ZHA integration.""" + +import logging + +from homeassistant.core import HomeAssistant + +from .core.helpers import get_zha_gateway + +_LOGGER = logging.getLogger(__name__) + + +async def async_pre_backup(hass: HomeAssistant) -> None: + """Perform operations before a backup starts.""" + _LOGGER.debug("Performing coordinator backup") + + zha_gateway = get_zha_gateway(hass) + await zha_gateway.application_controller.backups.create_backup(load_devices=True) + + +async def async_post_backup(hass: HomeAssistant) -> None: + """Perform operations after a backup finishes.""" diff --git a/zha/binary_sensor.py b/zha/binary_sensor.py new file mode 100644 index 00000000..56d80832 --- /dev/null +++ b/zha/binary_sensor.py @@ -0,0 +1,361 @@ +"""Binary sensors on Zigbee Home Automation networks.""" + +from __future__ import annotations + +import functools +from typing import Any + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON, EntityCategory, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from zigpy.quirks.v2 import BinarySensorMetadata, EntityMetadata +import zigpy.types as t +from zigpy.zcl.clusters.general import OnOff +from zigpy.zcl.clusters.security import IasZone + +from .core import discovery +from .core.const import ( + CLUSTER_HANDLER_ACCELEROMETER, + CLUSTER_HANDLER_BINARY_INPUT, + CLUSTER_HANDLER_HUE_OCCUPANCY, + CLUSTER_HANDLER_OCCUPANCY, + CLUSTER_HANDLER_ON_OFF, + CLUSTER_HANDLER_ZONE, + QUIRK_METADATA, + SIGNAL_ADD_ENTITIES, + SIGNAL_ATTR_UPDATED, +) +from .core.helpers import get_zha_data +from .core.registries import ZHA_ENTITIES +from .entity import ZhaEntity + +# Zigbee Cluster Library Zone Type to Home Assistant device class +IAS_ZONE_CLASS_MAPPING = { + IasZone.ZoneType.Motion_Sensor: BinarySensorDeviceClass.MOTION, + IasZone.ZoneType.Contact_Switch: BinarySensorDeviceClass.OPENING, + IasZone.ZoneType.Fire_Sensor: BinarySensorDeviceClass.SMOKE, + IasZone.ZoneType.Water_Sensor: BinarySensorDeviceClass.MOISTURE, + IasZone.ZoneType.Carbon_Monoxide_Sensor: BinarySensorDeviceClass.GAS, + IasZone.ZoneType.Vibration_Movement_Sensor: BinarySensorDeviceClass.VIBRATION, +} + +STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.BINARY_SENSOR) +MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.BINARY_SENSOR) +CONFIG_DIAGNOSTIC_MATCH = functools.partial( + ZHA_ENTITIES.config_diagnostic_match, Platform.BINARY_SENSOR +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Zigbee Home Automation binary sensor from config entry.""" + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.BINARY_SENSOR] + + unsub = async_dispatcher_connect( + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), + ) + config_entry.async_on_unload(unsub) + + +class BinarySensor(ZhaEntity, BinarySensorEntity): + """ZHA BinarySensor.""" + + _attribute_name: str + + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs) -> None: + """Initialize the ZHA binary sensor.""" + self._cluster_handler = cluster_handlers[0] + if QUIRK_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + super()._init_from_quirks_metadata(entity_metadata) + binary_sensor_metadata: BinarySensorMetadata = entity_metadata.entity_metadata + self._attribute_name = binary_sensor_metadata.attribute_name + + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + await super().async_added_to_hass() + self.async_accept_signal( + self._cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state + ) + + @property + def is_on(self) -> bool: + """Return True if the switch is on based on the state machine.""" + raw_state = self._cluster_handler.cluster.get(self._attribute_name) + if raw_state is None: + return False + return self.parse(raw_state) + + @callback + def async_set_state(self, attr_id, attr_name, value): + """Set the state.""" + self.async_write_ha_state() + + @staticmethod + def parse(value: bool | int) -> bool: + """Parse the raw attribute into a bool state.""" + return bool(value) + + +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ACCELEROMETER) +class Accelerometer(BinarySensor): + """ZHA BinarySensor.""" + + _attribute_name = "acceleration" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.MOVING + _attr_translation_key: str = "accelerometer" + + +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_OCCUPANCY) +class Occupancy(BinarySensor): + """ZHA BinarySensor.""" + + _attribute_name = "occupancy" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OCCUPANCY + + +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_HUE_OCCUPANCY) +class HueOccupancy(Occupancy): + """ZHA Hue occupancy.""" + + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OCCUPANCY + + +@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_ON_OFF) +class Opening(BinarySensor): + """ZHA OnOff BinarySensor.""" + + _attribute_name = "on_off" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OPENING + + # Client/out cluster attributes aren't stored in the zigpy database, but are properly stored in the runtime cache. + # We need to manually restore the last state from the sensor state to the runtime cache for now. + @callback + def async_restore_last_state(self, last_state): + """Restore previous state to zigpy cache.""" + self._cluster_handler.cluster.update_attribute( + OnOff.attributes_by_name[self._attribute_name].id, + t.Bool.true if last_state.state == STATE_ON else t.Bool.false, + ) + + +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_BINARY_INPUT) +class BinaryInput(BinarySensor): + """ZHA BinarySensor.""" + + _attribute_name = "present_value" + _attr_translation_key: str = "binary_input" + + +@STRICT_MATCH( + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, + manufacturers="IKEA of Sweden", + models=lambda model: isinstance(model, str) + and model is not None + and model.find("motion") != -1, +) +@STRICT_MATCH( + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, + manufacturers="Philips", + models={"SML001", "SML002"}, +) +class Motion(Opening): + """ZHA OnOff BinarySensor with motion device class.""" + + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.MOTION + + +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ZONE) +class IASZone(BinarySensor): + """ZHA IAS BinarySensor.""" + + _attribute_name = "zone_status" + + @property + def translation_key(self) -> str | None: + """Return the name of the sensor.""" + zone_type = self._cluster_handler.cluster.get("zone_type") + if zone_type in IAS_ZONE_CLASS_MAPPING: + return None + return "ias_zone" + + @property + def device_class(self) -> BinarySensorDeviceClass | None: + """Return device class from component DEVICE_CLASSES.""" + zone_type = self._cluster_handler.cluster.get("zone_type") + return IAS_ZONE_CLASS_MAPPING.get(zone_type) + + @staticmethod + def parse(value: bool | int) -> bool: + """Parse the raw attribute into a bool state.""" + return BinarySensor.parse(value & 3) # use only bit 0 and 1 for alarm state + + # temporary code to migrate old IasZone sensors to update attribute cache state once + # remove in 2024.4.0 + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return state attributes.""" + return {"migrated_to_cache": True} # writing new state means we're migrated + + # temporary migration code + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + # trigger migration if extra state attribute is not present + if "migrated_to_cache" not in last_state.attributes: + self.migrate_to_zigpy_cache(last_state) + + # temporary migration code + @callback + def migrate_to_zigpy_cache(self, last_state): + """Save old IasZone sensor state to attribute cache.""" + # previous HA versions did not update the attribute cache for IasZone sensors, so do it once here + # a HA state write is triggered shortly afterwards and writes the "migrated_to_cache" extra state attribute + if last_state.state == STATE_ON: + migrated_state = IasZone.ZoneStatus.Alarm_1 + else: + migrated_state = IasZone.ZoneStatus(0) + + self._cluster_handler.cluster.update_attribute( + IasZone.attributes_by_name[self._attribute_name].id, migrated_state + ) + + +@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_ZONE, models={"WL4200", "WL4200S"}) +class SinopeLeakStatus(BinarySensor): + """Sinope water leak sensor.""" + + _attribute_name = "leak_status" + _attr_device_class = BinarySensorDeviceClass.MOISTURE + + +@MULTI_MATCH( + cluster_handler_names="tuya_manufacturer", + manufacturers={ + "_TZE200_htnnfasr", + }, +) +class FrostLock(BinarySensor): + """ZHA BinarySensor.""" + + _attribute_name = "frost_lock" + _unique_id_suffix = "frost_lock" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.LOCK + _attr_translation_key: str = "frost_lock" + + +@MULTI_MATCH(cluster_handler_names="ikea_airpurifier") +class ReplaceFilter(BinarySensor): + """ZHA BinarySensor.""" + + _attribute_name = "replace_filter" + _unique_id_suffix = "replace_filter" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM + _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC + _attr_translation_key: str = "replace_filter" + + +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) +class AqaraPetFeederErrorDetected(BinarySensor): + """ZHA aqara pet feeder error detected binary sensor.""" + + _attribute_name = "error_detected" + _unique_id_suffix = "error_detected" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM + + +@MULTI_MATCH( + cluster_handler_names="opple_cluster", + models={"lumi.plug.mmeu01", "lumi.plug.maeu01"}, +) +class XiaomiPlugConsumerConnected(BinarySensor): + """ZHA Xiaomi plug consumer connected binary sensor.""" + + _attribute_name = "consumer_connected" + _unique_id_suffix = "consumer_connected" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PLUG + _attr_translation_key: str = "consumer_connected" + + +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatWindowOpen(BinarySensor): + """ZHA Aqara thermostat window open binary sensor.""" + + _attribute_name = "window_open" + _unique_id_suffix = "window_open" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.WINDOW + + +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatValveAlarm(BinarySensor): + """ZHA Aqara thermostat valve alarm binary sensor.""" + + _attribute_name = "valve_alarm" + _unique_id_suffix = "valve_alarm" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM + _attr_translation_key: str = "valve_alarm" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} +) +class AqaraThermostatCalibrated(BinarySensor): + """ZHA Aqara thermostat calibrated binary sensor.""" + + _attribute_name = "calibrated" + _unique_id_suffix = "calibrated" + _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC + _attr_translation_key: str = "calibrated" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} +) +class AqaraThermostatExternalSensor(BinarySensor): + """ZHA Aqara thermostat external sensor binary sensor.""" + + _attribute_name = "sensor" + _unique_id_suffix = "sensor" + _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC + _attr_translation_key: str = "external_sensor" + + +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"}) +class AqaraLinkageAlarmState(BinarySensor): + """ZHA Aqara linkage alarm state binary sensor.""" + + _attribute_name = "linkage_alarm_state" + _unique_id_suffix = "linkage_alarm_state" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.SMOKE + _attr_translation_key: str = "linkage_alarm_state" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.curtain.agl001"} +) +class AqaraE1CurtainMotorOpenedByHandBinarySensor(BinarySensor): + """Opened by hand binary sensor.""" + + _unique_id_suffix = "hand_open" + _attribute_name = "hand_open" + _attr_translation_key = "hand_open" + _attr_icon = "mdi:hand-wave" + _attr_entity_category = EntityCategory.DIAGNOSTIC diff --git a/zha/button.py b/zha/button.py new file mode 100644 index 00000000..347bda45 --- /dev/null +++ b/zha/button.py @@ -0,0 +1,220 @@ +"""Support for ZHA button.""" + +from __future__ import annotations + +import functools +import logging +from typing import TYPE_CHECKING, Any, Self + +from homeassistant.components.button import ButtonDeviceClass, ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from zigpy.quirks.v2 import ( + EntityMetadata, + WriteAttributeButtonMetadata, + ZCLCommandButtonMetadata, +) + +from .core import discovery +from .core.const import CLUSTER_HANDLER_IDENTIFY, QUIRK_METADATA, SIGNAL_ADD_ENTITIES +from .core.helpers import get_zha_data +from .core.registries import ZHA_ENTITIES +from .entity import ZhaEntity + +if TYPE_CHECKING: + from .core.cluster_handlers import ClusterHandler + from .core.device import ZHADevice + + +MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.BUTTON) +CONFIG_DIAGNOSTIC_MATCH = functools.partial( + ZHA_ENTITIES.config_diagnostic_match, Platform.BUTTON +) +DEFAULT_DURATION = 5 # seconds + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Zigbee Home Automation button from config entry.""" + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.BUTTON] + + unsub = async_dispatcher_connect( + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, + async_add_entities, + entities_to_create, + ), + ) + config_entry.async_on_unload(unsub) + + +class ZHAButton(ZhaEntity, ButtonEntity): + """Defines a ZHA button.""" + + _command_name: str + _args: list[Any] + _kwargs: dict[str, Any] + + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> None: + """Init this button.""" + self._cluster_handler: ClusterHandler = cluster_handlers[0] + if QUIRK_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + super()._init_from_quirks_metadata(entity_metadata) + button_metadata: ZCLCommandButtonMetadata = entity_metadata.entity_metadata + self._command_name = button_metadata.command_name + self._args = button_metadata.args + self._kwargs = button_metadata.kwargs + + def get_args(self) -> list[Any]: + """Return the arguments to use in the command.""" + return list(self._args) if self._args else [] + + def get_kwargs(self) -> dict[str, Any]: + """Return the keyword arguments to use in the command.""" + return self._kwargs + + async def async_press(self) -> None: + """Send out a update command.""" + command = getattr(self._cluster_handler, self._command_name) + arguments = self.get_args() or [] + kwargs = self.get_kwargs() or {} + await command(*arguments, **kwargs) + + +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_IDENTIFY) +class ZHAIdentifyButton(ZHAButton): + """Defines a ZHA identify button.""" + + @classmethod + def create_entity( + cls, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> Self | None: + """Entity Factory. + + Return entity if it is a supported configuration, otherwise return None + """ + if ZHA_ENTITIES.prevent_entity_creation( + Platform.BUTTON, zha_device.ieee, CLUSTER_HANDLER_IDENTIFY + ): + return None + return cls(unique_id, zha_device, cluster_handlers, **kwargs) + + _attr_device_class = ButtonDeviceClass.IDENTIFY + _attr_entity_category = EntityCategory.DIAGNOSTIC + _command_name = "identify" + _kwargs = {} + _args = [DEFAULT_DURATION] + + +class ZHAAttributeButton(ZhaEntity, ButtonEntity): + """Defines a ZHA button, which writes a value to an attribute.""" + + _attribute_name: str + _attribute_value: Any = None + + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> None: + """Init this button.""" + self._cluster_handler: ClusterHandler = cluster_handlers[0] + if QUIRK_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + super()._init_from_quirks_metadata(entity_metadata) + button_metadata: WriteAttributeButtonMetadata = entity_metadata.entity_metadata + self._attribute_name = button_metadata.attribute_name + self._attribute_value = button_metadata.attribute_value + + async def async_press(self) -> None: + """Write attribute with defined value.""" + await self._cluster_handler.write_attributes_safe( + {self._attribute_name: self._attribute_value} + ) + self.async_write_ha_state() + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="tuya_manufacturer", + manufacturers={ + "_TZE200_htnnfasr", + }, +) +class FrostLockResetButton(ZHAAttributeButton): + """Defines a ZHA frost lock reset button.""" + + _unique_id_suffix = "reset_frost_lock" + _attribute_name = "frost_lock_reset" + _attribute_value = 0 + _attr_device_class = ButtonDeviceClass.RESTART + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "reset_frost_lock" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.motion.ac01"} +) +class NoPresenceStatusResetButton(ZHAAttributeButton): + """Defines a ZHA no presence status reset button.""" + + _unique_id_suffix = "reset_no_presence_status" + _attribute_name = "reset_no_presence_status" + _attribute_value = 1 + _attr_device_class = ButtonDeviceClass.RESTART + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "reset_no_presence_status" + + +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) +class AqaraPetFeederFeedButton(ZHAAttributeButton): + """Defines a feed button for the aqara c1 pet feeder.""" + + _unique_id_suffix = "feeding" + _attribute_name = "feeding" + _attribute_value = 1 + _attr_translation_key = "feed" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} +) +class AqaraSelfTestButton(ZHAAttributeButton): + """Defines a ZHA self-test button for Aqara smoke sensors.""" + + _unique_id_suffix = "self_test" + _attribute_name = "self_test" + _attribute_value = 1 + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "self_test" diff --git a/zha/climate.py b/zha/climate.py new file mode 100644 index 00000000..2a0bfc2c --- /dev/null +++ b/zha/climate.py @@ -0,0 +1,824 @@ +"""Climate on Zigbee Home Automation networks. + +For more details on this platform, please refer to the documentation +at https://home-assistant.io/components/zha.climate/ +""" + +from __future__ import annotations + +from datetime import datetime, timedelta +import functools +from random import randint +from typing import Any + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + FAN_AUTO, + FAN_ON, + PRESET_AWAY, + PRESET_BOOST, + PRESET_COMFORT, + PRESET_ECO, + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_TEMPERATURE, + PRECISION_TENTHS, + Platform, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval +import homeassistant.util.dt as dt_util +from zigpy.zcl.clusters.hvac import Fan as F, Thermostat as T + +from .core import discovery +from .core.const import ( + CLUSTER_HANDLER_FAN, + CLUSTER_HANDLER_THERMOSTAT, + PRESET_COMPLEX, + PRESET_SCHEDULE, + PRESET_TEMP_MANUAL, + SIGNAL_ADD_ENTITIES, + SIGNAL_ATTR_UPDATED, +) +from .core.helpers import get_zha_data +from .core.registries import ZHA_ENTITIES +from .entity import ZhaEntity + +ATTR_SYS_MODE = "system_mode" +ATTR_RUNNING_MODE = "running_mode" +ATTR_SETPT_CHANGE_SRC = "setpoint_change_source" +ATTR_SETPT_CHANGE_AMT = "setpoint_change_amount" +ATTR_OCCUPANCY = "occupancy" +ATTR_PI_COOLING_DEMAND = "pi_cooling_demand" +ATTR_PI_HEATING_DEMAND = "pi_heating_demand" +ATTR_OCCP_COOL_SETPT = "occupied_cooling_setpoint" +ATTR_OCCP_HEAT_SETPT = "occupied_heating_setpoint" +ATTR_UNOCCP_HEAT_SETPT = "unoccupied_heating_setpoint" +ATTR_UNOCCP_COOL_SETPT = "unoccupied_cooling_setpoint" + + +STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.CLIMATE) +MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.CLIMATE) +RUNNING_MODE = {0x00: HVACMode.OFF, 0x03: HVACMode.COOL, 0x04: HVACMode.HEAT} + +SEQ_OF_OPERATION = { + 0x00: [HVACMode.OFF, HVACMode.COOL], # cooling only + 0x01: [HVACMode.OFF, HVACMode.COOL], # cooling with reheat + 0x02: [HVACMode.OFF, HVACMode.HEAT], # heating only + 0x03: [HVACMode.OFF, HVACMode.HEAT], # heating with reheat + # cooling and heating 4-pipes + 0x04: [HVACMode.OFF, HVACMode.HEAT_COOL, HVACMode.COOL, HVACMode.HEAT], + # cooling and heating 4-pipes + 0x05: [HVACMode.OFF, HVACMode.HEAT_COOL, HVACMode.COOL, HVACMode.HEAT], + 0x06: [HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF], # centralite specific + 0x07: [HVACMode.HEAT_COOL, HVACMode.OFF], # centralite specific +} + +HVAC_MODE_2_SYSTEM = { + HVACMode.OFF: T.SystemMode.Off, + HVACMode.HEAT_COOL: T.SystemMode.Auto, + HVACMode.COOL: T.SystemMode.Cool, + HVACMode.HEAT: T.SystemMode.Heat, + HVACMode.FAN_ONLY: T.SystemMode.Fan_only, + HVACMode.DRY: T.SystemMode.Dry, +} + +SYSTEM_MODE_2_HVAC = { + T.SystemMode.Off: HVACMode.OFF, + T.SystemMode.Auto: HVACMode.HEAT_COOL, + T.SystemMode.Cool: HVACMode.COOL, + T.SystemMode.Heat: HVACMode.HEAT, + T.SystemMode.Emergency_Heating: HVACMode.HEAT, + T.SystemMode.Pre_cooling: HVACMode.COOL, # this is 'precooling'. is it the same? + T.SystemMode.Fan_only: HVACMode.FAN_ONLY, + T.SystemMode.Dry: HVACMode.DRY, + T.SystemMode.Sleep: HVACMode.OFF, +} + +ZCL_TEMP = 100 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Zigbee Home Automation sensor from config entry.""" + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.CLIMATE] + unsub = async_dispatcher_connect( + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), + ) + config_entry.async_on_unload(unsub) + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + aux_cluster_handlers=CLUSTER_HANDLER_FAN, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, +) +class Thermostat(ZhaEntity, ClimateEntity): + """Representation of a ZHA Thermostat device.""" + + DEFAULT_MAX_TEMP = 35 + DEFAULT_MIN_TEMP = 7 + + _attr_precision = PRECISION_TENTHS + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key: str = "thermostat" + _enable_turn_on_off_backwards_compatibility = False + + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): + """Initialize ZHA Thermostat instance.""" + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._thrm = self.cluster_handlers.get(CLUSTER_HANDLER_THERMOSTAT) + self._preset = PRESET_NONE + self._presets = [] + self._supported_flags = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + self._fan = self.cluster_handlers.get(CLUSTER_HANDLER_FAN) + + @property + def current_temperature(self): + """Return the current temperature.""" + if self._thrm.local_temperature is None: + return None + return self._thrm.local_temperature / ZCL_TEMP + + @property + def extra_state_attributes(self): + """Return device specific state attributes.""" + data = {} + if self.hvac_mode: + mode = SYSTEM_MODE_2_HVAC.get(self._thrm.system_mode, "unknown") + data[ATTR_SYS_MODE] = f"[{self._thrm.system_mode}]/{mode}" + if self._thrm.occupancy is not None: + data[ATTR_OCCUPANCY] = self._thrm.occupancy + if self._thrm.occupied_cooling_setpoint is not None: + data[ATTR_OCCP_COOL_SETPT] = self._thrm.occupied_cooling_setpoint + if self._thrm.occupied_heating_setpoint is not None: + data[ATTR_OCCP_HEAT_SETPT] = self._thrm.occupied_heating_setpoint + if self._thrm.pi_heating_demand is not None: + data[ATTR_PI_HEATING_DEMAND] = self._thrm.pi_heating_demand + if self._thrm.pi_cooling_demand is not None: + data[ATTR_PI_COOLING_DEMAND] = self._thrm.pi_cooling_demand + + unoccupied_cooling_setpoint = self._thrm.unoccupied_cooling_setpoint + if unoccupied_cooling_setpoint is not None: + data[ATTR_UNOCCP_COOL_SETPT] = unoccupied_cooling_setpoint + + unoccupied_heating_setpoint = self._thrm.unoccupied_heating_setpoint + if unoccupied_heating_setpoint is not None: + data[ATTR_UNOCCP_HEAT_SETPT] = unoccupied_heating_setpoint + return data + + @property + def fan_mode(self) -> str | None: + """Return current FAN mode.""" + if self._thrm.running_state is None: + return FAN_AUTO + + if self._thrm.running_state & ( + T.RunningState.Fan_State_On + | T.RunningState.Fan_2nd_Stage_On + | T.RunningState.Fan_3rd_Stage_On + ): + return FAN_ON + return FAN_AUTO + + @property + def fan_modes(self) -> list[str] | None: + """Return supported FAN modes.""" + if not self._fan: + return None + return [FAN_AUTO, FAN_ON] + + @property + def hvac_action(self) -> HVACAction | None: + """Return the current HVAC action.""" + if ( + self._thrm.pi_heating_demand is None + and self._thrm.pi_cooling_demand is None + ): + return self._rm_rs_action + return self._pi_demand_action + + @property + def _rm_rs_action(self) -> HVACAction | None: + """Return the current HVAC action based on running mode and running state.""" + + if (running_state := self._thrm.running_state) is None: + return None + if running_state & ( + T.RunningState.Heat_State_On | T.RunningState.Heat_2nd_Stage_On + ): + return HVACAction.HEATING + if running_state & ( + T.RunningState.Cool_State_On | T.RunningState.Cool_2nd_Stage_On + ): + return HVACAction.COOLING + if running_state & ( + T.RunningState.Fan_State_On + | T.RunningState.Fan_2nd_Stage_On + | T.RunningState.Fan_3rd_Stage_On + ): + return HVACAction.FAN + if running_state & T.RunningState.Idle: + return HVACAction.IDLE + if self.hvac_mode != HVACMode.OFF: + return HVACAction.IDLE + return HVACAction.OFF + + @property + def _pi_demand_action(self) -> HVACAction | None: + """Return the current HVAC action based on pi_demands.""" + + heating_demand = self._thrm.pi_heating_demand + if heating_demand is not None and heating_demand > 0: + return HVACAction.HEATING + cooling_demand = self._thrm.pi_cooling_demand + if cooling_demand is not None and cooling_demand > 0: + return HVACAction.COOLING + + if self.hvac_mode != HVACMode.OFF: + return HVACAction.IDLE + return HVACAction.OFF + + @property + def hvac_mode(self) -> HVACMode | None: + """Return HVAC operation mode.""" + return SYSTEM_MODE_2_HVAC.get(self._thrm.system_mode) + + @property + def hvac_modes(self) -> list[HVACMode]: + """Return the list of available HVAC operation modes.""" + return SEQ_OF_OPERATION.get(self._thrm.ctrl_sequence_of_oper, [HVACMode.OFF]) + + @property + def preset_mode(self) -> str: + """Return current preset mode.""" + return self._preset + + @property + def preset_modes(self) -> list[str] | None: + """Return supported preset modes.""" + return self._presets + + @property + def supported_features(self) -> ClimateEntityFeature: + """Return the list of supported features.""" + features = self._supported_flags + if HVACMode.HEAT_COOL in self.hvac_modes: + features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + if self._fan is not None: + self._supported_flags |= ClimateEntityFeature.FAN_MODE + return features + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + temp = None + if self.hvac_mode == HVACMode.COOL: + if self.preset_mode == PRESET_AWAY: + temp = self._thrm.unoccupied_cooling_setpoint + else: + temp = self._thrm.occupied_cooling_setpoint + elif self.hvac_mode == HVACMode.HEAT: + if self.preset_mode == PRESET_AWAY: + temp = self._thrm.unoccupied_heating_setpoint + else: + temp = self._thrm.occupied_heating_setpoint + if temp is None: + return temp + return round(temp / ZCL_TEMP, 1) + + @property + def target_temperature_high(self): + """Return the upper bound temperature we try to reach.""" + if self.hvac_mode != HVACMode.HEAT_COOL: + return None + if self.preset_mode == PRESET_AWAY: + temp = self._thrm.unoccupied_cooling_setpoint + else: + temp = self._thrm.occupied_cooling_setpoint + + if temp is None: + return temp + + return round(temp / ZCL_TEMP, 1) + + @property + def target_temperature_low(self): + """Return the lower bound temperature we try to reach.""" + if self.hvac_mode != HVACMode.HEAT_COOL: + return None + if self.preset_mode == PRESET_AWAY: + temp = self._thrm.unoccupied_heating_setpoint + else: + temp = self._thrm.occupied_heating_setpoint + + if temp is None: + return temp + return round(temp / ZCL_TEMP, 1) + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + temps = [] + if HVACMode.HEAT in self.hvac_modes: + temps.append(self._thrm.max_heat_setpoint_limit) + if HVACMode.COOL in self.hvac_modes: + temps.append(self._thrm.max_cool_setpoint_limit) + + if not temps: + return self.DEFAULT_MAX_TEMP + return round(max(temps) / ZCL_TEMP, 1) + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + temps = [] + if HVACMode.HEAT in self.hvac_modes: + temps.append(self._thrm.min_heat_setpoint_limit) + if HVACMode.COOL in self.hvac_modes: + temps.append(self._thrm.min_cool_setpoint_limit) + + if not temps: + return self.DEFAULT_MIN_TEMP + return round(min(temps) / ZCL_TEMP, 1) + + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + await super().async_added_to_hass() + self.async_accept_signal( + self._thrm, SIGNAL_ATTR_UPDATED, self.async_attribute_updated + ) + + async def async_attribute_updated(self, attr_id, attr_name, value): + """Handle attribute update from device.""" + if ( + attr_name in (ATTR_OCCP_COOL_SETPT, ATTR_OCCP_HEAT_SETPT) + and self.preset_mode == PRESET_AWAY + ): + # occupancy attribute is an unreportable attribute, but if we get + # an attribute update for an "occupied" setpoint, there's a chance + # occupancy has changed + if await self._thrm.get_occupancy() is True: + self._preset = PRESET_NONE + + self.debug("Attribute '%s' = %s update", attr_name, value) + self.async_write_ha_state() + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set fan mode.""" + if not self.fan_modes or fan_mode not in self.fan_modes: + self.warning("Unsupported '%s' fan mode", fan_mode) + return + + if fan_mode == FAN_ON: + mode = F.FanMode.On + else: + mode = F.FanMode.Auto + + await self._fan.async_set_speed(mode) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target operation mode.""" + if hvac_mode not in self.hvac_modes: + self.warning( + "can't set '%s' mode. Supported modes are: %s", + hvac_mode, + self.hvac_modes, + ) + return + + if await self._thrm.async_set_operation_mode(HVAC_MODE_2_SYSTEM[hvac_mode]): + self.async_write_ha_state() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if not self.preset_modes or preset_mode not in self.preset_modes: + self.debug("Preset mode '%s' is not supported", preset_mode) + return + + if self.preset_mode not in ( + preset_mode, + PRESET_NONE, + ): + await self.async_preset_handler(self.preset_mode, enable=False) + + if preset_mode != PRESET_NONE: + await self.async_preset_handler(preset_mode, enable=True) + + self._preset = preset_mode + self.async_write_ha_state() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW) + high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH) + temp = kwargs.get(ATTR_TEMPERATURE) + hvac_mode = kwargs.get(ATTR_HVAC_MODE) + + if hvac_mode is not None: + await self.async_set_hvac_mode(hvac_mode) + + is_away = self.preset_mode == PRESET_AWAY + + if self.hvac_mode == HVACMode.HEAT_COOL: + if low_temp is not None: + await self._thrm.async_set_heating_setpoint( + temperature=int(low_temp * ZCL_TEMP), + is_away=is_away, + ) + if high_temp is not None: + await self._thrm.async_set_cooling_setpoint( + temperature=int(high_temp * ZCL_TEMP), + is_away=is_away, + ) + elif temp is not None: + if self.hvac_mode == HVACMode.COOL: + await self._thrm.async_set_cooling_setpoint( + temperature=int(temp * ZCL_TEMP), + is_away=is_away, + ) + elif self.hvac_mode == HVACMode.HEAT: + await self._thrm.async_set_heating_setpoint( + temperature=int(temp * ZCL_TEMP), + is_away=is_away, + ) + else: + self.debug("Not setting temperature for '%s' mode", self.hvac_mode) + return + else: + self.debug("incorrect %s setting for '%s' mode", kwargs, self.hvac_mode) + return + + self.async_write_ha_state() + + async def async_preset_handler(self, preset: str, enable: bool = False) -> None: + """Set the preset mode via handler.""" + + handler = getattr(self, f"async_preset_handler_{preset}") + await handler(enable) + + +@MULTI_MATCH( + cluster_handler_names={CLUSTER_HANDLER_THERMOSTAT, "sinope_manufacturer_specific"}, + manufacturers="Sinope Technologies", + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, +) +class SinopeTechnologiesThermostat(Thermostat): + """Sinope Technologies Thermostat.""" + + manufacturer = 0x119C + update_time_interval = timedelta(minutes=randint(45, 75)) + + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): + """Initialize ZHA Thermostat instance.""" + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._presets = [PRESET_AWAY, PRESET_NONE] + self._supported_flags |= ClimateEntityFeature.PRESET_MODE + self._manufacturer_ch = self.cluster_handlers["sinope_manufacturer_specific"] + + @property + def _rm_rs_action(self) -> HVACAction: + """Return the current HVAC action based on running mode and running state.""" + + running_mode = self._thrm.running_mode + if running_mode == T.SystemMode.Heat: + return HVACAction.HEATING + if running_mode == T.SystemMode.Cool: + return HVACAction.COOLING + + running_state = self._thrm.running_state + if running_state and running_state & ( + T.RunningState.Fan_State_On + | T.RunningState.Fan_2nd_Stage_On + | T.RunningState.Fan_3rd_Stage_On + ): + return HVACAction.FAN + if self.hvac_mode != HVACMode.OFF and running_mode == T.SystemMode.Off: + return HVACAction.IDLE + return HVACAction.OFF + + @callback + def _async_update_time(self, timestamp=None) -> None: + """Update thermostat's time display.""" + + secs_2k = ( + dt_util.now().replace(tzinfo=None) - datetime(2000, 1, 1, 0, 0, 0, 0) + ).total_seconds() + + self.debug("Updating time: %s", secs_2k) + self._manufacturer_ch.cluster.create_catching_task( + self._manufacturer_ch.write_attributes_safe( + {"secs_since_2k": secs_2k}, manufacturer=self.manufacturer + ) + ) + + async def async_added_to_hass(self) -> None: + """Run when about to be added to Hass.""" + await super().async_added_to_hass() + self.async_on_remove( + async_track_time_interval( + self.hass, self._async_update_time, self.update_time_interval + ) + ) + self._async_update_time() + + async def async_preset_handler_away(self, is_away: bool = False) -> None: + """Set occupancy.""" + mfg_code = self._zha_device.manufacturer_code + await self._thrm.write_attributes_safe( + {"set_occupancy": 0 if is_away else 1}, manufacturer=mfg_code + ) + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + aux_cluster_handlers=CLUSTER_HANDLER_FAN, + manufacturers={"Zen Within", "LUX"}, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, +) +class ZenWithinThermostat(Thermostat): + """Zen Within Thermostat implementation.""" + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + aux_cluster_handlers=CLUSTER_HANDLER_FAN, + manufacturers="Centralite", + models={"3157100", "3157100-E"}, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, +) +class CentralitePearl(ZenWithinThermostat): + """Centralite Pearl Thermostat implementation.""" + + +@STRICT_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + manufacturers={ + "_TZE200_ckud7u2l", + "_TZE200_ywdxldoj", + "_TZE200_cwnjrr72", + "_TZE200_2atgpdho", + "_TZE200_pvvbommb", + "_TZE200_4eeyebrt", + "_TZE200_cpmgn2cf", + "_TZE200_9sfg7gm0", + "_TZE200_8whxpsiw", + "_TYST11_ckud7u2l", + "_TYST11_ywdxldoj", + "_TYST11_cwnjrr72", + "_TYST11_2atgpdho", + }, +) +class MoesThermostat(Thermostat): + """Moes Thermostat implementation.""" + + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): + """Initialize ZHA Thermostat instance.""" + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._presets = [ + PRESET_NONE, + PRESET_AWAY, + PRESET_SCHEDULE, + PRESET_COMFORT, + PRESET_ECO, + PRESET_BOOST, + PRESET_COMPLEX, + ] + self._supported_flags |= ClimateEntityFeature.PRESET_MODE + + @property + def hvac_modes(self) -> list[HVACMode]: + """Return only the heat mode, because the device can't be turned off.""" + return [HVACMode.HEAT] + + async def async_attribute_updated(self, attr_id, attr_name, value): + """Handle attribute update from device.""" + if attr_name == "operation_preset": + if value == 0: + self._preset = PRESET_AWAY + if value == 1: + self._preset = PRESET_SCHEDULE + if value == 2: + self._preset = PRESET_NONE + if value == 3: + self._preset = PRESET_COMFORT + if value == 4: + self._preset = PRESET_ECO + if value == 5: + self._preset = PRESET_BOOST + if value == 6: + self._preset = PRESET_COMPLEX + await super().async_attribute_updated(attr_id, attr_name, value) + + async def async_preset_handler(self, preset: str, enable: bool = False) -> None: + """Set the preset mode.""" + mfg_code = self._zha_device.manufacturer_code + if not enable: + return await self._thrm.write_attributes_safe( + {"operation_preset": 2}, manufacturer=mfg_code + ) + if preset == PRESET_AWAY: + return await self._thrm.write_attributes_safe( + {"operation_preset": 0}, manufacturer=mfg_code + ) + if preset == PRESET_SCHEDULE: + return await self._thrm.write_attributes_safe( + {"operation_preset": 1}, manufacturer=mfg_code + ) + if preset == PRESET_COMFORT: + return await self._thrm.write_attributes_safe( + {"operation_preset": 3}, manufacturer=mfg_code + ) + if preset == PRESET_ECO: + return await self._thrm.write_attributes_safe( + {"operation_preset": 4}, manufacturer=mfg_code + ) + if preset == PRESET_BOOST: + return await self._thrm.write_attributes_safe( + {"operation_preset": 5}, manufacturer=mfg_code + ) + if preset == PRESET_COMPLEX: + return await self._thrm.write_attributes_safe( + {"operation_preset": 6}, manufacturer=mfg_code + ) + + +@STRICT_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + manufacturers={ + "_TZE200_b6wax7g0", + }, +) +class BecaThermostat(Thermostat): + """Beca Thermostat implementation.""" + + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): + """Initialize ZHA Thermostat instance.""" + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._presets = [ + PRESET_NONE, + PRESET_AWAY, + PRESET_SCHEDULE, + PRESET_ECO, + PRESET_BOOST, + PRESET_TEMP_MANUAL, + ] + self._supported_flags |= ClimateEntityFeature.PRESET_MODE + + @property + def hvac_modes(self) -> list[HVACMode]: + """Return only the heat mode, because the device can't be turned off.""" + return [HVACMode.HEAT] + + async def async_attribute_updated(self, attr_id, attr_name, value): + """Handle attribute update from device.""" + if attr_name == "operation_preset": + if value == 0: + self._preset = PRESET_AWAY + if value == 1: + self._preset = PRESET_SCHEDULE + if value == 2: + self._preset = PRESET_NONE + if value == 4: + self._preset = PRESET_ECO + if value == 5: + self._preset = PRESET_BOOST + if value == 7: + self._preset = PRESET_TEMP_MANUAL + await super().async_attribute_updated(attr_id, attr_name, value) + + async def async_preset_handler(self, preset: str, enable: bool = False) -> None: + """Set the preset mode.""" + mfg_code = self._zha_device.manufacturer_code + if not enable: + return await self._thrm.write_attributes_safe( + {"operation_preset": 2}, manufacturer=mfg_code + ) + if preset == PRESET_AWAY: + return await self._thrm.write_attributes_safe( + {"operation_preset": 0}, manufacturer=mfg_code + ) + if preset == PRESET_SCHEDULE: + return await self._thrm.write_attributes_safe( + {"operation_preset": 1}, manufacturer=mfg_code + ) + if preset == PRESET_ECO: + return await self._thrm.write_attributes_safe( + {"operation_preset": 4}, manufacturer=mfg_code + ) + if preset == PRESET_BOOST: + return await self._thrm.write_attributes_safe( + {"operation_preset": 5}, manufacturer=mfg_code + ) + if preset == PRESET_TEMP_MANUAL: + return await self._thrm.write_attributes_safe( + {"operation_preset": 7}, manufacturer=mfg_code + ) + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + manufacturers="Stelpro", + models={"SORB"}, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, +) +class StelproFanHeater(Thermostat): + """Stelpro Fan Heater implementation.""" + + @property + def hvac_modes(self) -> list[HVACMode]: + """Return only the heat mode, because the device can't be turned off.""" + return [HVACMode.HEAT] + + +@STRICT_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + manufacturers={ + "_TZE200_7yoranx2", + "_TZE200_e9ba97vf", # TV01-ZG + "_TZE200_hue3yfsn", # TV02-ZG + "_TZE200_husqqvux", # TSL-TRV-TV01ZG + "_TZE200_kds0pmmv", # MOES TRV TV02 + "_TZE200_kly8gjlz", # TV05-ZG + "_TZE200_lnbfnyxd", + "_TZE200_mudxchsu", + }, +) +class ZONNSMARTThermostat(Thermostat): + """ZONNSMART Thermostat implementation. + + Notice that this device uses two holiday presets (2: HolidayMode, + 3: HolidayModeTemp), but only one of them can be set. + """ + + PRESET_HOLIDAY = "holiday" + PRESET_FROST = "frost protect" + + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): + """Initialize ZHA Thermostat instance.""" + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._presets = [ + PRESET_NONE, + self.PRESET_HOLIDAY, + PRESET_SCHEDULE, + self.PRESET_FROST, + ] + self._supported_flags |= ClimateEntityFeature.PRESET_MODE + + async def async_attribute_updated(self, attr_id, attr_name, value): + """Handle attribute update from device.""" + if attr_name == "operation_preset": + if value == 0: + self._preset = PRESET_SCHEDULE + if value == 1: + self._preset = PRESET_NONE + if value == 2: + self._preset = self.PRESET_HOLIDAY + if value == 3: + self._preset = self.PRESET_HOLIDAY + if value == 4: + self._preset = self.PRESET_FROST + await super().async_attribute_updated(attr_id, attr_name, value) + + async def async_preset_handler(self, preset: str, enable: bool = False) -> None: + """Set the preset mode.""" + mfg_code = self._zha_device.manufacturer_code + if not enable: + return await self._thrm.write_attributes_safe( + {"operation_preset": 1}, manufacturer=mfg_code + ) + if preset == PRESET_SCHEDULE: + return await self._thrm.write_attributes_safe( + {"operation_preset": 0}, manufacturer=mfg_code + ) + if preset == self.PRESET_HOLIDAY: + return await self._thrm.write_attributes_safe( + {"operation_preset": 3}, manufacturer=mfg_code + ) + if preset == self.PRESET_FROST: + return await self._thrm.write_attributes_safe( + {"operation_preset": 4}, manufacturer=mfg_code + ) diff --git a/zha/config_flow.py b/zha/config_flow.py new file mode 100644 index 00000000..c2b644e3 --- /dev/null +++ b/zha/config_flow.py @@ -0,0 +1,783 @@ +"""Config flow for ZHA.""" + +from __future__ import annotations + +import collections +from contextlib import suppress +import json +from typing import Any + +from homeassistant.components import onboarding, usb, zeroconf +from homeassistant.components.file_upload import process_uploaded_file +from homeassistant.components.hassio import AddonError, AddonState +from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon +from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware +from homeassistant.config_entries import ( + SOURCE_IGNORE, + SOURCE_ZEROCONF, + ConfigEntry, + ConfigEntryBaseFlow, + ConfigEntryState, + ConfigFlow, + ConfigFlowResult, + OperationNotAllowed, + OptionsFlow, +) +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.selector import FileSelector, FileSelectorConfig +from homeassistant.util import dt as dt_util +import serial.tools.list_ports +from serial.tools.list_ports_common import ListPortInfo +import voluptuous as vol +import zigpy.backups +from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH + +from .core.const import ( + CONF_BAUDRATE, + CONF_FLOW_CONTROL, + CONF_RADIO_TYPE, + DOMAIN, + RadioType, +) +from .radio_manager import ( + DEVICE_SCHEMA, + HARDWARE_DISCOVERY_SCHEMA, + RECOMMENDED_RADIOS, + ProbeResult, + ZhaRadioManager, +) + +CONF_MANUAL_PATH = "Enter Manually" +SUPPORTED_PORT_SETTINGS = ( + CONF_BAUDRATE, + CONF_FLOW_CONTROL, +) +DECONZ_DOMAIN = "deconz" + +FORMATION_STRATEGY = "formation_strategy" +FORMATION_FORM_NEW_NETWORK = "form_new_network" +FORMATION_FORM_INITIAL_NETWORK = "form_initial_network" +FORMATION_REUSE_SETTINGS = "reuse_settings" +FORMATION_CHOOSE_AUTOMATIC_BACKUP = "choose_automatic_backup" +FORMATION_UPLOAD_MANUAL_BACKUP = "upload_manual_backup" + +CHOOSE_AUTOMATIC_BACKUP = "choose_automatic_backup" +OVERWRITE_COORDINATOR_IEEE = "overwrite_coordinator_ieee" + +OPTIONS_INTENT_MIGRATE = "intent_migrate" +OPTIONS_INTENT_RECONFIGURE = "intent_reconfigure" + +UPLOADED_BACKUP_FILE = "uploaded_backup_file" + +REPAIR_MY_URL = "https://my.home-assistant.io/redirect/repairs/" + +DEFAULT_ZHA_ZEROCONF_PORT = 6638 +ESPHOME_API_PORT = 6053 + + +def _format_backup_choice( + backup: zigpy.backups.NetworkBackup, *, pan_ids: bool = True +) -> str: + """Format network backup info into a short piece of text.""" + if not pan_ids: + return dt_util.as_local(backup.backup_time).strftime("%c") + + identifier = ( + # PAN ID + f"{str(backup.network_info.pan_id)[2:]}" + # EPID + f":{str(backup.network_info.extended_pan_id).replace(':', '')}" + ).lower() + + return f"{dt_util.as_local(backup.backup_time).strftime('%c')} ({identifier})" + + +async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]: + """List all serial ports, including the Yellow radio and the multi-PAN addon.""" + ports = await hass.async_add_executor_job(serial.tools.list_ports.comports) + + # Add useful info to the Yellow's serial port selection screen + try: + yellow_hardware.async_info(hass) + except HomeAssistantError: + pass + else: + yellow_radio = next(p for p in ports if p.device == "/dev/ttyAMA1") + yellow_radio.description = "Yellow Zigbee module" + yellow_radio.manufacturer = "Nabu Casa" + + # Present the multi-PAN addon as a setup option, if it's available + multipan_manager = await silabs_multiprotocol_addon.get_multiprotocol_addon_manager( + hass + ) + + try: + addon_info = await multipan_manager.async_get_addon_info() + except (AddonError, KeyError): + addon_info = None + + if addon_info is not None and addon_info.state != AddonState.NOT_INSTALLED: + addon_port = ListPortInfo( + device=silabs_multiprotocol_addon.get_zigbee_socket(), + skip_link_detection=True, + ) + + addon_port.description = "Multiprotocol add-on" + addon_port.manufacturer = "Nabu Casa" + ports.append(addon_port) + + return ports + + +class BaseZhaFlow(ConfigEntryBaseFlow): + """Mixin for common ZHA flow steps and forms.""" + + _hass: HomeAssistant + + def __init__(self) -> None: + """Initialize flow instance.""" + super().__init__() + + self._hass = None # type: ignore[assignment] + self._radio_mgr = ZhaRadioManager() + self._title: str | None = None + + @property + def hass(self): + """Return hass.""" + return self._hass + + @hass.setter + def hass(self, hass): + """Set hass.""" + self._hass = hass + self._radio_mgr.hass = hass + + async def _async_create_radio_entry(self) -> ConfigFlowResult: + """Create a config entry with the current flow state.""" + assert self._title is not None + assert self._radio_mgr.radio_type is not None + assert self._radio_mgr.device_path is not None + assert self._radio_mgr.device_settings is not None + + device_settings = self._radio_mgr.device_settings.copy() + device_settings[CONF_DEVICE_PATH] = await self.hass.async_add_executor_job( + usb.get_serial_by_id, self._radio_mgr.device_path + ) + + return self.async_create_entry( + title=self._title, + data={ + CONF_DEVICE: DEVICE_SCHEMA(device_settings), + CONF_RADIO_TYPE: self._radio_mgr.radio_type.name, + }, + ) + + async def async_step_choose_serial_port( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Choose a serial port.""" + ports = await list_serial_ports(self.hass) + list_of_ports = [ + f"{p}{', s/n: ' + p.serial_number if p.serial_number else ''}" + + (f" - {p.manufacturer}" if p.manufacturer else "") + for p in ports + ] + + if not list_of_ports: + return await self.async_step_manual_pick_radio_type() + + list_of_ports.append(CONF_MANUAL_PATH) + + if user_input is not None: + user_selection = user_input[CONF_DEVICE_PATH] + + if user_selection == CONF_MANUAL_PATH: + return await self.async_step_manual_pick_radio_type() + + port = ports[list_of_ports.index(user_selection)] + self._radio_mgr.device_path = port.device + + probe_result = await self._radio_mgr.detect_radio_type() + if probe_result == ProbeResult.WRONG_FIRMWARE_INSTALLED: + return self.async_abort( + reason="wrong_firmware_installed", + description_placeholders={"repair_url": REPAIR_MY_URL}, + ) + if probe_result == ProbeResult.PROBING_FAILED: + # Did not autodetect anything, proceed to manual selection + return await self.async_step_manual_pick_radio_type() + + self._title = ( + f"{port.description}{', s/n: ' + port.serial_number if port.serial_number else ''}" + f" - {port.manufacturer}" + if port.manufacturer + else "" + ) + + return await self.async_step_verify_radio() + + # Pre-select the currently configured port + default_port = vol.UNDEFINED + + if self._radio_mgr.device_path is not None: + for description, port in zip(list_of_ports, ports): + if port.device == self._radio_mgr.device_path: + default_port = description + break + else: + default_port = CONF_MANUAL_PATH + + schema = vol.Schema( + { + vol.Required(CONF_DEVICE_PATH, default=default_port): vol.In( + list_of_ports + ) + } + ) + return self.async_show_form(step_id="choose_serial_port", data_schema=schema) + + async def async_step_manual_pick_radio_type( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manually select the radio type.""" + if user_input is not None: + self._radio_mgr.radio_type = RadioType.get_by_description( + user_input[CONF_RADIO_TYPE] + ) + return await self.async_step_manual_port_config() + + # Pre-select the current radio type + default = vol.UNDEFINED + + if self._radio_mgr.radio_type is not None: + default = self._radio_mgr.radio_type.description + + schema = { + vol.Required(CONF_RADIO_TYPE, default=default): vol.In(RadioType.list()) + } + + return self.async_show_form( + step_id="manual_pick_radio_type", + data_schema=vol.Schema(schema), + ) + + async def async_step_manual_port_config( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Enter port settings specific for this type of radio.""" + assert self._radio_mgr.radio_type is not None + errors = {} + + if user_input is not None: + self._title = user_input[CONF_DEVICE_PATH] + self._radio_mgr.device_path = user_input[CONF_DEVICE_PATH] + self._radio_mgr.device_settings = user_input.copy() + + if await self._radio_mgr.radio_type.controller.probe(user_input): + return await self.async_step_verify_radio() + + errors["base"] = "cannot_connect" + + schema = { + vol.Required( + CONF_DEVICE_PATH, default=self._radio_mgr.device_path or vol.UNDEFINED + ): str + } + + source = self.context.get("source") + for ( + param, + value, + ) in DEVICE_SCHEMA.schema.items(): + if param not in SUPPORTED_PORT_SETTINGS: + continue + + if source == SOURCE_ZEROCONF and param == CONF_BAUDRATE: + value = 115200 + param = vol.Required(CONF_BAUDRATE, default=value) + elif ( + self._radio_mgr.device_settings is not None + and param in self._radio_mgr.device_settings + ): + param = vol.Required( + str(param), default=self._radio_mgr.device_settings[param] + ) + + schema[param] = value + + return self.async_show_form( + step_id="manual_port_config", + data_schema=vol.Schema(schema), + errors=errors, + ) + + async def async_step_verify_radio( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Add a warning step to dissuade the use of deprecated radios.""" + assert self._radio_mgr.radio_type is not None + + # Skip this step if we are using a recommended radio + if user_input is not None or self._radio_mgr.radio_type in RECOMMENDED_RADIOS: + return await self.async_step_choose_formation_strategy() + + return self.async_show_form( + step_id="verify_radio", + description_placeholders={ + CONF_NAME: self._radio_mgr.radio_type.description, + "docs_recommended_adapters_url": ( + "https://www.home-assistant.io/integrations/zha/#recommended-zigbee-radio-adapters-and-modules" + ), + }, + ) + + async def async_step_choose_formation_strategy( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Choose how to deal with the current radio's settings.""" + await self._radio_mgr.async_load_network_settings() + + strategies = [] + + # Check if we have any automatic backups *and* if the backups differ from + # the current radio settings, if they exist (since restoring would be redundant) + if self._radio_mgr.backups and ( + self._radio_mgr.current_settings is None + or any( + not backup.is_compatible_with(self._radio_mgr.current_settings) + for backup in self._radio_mgr.backups + ) + ): + strategies.append(CHOOSE_AUTOMATIC_BACKUP) + + if self._radio_mgr.current_settings is not None: + strategies.append(FORMATION_REUSE_SETTINGS) + + strategies.append(FORMATION_UPLOAD_MANUAL_BACKUP) + + # Do not show "erase network settings" if there are none to erase + if self._radio_mgr.current_settings is None: + strategies.append(FORMATION_FORM_INITIAL_NETWORK) + else: + strategies.append(FORMATION_FORM_NEW_NETWORK) + + # Automatically form a new network if we're onboarding with a brand new radio + if not onboarding.async_is_onboarded(self.hass) and set(strategies) == { + FORMATION_UPLOAD_MANUAL_BACKUP, + FORMATION_FORM_INITIAL_NETWORK, + }: + return await self.async_step_form_initial_network() + + # Otherwise, let the user choose + return self.async_show_menu( + step_id="choose_formation_strategy", + menu_options=strategies, + ) + + async def async_step_reuse_settings( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reuse the existing network settings on the stick.""" + return await self._async_create_radio_entry() + + async def async_step_form_initial_network( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Form an initial network.""" + # This step exists only for translations, it does nothing new + return await self.async_step_form_new_network(user_input) + + async def async_step_form_new_network( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Form a brand-new network.""" + await self._radio_mgr.async_form_network() + return await self._async_create_radio_entry() + + def _parse_uploaded_backup( + self, uploaded_file_id: str + ) -> zigpy.backups.NetworkBackup: + """Read and parse an uploaded backup JSON file.""" + with process_uploaded_file(self.hass, uploaded_file_id) as file_path: + contents = file_path.read_text() + + return zigpy.backups.NetworkBackup.from_dict(json.loads(contents)) + + async def async_step_upload_manual_backup( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Upload and restore a coordinator backup JSON file.""" + errors = {} + + if user_input is not None: + try: + self._radio_mgr.chosen_backup = await self.hass.async_add_executor_job( + self._parse_uploaded_backup, user_input[UPLOADED_BACKUP_FILE] + ) + except ValueError: + errors["base"] = "invalid_backup_json" + else: + return await self.async_step_maybe_confirm_ezsp_restore() + + return self.async_show_form( + step_id="upload_manual_backup", + data_schema=vol.Schema( + { + vol.Required(UPLOADED_BACKUP_FILE): FileSelector( + FileSelectorConfig(accept=".json,application/json") + ) + } + ), + errors=errors, + ) + + async def async_step_choose_automatic_backup( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Choose an automatic backup.""" + if self.show_advanced_options: + # Always show the PAN IDs when in advanced mode + choices = [ + _format_backup_choice(backup, pan_ids=True) + for backup in self._radio_mgr.backups + ] + else: + # Only show the PAN IDs for multiple backups taken on the same day + num_backups_on_date = collections.Counter( + backup.backup_time.date() for backup in self._radio_mgr.backups + ) + choices = [ + _format_backup_choice( + backup, pan_ids=(num_backups_on_date[backup.backup_time.date()] > 1) + ) + for backup in self._radio_mgr.backups + ] + + if user_input is not None: + index = choices.index(user_input[CHOOSE_AUTOMATIC_BACKUP]) + self._radio_mgr.chosen_backup = self._radio_mgr.backups[index] + + return await self.async_step_maybe_confirm_ezsp_restore() + + return self.async_show_form( + step_id="choose_automatic_backup", + data_schema=vol.Schema( + { + vol.Required(CHOOSE_AUTOMATIC_BACKUP, default=choices[0]): vol.In( + choices + ), + } + ), + ) + + async def async_step_maybe_confirm_ezsp_restore( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm restore for EZSP radios that require permanent IEEE writes.""" + call_step_2 = await self._radio_mgr.async_restore_backup_step_1() + if not call_step_2: + return await self._async_create_radio_entry() + + if user_input is not None: + await self._radio_mgr.async_restore_backup_step_2( + user_input[OVERWRITE_COORDINATOR_IEEE] + ) + return await self._async_create_radio_entry() + + return self.async_show_form( + step_id="maybe_confirm_ezsp_restore", + data_schema=vol.Schema( + {vol.Required(OVERWRITE_COORDINATOR_IEEE, default=True): bool} + ), + ) + + +class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 4 + + async def _set_unique_id_and_update_ignored_flow( + self, unique_id: str, device_path: str + ) -> None: + """Set the flow's unique ID and update the device path in an ignored flow.""" + current_entry = await self.async_set_unique_id(unique_id) + + if not current_entry: + return + + if current_entry.source != SOURCE_IGNORE: + self._abort_if_unique_id_configured() + else: + # Only update the current entry if it is an ignored discovery + self._abort_if_unique_id_configured( + updates={ + CONF_DEVICE: { + **current_entry.data.get(CONF_DEVICE, {}), + CONF_DEVICE_PATH: device_path, + }, + } + ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OptionsFlow: + """Create the options flow.""" + return ZhaOptionsFlowHandler(config_entry) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a ZHA config flow start.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + return await self.async_step_choose_serial_port(user_input) + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm a discovery.""" + self._set_confirm_only() + + # Don't permit discovery if ZHA is already set up + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + # Without confirmation, discovery can automatically progress into parts of the + # config flow logic that interacts with hardware. + if user_input is not None or not onboarding.async_is_onboarded(self.hass): + # Probe the radio type if we don't have one yet + if self._radio_mgr.radio_type is None: + probe_result = await self._radio_mgr.detect_radio_type() + else: + probe_result = ProbeResult.RADIO_TYPE_DETECTED + + if probe_result == ProbeResult.WRONG_FIRMWARE_INSTALLED: + return self.async_abort( + reason="wrong_firmware_installed", + description_placeholders={"repair_url": REPAIR_MY_URL}, + ) + if probe_result == ProbeResult.PROBING_FAILED: + # This path probably will not happen now that we have + # more precise USB matching unless there is a problem + # with the device + return self.async_abort(reason="usb_probe_failed") + + if self._radio_mgr.device_settings is None: + return await self.async_step_manual_port_config() + + return await self.async_step_verify_radio() + + return self.async_show_form( + step_id="confirm", + description_placeholders={CONF_NAME: self._title}, + ) + + async def async_step_usb( + self, discovery_info: usb.UsbServiceInfo + ) -> ConfigFlowResult: + """Handle usb discovery.""" + vid = discovery_info.vid + pid = discovery_info.pid + serial_number = discovery_info.serial_number + manufacturer = discovery_info.manufacturer + description = discovery_info.description + dev_path = discovery_info.device + + await self._set_unique_id_and_update_ignored_flow( + unique_id=f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}", + device_path=dev_path, + ) + + # If they already have a discovery for deconz we ignore the usb discovery as + # they probably want to use it there instead + if self.hass.config_entries.flow.async_progress_by_handler(DECONZ_DOMAIN): + return self.async_abort(reason="not_zha_device") + for entry in self.hass.config_entries.async_entries(DECONZ_DOMAIN): + if entry.source != SOURCE_IGNORE: + return self.async_abort(reason="not_zha_device") + + self._radio_mgr.device_path = dev_path + self._title = description or usb.human_readable_device_name( + dev_path, + serial_number, + manufacturer, + description, + vid, + pid, + ) + self.context["title_placeholders"] = {CONF_NAME: self._title} + return await self.async_step_confirm() + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + + # Hostname is format: livingroom.local. + local_name = discovery_info.hostname[:-1] + port = discovery_info.port or DEFAULT_ZHA_ZEROCONF_PORT + + # Fix incorrect port for older TubesZB devices + if "tube" in local_name and port == ESPHOME_API_PORT: + port = DEFAULT_ZHA_ZEROCONF_PORT + + if "radio_type" in discovery_info.properties: + self._radio_mgr.radio_type = self._radio_mgr.parse_radio_type( + discovery_info.properties["radio_type"] + ) + elif "efr32" in local_name: + self._radio_mgr.radio_type = RadioType.ezsp + else: + self._radio_mgr.radio_type = RadioType.znp + + node_name = local_name.removesuffix(".local") + device_path = f"socket://{discovery_info.host}:{port}" + + await self._set_unique_id_and_update_ignored_flow( + unique_id=node_name, + device_path=device_path, + ) + + self.context["title_placeholders"] = {CONF_NAME: node_name} + self._title = device_path + self._radio_mgr.device_path = device_path + + return await self.async_step_confirm() + + async def async_step_hardware( + self, data: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle hardware flow.""" + try: + discovery_data = HARDWARE_DISCOVERY_SCHEMA(data) + except vol.Invalid: + return self.async_abort(reason="invalid_hardware_data") + + name = discovery_data["name"] + radio_type = self._radio_mgr.parse_radio_type(discovery_data["radio_type"]) + device_settings = discovery_data["port"] + device_path = device_settings[CONF_DEVICE_PATH] + + await self._set_unique_id_and_update_ignored_flow( + unique_id=f"{name}_{radio_type.name}_{device_path}", + device_path=device_path, + ) + + self._title = name + self._radio_mgr.radio_type = radio_type + self._radio_mgr.device_path = device_path + self._radio_mgr.device_settings = device_settings + self.context["title_placeholders"] = {CONF_NAME: name} + + return await self.async_step_confirm() + + +class ZhaOptionsFlowHandler(BaseZhaFlow, OptionsFlow): + """Handle an options flow.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + super().__init__() + self.config_entry = config_entry + + self._radio_mgr.device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] + self._radio_mgr.device_settings = config_entry.data[CONF_DEVICE] + self._radio_mgr.radio_type = RadioType[config_entry.data[CONF_RADIO_TYPE]] + self._title = config_entry.title + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Launch the options flow.""" + if user_input is not None: + # OperationNotAllowed: ZHA is not running + with suppress(OperationNotAllowed): + await self.hass.config_entries.async_unload(self.config_entry.entry_id) + + return await self.async_step_prompt_migrate_or_reconfigure() + + return self.async_show_form(step_id="init") + + async def async_step_prompt_migrate_or_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm if we are migrating adapters or just re-configuring.""" + + return self.async_show_menu( + step_id="prompt_migrate_or_reconfigure", + menu_options=[ + OPTIONS_INTENT_RECONFIGURE, + OPTIONS_INTENT_MIGRATE, + ], + ) + + async def async_step_intent_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Virtual step for when the user is reconfiguring the integration.""" + return await self.async_step_choose_serial_port() + + async def async_step_intent_migrate( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the user wants to reset their current radio.""" + + if user_input is not None: + await self._radio_mgr.async_reset_adapter() + + return await self.async_step_instruct_unplug() + + return self.async_show_form(step_id="intent_migrate") + + async def async_step_instruct_unplug( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Instruct the user to unplug the current radio, if possible.""" + + if user_input is not None: + # Now that the old radio is gone, we can scan for serial ports again + return await self.async_step_choose_serial_port() + + return self.async_show_form(step_id="instruct_unplug") + + async def _async_create_radio_entry(self): + """Re-implementation of the base flow's final step to update the config.""" + device_settings = self._radio_mgr.device_settings.copy() + device_settings[CONF_DEVICE_PATH] = await self.hass.async_add_executor_job( + usb.get_serial_by_id, self._radio_mgr.device_path + ) + + # Avoid creating both `.options` and `.data` by directly writing `data` here + self.hass.config_entries.async_update_entry( + entry=self.config_entry, + data={ + CONF_DEVICE: device_settings, + CONF_RADIO_TYPE: self._radio_mgr.radio_type.name, + }, + options=self.config_entry.options, + ) + + # Reload ZHA after we finish + await self.hass.config_entries.async_setup(self.config_entry.entry_id) + + # Intentionally do not set `data` to avoid creating `options`, we set it above + return self.async_create_entry(title=self._title, data={}) + + def async_remove(self): + """Maybe reload ZHA if the flow is aborted.""" + if self.config_entry.state not in ( + ConfigEntryState.SETUP_ERROR, + ConfigEntryState.NOT_LOADED, + ): + return + + self.hass.async_create_task( + self.hass.config_entries.async_setup(self.config_entry.entry_id) + ) diff --git a/zha/core/__init__.py b/zha/core/__init__.py new file mode 100644 index 00000000..755eac3c --- /dev/null +++ b/zha/core/__init__.py @@ -0,0 +1,6 @@ +"""Core module for Zigbee Home Automation.""" + +from .device import ZHADevice +from .gateway import ZHAGateway + +__all__ = ["ZHADevice", "ZHAGateway"] diff --git a/zha/core/cluster_handlers/__init__.py b/zha/core/cluster_handlers/__init__.py new file mode 100644 index 00000000..d8400880 --- /dev/null +++ b/zha/core/cluster_handlers/__init__.py @@ -0,0 +1,655 @@ +"""Cluster handlers module for Zigbee Home Automation.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Coroutine, Iterator +import contextlib +from enum import Enum +import functools +import logging +from typing import TYPE_CHECKING, Any, ParamSpec, TypedDict + +from homeassistant.const import ATTR_COMMAND +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_send +import zigpy.exceptions +import zigpy.util +import zigpy.zcl +from zigpy.zcl.foundation import ( + CommandSchema, + ConfigureReportingResponseRecord, + Status, + ZCLAttributeDef, +) + +from ..const import ( + ATTR_ARGS, + ATTR_ATTRIBUTE_ID, + ATTR_ATTRIBUTE_NAME, + ATTR_CLUSTER_ID, + ATTR_PARAMS, + ATTR_TYPE, + ATTR_UNIQUE_ID, + ATTR_VALUE, + CLUSTER_HANDLER_ZDO, + REPORT_CONFIG_ATTR_PER_REQ, + SIGNAL_ATTR_UPDATED, + ZHA_CLUSTER_HANDLER_MSG, + ZHA_CLUSTER_HANDLER_MSG_BIND, + ZHA_CLUSTER_HANDLER_MSG_CFG_RPT, + ZHA_CLUSTER_HANDLER_MSG_DATA, + ZHA_CLUSTER_HANDLER_READS_PER_REQ, +) +from ..helpers import LogMixin, safe_read + +if TYPE_CHECKING: + from ..endpoint import Endpoint + +_LOGGER = logging.getLogger(__name__) +RETRYABLE_REQUEST_DECORATOR = zigpy.util.retryable_request(tries=3) +UNPROXIED_CLUSTER_METHODS = {"general_command"} + + +_P = ParamSpec("_P") +_FuncType = Callable[_P, Awaitable[Any]] +_ReturnFuncType = Callable[_P, Coroutine[Any, Any, Any]] + + +@contextlib.contextmanager +def wrap_zigpy_exceptions() -> Iterator[None]: + """Wrap zigpy exceptions in `HomeAssistantError` exceptions.""" + try: + yield + except TimeoutError as exc: + raise HomeAssistantError( + "Failed to send request: device did not respond" + ) from exc + except zigpy.exceptions.ZigbeeException as exc: + message = "Failed to send request" + + if str(exc): + message = f"{message}: {exc}" + + raise HomeAssistantError(message) from exc + + +def retry_request(func: _FuncType[_P]) -> _ReturnFuncType[_P]: + """Send a request with retries and wrap expected zigpy exceptions.""" + + @functools.wraps(func) + async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> Any: + with wrap_zigpy_exceptions(): + return await RETRYABLE_REQUEST_DECORATOR(func)(*args, **kwargs) + + return wrapper + + +class AttrReportConfig(TypedDict, total=True): + """Configuration to report for the attributes.""" + + # An attribute name + attr: str + # The config for the attribute reporting configuration consists of a tuple for + # (minimum_reported_time_interval_s, maximum_reported_time_interval_s, value_delta) + config: tuple[int, int, int | float] + + +def parse_and_log_command(cluster_handler, tsn, command_id, args): + """Parse and log a zigbee cluster command.""" + try: + name = cluster_handler.cluster.server_commands[command_id].name + except KeyError: + name = f"0x{command_id:02X}" + + cluster_handler.debug( + "received '%s' command with %s args on cluster_id '%s' tsn '%s'", + name, + args, + cluster_handler.cluster.cluster_id, + tsn, + ) + return name + + +class ClusterHandlerStatus(Enum): + """Status of a cluster handler.""" + + CREATED = 1 + CONFIGURED = 2 + INITIALIZED = 3 + + +class ClusterHandler(LogMixin): + """Base cluster handler for a Zigbee cluster.""" + + REPORT_CONFIG: tuple[AttrReportConfig, ...] = () + BIND: bool = True + + # Dict of attributes to read on cluster handler initialization. + # Dict keys -- attribute ID or names, with bool value indicating whether a cached + # attribute read is acceptable. + ZCL_INIT_ATTRS: dict[str, bool] = {} + + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize ClusterHandler.""" + self._generic_id = f"cluster_handler_0x{cluster.cluster_id:04x}" + self._endpoint: Endpoint = endpoint + self._cluster = cluster + self._id = f"{endpoint.id}:0x{cluster.cluster_id:04x}" + unique_id = endpoint.unique_id.replace("-", ":") + self._unique_id = f"{unique_id}:0x{cluster.cluster_id:04x}" + if not hasattr(self, "_value_attribute") and self.REPORT_CONFIG: + attr_def: ZCLAttributeDef = self.cluster.attributes_by_name[ + self.REPORT_CONFIG[0]["attr"] + ] + self.value_attribute = attr_def.id + self._status = ClusterHandlerStatus.CREATED + self._cluster.add_listener(self) + self.data_cache: dict[str, Enum] = {} + + @classmethod + def matches(cls, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> bool: + """Filter the cluster match for specific devices.""" + return True + + @property + def id(self) -> str: + """Return cluster handler id unique for this device only.""" + return self._id + + @property + def generic_id(self): + """Return the generic id for this cluster handler.""" + return self._generic_id + + @property + def unique_id(self): + """Return the unique id for this cluster handler.""" + return self._unique_id + + @property + def cluster(self): + """Return the zigpy cluster for this cluster handler.""" + return self._cluster + + @property + def name(self) -> str: + """Return friendly name.""" + return self.cluster.ep_attribute or self._generic_id + + @property + def status(self): + """Return the status of the cluster handler.""" + return self._status + + def __hash__(self) -> int: + """Make this a hashable.""" + return hash(self._unique_id) + + @callback + def async_send_signal(self, signal: str, *args: Any) -> None: + """Send a signal through hass dispatcher.""" + self._endpoint.async_send_signal(signal, *args) + + async def bind(self): + """Bind a zigbee cluster. + + This also swallows ZigbeeException exceptions that are thrown when + devices are unreachable. + """ + try: + res = await self.cluster.bind() + self.debug("bound '%s' cluster: %s", self.cluster.ep_attribute, res[0]) + async_dispatcher_send( + self._endpoint.device.hass, + ZHA_CLUSTER_HANDLER_MSG, + { + ATTR_TYPE: ZHA_CLUSTER_HANDLER_MSG_BIND, + ZHA_CLUSTER_HANDLER_MSG_DATA: { + "cluster_name": self.cluster.name, + "cluster_id": self.cluster.cluster_id, + "success": res[0] == 0, + }, + }, + ) + except (zigpy.exceptions.ZigbeeException, TimeoutError) as ex: + self.debug( + "Failed to bind '%s' cluster: %s", + self.cluster.ep_attribute, + str(ex), + exc_info=ex, + ) + async_dispatcher_send( + self._endpoint.device.hass, + ZHA_CLUSTER_HANDLER_MSG, + { + ATTR_TYPE: ZHA_CLUSTER_HANDLER_MSG_BIND, + ZHA_CLUSTER_HANDLER_MSG_DATA: { + "cluster_name": self.cluster.name, + "cluster_id": self.cluster.cluster_id, + "success": False, + }, + }, + ) + + async def configure_reporting(self) -> None: + """Configure attribute reporting for a cluster. + + This also swallows ZigbeeException exceptions that are thrown when + devices are unreachable. + """ + event_data = {} + kwargs = {} + if ( + self.cluster.cluster_id >= 0xFC00 + and self._endpoint.device.manufacturer_code + ): + kwargs["manufacturer"] = self._endpoint.device.manufacturer_code + + for attr_report in self.REPORT_CONFIG: + attr, config = attr_report["attr"], attr_report["config"] + + try: + attr_name = self.cluster.find_attribute(attr).name + except KeyError: + attr_name = attr + + event_data[attr_name] = { + "min": config[0], + "max": config[1], + "id": attr, + "name": attr_name, + "change": config[2], + "status": None, + } + + to_configure = [*self.REPORT_CONFIG] + chunk, rest = ( + to_configure[:REPORT_CONFIG_ATTR_PER_REQ], + to_configure[REPORT_CONFIG_ATTR_PER_REQ:], + ) + while chunk: + reports = {rec["attr"]: rec["config"] for rec in chunk} + try: + res = await self.cluster.configure_reporting_multiple(reports, **kwargs) + self._configure_reporting_status(reports, res[0], event_data) + except (zigpy.exceptions.ZigbeeException, TimeoutError) as ex: + self.debug( + "failed to set reporting on '%s' cluster for: %s", + self.cluster.ep_attribute, + str(ex), + ) + break + chunk, rest = ( + rest[:REPORT_CONFIG_ATTR_PER_REQ], + rest[REPORT_CONFIG_ATTR_PER_REQ:], + ) + + async_dispatcher_send( + self._endpoint.device.hass, + ZHA_CLUSTER_HANDLER_MSG, + { + ATTR_TYPE: ZHA_CLUSTER_HANDLER_MSG_CFG_RPT, + ZHA_CLUSTER_HANDLER_MSG_DATA: { + "cluster_name": self.cluster.name, + "cluster_id": self.cluster.cluster_id, + "attributes": event_data, + }, + }, + ) + + def _configure_reporting_status( + self, + attrs: dict[str, tuple[int, int, float | int]], + res: list | tuple, + event_data: dict[str, dict[str, Any]], + ) -> None: + """Parse configure reporting result.""" + if isinstance(res, (Exception, ConfigureReportingResponseRecord)): + # assume default response + self.debug( + "attr reporting for '%s' on '%s': %s", + attrs, + self.name, + res, + ) + for attr in attrs: + event_data[attr]["status"] = Status.FAILURE.name + return + if res[0].status == Status.SUCCESS and len(res) == 1: + self.debug( + "Successfully configured reporting for '%s' on '%s' cluster: %s", + attrs, + self.name, + res, + ) + # 2.5.8.1.3 Status Field + # The status field specifies the status of the Configure Reporting operation attempted on this attribute, as detailed in 2.5.7.3. + # Note that attribute status records are not included for successfully configured attributes, in order to save bandwidth. + # In the case of successful configuration of all attributes, only a single attribute status record SHALL be included in the command, + # with the status field set to SUCCESS and the direction and attribute identifier fields omitted. + for attr in attrs: + event_data[attr]["status"] = Status.SUCCESS.name + return + + for record in res: + event_data[self.cluster.find_attribute(record.attrid).name][ + "status" + ] = record.status.name + failed = [ + self.cluster.find_attribute(record.attrid).name + for record in res + if record.status != Status.SUCCESS + ] + self.debug( + "Failed to configure reporting for '%s' on '%s' cluster: %s", + failed, + self.name, + res, + ) + success = set(attrs) - set(failed) + self.debug( + "Successfully configured reporting for '%s' on '%s' cluster", + set(attrs) - set(failed), + self.name, + ) + for attr in success: + event_data[attr]["status"] = Status.SUCCESS.name + + async def async_configure(self) -> None: + """Set cluster binding and attribute reporting.""" + if not self._endpoint.device.skip_configuration: + if self.BIND: + self.debug("Performing cluster binding") + await self.bind() + if self.cluster.is_server: + self.debug("Configuring cluster attribute reporting") + await self.configure_reporting() + ch_specific_cfg = getattr( + self, "async_configure_cluster_handler_specific", None + ) + if ch_specific_cfg: + self.debug("Performing cluster handler specific configuration") + await ch_specific_cfg() + self.debug("finished cluster handler configuration") + else: + self.debug("skipping cluster handler configuration") + self._status = ClusterHandlerStatus.CONFIGURED + + async def async_initialize(self, from_cache: bool) -> None: + """Initialize cluster handler.""" + if not from_cache and self._endpoint.device.skip_configuration: + self.debug("Skipping cluster handler initialization") + self._status = ClusterHandlerStatus.INITIALIZED + return + + self.debug("initializing cluster handler: from_cache: %s", from_cache) + cached = [a for a, cached in self.ZCL_INIT_ATTRS.items() if cached] + uncached = [a for a, cached in self.ZCL_INIT_ATTRS.items() if not cached] + uncached.extend([cfg["attr"] for cfg in self.REPORT_CONFIG]) + + if cached: + self.debug("initializing cached cluster handler attributes: %s", cached) + await self._get_attributes( + True, cached, from_cache=True, only_cache=from_cache + ) + if uncached: + self.debug( + "initializing uncached cluster handler attributes: %s - from cache[%s]", + uncached, + from_cache, + ) + await self._get_attributes( + True, uncached, from_cache=from_cache, only_cache=from_cache + ) + + ch_specific_init = getattr( + self, "async_initialize_cluster_handler_specific", None + ) + if ch_specific_init: + self.debug( + "Performing cluster handler specific initialization: %s", uncached + ) + await ch_specific_init(from_cache=from_cache) + + self.debug("finished cluster handler initialization") + self._status = ClusterHandlerStatus.INITIALIZED + + @callback + def cluster_command(self, tsn, command_id, args): + """Handle commands received to this cluster.""" + + @callback + def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: + """Handle attribute updates on this cluster.""" + attr_name = self._get_attribute_name(attrid) + self.debug( + "cluster_handler[%s] attribute_updated - cluster[%s] attr[%s] value[%s]", + self.name, + self.cluster.name, + attr_name, + value, + ) + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + attrid, + attr_name, + value, + ) + + @callback + def zdo_command(self, *args, **kwargs): + """Handle ZDO commands on this cluster.""" + + @callback + def zha_send_event(self, command: str, arg: list | dict | CommandSchema) -> None: + """Relay events to hass.""" + + args: list | dict + if isinstance(arg, CommandSchema): + args = [a for a in arg if a is not None] + params = arg.as_dict() + elif isinstance(arg, (list, dict)): + # Quirks can directly send lists and dicts to ZHA this way + args = arg + params = {} + else: + raise TypeError(f"Unexpected zha_send_event {command!r} argument: {arg!r}") + + self._endpoint.send_event( + { + ATTR_UNIQUE_ID: self.unique_id, + ATTR_CLUSTER_ID: self.cluster.cluster_id, + ATTR_COMMAND: command, + # Maintain backwards compatibility with the old zigpy response format + ATTR_ARGS: args, + ATTR_PARAMS: params, + } + ) + + async def async_update(self): + """Retrieve latest state from cluster.""" + + def _get_attribute_name(self, attrid: int) -> str | int: + if attrid not in self.cluster.attributes: + return attrid + + return self.cluster.attributes[attrid].name + + async def get_attribute_value(self, attribute, from_cache=True): + """Get the value for an attribute.""" + manufacturer = None + manufacturer_code = self._endpoint.device.manufacturer_code + if self.cluster.cluster_id >= 0xFC00 and manufacturer_code: + manufacturer = manufacturer_code + result = await safe_read( + self._cluster, + [attribute], + allow_cache=from_cache, + only_cache=from_cache, + manufacturer=manufacturer, + ) + return result.get(attribute) + + async def _get_attributes( + self, + raise_exceptions: bool, + attributes: list[str], + from_cache: bool = True, + only_cache: bool = True, + ) -> dict[int | str, Any]: + """Get the values for a list of attributes.""" + manufacturer = None + manufacturer_code = self._endpoint.device.manufacturer_code + if self.cluster.cluster_id >= 0xFC00 and manufacturer_code: + manufacturer = manufacturer_code + chunk = attributes[:ZHA_CLUSTER_HANDLER_READS_PER_REQ] + rest = attributes[ZHA_CLUSTER_HANDLER_READS_PER_REQ:] + result = {} + while chunk: + try: + self.debug("Reading attributes in chunks: %s", chunk) + read, _ = await self.cluster.read_attributes( + chunk, + allow_cache=from_cache, + only_cache=only_cache, + manufacturer=manufacturer, + ) + result.update(read) + except (TimeoutError, zigpy.exceptions.ZigbeeException) as ex: + self.debug( + "failed to get attributes '%s' on '%s' cluster: %s", + chunk, + self.cluster.ep_attribute, + str(ex), + ) + if raise_exceptions: + raise + chunk = rest[:ZHA_CLUSTER_HANDLER_READS_PER_REQ] + rest = rest[ZHA_CLUSTER_HANDLER_READS_PER_REQ:] + return result + + get_attributes = functools.partialmethod(_get_attributes, False) + + async def write_attributes_safe( + self, attributes: dict[str, Any], manufacturer: int | None = None + ) -> None: + """Wrap `write_attributes` to throw an exception on attribute write failure.""" + + res = await self.write_attributes(attributes, manufacturer=manufacturer) + + for record in res[0]: + if record.status != Status.SUCCESS: + try: + name = self.cluster.attributes[record.attrid].name + value = attributes.get(name, "unknown") + except KeyError: + name = f"0x{record.attrid:04x}" + value = "unknown" + + raise HomeAssistantError( + f"Failed to write attribute {name}={value}: {record.status}", + ) + + def log(self, level, msg, *args, **kwargs): + """Log a message.""" + msg = f"[%s:%s]: {msg}" + args = (self._endpoint.device.nwk, self._id) + args + _LOGGER.log(level, msg, *args, **kwargs) + + def __getattr__(self, name): + """Get attribute or a decorated cluster command.""" + if ( + hasattr(self._cluster, name) + and callable(getattr(self._cluster, name)) + and name not in UNPROXIED_CLUSTER_METHODS + ): + command = getattr(self._cluster, name) + wrapped_command = retry_request(command) + wrapped_command.__name__ = name + + return wrapped_command + return self.__getattribute__(name) + + +class ZDOClusterHandler(LogMixin): + """Cluster handler for ZDO events.""" + + def __init__(self, device) -> None: + """Initialize ZDOClusterHandler.""" + self.name = CLUSTER_HANDLER_ZDO + self._cluster = device.device.endpoints[0] + self._zha_device = device + self._status = ClusterHandlerStatus.CREATED + self._unique_id = f"{str(device.ieee)}:{device.name}_ZDO" + self._cluster.add_listener(self) + + @property + def unique_id(self): + """Return the unique id for this cluster handler.""" + return self._unique_id + + @property + def cluster(self): + """Return the aigpy cluster for this cluster handler.""" + return self._cluster + + @property + def status(self): + """Return the status of the cluster handler.""" + return self._status + + @callback + def device_announce(self, zigpy_device): + """Device announce handler.""" + + @callback + def permit_duration(self, duration): + """Permit handler.""" + + async def async_initialize(self, from_cache): + """Initialize cluster handler.""" + self._status = ClusterHandlerStatus.INITIALIZED + + async def async_configure(self): + """Configure cluster handler.""" + self._status = ClusterHandlerStatus.CONFIGURED + + def log(self, level, msg, *args, **kwargs): + """Log a message.""" + msg = f"[%s:ZDO](%s): {msg}" + args = (self._zha_device.nwk, self._zha_device.model) + args + _LOGGER.log(level, msg, *args, **kwargs) + + +class ClientClusterHandler(ClusterHandler): + """ClusterHandler for Zigbee client (output) clusters.""" + + @callback + def attribute_updated(self, attrid: int, value: Any, timestamp: Any) -> None: + """Handle an attribute updated on this cluster.""" + super().attribute_updated(attrid, value, timestamp) + + try: + attr_name = self._cluster.attributes[attrid].name + except KeyError: + attr_name = "Unknown" + + self.zha_send_event( + SIGNAL_ATTR_UPDATED, + { + ATTR_ATTRIBUTE_ID: attrid, + ATTR_ATTRIBUTE_NAME: attr_name, + ATTR_VALUE: value, + }, + ) + + @callback + def cluster_command(self, tsn, command_id, args): + """Handle a cluster command received on this cluster.""" + if ( + self._cluster.server_commands is not None + and self._cluster.server_commands.get(command_id) is not None + ): + self.zha_send_event(self._cluster.server_commands[command_id].name, args) diff --git a/zha/core/cluster_handlers/closures.py b/zha/core/cluster_handlers/closures.py new file mode 100644 index 00000000..f2b7654a --- /dev/null +++ b/zha/core/cluster_handlers/closures.py @@ -0,0 +1,272 @@ +"""Closures cluster handlers module for Zigbee Home Automation.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import callback +import zigpy.types as t +from zigpy.zcl.clusters.closures import ConfigStatus, DoorLock, Shade, WindowCovering + +from .. import registries +from ..const import REPORT_CONFIG_IMMEDIATE, SIGNAL_ATTR_UPDATED +from . import AttrReportConfig, ClientClusterHandler, ClusterHandler + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(DoorLock.cluster_id) +class DoorLockClusterHandler(ClusterHandler): + """Door lock cluster handler.""" + + _value_attribute = 0 + REPORT_CONFIG = ( + AttrReportConfig( + attr=DoorLock.AttributeDefs.lock_state.name, + config=REPORT_CONFIG_IMMEDIATE, + ), + ) + + async def async_update(self): + """Retrieve latest state.""" + result = await self.get_attribute_value( + DoorLock.AttributeDefs.lock_state.name, from_cache=True + ) + if result is not None: + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + DoorLock.AttributeDefs.lock_state.id, + DoorLock.AttributeDefs.lock_state.name, + result, + ) + + @callback + def cluster_command(self, tsn, command_id, args): + """Handle a cluster command received on this cluster.""" + + if ( + self._cluster.client_commands is None + or self._cluster.client_commands.get(command_id) is None + ): + return + + command_name = self._cluster.client_commands[command_id].name + + if command_name == DoorLock.ClientCommandDefs.operation_event_notification.name: + self.zha_send_event( + command_name, + { + "source": args[0].name, + "operation": args[1].name, + "code_slot": (args[2] + 1), # start code slots at 1 + }, + ) + + @callback + def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: + """Handle attribute update from lock cluster.""" + attr_name = self._get_attribute_name(attrid) + self.debug( + "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value + ) + if attrid == self._value_attribute: + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value + ) + + async def async_set_user_code(self, code_slot: int, user_code: str) -> None: + """Set the user code for the code slot.""" + + await self.set_pin_code( + code_slot - 1, # start code slots at 1, Zigbee internals use 0 + DoorLock.UserStatus.Enabled, + DoorLock.UserType.Unrestricted, + user_code, + ) + + async def async_enable_user_code(self, code_slot: int) -> None: + """Enable the code slot.""" + + await self.set_user_status(code_slot - 1, DoorLock.UserStatus.Enabled) + + async def async_disable_user_code(self, code_slot: int) -> None: + """Disable the code slot.""" + + await self.set_user_status(code_slot - 1, DoorLock.UserStatus.Disabled) + + async def async_get_user_code(self, code_slot: int) -> int: + """Get the user code from the code slot.""" + + result = await self.get_pin_code(code_slot - 1) + return result + + async def async_clear_user_code(self, code_slot: int) -> None: + """Clear the code slot.""" + + await self.clear_pin_code(code_slot - 1) + + async def async_clear_all_user_codes(self) -> None: + """Clear all code slots.""" + + await self.clear_all_pin_codes() + + async def async_set_user_type(self, code_slot: int, user_type: str) -> None: + """Set user type.""" + + await self.set_user_type(code_slot - 1, user_type) + + async def async_get_user_type(self, code_slot: int) -> str: + """Get user type.""" + + result = await self.get_user_type(code_slot - 1) + return result + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Shade.cluster_id) +class ShadeClusterHandler(ClusterHandler): + """Shade cluster handler.""" + + +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(WindowCovering.cluster_id) +class WindowCoveringClientClusterHandler(ClientClusterHandler): + """Window client cluster handler.""" + + +@registries.BINDABLE_CLUSTERS.register(WindowCovering.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(WindowCovering.cluster_id) +class WindowCoveringClusterHandler(ClusterHandler): + """Window cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=WindowCovering.AttributeDefs.current_position_lift_percentage.name, + config=REPORT_CONFIG_IMMEDIATE, + ), + AttrReportConfig( + attr=WindowCovering.AttributeDefs.current_position_tilt_percentage.name, + config=REPORT_CONFIG_IMMEDIATE, + ), + ) + + ZCL_INIT_ATTRS = { + WindowCovering.AttributeDefs.window_covering_type.name: True, + WindowCovering.AttributeDefs.window_covering_mode.name: True, + WindowCovering.AttributeDefs.config_status.name: True, + WindowCovering.AttributeDefs.installed_closed_limit_lift.name: True, + WindowCovering.AttributeDefs.installed_closed_limit_tilt.name: True, + WindowCovering.AttributeDefs.installed_open_limit_lift.name: True, + WindowCovering.AttributeDefs.installed_open_limit_tilt.name: True, + } + + async def async_update(self): + """Retrieve latest state.""" + results = await self.get_attributes( + [ + WindowCovering.AttributeDefs.current_position_lift_percentage.name, + WindowCovering.AttributeDefs.current_position_tilt_percentage.name, + ], + from_cache=False, + only_cache=False, + ) + self.debug( + "read current_position_lift_percentage and current_position_tilt_percentage - results: %s", + results, + ) + if ( + results + and results.get( + WindowCovering.AttributeDefs.current_position_lift_percentage.name + ) + is not None + ): + # the 100 - value is because we need to invert the value before giving it to the entity + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + WindowCovering.AttributeDefs.current_position_lift_percentage.id, + WindowCovering.AttributeDefs.current_position_lift_percentage.name, + 100 + - results.get( + WindowCovering.AttributeDefs.current_position_lift_percentage.name + ), + ) + if ( + results + and results.get( + WindowCovering.AttributeDefs.current_position_tilt_percentage.name + ) + is not None + ): + # the 100 - value is because we need to invert the value before giving it to the entity + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + WindowCovering.AttributeDefs.current_position_tilt_percentage.id, + WindowCovering.AttributeDefs.current_position_tilt_percentage.name, + 100 + - results.get( + WindowCovering.AttributeDefs.current_position_tilt_percentage.name + ), + ) + + @property + def inverted(self): + """Return true if the window covering is inverted.""" + config_status = self.cluster.get( + WindowCovering.AttributeDefs.config_status.name + ) + return ( + config_status is not None + and ConfigStatus.Open_up_commands_reversed in ConfigStatus(config_status) + ) + + @property + def current_position_lift_percentage(self) -> t.uint16_t | None: + """Return the current lift percentage of the window covering.""" + lift_percentage = self.cluster.get( + WindowCovering.AttributeDefs.current_position_lift_percentage.name + ) + if lift_percentage is not None: + # the 100 - value is because we need to invert the value before giving it to the entity + lift_percentage = 100 - lift_percentage + return lift_percentage + + @property + def current_position_tilt_percentage(self) -> t.uint16_t | None: + """Return the current tilt percentage of the window covering.""" + tilt_percentage = self.cluster.get( + WindowCovering.AttributeDefs.current_position_tilt_percentage.name + ) + if tilt_percentage is not None: + # the 100 - value is because we need to invert the value before giving it to the entity + tilt_percentage = 100 - tilt_percentage + return tilt_percentage + + @property + def installed_open_limit_lift(self) -> t.uint16_t | None: + """Return the installed open lift limit of the window covering.""" + return self.cluster.get( + WindowCovering.AttributeDefs.installed_open_limit_lift.name + ) + + @property + def installed_closed_limit_lift(self) -> t.uint16_t | None: + """Return the installed closed lift limit of the window covering.""" + return self.cluster.get( + WindowCovering.AttributeDefs.installed_closed_limit_lift.name + ) + + @property + def installed_open_limit_tilt(self) -> t.uint16_t | None: + """Return the installed open tilt limit of the window covering.""" + return self.cluster.get( + WindowCovering.AttributeDefs.installed_open_limit_tilt.name + ) + + @property + def installed_closed_limit_tilt(self) -> t.uint16_t | None: + """Return the installed closed tilt limit of the window covering.""" + return self.cluster.get( + WindowCovering.AttributeDefs.installed_closed_limit_tilt.name + ) + + @property + def window_covering_type(self) -> WindowCovering.WindowCoveringType | None: + """Return the window covering type.""" + return self.cluster.get(WindowCovering.AttributeDefs.window_covering_type.name) diff --git a/zha/core/cluster_handlers/general.py b/zha/core/cluster_handlers/general.py new file mode 100644 index 00000000..3245f8f5 --- /dev/null +++ b/zha/core/cluster_handlers/general.py @@ -0,0 +1,682 @@ +"""General cluster handlers module for Zigbee Home Automation.""" + +from __future__ import annotations + +from collections.abc import Coroutine +from typing import TYPE_CHECKING, Any + +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.event import async_call_later +from zhaquirks.quirk_ids import TUYA_PLUG_ONOFF +import zigpy.exceptions +import zigpy.types as t +import zigpy.zcl +from zigpy.zcl.clusters.general import ( + Alarms, + AnalogInput, + AnalogOutput, + AnalogValue, + ApplianceControl, + Basic, + BinaryInput, + BinaryOutput, + BinaryValue, + Commissioning, + DeviceTemperature, + GreenPowerProxy, + Groups, + Identify, + LevelControl, + MultistateInput, + MultistateOutput, + MultistateValue, + OnOff, + OnOffConfiguration, + Ota, + Partition, + PollControl, + PowerConfiguration, + PowerProfile, + RSSILocation, + Scenes, + Time, +) +from zigpy.zcl.foundation import Status + +from .. import registries +from ..const import ( + REPORT_CONFIG_ASAP, + REPORT_CONFIG_BATTERY_SAVE, + REPORT_CONFIG_DEFAULT, + REPORT_CONFIG_IMMEDIATE, + REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_MIN_INT, + SIGNAL_ATTR_UPDATED, + SIGNAL_MOVE_LEVEL, + SIGNAL_SET_LEVEL, + SIGNAL_UPDATE_DEVICE, +) +from . import ( + AttrReportConfig, + ClientClusterHandler, + ClusterHandler, + parse_and_log_command, +) +from .helpers import is_hue_motion_sensor + +if TYPE_CHECKING: + from ..endpoint import Endpoint + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Alarms.cluster_id) +class AlarmsClusterHandler(ClusterHandler): + """Alarms cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogInput.cluster_id) +class AnalogInputClusterHandler(ClusterHandler): + """Analog Input cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=AnalogInput.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), + ) + + +@registries.BINDABLE_CLUSTERS.register(AnalogOutput.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogOutput.cluster_id) +class AnalogOutputClusterHandler(ClusterHandler): + """Analog Output cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=AnalogOutput.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), + ) + ZCL_INIT_ATTRS = { + AnalogOutput.AttributeDefs.min_present_value.name: True, + AnalogOutput.AttributeDefs.max_present_value.name: True, + AnalogOutput.AttributeDefs.resolution.name: True, + AnalogOutput.AttributeDefs.relinquish_default.name: True, + AnalogOutput.AttributeDefs.description.name: True, + AnalogOutput.AttributeDefs.engineering_units.name: True, + AnalogOutput.AttributeDefs.application_type.name: True, + } + + @property + def present_value(self) -> float | None: + """Return cached value of present_value.""" + return self.cluster.get(AnalogOutput.AttributeDefs.present_value.name) + + @property + def min_present_value(self) -> float | None: + """Return cached value of min_present_value.""" + return self.cluster.get(AnalogOutput.AttributeDefs.min_present_value.name) + + @property + def max_present_value(self) -> float | None: + """Return cached value of max_present_value.""" + return self.cluster.get(AnalogOutput.AttributeDefs.max_present_value.name) + + @property + def resolution(self) -> float | None: + """Return cached value of resolution.""" + return self.cluster.get(AnalogOutput.AttributeDefs.resolution.name) + + @property + def relinquish_default(self) -> float | None: + """Return cached value of relinquish_default.""" + return self.cluster.get(AnalogOutput.AttributeDefs.relinquish_default.name) + + @property + def description(self) -> str | None: + """Return cached value of description.""" + return self.cluster.get(AnalogOutput.AttributeDefs.description.name) + + @property + def engineering_units(self) -> int | None: + """Return cached value of engineering_units.""" + return self.cluster.get(AnalogOutput.AttributeDefs.engineering_units.name) + + @property + def application_type(self) -> int | None: + """Return cached value of application_type.""" + return self.cluster.get(AnalogOutput.AttributeDefs.application_type.name) + + async def async_set_present_value(self, value: float) -> None: + """Update present_value.""" + await self.write_attributes_safe( + {AnalogOutput.AttributeDefs.present_value.name: value} + ) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogValue.cluster_id) +class AnalogValueClusterHandler(ClusterHandler): + """Analog Value cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=AnalogValue.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), + ) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(ApplianceControl.cluster_id) +class ApplianceControlClusterHandler(ClusterHandler): + """Appliance Control cluster handler.""" + + +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(Basic.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Basic.cluster_id) +class BasicClusterHandler(ClusterHandler): + """Cluster handler to interact with the basic cluster.""" + + UNKNOWN = 0 + BATTERY = 3 + BIND: bool = False + + POWER_SOURCES = { + UNKNOWN: "Unknown", + 1: "Mains (single phase)", + 2: "Mains (3 phase)", + BATTERY: "Battery", + 4: "DC source", + 5: "Emergency mains constantly powered", + 6: "Emergency mains and transfer switch", + } + + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize Basic cluster handler.""" + super().__init__(cluster, endpoint) + if is_hue_motion_sensor(self) and self.cluster.endpoint.endpoint_id == 2: + self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() + self.ZCL_INIT_ATTRS["trigger_indicator"] = True + elif ( + self.cluster.endpoint.manufacturer == "TexasInstruments" + and self.cluster.endpoint.model == "ti.router" + ): + self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() + self.ZCL_INIT_ATTRS["transmit_power"] = True + elif self.cluster.endpoint.model == "lumi.curtain.agl001": + self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() + self.ZCL_INIT_ATTRS["power_source"] = True + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryInput.cluster_id) +class BinaryInputClusterHandler(ClusterHandler): + """Binary Input cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=BinaryInput.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), + ) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryOutput.cluster_id) +class BinaryOutputClusterHandler(ClusterHandler): + """Binary Output cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=BinaryOutput.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), + ) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryValue.cluster_id) +class BinaryValueClusterHandler(ClusterHandler): + """Binary Value cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=BinaryValue.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), + ) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Commissioning.cluster_id) +class CommissioningClusterHandler(ClusterHandler): + """Commissioning cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(DeviceTemperature.cluster_id) +class DeviceTemperatureClusterHandler(ClusterHandler): + """Device Temperature cluster handler.""" + + REPORT_CONFIG = ( + { + "attr": DeviceTemperature.AttributeDefs.current_temperature.name, + "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 50), + }, + ) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(GreenPowerProxy.cluster_id) +class GreenPowerProxyClusterHandler(ClusterHandler): + """Green Power Proxy cluster handler.""" + + BIND: bool = False + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Groups.cluster_id) +class GroupsClusterHandler(ClusterHandler): + """Groups cluster handler.""" + + BIND: bool = False + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Identify.cluster_id) +class IdentifyClusterHandler(ClusterHandler): + """Identify cluster handler.""" + + BIND: bool = False + + @callback + def cluster_command(self, tsn, command_id, args): + """Handle commands received to this cluster.""" + cmd = parse_and_log_command(self, tsn, command_id, args) + + if cmd == Identify.ServerCommandDefs.trigger_effect.name: + self.async_send_signal(f"{self.unique_id}_{cmd}", args[0]) + + +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(LevelControl.cluster_id) +class LevelControlClientClusterHandler(ClientClusterHandler): + """LevelControl client cluster.""" + + +@registries.BINDABLE_CLUSTERS.register(LevelControl.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(LevelControl.cluster_id) +class LevelControlClusterHandler(ClusterHandler): + """Cluster handler for the LevelControl Zigbee cluster.""" + + CURRENT_LEVEL = 0 + REPORT_CONFIG = ( + AttrReportConfig( + attr=LevelControl.AttributeDefs.current_level.name, + config=REPORT_CONFIG_ASAP, + ), + ) + ZCL_INIT_ATTRS = { + LevelControl.AttributeDefs.on_off_transition_time.name: True, + LevelControl.AttributeDefs.on_level.name: True, + LevelControl.AttributeDefs.on_transition_time.name: True, + LevelControl.AttributeDefs.off_transition_time.name: True, + LevelControl.AttributeDefs.default_move_rate.name: True, + LevelControl.AttributeDefs.start_up_current_level.name: True, + } + + @property + def current_level(self) -> int | None: + """Return cached value of the current_level attribute.""" + return self.cluster.get(LevelControl.AttributeDefs.current_level.name) + + @callback + def cluster_command(self, tsn, command_id, args): + """Handle commands received to this cluster.""" + cmd = parse_and_log_command(self, tsn, command_id, args) + + if cmd in ( + LevelControl.ServerCommandDefs.move_to_level.name, + LevelControl.ServerCommandDefs.move_to_level_with_on_off.name, + ): + self.dispatch_level_change(SIGNAL_SET_LEVEL, args[0]) + elif cmd in ( + LevelControl.ServerCommandDefs.move.name, + LevelControl.ServerCommandDefs.move_with_on_off.name, + ): + # We should dim slowly -- for now, just step once + rate = args[1] + if args[0] == 0xFF: + rate = 10 # Should read default move rate + self.dispatch_level_change(SIGNAL_MOVE_LEVEL, -rate if args[0] else rate) + elif cmd in ( + LevelControl.ServerCommandDefs.step.name, + LevelControl.ServerCommandDefs.step_with_on_off.name, + ): + # Step (technically may change on/off) + self.dispatch_level_change( + SIGNAL_MOVE_LEVEL, -args[1] if args[0] else args[1] + ) + + @callback + def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: + """Handle attribute updates on this cluster.""" + self.debug("received attribute: %s update with value: %s", attrid, value) + if attrid == self.CURRENT_LEVEL: + self.dispatch_level_change(SIGNAL_SET_LEVEL, value) + + def dispatch_level_change(self, command, level): + """Dispatch level change.""" + self.async_send_signal(f"{self.unique_id}_{command}", level) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateInput.cluster_id) +class MultistateInputClusterHandler(ClusterHandler): + """Multistate Input cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=MultistateInput.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), + ) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateOutput.cluster_id) +class MultistateOutputClusterHandler(ClusterHandler): + """Multistate Output cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=MultistateOutput.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), + ) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateValue.cluster_id) +class MultistateValueClusterHandler(ClusterHandler): + """Multistate Value cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=MultistateValue.AttributeDefs.present_value.name, + config=REPORT_CONFIG_DEFAULT, + ), + ) + + +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(OnOff.cluster_id) +class OnOffClientClusterHandler(ClientClusterHandler): + """OnOff client cluster handler.""" + + +@registries.BINDABLE_CLUSTERS.register(OnOff.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(OnOff.cluster_id) +class OnOffClusterHandler(ClusterHandler): + """Cluster handler for the OnOff Zigbee cluster.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=OnOff.AttributeDefs.on_off.name, config=REPORT_CONFIG_IMMEDIATE + ), + ) + ZCL_INIT_ATTRS = { + OnOff.AttributeDefs.start_up_on_off.name: True, + } + + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize OnOffClusterHandler.""" + super().__init__(cluster, endpoint) + self._off_listener = None + + if endpoint.device.quirk_id == TUYA_PLUG_ONOFF: + self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() + self.ZCL_INIT_ATTRS["backlight_mode"] = True + self.ZCL_INIT_ATTRS["power_on_state"] = True + self.ZCL_INIT_ATTRS["child_lock"] = True + + @classmethod + def matches(cls, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> bool: + """Filter the cluster match for specific devices.""" + return not ( + cluster.endpoint.device.manufacturer == "Konke" + and cluster.endpoint.device.model + in ("3AFE280100510001", "3AFE170100510001") + ) + + @property + def on_off(self) -> bool | None: + """Return cached value of on/off attribute.""" + return self.cluster.get(OnOff.AttributeDefs.on_off.name) + + async def turn_on(self) -> None: + """Turn the on off cluster on.""" + result = await self.on() + if result[1] is not Status.SUCCESS: + raise HomeAssistantError(f"Failed to turn on: {result[1]}") + self.cluster.update_attribute(OnOff.AttributeDefs.on_off.id, t.Bool.true) + + async def turn_off(self) -> None: + """Turn the on off cluster off.""" + result = await self.off() + if result[1] is not Status.SUCCESS: + raise HomeAssistantError(f"Failed to turn off: {result[1]}") + self.cluster.update_attribute(OnOff.AttributeDefs.on_off.id, t.Bool.false) + + @callback + def cluster_command(self, tsn, command_id, args): + """Handle commands received to this cluster.""" + cmd = parse_and_log_command(self, tsn, command_id, args) + + if cmd in ( + OnOff.ServerCommandDefs.off.name, + OnOff.ServerCommandDefs.off_with_effect.name, + ): + self.cluster.update_attribute(OnOff.AttributeDefs.on_off.id, t.Bool.false) + elif cmd in ( + OnOff.ServerCommandDefs.on.name, + OnOff.ServerCommandDefs.on_with_recall_global_scene.name, + ): + self.cluster.update_attribute(OnOff.AttributeDefs.on_off.id, t.Bool.true) + elif cmd == OnOff.ServerCommandDefs.on_with_timed_off.name: + should_accept = args[0] + on_time = args[1] + # 0 is always accept 1 is only accept when already on + if should_accept == 0 or (should_accept == 1 and bool(self.on_off)): + if self._off_listener is not None: + self._off_listener() + self._off_listener = None + self.cluster.update_attribute( + OnOff.AttributeDefs.on_off.id, t.Bool.true + ) + if on_time > 0: + self._off_listener = async_call_later( + self._endpoint.device.hass, + (on_time / 10), # value is in 10ths of a second + self.set_to_off, + ) + elif cmd == "toggle": + self.cluster.update_attribute( + OnOff.AttributeDefs.on_off.id, not bool(self.on_off) + ) + + @callback + def set_to_off(self, *_): + """Set the state to off.""" + self._off_listener = None + self.cluster.update_attribute(OnOff.AttributeDefs.on_off.id, t.Bool.false) + + @callback + def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: + """Handle attribute updates on this cluster.""" + if attrid == OnOff.AttributeDefs.on_off.id: + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + attrid, + OnOff.AttributeDefs.on_off.name, + value, + ) + + async def async_update(self): + """Initialize cluster handler.""" + if self.cluster.is_client: + return + from_cache = not self._endpoint.device.is_mains_powered + self.debug("attempting to update onoff state - from cache: %s", from_cache) + await self.get_attribute_value( + OnOff.AttributeDefs.on_off.id, from_cache=from_cache + ) + await super().async_update() + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(OnOffConfiguration.cluster_id) +class OnOffConfigurationClusterHandler(ClusterHandler): + """OnOff Configuration cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Ota.cluster_id) +class OtaClusterHandler(ClusterHandler): + """OTA cluster handler.""" + + BIND: bool = False + + # Some devices have this cluster in the wrong collection (e.g. Third Reality) + ZCL_INIT_ATTRS = { + Ota.AttributeDefs.current_file_version.name: True, + } + + @property + def current_file_version(self) -> int | None: + """Return cached value of current_file_version attribute.""" + return self.cluster.get(Ota.AttributeDefs.current_file_version.name) + + +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(Ota.cluster_id) +class OtaClientClusterHandler(ClientClusterHandler): + """OTA client cluster handler.""" + + BIND: bool = False + + ZCL_INIT_ATTRS = { + Ota.AttributeDefs.current_file_version.name: True, + } + + @property + def current_file_version(self) -> int | None: + """Return cached value of current_file_version attribute.""" + return self.cluster.get(Ota.AttributeDefs.current_file_version.name) + + @callback + def cluster_command( + self, tsn: int, command_id: int, args: list[Any] | None + ) -> None: + """Handle OTA commands.""" + if command_id not in self.cluster.server_commands: + return + + signal_id = self._endpoint.unique_id.split("-")[0] + cmd_name = self.cluster.server_commands[command_id].name + + if cmd_name == Ota.ServerCommandDefs.query_next_image.name: + assert args + + current_file_version = args[3] + self.cluster.update_attribute( + Ota.AttributeDefs.current_file_version.id, current_file_version + ) + self.async_send_signal( + SIGNAL_UPDATE_DEVICE.format(signal_id), current_file_version + ) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Partition.cluster_id) +class PartitionClusterHandler(ClusterHandler): + """Partition cluster handler.""" + + +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(PollControl.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(PollControl.cluster_id) +class PollControlClusterHandler(ClusterHandler): + """Poll Control cluster handler.""" + + CHECKIN_INTERVAL = 55 * 60 * 4 # 55min + CHECKIN_FAST_POLL_TIMEOUT = 2 * 4 # 2s + LONG_POLL = 6 * 4 # 6s + _IGNORED_MANUFACTURER_ID = { + 4476, + } # IKEA + + async def async_configure_cluster_handler_specific(self) -> None: + """Configure cluster handler: set check-in interval.""" + await self.write_attributes_safe( + {PollControl.AttributeDefs.checkin_interval.name: self.CHECKIN_INTERVAL} + ) + + @callback + def cluster_command( + self, tsn: int, command_id: int, args: list[Any] | None + ) -> None: + """Handle commands received to this cluster.""" + if command_id in self.cluster.client_commands: + cmd_name = self.cluster.client_commands[command_id].name + else: + cmd_name = command_id + + self.debug("Received %s tsn command '%s': %s", tsn, cmd_name, args) + self.zha_send_event(cmd_name, args) + if cmd_name == PollControl.ClientCommandDefs.checkin.name: + self.cluster.create_catching_task(self.check_in_response(tsn)) + + async def check_in_response(self, tsn: int) -> None: + """Respond to checkin command.""" + await self.checkin_response(True, self.CHECKIN_FAST_POLL_TIMEOUT, tsn=tsn) + if self._endpoint.device.manufacturer_code not in self._IGNORED_MANUFACTURER_ID: + await self.set_long_poll_interval(self.LONG_POLL) + await self.fast_poll_stop() + + @callback + def skip_manufacturer_id(self, manufacturer_code: int) -> None: + """Block a specific manufacturer id from changing default polling.""" + self._IGNORED_MANUFACTURER_ID.add(manufacturer_code) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(PowerConfiguration.cluster_id) +class PowerConfigurationClusterHandler(ClusterHandler): + """Cluster handler for the zigbee power configuration cluster.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=PowerConfiguration.AttributeDefs.battery_voltage.name, + config=REPORT_CONFIG_BATTERY_SAVE, + ), + AttrReportConfig( + attr=PowerConfiguration.AttributeDefs.battery_percentage_remaining.name, + config=REPORT_CONFIG_BATTERY_SAVE, + ), + ) + + def async_initialize_cluster_handler_specific(self, from_cache: bool) -> Coroutine: + """Initialize cluster handler specific attrs.""" + attributes = [ + PowerConfiguration.AttributeDefs.battery_size.name, + PowerConfiguration.AttributeDefs.battery_quantity.name, + ] + return self.get_attributes( + attributes, from_cache=from_cache, only_cache=from_cache + ) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(PowerProfile.cluster_id) +class PowerProfileClusterHandler(ClusterHandler): + """Power Profile cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(RSSILocation.cluster_id) +class RSSILocationClusterHandler(ClusterHandler): + """RSSI Location cluster handler.""" + + +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(Scenes.cluster_id) +class ScenesClientClusterHandler(ClientClusterHandler): + """Scenes cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Scenes.cluster_id) +class ScenesClusterHandler(ClusterHandler): + """Scenes cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Time.cluster_id) +class TimeClusterHandler(ClusterHandler): + """Time cluster handler.""" diff --git a/zha/core/cluster_handlers/helpers.py b/zha/core/cluster_handlers/helpers.py new file mode 100644 index 00000000..46557bf2 --- /dev/null +++ b/zha/core/cluster_handlers/helpers.py @@ -0,0 +1,23 @@ +"""Helpers for use with ZHA Zigbee cluster handlers.""" + +from . import ClusterHandler + + +def is_hue_motion_sensor(cluster_handler: ClusterHandler) -> bool: + """Return true if the manufacturer and model match known Hue motion sensor models.""" + return cluster_handler.cluster.endpoint.manufacturer in ( + "Philips", + "Signify Netherlands B.V.", + ) and cluster_handler.cluster.endpoint.model in ( + "SML001", + "SML002", + "SML003", + "SML004", + ) + + +def is_sonoff_presence_sensor(cluster_handler: ClusterHandler) -> bool: + """Return true if the manufacturer and model match known Sonoff sensor models.""" + return cluster_handler.cluster.endpoint.manufacturer in ( + "SONOFF", + ) and cluster_handler.cluster.endpoint.model in ("SNZB-06P",) diff --git a/zha/core/cluster_handlers/homeautomation.py b/zha/core/cluster_handlers/homeautomation.py new file mode 100644 index 00000000..b287cb98 --- /dev/null +++ b/zha/core/cluster_handlers/homeautomation.py @@ -0,0 +1,236 @@ +"""Home automation cluster handlers module for Zigbee Home Automation.""" + +from __future__ import annotations + +import enum + +from zigpy.zcl.clusters.homeautomation import ( + ApplianceEventAlerts, + ApplianceIdentification, + ApplianceStatistics, + Diagnostic, + ElectricalMeasurement, + MeterIdentification, +) + +from .. import registries +from ..const import ( + CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, + REPORT_CONFIG_DEFAULT, + REPORT_CONFIG_OP, + SIGNAL_ATTR_UPDATED, +) +from . import AttrReportConfig, ClusterHandler + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(ApplianceEventAlerts.cluster_id) +class ApplianceEventAlertsClusterHandler(ClusterHandler): + """Appliance Event Alerts cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(ApplianceIdentification.cluster_id) +class ApplianceIdentificationClusterHandler(ClusterHandler): + """Appliance Identification cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(ApplianceStatistics.cluster_id) +class ApplianceStatisticsClusterHandler(ClusterHandler): + """Appliance Statistics cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Diagnostic.cluster_id) +class DiagnosticClusterHandler(ClusterHandler): + """Diagnostic cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(ElectricalMeasurement.cluster_id) +class ElectricalMeasurementClusterHandler(ClusterHandler): + """Cluster handler that polls active power level.""" + + CLUSTER_HANDLER_NAME = CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT + + class MeasurementType(enum.IntFlag): + """Measurement types.""" + + ACTIVE_MEASUREMENT = 1 + REACTIVE_MEASUREMENT = 2 + APPARENT_MEASUREMENT = 4 + PHASE_A_MEASUREMENT = 8 + PHASE_B_MEASUREMENT = 16 + PHASE_C_MEASUREMENT = 32 + DC_MEASUREMENT = 64 + HARMONICS_MEASUREMENT = 128 + POWER_QUALITY_MEASUREMENT = 256 + + REPORT_CONFIG = ( + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.active_power.name, + config=REPORT_CONFIG_OP, + ), + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.active_power_max.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.apparent_power.name, + config=REPORT_CONFIG_OP, + ), + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.rms_current.name, + config=REPORT_CONFIG_OP, + ), + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.rms_current_max.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.rms_voltage.name, + config=REPORT_CONFIG_OP, + ), + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.rms_voltage_max.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.ac_frequency.name, + config=REPORT_CONFIG_OP, + ), + AttrReportConfig( + attr=ElectricalMeasurement.AttributeDefs.ac_frequency_max.name, + config=REPORT_CONFIG_DEFAULT, + ), + ) + ZCL_INIT_ATTRS = { + ElectricalMeasurement.AttributeDefs.ac_current_divisor.name: True, + ElectricalMeasurement.AttributeDefs.ac_current_multiplier.name: True, + ElectricalMeasurement.AttributeDefs.ac_power_divisor.name: True, + ElectricalMeasurement.AttributeDefs.ac_power_multiplier.name: True, + ElectricalMeasurement.AttributeDefs.ac_voltage_divisor.name: True, + ElectricalMeasurement.AttributeDefs.ac_voltage_multiplier.name: True, + ElectricalMeasurement.AttributeDefs.ac_frequency_divisor.name: True, + ElectricalMeasurement.AttributeDefs.ac_frequency_multiplier.name: True, + ElectricalMeasurement.AttributeDefs.measurement_type.name: True, + ElectricalMeasurement.AttributeDefs.power_divisor.name: True, + ElectricalMeasurement.AttributeDefs.power_multiplier.name: True, + ElectricalMeasurement.AttributeDefs.power_factor.name: True, + } + + async def async_update(self): + """Retrieve latest state.""" + self.debug("async_update") + + # This is a polling cluster handler. Don't allow cache. + attrs = [ + a["attr"] + for a in self.REPORT_CONFIG + if a["attr"] not in self.cluster.unsupported_attributes + ] + result = await self.get_attributes(attrs, from_cache=False, only_cache=False) + if result: + for attr, value in result.items(): + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + self.cluster.find_attribute(attr).id, + attr, + value, + ) + + @property + def ac_current_divisor(self) -> int: + """Return ac current divisor.""" + return ( + self.cluster.get( + ElectricalMeasurement.AttributeDefs.ac_current_divisor.name + ) + or 1 + ) + + @property + def ac_current_multiplier(self) -> int: + """Return ac current multiplier.""" + return ( + self.cluster.get( + ElectricalMeasurement.AttributeDefs.ac_current_multiplier.name + ) + or 1 + ) + + @property + def ac_voltage_divisor(self) -> int: + """Return ac voltage divisor.""" + return ( + self.cluster.get( + ElectricalMeasurement.AttributeDefs.ac_voltage_divisor.name + ) + or 1 + ) + + @property + def ac_voltage_multiplier(self) -> int: + """Return ac voltage multiplier.""" + return ( + self.cluster.get( + ElectricalMeasurement.AttributeDefs.ac_voltage_multiplier.name + ) + or 1 + ) + + @property + def ac_frequency_divisor(self) -> int: + """Return ac frequency divisor.""" + return ( + self.cluster.get( + ElectricalMeasurement.AttributeDefs.ac_frequency_divisor.name + ) + or 1 + ) + + @property + def ac_frequency_multiplier(self) -> int: + """Return ac frequency multiplier.""" + return ( + self.cluster.get( + ElectricalMeasurement.AttributeDefs.ac_frequency_multiplier.name + ) + or 1 + ) + + @property + def ac_power_divisor(self) -> int: + """Return active power divisor.""" + return self.cluster.get( + ElectricalMeasurement.AttributeDefs.ac_power_divisor.name, + self.cluster.get(ElectricalMeasurement.AttributeDefs.power_divisor.name) + or 1, + ) + + @property + def ac_power_multiplier(self) -> int: + """Return active power divisor.""" + return self.cluster.get( + ElectricalMeasurement.AttributeDefs.ac_power_multiplier.name, + self.cluster.get(ElectricalMeasurement.AttributeDefs.power_multiplier.name) + or 1, + ) + + @property + def measurement_type(self) -> str | None: + """Return Measurement type.""" + if ( + meas_type := self.cluster.get( + ElectricalMeasurement.AttributeDefs.measurement_type.name + ) + ) is None: + return None + + meas_type = self.MeasurementType(meas_type) + return ", ".join( + m.name + for m in self.MeasurementType + if m in meas_type and m.name is not None + ) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MeterIdentification.cluster_id) +class MeterIdentificationClusterHandler(ClusterHandler): + """Metering Identification cluster handler.""" diff --git a/zha/core/cluster_handlers/hvac.py b/zha/core/cluster_handlers/hvac.py new file mode 100644 index 00000000..a0d66a92 --- /dev/null +++ b/zha/core/cluster_handlers/hvac.py @@ -0,0 +1,346 @@ +"""HVAC cluster handlers module for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/integrations/zha/ +""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import callback +from zigpy.zcl.clusters.hvac import ( + Dehumidification, + Fan, + Pump, + Thermostat, + UserInterface, +) + +from .. import registries +from ..const import ( + REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_MIN_INT, + REPORT_CONFIG_OP, + SIGNAL_ATTR_UPDATED, +) +from . import AttrReportConfig, ClusterHandler + +REPORT_CONFIG_CLIMATE = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 25) +REPORT_CONFIG_CLIMATE_DEMAND = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 5) +REPORT_CONFIG_CLIMATE_DISCRETE = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 1) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Dehumidification.cluster_id) +class DehumidificationClusterHandler(ClusterHandler): + """Dehumidification cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Fan.cluster_id) +class FanClusterHandler(ClusterHandler): + """Fan cluster handler.""" + + _value_attribute = 0 + + REPORT_CONFIG = ( + AttrReportConfig(attr=Fan.AttributeDefs.fan_mode.name, config=REPORT_CONFIG_OP), + ) + ZCL_INIT_ATTRS = {Fan.AttributeDefs.fan_mode_sequence.name: True} + + @property + def fan_mode(self) -> int | None: + """Return current fan mode.""" + return self.cluster.get(Fan.AttributeDefs.fan_mode.name) + + @property + def fan_mode_sequence(self) -> int | None: + """Return possible fan mode speeds.""" + return self.cluster.get(Fan.AttributeDefs.fan_mode_sequence.name) + + async def async_set_speed(self, value) -> None: + """Set the speed of the fan.""" + await self.write_attributes_safe({Fan.AttributeDefs.fan_mode.name: value}) + + async def async_update(self) -> None: + """Retrieve latest state.""" + await self.get_attribute_value( + Fan.AttributeDefs.fan_mode.name, from_cache=False + ) + + @callback + def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: + """Handle attribute update from fan cluster.""" + attr_name = self._get_attribute_name(attrid) + self.debug( + "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value + ) + if attr_name == "fan_mode": + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value + ) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Pump.cluster_id) +class PumpClusterHandler(ClusterHandler): + """Pump cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Thermostat.cluster_id) +class ThermostatClusterHandler(ClusterHandler): + """Thermostat cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=Thermostat.AttributeDefs.local_temperature.name, + config=REPORT_CONFIG_CLIMATE, + ), + AttrReportConfig( + attr=Thermostat.AttributeDefs.occupied_cooling_setpoint.name, + config=REPORT_CONFIG_CLIMATE, + ), + AttrReportConfig( + attr=Thermostat.AttributeDefs.occupied_heating_setpoint.name, + config=REPORT_CONFIG_CLIMATE, + ), + AttrReportConfig( + attr=Thermostat.AttributeDefs.unoccupied_cooling_setpoint.name, + config=REPORT_CONFIG_CLIMATE, + ), + AttrReportConfig( + attr=Thermostat.AttributeDefs.unoccupied_heating_setpoint.name, + config=REPORT_CONFIG_CLIMATE, + ), + AttrReportConfig( + attr=Thermostat.AttributeDefs.running_mode.name, + config=REPORT_CONFIG_CLIMATE, + ), + AttrReportConfig( + attr=Thermostat.AttributeDefs.running_state.name, + config=REPORT_CONFIG_CLIMATE_DEMAND, + ), + AttrReportConfig( + attr=Thermostat.AttributeDefs.system_mode.name, + config=REPORT_CONFIG_CLIMATE, + ), + AttrReportConfig( + attr=Thermostat.AttributeDefs.occupancy.name, + config=REPORT_CONFIG_CLIMATE_DISCRETE, + ), + AttrReportConfig( + attr=Thermostat.AttributeDefs.pi_cooling_demand.name, + config=REPORT_CONFIG_CLIMATE_DEMAND, + ), + AttrReportConfig( + attr=Thermostat.AttributeDefs.pi_heating_demand.name, + config=REPORT_CONFIG_CLIMATE_DEMAND, + ), + ) + ZCL_INIT_ATTRS: dict[str, bool] = { + Thermostat.AttributeDefs.abs_min_heat_setpoint_limit.name: True, + Thermostat.AttributeDefs.abs_max_heat_setpoint_limit.name: True, + Thermostat.AttributeDefs.abs_min_cool_setpoint_limit.name: True, + Thermostat.AttributeDefs.abs_max_cool_setpoint_limit.name: True, + Thermostat.AttributeDefs.ctrl_sequence_of_oper.name: False, + Thermostat.AttributeDefs.max_cool_setpoint_limit.name: True, + Thermostat.AttributeDefs.max_heat_setpoint_limit.name: True, + Thermostat.AttributeDefs.min_cool_setpoint_limit.name: True, + Thermostat.AttributeDefs.min_heat_setpoint_limit.name: True, + Thermostat.AttributeDefs.local_temperature_calibration.name: True, + Thermostat.AttributeDefs.setpoint_change_source.name: True, + } + + @property + def abs_max_cool_setpoint_limit(self) -> int: + """Absolute maximum cooling setpoint.""" + return self.cluster.get( + Thermostat.AttributeDefs.abs_max_cool_setpoint_limit.name, 3200 + ) + + @property + def abs_min_cool_setpoint_limit(self) -> int: + """Absolute minimum cooling setpoint.""" + return self.cluster.get( + Thermostat.AttributeDefs.abs_min_cool_setpoint_limit.name, 1600 + ) + + @property + def abs_max_heat_setpoint_limit(self) -> int: + """Absolute maximum heating setpoint.""" + return self.cluster.get( + Thermostat.AttributeDefs.abs_max_heat_setpoint_limit.name, 3000 + ) + + @property + def abs_min_heat_setpoint_limit(self) -> int: + """Absolute minimum heating setpoint.""" + return self.cluster.get( + Thermostat.AttributeDefs.abs_min_heat_setpoint_limit.name, 700 + ) + + @property + def ctrl_sequence_of_oper(self) -> int: + """Control Sequence of operations attribute.""" + return self.cluster.get( + Thermostat.AttributeDefs.ctrl_sequence_of_oper.name, 0xFF + ) + + @property + def max_cool_setpoint_limit(self) -> int: + """Maximum cooling setpoint.""" + sp_limit = self.cluster.get( + Thermostat.AttributeDefs.max_cool_setpoint_limit.name + ) + if sp_limit is None: + return self.abs_max_cool_setpoint_limit + return sp_limit + + @property + def min_cool_setpoint_limit(self) -> int: + """Minimum cooling setpoint.""" + sp_limit = self.cluster.get( + Thermostat.AttributeDefs.min_cool_setpoint_limit.name + ) + if sp_limit is None: + return self.abs_min_cool_setpoint_limit + return sp_limit + + @property + def max_heat_setpoint_limit(self) -> int: + """Maximum heating setpoint.""" + sp_limit = self.cluster.get( + Thermostat.AttributeDefs.max_heat_setpoint_limit.name + ) + if sp_limit is None: + return self.abs_max_heat_setpoint_limit + return sp_limit + + @property + def min_heat_setpoint_limit(self) -> int: + """Minimum heating setpoint.""" + sp_limit = self.cluster.get( + Thermostat.AttributeDefs.min_heat_setpoint_limit.name + ) + if sp_limit is None: + return self.abs_min_heat_setpoint_limit + return sp_limit + + @property + def local_temperature(self) -> int | None: + """Thermostat temperature.""" + return self.cluster.get(Thermostat.AttributeDefs.local_temperature.name) + + @property + def occupancy(self) -> int | None: + """Is occupancy detected.""" + return self.cluster.get(Thermostat.AttributeDefs.occupancy.name) + + @property + def occupied_cooling_setpoint(self) -> int | None: + """Temperature when room is occupied.""" + return self.cluster.get(Thermostat.AttributeDefs.occupied_cooling_setpoint.name) + + @property + def occupied_heating_setpoint(self) -> int | None: + """Temperature when room is occupied.""" + return self.cluster.get(Thermostat.AttributeDefs.occupied_heating_setpoint.name) + + @property + def pi_cooling_demand(self) -> int: + """Cooling demand.""" + return self.cluster.get(Thermostat.AttributeDefs.pi_cooling_demand.name) + + @property + def pi_heating_demand(self) -> int: + """Heating demand.""" + return self.cluster.get(Thermostat.AttributeDefs.pi_heating_demand.name) + + @property + def running_mode(self) -> int | None: + """Thermostat running mode.""" + return self.cluster.get(Thermostat.AttributeDefs.running_mode.name) + + @property + def running_state(self) -> int | None: + """Thermostat running state, state of heat, cool, fan relays.""" + return self.cluster.get(Thermostat.AttributeDefs.running_state.name) + + @property + def system_mode(self) -> int | None: + """System mode.""" + return self.cluster.get(Thermostat.AttributeDefs.system_mode.name) + + @property + def unoccupied_cooling_setpoint(self) -> int | None: + """Temperature when room is not occupied.""" + return self.cluster.get( + Thermostat.AttributeDefs.unoccupied_cooling_setpoint.name + ) + + @property + def unoccupied_heating_setpoint(self) -> int | None: + """Temperature when room is not occupied.""" + return self.cluster.get( + Thermostat.AttributeDefs.unoccupied_heating_setpoint.name + ) + + @callback + def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: + """Handle attribute update cluster.""" + attr_name = self._get_attribute_name(attrid) + self.debug( + "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value + ) + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + attrid, + attr_name, + value, + ) + + async def async_set_operation_mode(self, mode) -> bool: + """Set Operation mode.""" + await self.write_attributes_safe( + {Thermostat.AttributeDefs.system_mode.name: mode} + ) + return True + + async def async_set_heating_setpoint( + self, temperature: int, is_away: bool = False + ) -> bool: + """Set heating setpoint.""" + attr = ( + Thermostat.AttributeDefs.unoccupied_heating_setpoint.name + if is_away + else Thermostat.AttributeDefs.occupied_heating_setpoint.name + ) + await self.write_attributes_safe({attr: temperature}) + return True + + async def async_set_cooling_setpoint( + self, temperature: int, is_away: bool = False + ) -> bool: + """Set cooling setpoint.""" + attr = ( + Thermostat.AttributeDefs.unoccupied_cooling_setpoint.name + if is_away + else Thermostat.AttributeDefs.occupied_cooling_setpoint.name + ) + await self.write_attributes_safe({attr: temperature}) + return True + + async def get_occupancy(self) -> bool | None: + """Get unreportable occupancy attribute.""" + res, fail = await self.read_attributes( + [Thermostat.AttributeDefs.occupancy.name] + ) + self.debug("read 'occupancy' attr, success: %s, fail: %s", res, fail) + if Thermostat.AttributeDefs.occupancy.name not in res: + return None + return bool(self.occupancy) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(UserInterface.cluster_id) +class UserInterfaceClusterHandler(ClusterHandler): + """User interface (thermostat) cluster handler.""" + + ZCL_INIT_ATTRS = {UserInterface.AttributeDefs.keypad_lockout.name: True} diff --git a/zha/core/cluster_handlers/lighting.py b/zha/core/cluster_handlers/lighting.py new file mode 100644 index 00000000..6caa150c --- /dev/null +++ b/zha/core/cluster_handlers/lighting.py @@ -0,0 +1,195 @@ +"""Lighting cluster handlers module for Zigbee Home Automation.""" + +from __future__ import annotations + +from homeassistant.backports.functools import cached_property +from zigpy.zcl.clusters.lighting import Ballast, Color + +from .. import registries +from ..const import REPORT_CONFIG_DEFAULT +from . import AttrReportConfig, ClientClusterHandler, ClusterHandler + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Ballast.cluster_id) +class BallastClusterHandler(ClusterHandler): + """Ballast cluster handler.""" + + +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(Color.cluster_id) +class ColorClientClusterHandler(ClientClusterHandler): + """Color client cluster handler.""" + + +@registries.BINDABLE_CLUSTERS.register(Color.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Color.cluster_id) +class ColorClusterHandler(ClusterHandler): + """Color cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=Color.AttributeDefs.current_x.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=Color.AttributeDefs.current_y.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=Color.AttributeDefs.current_hue.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=Color.AttributeDefs.current_saturation.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=Color.AttributeDefs.color_temperature.name, + config=REPORT_CONFIG_DEFAULT, + ), + ) + MAX_MIREDS: int = 500 + MIN_MIREDS: int = 153 + ZCL_INIT_ATTRS = { + Color.AttributeDefs.color_mode.name: False, + Color.AttributeDefs.color_temp_physical_min.name: True, + Color.AttributeDefs.color_temp_physical_max.name: True, + Color.AttributeDefs.color_capabilities.name: True, + Color.AttributeDefs.color_loop_active.name: False, + Color.AttributeDefs.enhanced_current_hue.name: False, + Color.AttributeDefs.start_up_color_temperature.name: True, + Color.AttributeDefs.options.name: True, + } + + @cached_property + def color_capabilities(self) -> Color.ColorCapabilities: + """Return ZCL color capabilities of the light.""" + color_capabilities = self.cluster.get( + Color.AttributeDefs.color_capabilities.name + ) + if color_capabilities is None: + return Color.ColorCapabilities.XY_attributes + return Color.ColorCapabilities(color_capabilities) + + @property + def color_mode(self) -> int | None: + """Return cached value of the color_mode attribute.""" + return self.cluster.get(Color.AttributeDefs.color_mode.name) + + @property + def color_loop_active(self) -> int | None: + """Return cached value of the color_loop_active attribute.""" + return self.cluster.get(Color.AttributeDefs.color_loop_active.name) + + @property + def color_temperature(self) -> int | None: + """Return cached value of color temperature.""" + return self.cluster.get(Color.AttributeDefs.color_temperature.name) + + @property + def current_x(self) -> int | None: + """Return cached value of the current_x attribute.""" + return self.cluster.get(Color.AttributeDefs.current_x.name) + + @property + def current_y(self) -> int | None: + """Return cached value of the current_y attribute.""" + return self.cluster.get(Color.AttributeDefs.current_y.name) + + @property + def current_hue(self) -> int | None: + """Return cached value of the current_hue attribute.""" + return self.cluster.get(Color.AttributeDefs.current_hue.name) + + @property + def enhanced_current_hue(self) -> int | None: + """Return cached value of the enhanced_current_hue attribute.""" + return self.cluster.get(Color.AttributeDefs.enhanced_current_hue.name) + + @property + def current_saturation(self) -> int | None: + """Return cached value of the current_saturation attribute.""" + return self.cluster.get(Color.AttributeDefs.current_saturation.name) + + @property + def min_mireds(self) -> int: + """Return the coldest color_temp that this cluster handler supports.""" + min_mireds = self.cluster.get( + Color.AttributeDefs.color_temp_physical_min.name, self.MIN_MIREDS + ) + if min_mireds == 0: + self.warning( + ( + "[Min mireds is 0, setting to %s] Please open an issue on the" + " quirks repo to have this device corrected" + ), + self.MIN_MIREDS, + ) + min_mireds = self.MIN_MIREDS + return min_mireds + + @property + def max_mireds(self) -> int: + """Return the warmest color_temp that this cluster handler supports.""" + max_mireds = self.cluster.get( + Color.AttributeDefs.color_temp_physical_max.name, self.MAX_MIREDS + ) + if max_mireds == 0: + self.warning( + ( + "[Max mireds is 0, setting to %s] Please open an issue on the" + " quirks repo to have this device corrected" + ), + self.MAX_MIREDS, + ) + max_mireds = self.MAX_MIREDS + return max_mireds + + @property + def hs_supported(self) -> bool: + """Return True if the cluster handler supports hue and saturation.""" + return ( + self.color_capabilities is not None + and Color.ColorCapabilities.Hue_and_saturation in self.color_capabilities + ) + + @property + def enhanced_hue_supported(self) -> bool: + """Return True if the cluster handler supports enhanced hue and saturation.""" + return ( + self.color_capabilities is not None + and Color.ColorCapabilities.Enhanced_hue in self.color_capabilities + ) + + @property + def xy_supported(self) -> bool: + """Return True if the cluster handler supports xy.""" + return ( + self.color_capabilities is not None + and Color.ColorCapabilities.XY_attributes in self.color_capabilities + ) + + @property + def color_temp_supported(self) -> bool: + """Return True if the cluster handler supports color temperature.""" + return ( + self.color_capabilities is not None + and Color.ColorCapabilities.Color_temperature in self.color_capabilities + ) or self.color_temperature is not None + + @property + def color_loop_supported(self) -> bool: + """Return True if the cluster handler supports color loop.""" + return ( + self.color_capabilities is not None + and Color.ColorCapabilities.Color_loop in self.color_capabilities + ) + + @property + def options(self) -> Color.Options: + """Return ZCL options of the cluster handler.""" + return Color.Options(self.cluster.get(Color.AttributeDefs.options.name, 0)) + + @property + def execute_if_off_supported(self) -> bool: + """Return True if the cluster handler can execute commands when off.""" + return Color.Options.Execute_if_off in self.options diff --git a/zha/core/cluster_handlers/lightlink.py b/zha/core/cluster_handlers/lightlink.py new file mode 100644 index 00000000..85ec6905 --- /dev/null +++ b/zha/core/cluster_handlers/lightlink.py @@ -0,0 +1,48 @@ +"""Lightlink cluster handlers module for Zigbee Home Automation.""" + +import zigpy.exceptions +from zigpy.zcl.clusters.lightlink import LightLink +from zigpy.zcl.foundation import GENERAL_COMMANDS, GeneralCommand + +from .. import registries +from . import ClusterHandler, ClusterHandlerStatus + + +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(LightLink.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(LightLink.cluster_id) +class LightLinkClusterHandler(ClusterHandler): + """Lightlink cluster handler.""" + + BIND: bool = False + + async def async_configure(self) -> None: + """Add Coordinator to LightLink group.""" + + if self._endpoint.device.skip_configuration: + self._status = ClusterHandlerStatus.CONFIGURED + return + + application = self._endpoint.zigpy_endpoint.device.application + try: + coordinator = application.get_device(application.state.node_info.ieee) + except KeyError: + self.warning("Aborting - unable to locate required coordinator device.") + return + + try: + rsp = await self.cluster.get_group_identifiers(0) + except (zigpy.exceptions.ZigbeeException, TimeoutError) as exc: + self.warning("Couldn't get list of groups: %s", str(exc)) + return + + if isinstance(rsp, GENERAL_COMMANDS[GeneralCommand.Default_Response].schema): + groups = [] + else: + groups = rsp.group_info_records + + if groups: + for group in groups: + self.debug("Adding coordinator to 0x%04x group id", group.group_id) + await coordinator.add_to_group(group.group_id) + else: + await coordinator.add_to_group(0x0000, name="Default Lightlink Group") diff --git a/zha/core/cluster_handlers/manufacturerspecific.py b/zha/core/cluster_handlers/manufacturerspecific.py new file mode 100644 index 00000000..cc7f7052 --- /dev/null +++ b/zha/core/cluster_handlers/manufacturerspecific.py @@ -0,0 +1,445 @@ +"""Manufacturer specific cluster handlers module for Zigbee Home Automation.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from homeassistant.core import callback +from zhaquirks.inovelli.types import AllLEDEffectType, SingleLEDEffectType +from zhaquirks.quirk_ids import TUYA_PLUG_MANUFACTURER, XIAOMI_AQARA_VIBRATION_AQ1 +import zigpy.zcl +from zigpy.zcl.clusters.closures import DoorLock + +from .. import registries +from ..const import ( + ATTR_ATTRIBUTE_ID, + ATTR_ATTRIBUTE_NAME, + ATTR_VALUE, + REPORT_CONFIG_ASAP, + REPORT_CONFIG_DEFAULT, + REPORT_CONFIG_IMMEDIATE, + REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_MIN_INT, + SIGNAL_ATTR_UPDATED, + UNKNOWN, +) +from . import AttrReportConfig, ClientClusterHandler, ClusterHandler +from .general import MultistateInputClusterHandler + +if TYPE_CHECKING: + from ..endpoint import Endpoint + +_LOGGER = logging.getLogger(__name__) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + registries.SMARTTHINGS_HUMIDITY_CLUSTER +) +class SmartThingsHumidityClusterHandler(ClusterHandler): + """Smart Things Humidity cluster handler.""" + + REPORT_CONFIG = ( + { + "attr": "measured_value", + "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 50), + }, + ) + + +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(0xFD00) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(0xFD00) +class OsramButtonClusterHandler(ClusterHandler): + """Osram button cluster handler.""" + + REPORT_CONFIG = () + + +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(registries.PHILLIPS_REMOTE_CLUSTER) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(registries.PHILLIPS_REMOTE_CLUSTER) +class PhillipsRemoteClusterHandler(ClusterHandler): + """Phillips remote cluster handler.""" + + REPORT_CONFIG = () + + +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(registries.TUYA_MANUFACTURER_CLUSTER) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + registries.TUYA_MANUFACTURER_CLUSTER +) +class TuyaClusterHandler(ClusterHandler): + """Cluster handler for the Tuya manufacturer Zigbee cluster.""" + + REPORT_CONFIG = () + + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize TuyaClusterHandler.""" + super().__init__(cluster, endpoint) + if endpoint.device.quirk_id == TUYA_PLUG_MANUFACTURER: + self.ZCL_INIT_ATTRS = { + "backlight_mode": True, + "power_on_state": True, + } + + +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(0xFCC0) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(0xFCC0) +class OppleRemoteClusterHandler(ClusterHandler): + """Opple cluster handler.""" + + REPORT_CONFIG = () + + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize Opple cluster handler.""" + super().__init__(cluster, endpoint) + if self.cluster.endpoint.model == "lumi.motion.ac02": + self.ZCL_INIT_ATTRS = { + "detection_interval": True, + "motion_sensitivity": True, + "trigger_indicator": True, + } + elif self.cluster.endpoint.model == "lumi.motion.agl04": + self.ZCL_INIT_ATTRS = { + "detection_interval": True, + "motion_sensitivity": True, + } + elif self.cluster.endpoint.model == "lumi.motion.ac01": + self.ZCL_INIT_ATTRS = { + "presence": True, + "monitoring_mode": True, + "motion_sensitivity": True, + "approach_distance": True, + } + elif self.cluster.endpoint.model in ("lumi.plug.mmeu01", "lumi.plug.maeu01"): + self.ZCL_INIT_ATTRS = { + "power_outage_memory": True, + "consumer_connected": True, + } + elif self.cluster.endpoint.model == "aqara.feeder.acn001": + self.ZCL_INIT_ATTRS = { + "portions_dispensed": True, + "weight_dispensed": True, + "error_detected": True, + "disable_led_indicator": True, + "child_lock": True, + "feeding_mode": True, + "serving_size": True, + "portion_weight": True, + } + elif self.cluster.endpoint.model == "lumi.airrtc.agl001": + self.ZCL_INIT_ATTRS = { + "system_mode": True, + "preset": True, + "window_detection": True, + "valve_detection": True, + "valve_alarm": True, + "child_lock": True, + "away_preset_temperature": True, + "window_open": True, + "calibrated": True, + "schedule": True, + "sensor": True, + } + elif self.cluster.endpoint.model == "lumi.sensor_smoke.acn03": + self.ZCL_INIT_ATTRS = { + "buzzer_manual_mute": True, + "smoke_density": True, + "heartbeat_indicator": True, + "buzzer_manual_alarm": True, + "buzzer": True, + "linkage_alarm": True, + } + elif self.cluster.endpoint.model == "lumi.magnet.ac01": + self.ZCL_INIT_ATTRS = { + "detection_distance": True, + } + elif self.cluster.endpoint.model == "lumi.switch.acn047": + self.ZCL_INIT_ATTRS = { + "switch_mode": True, + "switch_type": True, + "startup_on_off": True, + "decoupled_mode": True, + } + elif self.cluster.endpoint.model == "lumi.curtain.agl001": + self.ZCL_INIT_ATTRS = { + "hooks_state": True, + "hooks_lock": True, + "positions_stored": True, + "light_level": True, + "hand_open": True, + } + + async def async_initialize_cluster_handler_specific(self, from_cache: bool) -> None: + """Initialize cluster handler specific.""" + if self.cluster.endpoint.model in ("lumi.motion.ac02", "lumi.motion.agl04"): + interval = self.cluster.get("detection_interval", self.cluster.get(0x0102)) + if interval is not None: + self.debug("Loaded detection interval at startup: %s", interval) + self.cluster.endpoint.ias_zone.reset_s = int(interval) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + registries.SMARTTHINGS_ACCELERATION_CLUSTER +) +class SmartThingsAccelerationClusterHandler(ClusterHandler): + """Smart Things Acceleration cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig(attr="acceleration", config=REPORT_CONFIG_ASAP), + AttrReportConfig(attr="x_axis", config=REPORT_CONFIG_ASAP), + AttrReportConfig(attr="y_axis", config=REPORT_CONFIG_ASAP), + AttrReportConfig(attr="z_axis", config=REPORT_CONFIG_ASAP), + ) + + @classmethod + def matches(cls, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> bool: + """Filter the cluster match for specific devices.""" + return cluster.endpoint.device.manufacturer in ( + "CentraLite", + "Samjin", + "SmartThings", + ) + + @callback + def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: + """Handle attribute updates on this cluster.""" + try: + attr_name = self._cluster.attributes[attrid].name + except KeyError: + attr_name = UNKNOWN + + if attrid == self.value_attribute: + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + attrid, + attr_name, + value, + ) + return + + self.zha_send_event( + SIGNAL_ATTR_UPDATED, + { + ATTR_ATTRIBUTE_ID: attrid, + ATTR_ATTRIBUTE_NAME: attr_name, + ATTR_VALUE: value, + }, + ) + + +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(0xFC31) +class InovelliNotificationClientClusterHandler(ClientClusterHandler): + """Inovelli Notification cluster handler.""" + + @callback + def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: + """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_CLUSTER_HANDLER_REGISTRY.register(0xFC31) +class InovelliConfigEntityClusterHandler(ClusterHandler): + """Inovelli Configuration Entity cluster handler.""" + + REPORT_CONFIG = () + + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize Inovelli cluster handler.""" + super().__init__(cluster, endpoint) + if self.cluster.endpoint.model == "VZM31-SN": + self.ZCL_INIT_ATTRS = { + "dimming_speed_up_remote": True, + "dimming_speed_up_local": True, + "ramp_rate_off_to_on_local": True, + "ramp_rate_off_to_on_remote": True, + "dimming_speed_down_remote": True, + "dimming_speed_down_local": True, + "ramp_rate_on_to_off_local": True, + "ramp_rate_on_to_off_remote": True, + "minimum_level": True, + "maximum_level": True, + "invert_switch": True, + "auto_off_timer": True, + "default_level_local": True, + "default_level_remote": True, + "state_after_power_restored": True, + "load_level_indicator_timeout": True, + "active_power_reports": True, + "periodic_power_and_energy_reports": True, + "active_energy_reports": True, + "power_type": False, + "switch_type": False, + "increased_non_neutral_output": True, + "button_delay": False, + "smart_bulb_mode": False, + "double_tap_up_enabled": True, + "double_tap_down_enabled": True, + "double_tap_up_level": True, + "double_tap_down_level": True, + "led_color_when_on": True, + "led_color_when_off": True, + "led_intensity_when_on": True, + "led_intensity_when_off": True, + "led_scaling_mode": True, + "aux_switch_scenes": True, + "binding_off_to_on_sync_level": True, + "local_protection": False, + "output_mode": False, + "on_off_led_mode": True, + "firmware_progress_led": True, + "relay_click_in_on_off_mode": True, + "disable_clear_notifications_double_tap": True, + } + elif self.cluster.endpoint.model == "VZM35-SN": + self.ZCL_INIT_ATTRS = { + "dimming_speed_up_remote": True, + "dimming_speed_up_local": True, + "ramp_rate_off_to_on_local": True, + "ramp_rate_off_to_on_remote": True, + "dimming_speed_down_remote": True, + "dimming_speed_down_local": True, + "ramp_rate_on_to_off_local": True, + "ramp_rate_on_to_off_remote": True, + "minimum_level": True, + "maximum_level": True, + "invert_switch": True, + "auto_off_timer": True, + "default_level_local": True, + "default_level_remote": True, + "state_after_power_restored": True, + "load_level_indicator_timeout": True, + "power_type": False, + "switch_type": False, + "non_neutral_aux_med_gear_learn_value": True, + "non_neutral_aux_low_gear_learn_value": True, + "quick_start_time": False, + "button_delay": False, + "smart_fan_mode": False, + "double_tap_up_enabled": True, + "double_tap_down_enabled": True, + "double_tap_up_level": True, + "double_tap_down_level": True, + "led_color_when_on": True, + "led_color_when_off": True, + "led_intensity_when_on": True, + "led_intensity_when_off": True, + "aux_switch_scenes": True, + "local_protection": False, + "output_mode": False, + "on_off_led_mode": True, + "firmware_progress_led": True, + "smart_fan_led_display_levels": True, + } + + async def issue_all_led_effect( + self, + effect_type: AllLEDEffectType | int = AllLEDEffectType.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: SingleLEDEffectType | int = SingleLEDEffectType.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.CLUSTER_HANDLER_ONLY_CLUSTERS.register(registries.IKEA_AIR_PURIFIER_CLUSTER) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + registries.IKEA_AIR_PURIFIER_CLUSTER +) +class IkeaAirPurifierClusterHandler(ClusterHandler): + """IKEA Air Purifier cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig(attr="filter_run_time", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="replace_filter", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig(attr="filter_life_time", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="disable_led", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig(attr="air_quality_25pm", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig(attr="child_lock", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig(attr="fan_mode", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig(attr="fan_speed", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig(attr="device_run_time", config=REPORT_CONFIG_DEFAULT), + ) + + @property + def fan_mode(self) -> int | None: + """Return current fan mode.""" + return self.cluster.get("fan_mode") + + @property + def fan_mode_sequence(self) -> int | None: + """Return possible fan mode speeds.""" + return self.cluster.get("fan_mode_sequence") + + async def async_set_speed(self, value) -> None: + """Set the speed of the fan.""" + await self.write_attributes_safe({"fan_mode": value}) + + async def async_update(self) -> None: + """Retrieve latest state.""" + await self.get_attribute_value("fan_mode", from_cache=False) + + @callback + def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: + """Handle attribute update from fan cluster.""" + attr_name = self._get_attribute_name(attrid) + self.debug( + "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value + ) + if attr_name == "fan_mode": + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value + ) + + +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(0xFC80) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(0xFC80) +class IkeaRemoteClusterHandler(ClusterHandler): + """Ikea Matter remote cluster handler.""" + + REPORT_CONFIG = () + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + DoorLock.cluster_id, XIAOMI_AQARA_VIBRATION_AQ1 +) +class XiaomiVibrationAQ1ClusterHandler(MultistateInputClusterHandler): + """Xiaomi DoorLock Cluster is in fact a MultiStateInput Cluster.""" + + +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(0xFC11) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(0xFC11) +class SonoffPresenceSenorClusterHandler(ClusterHandler): + """SonoffPresenceSensor cluster handler.""" + + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize SonoffPresenceSensor cluster handler.""" + super().__init__(cluster, endpoint) + if self.cluster.endpoint.model == "SNZB-06P": + self.ZCL_INIT_ATTRS = {"last_illumination_state": True} diff --git a/zha/core/cluster_handlers/measurement.py b/zha/core/cluster_handlers/measurement.py new file mode 100644 index 00000000..768de8c4 --- /dev/null +++ b/zha/core/cluster_handlers/measurement.py @@ -0,0 +1,208 @@ +"""Measurement cluster handlers module for Zigbee Home Automation.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import zigpy.zcl +from zigpy.zcl.clusters.measurement import ( + PM25, + CarbonDioxideConcentration, + CarbonMonoxideConcentration, + FlowMeasurement, + FormaldehydeConcentration, + IlluminanceLevelSensing, + IlluminanceMeasurement, + LeafWetness, + OccupancySensing, + PressureMeasurement, + RelativeHumidity, + SoilMoisture, + TemperatureMeasurement, +) + +from .. import registries +from ..const import ( + REPORT_CONFIG_DEFAULT, + REPORT_CONFIG_IMMEDIATE, + REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_MIN_INT, +) +from . import AttrReportConfig, ClusterHandler +from .helpers import is_hue_motion_sensor, is_sonoff_presence_sensor + +if TYPE_CHECKING: + from ..endpoint import Endpoint + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(FlowMeasurement.cluster_id) +class FlowMeasurementClusterHandler(ClusterHandler): + """Flow Measurement cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=FlowMeasurement.AttributeDefs.measured_value.name, + config=REPORT_CONFIG_DEFAULT, + ), + ) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(IlluminanceLevelSensing.cluster_id) +class IlluminanceLevelSensingClusterHandler(ClusterHandler): + """Illuminance Level Sensing cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=IlluminanceLevelSensing.AttributeDefs.level_status.name, + config=REPORT_CONFIG_DEFAULT, + ), + ) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(IlluminanceMeasurement.cluster_id) +class IlluminanceMeasurementClusterHandler(ClusterHandler): + """Illuminance Measurement cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=IlluminanceMeasurement.AttributeDefs.measured_value.name, + config=REPORT_CONFIG_DEFAULT, + ), + ) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(OccupancySensing.cluster_id) +class OccupancySensingClusterHandler(ClusterHandler): + """Occupancy Sensing cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=OccupancySensing.AttributeDefs.occupancy.name, + config=REPORT_CONFIG_IMMEDIATE, + ), + ) + + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize Occupancy cluster handler.""" + super().__init__(cluster, endpoint) + if is_hue_motion_sensor(self): + self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() + self.ZCL_INIT_ATTRS["sensitivity"] = True + if is_sonoff_presence_sensor(self): + self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() + self.ZCL_INIT_ATTRS["ultrasonic_o_to_u_delay"] = True + self.ZCL_INIT_ATTRS["ultrasonic_u_to_o_threshold"] = True + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(PressureMeasurement.cluster_id) +class PressureMeasurementClusterHandler(ClusterHandler): + """Pressure measurement cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=PressureMeasurement.AttributeDefs.measured_value.name, + config=REPORT_CONFIG_DEFAULT, + ), + ) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(RelativeHumidity.cluster_id) +class RelativeHumidityClusterHandler(ClusterHandler): + """Relative Humidity measurement cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=RelativeHumidity.AttributeDefs.measured_value.name, + config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 100), + ), + ) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(SoilMoisture.cluster_id) +class SoilMoistureClusterHandler(ClusterHandler): + """Soil Moisture measurement cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=SoilMoisture.AttributeDefs.measured_value.name, + config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 100), + ), + ) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(LeafWetness.cluster_id) +class LeafWetnessClusterHandler(ClusterHandler): + """Leaf Wetness measurement cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=LeafWetness.AttributeDefs.measured_value.name, + config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 100), + ), + ) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(TemperatureMeasurement.cluster_id) +class TemperatureMeasurementClusterHandler(ClusterHandler): + """Temperature measurement cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=TemperatureMeasurement.AttributeDefs.measured_value.name, + config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 50), + ), + ) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + CarbonMonoxideConcentration.cluster_id +) +class CarbonMonoxideConcentrationClusterHandler(ClusterHandler): + """Carbon Monoxide measurement cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=CarbonMonoxideConcentration.AttributeDefs.measured_value.name, + config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.000001), + ), + ) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + CarbonDioxideConcentration.cluster_id +) +class CarbonDioxideConcentrationClusterHandler(ClusterHandler): + """Carbon Dioxide measurement cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=CarbonDioxideConcentration.AttributeDefs.measured_value.name, + config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.000001), + ), + ) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(PM25.cluster_id) +class PM25ClusterHandler(ClusterHandler): + """Particulate Matter 2.5 microns or less measurement cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=PM25.AttributeDefs.measured_value.name, + config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.1), + ), + ) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + FormaldehydeConcentration.cluster_id +) +class FormaldehydeConcentrationClusterHandler(ClusterHandler): + """Formaldehyde measurement cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=FormaldehydeConcentration.AttributeDefs.measured_value.name, + config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.000001), + ), + ) diff --git a/zha/core/cluster_handlers/protocol.py b/zha/core/cluster_handlers/protocol.py new file mode 100644 index 00000000..e1e3d7a5 --- /dev/null +++ b/zha/core/cluster_handlers/protocol.py @@ -0,0 +1,129 @@ +"""Protocol cluster handlers module for Zigbee Home Automation.""" + +from zigpy.zcl.clusters.protocol import ( + AnalogInputExtended, + AnalogInputRegular, + AnalogOutputExtended, + AnalogOutputRegular, + AnalogValueExtended, + AnalogValueRegular, + BacnetProtocolTunnel, + BinaryInputExtended, + BinaryInputRegular, + BinaryOutputExtended, + BinaryOutputRegular, + BinaryValueExtended, + BinaryValueRegular, + GenericTunnel, + MultistateInputExtended, + MultistateInputRegular, + MultistateOutputExtended, + MultistateOutputRegular, + MultistateValueExtended, + MultistateValueRegular, +) + +from .. import registries +from . import ClusterHandler + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogInputExtended.cluster_id) +class AnalogInputExtendedClusterHandler(ClusterHandler): + """Analog Input Extended cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogInputRegular.cluster_id) +class AnalogInputRegularClusterHandler(ClusterHandler): + """Analog Input Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogOutputExtended.cluster_id) +class AnalogOutputExtendedClusterHandler(ClusterHandler): + """Analog Output Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogOutputRegular.cluster_id) +class AnalogOutputRegularClusterHandler(ClusterHandler): + """Analog Output Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogValueExtended.cluster_id) +class AnalogValueExtendedClusterHandler(ClusterHandler): + """Analog Value Extended edition cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogValueRegular.cluster_id) +class AnalogValueRegularClusterHandler(ClusterHandler): + """Analog Value Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BacnetProtocolTunnel.cluster_id) +class BacnetProtocolTunnelClusterHandler(ClusterHandler): + """Bacnet Protocol Tunnel cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryInputExtended.cluster_id) +class BinaryInputExtendedClusterHandler(ClusterHandler): + """Binary Input Extended cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryInputRegular.cluster_id) +class BinaryInputRegularClusterHandler(ClusterHandler): + """Binary Input Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryOutputExtended.cluster_id) +class BinaryOutputExtendedClusterHandler(ClusterHandler): + """Binary Output Extended cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryOutputRegular.cluster_id) +class BinaryOutputRegularClusterHandler(ClusterHandler): + """Binary Output Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryValueExtended.cluster_id) +class BinaryValueExtendedClusterHandler(ClusterHandler): + """Binary Value Extended cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryValueRegular.cluster_id) +class BinaryValueRegularClusterHandler(ClusterHandler): + """Binary Value Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(GenericTunnel.cluster_id) +class GenericTunnelClusterHandler(ClusterHandler): + """Generic Tunnel cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateInputExtended.cluster_id) +class MultiStateInputExtendedClusterHandler(ClusterHandler): + """Multistate Input Extended cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateInputRegular.cluster_id) +class MultiStateInputRegularClusterHandler(ClusterHandler): + """Multistate Input Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + MultistateOutputExtended.cluster_id +) +class MultiStateOutputExtendedClusterHandler(ClusterHandler): + """Multistate Output Extended cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateOutputRegular.cluster_id) +class MultiStateOutputRegularClusterHandler(ClusterHandler): + """Multistate Output Regular cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateValueExtended.cluster_id) +class MultiStateValueExtendedClusterHandler(ClusterHandler): + """Multistate Value Extended cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateValueRegular.cluster_id) +class MultiStateValueRegularClusterHandler(ClusterHandler): + """Multistate Value Regular cluster handler.""" diff --git a/zha/core/cluster_handlers/security.py b/zha/core/cluster_handlers/security.py new file mode 100644 index 00000000..eb8e24aa --- /dev/null +++ b/zha/core/cluster_handlers/security.py @@ -0,0 +1,399 @@ +"""Security cluster handlers module for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/integrations/zha/ +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +import zigpy.zcl +from zigpy.zcl.clusters.security import IasAce as AceCluster, IasWd, IasZone + +from .. import registries +from ..const import ( + SIGNAL_ATTR_UPDATED, + WARNING_DEVICE_MODE_EMERGENCY, + WARNING_DEVICE_SOUND_HIGH, + WARNING_DEVICE_SQUAWK_MODE_ARMED, + WARNING_DEVICE_STROBE_HIGH, + WARNING_DEVICE_STROBE_YES, +) +from . import ClusterHandler, ClusterHandlerStatus + +if TYPE_CHECKING: + from ..endpoint import Endpoint + +SIGNAL_ARMED_STATE_CHANGED = "zha_armed_state_changed" +SIGNAL_ALARM_TRIGGERED = "zha_armed_triggered" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AceCluster.cluster_id) +class IasAceClusterHandler(ClusterHandler): + """IAS Ancillary Control Equipment cluster handler.""" + + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize IAS Ancillary Control Equipment cluster handler.""" + super().__init__(cluster, endpoint) + self.command_map: dict[int, Callable[..., Any]] = { + AceCluster.ServerCommandDefs.arm.id: self.arm, + AceCluster.ServerCommandDefs.bypass.id: self._bypass, + AceCluster.ServerCommandDefs.emergency.id: self._emergency, + AceCluster.ServerCommandDefs.fire.id: self._fire, + AceCluster.ServerCommandDefs.panic.id: self._panic, + AceCluster.ServerCommandDefs.get_zone_id_map.id: self._get_zone_id_map, + AceCluster.ServerCommandDefs.get_zone_info.id: self._get_zone_info, + AceCluster.ServerCommandDefs.get_panel_status.id: self._send_panel_status_response, + AceCluster.ServerCommandDefs.get_bypassed_zone_list.id: self._get_bypassed_zone_list, + AceCluster.ServerCommandDefs.get_zone_status.id: self._get_zone_status, + } + self.arm_map: dict[AceCluster.ArmMode, Callable[..., Any]] = { + AceCluster.ArmMode.Disarm: self._disarm, + AceCluster.ArmMode.Arm_All_Zones: self._arm_away, + AceCluster.ArmMode.Arm_Day_Home_Only: self._arm_day, + AceCluster.ArmMode.Arm_Night_Sleep_Only: self._arm_night, + } + self.armed_state: AceCluster.PanelStatus = AceCluster.PanelStatus.Panel_Disarmed + self.invalid_tries: int = 0 + + # These will all be setup by the entity from ZHA configuration + self.panel_code: str = "1234" + self.code_required_arm_actions = False + self.max_invalid_tries: int = 3 + + # where do we store this to handle restarts + self.alarm_status: AceCluster.AlarmStatus = AceCluster.AlarmStatus.No_Alarm + + @callback + def cluster_command(self, tsn, command_id, args) -> None: + """Handle commands received to this cluster.""" + self.debug( + "received command %s", self._cluster.server_commands[command_id].name + ) + self.command_map[command_id](*args) + + def arm(self, arm_mode: int, code: str | None, zone_id: int) -> None: + """Handle the IAS ACE arm command.""" + mode = AceCluster.ArmMode(arm_mode) + + self.zha_send_event( + AceCluster.ServerCommandDefs.arm.name, + { + "arm_mode": mode.value, + "arm_mode_description": mode.name, + "code": code, + "zone_id": zone_id, + }, + ) + + zigbee_reply = self.arm_map[mode](code) + self._endpoint.device.hass.async_create_task(zigbee_reply) + + if self.invalid_tries >= self.max_invalid_tries: + self.alarm_status = AceCluster.AlarmStatus.Emergency + self.armed_state = AceCluster.PanelStatus.In_Alarm + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ALARM_TRIGGERED}") + else: + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ARMED_STATE_CHANGED}") + self._send_panel_status_changed() + + def _disarm(self, code: str): + """Test the code and disarm the panel if the code is correct.""" + if ( + code != self.panel_code + and self.armed_state != AceCluster.PanelStatus.Panel_Disarmed + ): + self.debug("Invalid code supplied to IAS ACE") + self.invalid_tries += 1 + zigbee_reply = self.arm_response( + AceCluster.ArmNotification.Invalid_Arm_Disarm_Code + ) + else: + self.invalid_tries = 0 + if ( + self.armed_state == AceCluster.PanelStatus.Panel_Disarmed + and self.alarm_status == AceCluster.AlarmStatus.No_Alarm + ): + self.debug("IAS ACE already disarmed") + zigbee_reply = self.arm_response( + AceCluster.ArmNotification.Already_Disarmed + ) + else: + self.debug("Disarming all IAS ACE zones") + zigbee_reply = self.arm_response( + AceCluster.ArmNotification.All_Zones_Disarmed + ) + + self.armed_state = AceCluster.PanelStatus.Panel_Disarmed + self.alarm_status = AceCluster.AlarmStatus.No_Alarm + return zigbee_reply + + def _arm_day(self, code: str) -> None: + """Arm the panel for day / home zones.""" + return self._handle_arm( + code, + AceCluster.PanelStatus.Armed_Stay, + AceCluster.ArmNotification.Only_Day_Home_Zones_Armed, + ) + + def _arm_night(self, code: str) -> None: + """Arm the panel for night / sleep zones.""" + return self._handle_arm( + code, + AceCluster.PanelStatus.Armed_Night, + AceCluster.ArmNotification.Only_Night_Sleep_Zones_Armed, + ) + + def _arm_away(self, code: str) -> None: + """Arm the panel for away mode.""" + return self._handle_arm( + code, + AceCluster.PanelStatus.Armed_Away, + AceCluster.ArmNotification.All_Zones_Armed, + ) + + def _handle_arm( + self, + code: str, + panel_status: AceCluster.PanelStatus, + armed_type: AceCluster.ArmNotification, + ) -> None: + """Arm the panel with the specified statuses.""" + if self.code_required_arm_actions and code != self.panel_code: + self.debug("Invalid code supplied to IAS ACE") + zigbee_reply = self.arm_response( + AceCluster.ArmNotification.Invalid_Arm_Disarm_Code + ) + else: + self.debug("Arming all IAS ACE zones") + self.armed_state = panel_status + zigbee_reply = self.arm_response(armed_type) + return zigbee_reply + + def _bypass(self, zone_list, code) -> None: + """Handle the IAS ACE bypass command.""" + self.zha_send_event( + AceCluster.ServerCommandDefs.bypass.name, + {"zone_list": zone_list, "code": code}, + ) + + def _emergency(self) -> None: + """Handle the IAS ACE emergency command.""" + self._set_alarm(AceCluster.AlarmStatus.Emergency) + + def _fire(self) -> None: + """Handle the IAS ACE fire command.""" + self._set_alarm(AceCluster.AlarmStatus.Fire) + + def _panic(self) -> None: + """Handle the IAS ACE panic command.""" + self._set_alarm(AceCluster.AlarmStatus.Emergency_Panic) + + def _set_alarm(self, status: AceCluster.AlarmStatus) -> None: + """Set the specified alarm status.""" + self.alarm_status = status + self.armed_state = AceCluster.PanelStatus.In_Alarm + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ALARM_TRIGGERED}") + self._send_panel_status_changed() + + def _get_zone_id_map(self): + """Handle the IAS ACE zone id map command.""" + + def _get_zone_info(self, zone_id): + """Handle the IAS ACE zone info command.""" + + def _send_panel_status_response(self) -> None: + """Handle the IAS ACE panel status response command.""" + response = self.panel_status_response( + self.armed_state, + 0x00, + AceCluster.AudibleNotification.Default_Sound, + self.alarm_status, + ) + self._endpoint.device.hass.async_create_task(response) + + def _send_panel_status_changed(self) -> None: + """Handle the IAS ACE panel status changed command.""" + response = self.panel_status_changed( + self.armed_state, + 0x00, + AceCluster.AudibleNotification.Default_Sound, + self.alarm_status, + ) + self._endpoint.device.hass.async_create_task(response) + + def _get_bypassed_zone_list(self): + """Handle the IAS ACE bypassed zone list command.""" + + def _get_zone_status( + self, starting_zone_id, max_zone_ids, zone_status_mask_flag, zone_status_mask + ): + """Handle the IAS ACE zone status command.""" + + +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(IasWd.cluster_id) +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(IasWd.cluster_id) +class IasWdClusterHandler(ClusterHandler): + """IAS Warning Device cluster handler.""" + + @staticmethod + def set_bit(destination_value, destination_bit, source_value, source_bit): + """Set the specified bit in the value.""" + + if IasWdClusterHandler.get_bit(source_value, source_bit): + return destination_value | (1 << destination_bit) + return destination_value + + @staticmethod + def get_bit(value, bit): + """Get the specified bit from the value.""" + return (value & (1 << bit)) != 0 + + async def issue_squawk( + self, + mode=WARNING_DEVICE_SQUAWK_MODE_ARMED, + strobe=WARNING_DEVICE_STROBE_YES, + squawk_level=WARNING_DEVICE_SOUND_HIGH, + ): + """Issue a squawk command. + + This command uses the WD capabilities to emit a quick audible/visible + pulse called a "squawk". The squawk command has no effect if the WD + is currently active (warning in progress). + """ + value = 0 + value = IasWdClusterHandler.set_bit(value, 0, squawk_level, 0) + value = IasWdClusterHandler.set_bit(value, 1, squawk_level, 1) + + value = IasWdClusterHandler.set_bit(value, 3, strobe, 0) + + value = IasWdClusterHandler.set_bit(value, 4, mode, 0) + value = IasWdClusterHandler.set_bit(value, 5, mode, 1) + value = IasWdClusterHandler.set_bit(value, 6, mode, 2) + value = IasWdClusterHandler.set_bit(value, 7, mode, 3) + + await self.squawk(value) + + async def issue_start_warning( + self, + mode=WARNING_DEVICE_MODE_EMERGENCY, + strobe=WARNING_DEVICE_STROBE_YES, + siren_level=WARNING_DEVICE_SOUND_HIGH, + warning_duration=5, # seconds + strobe_duty_cycle=0x00, + strobe_intensity=WARNING_DEVICE_STROBE_HIGH, + ): + """Issue a start warning command. + + This command starts the WD operation. The WD alerts the surrounding area + by audible (siren) and visual (strobe) signals. + + strobe_duty_cycle indicates the length of the flash cycle. This provides a means + of varying the flash duration for different alarm types (e.g., fire, police, + burglar). Valid range is 0-100 in increments of 10. All other values SHALL + be rounded to the nearest valid value. Strobe SHALL calculate duty cycle over + a duration of one second. + + The ON state SHALL precede the OFF state. For example, if Strobe Duty Cycle + Field specifies “40,” then the strobe SHALL flash ON for 4/10ths of a second + and then turn OFF for 6/10ths of a second. + """ + value = 0 + value = IasWdClusterHandler.set_bit(value, 0, siren_level, 0) + value = IasWdClusterHandler.set_bit(value, 1, siren_level, 1) + + value = IasWdClusterHandler.set_bit(value, 2, strobe, 0) + + value = IasWdClusterHandler.set_bit(value, 4, mode, 0) + value = IasWdClusterHandler.set_bit(value, 5, mode, 1) + value = IasWdClusterHandler.set_bit(value, 6, mode, 2) + value = IasWdClusterHandler.set_bit(value, 7, mode, 3) + + await self.start_warning( + value, warning_duration, strobe_duty_cycle, strobe_intensity + ) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(IasZone.cluster_id) +class IASZoneClusterHandler(ClusterHandler): + """Cluster handler for the IASZone Zigbee cluster.""" + + ZCL_INIT_ATTRS = { + IasZone.AttributeDefs.zone_status.name: False, + IasZone.AttributeDefs.zone_state.name: True, + IasZone.AttributeDefs.zone_type.name: True, + } + + @callback + def cluster_command(self, tsn, command_id, args): + """Handle commands received to this cluster.""" + if command_id == IasZone.ClientCommandDefs.status_change_notification.id: + zone_status = args[0] + # update attribute cache with new zone status + self.cluster.update_attribute( + IasZone.AttributeDefs.zone_status.id, zone_status + ) + self.debug("Updated alarm state: %s", zone_status) + elif command_id == IasZone.ClientCommandDefs.enroll.id: + self.debug("Enroll requested") + self._cluster.create_catching_task( + self.enroll_response( + enroll_response_code=IasZone.EnrollResponse.Success, zone_id=0 + ) + ) + + async def async_configure(self): + """Configure IAS device.""" + await self.get_attribute_value( + IasZone.AttributeDefs.zone_type.name, from_cache=False + ) + if self._endpoint.device.skip_configuration: + self.debug("skipping IASZoneClusterHandler configuration") + return + + self.debug("started IASZoneClusterHandler configuration") + + await self.bind() + ieee = self.cluster.endpoint.device.application.state.node_info.ieee + + try: + await self.write_attributes_safe( + {IasZone.AttributeDefs.cie_addr.name: ieee} + ) + self.debug( + "wrote cie_addr: %s to '%s' cluster", + str(ieee), + self._cluster.ep_attribute, + ) + except HomeAssistantError as ex: + self.debug( + "Failed to write cie_addr: %s to '%s' cluster: %s", + str(ieee), + self._cluster.ep_attribute, + str(ex), + ) + + self.debug("Sending pro-active IAS enroll response") + self._cluster.create_catching_task( + self.enroll_response( + enroll_response_code=IasZone.EnrollResponse.Success, zone_id=0 + ) + ) + + self._status = ClusterHandlerStatus.CONFIGURED + self.debug("finished IASZoneClusterHandler configuration") + + @callback + def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: + """Handle attribute updates on this cluster.""" + if attrid == IasZone.AttributeDefs.zone_status.id: + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + attrid, + IasZone.AttributeDefs.zone_status.name, + value, + ) diff --git a/zha/core/cluster_handlers/smartenergy.py b/zha/core/cluster_handlers/smartenergy.py new file mode 100644 index 00000000..d167b8b1 --- /dev/null +++ b/zha/core/cluster_handlers/smartenergy.py @@ -0,0 +1,388 @@ +"""Smart energy cluster handlers module for Zigbee Home Automation.""" + +from __future__ import annotations + +import enum +from functools import partialmethod +from typing import TYPE_CHECKING + +import zigpy.zcl +from zigpy.zcl.clusters.smartenergy import ( + Calendar, + DeviceManagement, + Drlc, + EnergyManagement, + Events, + KeyEstablishment, + MduPairing, + Messaging, + Metering, + Prepayment, + Price, + Tunneling, +) + +from .. import registries +from ..const import ( + REPORT_CONFIG_ASAP, + REPORT_CONFIG_DEFAULT, + REPORT_CONFIG_OP, + SIGNAL_ATTR_UPDATED, +) +from . import AttrReportConfig, ClusterHandler + +if TYPE_CHECKING: + from ..endpoint import Endpoint + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Calendar.cluster_id) +class CalendarClusterHandler(ClusterHandler): + """Calendar cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(DeviceManagement.cluster_id) +class DeviceManagementClusterHandler(ClusterHandler): + """Device Management cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Drlc.cluster_id) +class DrlcClusterHandler(ClusterHandler): + """Demand Response and Load Control cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(EnergyManagement.cluster_id) +class EnergyManagementClusterHandler(ClusterHandler): + """Energy Management cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Events.cluster_id) +class EventsClusterHandler(ClusterHandler): + """Event cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(KeyEstablishment.cluster_id) +class KeyEstablishmentClusterHandler(ClusterHandler): + """Key Establishment cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MduPairing.cluster_id) +class MduPairingClusterHandler(ClusterHandler): + """Pairing cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Messaging.cluster_id) +class MessagingClusterHandler(ClusterHandler): + """Messaging cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Metering.cluster_id) +class MeteringClusterHandler(ClusterHandler): + """Metering cluster handler.""" + + REPORT_CONFIG = ( + AttrReportConfig( + attr=Metering.AttributeDefs.instantaneous_demand.name, + config=REPORT_CONFIG_OP, + ), + AttrReportConfig( + attr=Metering.AttributeDefs.current_summ_delivered.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=Metering.AttributeDefs.current_tier1_summ_delivered.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=Metering.AttributeDefs.current_tier2_summ_delivered.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=Metering.AttributeDefs.current_tier3_summ_delivered.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=Metering.AttributeDefs.current_tier4_summ_delivered.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=Metering.AttributeDefs.current_tier5_summ_delivered.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=Metering.AttributeDefs.current_tier6_summ_delivered.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=Metering.AttributeDefs.current_summ_received.name, + config=REPORT_CONFIG_DEFAULT, + ), + AttrReportConfig( + attr=Metering.AttributeDefs.status.name, + config=REPORT_CONFIG_ASAP, + ), + ) + ZCL_INIT_ATTRS = { + Metering.AttributeDefs.demand_formatting.name: True, + Metering.AttributeDefs.divisor.name: True, + Metering.AttributeDefs.metering_device_type.name: True, + Metering.AttributeDefs.multiplier.name: True, + Metering.AttributeDefs.summation_formatting.name: True, + Metering.AttributeDefs.unit_of_measure.name: True, + } + + METERING_DEVICE_TYPES_ELECTRIC = { + 0, + 7, + 8, + 9, + 10, + 11, + 13, + 14, + 15, + 127, + 134, + 135, + 136, + 137, + 138, + 140, + 141, + 142, + } + METERING_DEVICE_TYPES_GAS = {1, 128} + METERING_DEVICE_TYPES_WATER = {2, 129} + METERING_DEVICE_TYPES_HEATING_COOLING = {3, 5, 6, 130, 132, 133} + + metering_device_type = { + 0: "Electric Metering", + 1: "Gas Metering", + 2: "Water Metering", + 3: "Thermal Metering", # deprecated + 4: "Pressure Metering", + 5: "Heat Metering", + 6: "Cooling Metering", + 7: "End Use Measurement Device (EUMD) for metering electric vehicle charging", + 8: "PV Generation Metering", + 9: "Wind Turbine Generation Metering", + 10: "Water Turbine Generation Metering", + 11: "Micro Generation Metering", + 12: "Solar Hot Water Generation Metering", + 13: "Electric Metering Element/Phase 1", + 14: "Electric Metering Element/Phase 2", + 15: "Electric Metering Element/Phase 3", + 127: "Mirrored Electric Metering", + 128: "Mirrored Gas Metering", + 129: "Mirrored Water Metering", + 130: "Mirrored Thermal Metering", # deprecated + 131: "Mirrored Pressure Metering", + 132: "Mirrored Heat Metering", + 133: "Mirrored Cooling Metering", + 134: "Mirrored End Use Measurement Device (EUMD) for metering electric vehicle charging", + 135: "Mirrored PV Generation Metering", + 136: "Mirrored Wind Turbine Generation Metering", + 137: "Mirrored Water Turbine Generation Metering", + 138: "Mirrored Micro Generation Metering", + 139: "Mirrored Solar Hot Water Generation Metering", + 140: "Mirrored Electric Metering Element/Phase 1", + 141: "Mirrored Electric Metering Element/Phase 2", + 142: "Mirrored Electric Metering Element/Phase 3", + } + + class DeviceStatusElectric(enum.IntFlag): + """Electric Metering Device Status.""" + + NO_ALARMS = 0 + CHECK_METER = 1 + LOW_BATTERY = 2 + TAMPER_DETECT = 4 + POWER_FAILURE = 8 + POWER_QUALITY = 16 + LEAK_DETECT = 32 # Really? + SERVICE_DISCONNECT = 64 + RESERVED = 128 + + class DeviceStatusGas(enum.IntFlag): + """Gas Metering Device Status.""" + + NO_ALARMS = 0 + CHECK_METER = 1 + LOW_BATTERY = 2 + TAMPER_DETECT = 4 + NOT_DEFINED = 8 + LOW_PRESSURE = 16 + LEAK_DETECT = 32 + SERVICE_DISCONNECT = 64 + REVERSE_FLOW = 128 + + class DeviceStatusWater(enum.IntFlag): + """Water Metering Device Status.""" + + NO_ALARMS = 0 + CHECK_METER = 1 + LOW_BATTERY = 2 + TAMPER_DETECT = 4 + PIPE_EMPTY = 8 + LOW_PRESSURE = 16 + LEAK_DETECT = 32 + SERVICE_DISCONNECT = 64 + REVERSE_FLOW = 128 + + class DeviceStatusHeatingCooling(enum.IntFlag): + """Heating and Cooling Metering Device Status.""" + + NO_ALARMS = 0 + CHECK_METER = 1 + LOW_BATTERY = 2 + TAMPER_DETECT = 4 + TEMPERATURE_SENSOR = 8 + BURST_DETECT = 16 + LEAK_DETECT = 32 + SERVICE_DISCONNECT = 64 + REVERSE_FLOW = 128 + + class DeviceStatusDefault(enum.IntFlag): + """Metering Device Status.""" + + NO_ALARMS = 0 + + class FormatSelector(enum.IntEnum): + """Format specified selector.""" + + DEMAND = 0 + SUMMATION = 1 + + def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: + """Initialize Metering.""" + super().__init__(cluster, endpoint) + self._format_spec: str | None = None + self._summa_format: str | None = None + + @property + def divisor(self) -> int: + """Return divisor for the value.""" + return self.cluster.get(Metering.AttributeDefs.divisor.name) or 1 + + @property + def device_type(self) -> str | int | None: + """Return metering device type.""" + dev_type = self.cluster.get(Metering.AttributeDefs.metering_device_type.name) + if dev_type is None: + return None + return self.metering_device_type.get(dev_type, dev_type) + + @property + def multiplier(self) -> int: + """Return multiplier for the value.""" + return self.cluster.get(Metering.AttributeDefs.multiplier.name) or 1 + + @property + def status(self) -> int | None: + """Return metering device status.""" + if (status := self.cluster.get(Metering.AttributeDefs.status.name)) is None: + return None + + metering_device_type = self.cluster.get( + Metering.AttributeDefs.metering_device_type.name + ) + if metering_device_type in self.METERING_DEVICE_TYPES_ELECTRIC: + return self.DeviceStatusElectric(status) + if metering_device_type in self.METERING_DEVICE_TYPES_GAS: + return self.DeviceStatusGas(status) + if metering_device_type in self.METERING_DEVICE_TYPES_WATER: + return self.DeviceStatusWater(status) + if metering_device_type in self.METERING_DEVICE_TYPES_HEATING_COOLING: + return self.DeviceStatusHeatingCooling(status) + return self.DeviceStatusDefault(status) + + @property + def unit_of_measurement(self) -> int: + """Return unit of measurement.""" + return self.cluster.get(Metering.AttributeDefs.unit_of_measure.name) + + async def async_initialize_cluster_handler_specific(self, from_cache: bool) -> None: + """Fetch config from device and updates format specifier.""" + + fmting = self.cluster.get( + Metering.AttributeDefs.demand_formatting.name, 0xF9 + ) # 1 digit to the right, 15 digits to the left + self._format_spec = self.get_formatting(fmting) + + fmting = self.cluster.get( + Metering.AttributeDefs.summation_formatting.name, 0xF9 + ) # 1 digit to the right, 15 digits to the left + self._summa_format = self.get_formatting(fmting) + + async def async_update(self) -> None: + """Retrieve latest state.""" + self.debug("async_update") + + attrs = [ + a["attr"] + for a in self.REPORT_CONFIG + if a["attr"] not in self.cluster.unsupported_attributes + ] + result = await self.get_attributes(attrs, from_cache=False, only_cache=False) + if result: + for attr, value in result.items(): + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + self.cluster.find_attribute(attr).id, + attr, + value, + ) + + @staticmethod + def get_formatting(formatting: int) -> str: + """Return a formatting string, given the formatting value. + + Bits 0 to 2: Number of Digits to the right of the Decimal Point. + Bits 3 to 6: Number of Digits to the left of the Decimal Point. + Bit 7: If set, suppress leading zeros. + """ + r_digits = int(formatting & 0x07) # digits to the right of decimal point + l_digits = (formatting >> 3) & 0x0F # digits to the left of decimal point + if l_digits == 0: + l_digits = 15 + width = r_digits + l_digits + (1 if r_digits > 0 else 0) + + if formatting & 0x80: + # suppress leading 0 + return f"{{:{width}.{r_digits}f}}" + + return f"{{:0{width}.{r_digits}f}}" + + def _formatter_function( + self, selector: FormatSelector, value: int + ) -> int | float | str: + """Return formatted value for display.""" + value_float = value * self.multiplier / self.divisor + if self.unit_of_measurement == 0: + # Zigbee spec power unit is kW, but we show the value in W + value_watt = value_float * 1000 + if value_watt < 100: + return round(value_watt, 1) + return round(value_watt) + if selector == self.FormatSelector.SUMMATION: + assert self._summa_format + return self._summa_format.format(value_float).lstrip() + assert self._format_spec + return self._format_spec.format(value_float).lstrip() + + demand_formatter = partialmethod(_formatter_function, FormatSelector.DEMAND) + summa_formatter = partialmethod(_formatter_function, FormatSelector.SUMMATION) + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Prepayment.cluster_id) +class PrepaymentClusterHandler(ClusterHandler): + """Prepayment cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Price.cluster_id) +class PriceClusterHandler(ClusterHandler): + """Price cluster handler.""" + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Tunneling.cluster_id) +class TunnelingClusterHandler(ClusterHandler): + """Tunneling cluster handler.""" diff --git a/zha/core/const.py b/zha/core/const.py new file mode 100644 index 00000000..e927f615 --- /dev/null +++ b/zha/core/const.py @@ -0,0 +1,422 @@ +"""All constants related to the ZHA component.""" + +from __future__ import annotations + +import enum +import logging + +import bellows.zigbee.application +from homeassistant.const import Platform +import homeassistant.helpers.config_validation as cv +import voluptuous as vol +import zigpy.application +import zigpy.types as t +import zigpy_deconz.zigbee.application +import zigpy_xbee.zigbee.application +import zigpy_zigate.zigbee.application +import zigpy_znp.zigbee.application + +ATTR_ACTIVE_COORDINATOR = "active_coordinator" +ATTR_ARGS = "args" +ATTR_ATTRIBUTE = "attribute" +ATTR_ATTRIBUTE_ID = "attribute_id" +ATTR_ATTRIBUTE_NAME = "attribute_name" +ATTR_AVAILABLE = "available" +ATTR_CLUSTER_ID = "cluster_id" +ATTR_CLUSTER_TYPE = "cluster_type" +ATTR_COMMAND_TYPE = "command_type" +ATTR_DEVICE_IEEE = "device_ieee" +ATTR_DEVICE_TYPE = "device_type" +ATTR_ENDPOINTS = "endpoints" +ATTR_ENDPOINT_NAMES = "endpoint_names" +ATTR_ENDPOINT_ID = "endpoint_id" +ATTR_IEEE = "ieee" +ATTR_IN_CLUSTERS = "in_clusters" +ATTR_LAST_SEEN = "last_seen" +ATTR_LEVEL = "level" +ATTR_LQI = "lqi" +ATTR_MANUFACTURER = "manufacturer" +ATTR_MANUFACTURER_CODE = "manufacturer_code" +ATTR_MEMBERS = "members" +ATTR_MODEL = "model" +ATTR_NEIGHBORS = "neighbors" +ATTR_NODE_DESCRIPTOR = "node_descriptor" +ATTR_NWK = "nwk" +ATTR_OUT_CLUSTERS = "out_clusters" +ATTR_PARAMS = "params" +ATTR_POWER_SOURCE = "power_source" +ATTR_PROFILE_ID = "profile_id" +ATTR_QUIRK_APPLIED = "quirk_applied" +ATTR_QUIRK_CLASS = "quirk_class" +ATTR_QUIRK_ID = "quirk_id" +ATTR_ROUTES = "routes" +ATTR_RSSI = "rssi" +ATTR_SIGNATURE = "signature" +ATTR_TYPE = "type" +ATTR_UNIQUE_ID = "unique_id" +ATTR_VALUE = "value" +ATTR_WARNING_DEVICE_DURATION = "duration" +ATTR_WARNING_DEVICE_MODE = "mode" +ATTR_WARNING_DEVICE_STROBE = "strobe" +ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE = "duty_cycle" +ATTR_WARNING_DEVICE_STROBE_INTENSITY = "intensity" + +BAUD_RATES = [2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 128000, 256000] +BINDINGS = "bindings" + +CLUSTER_DETAILS = "cluster_details" + +CLUSTER_HANDLER_ACCELEROMETER = "accelerometer" +CLUSTER_HANDLER_BINARY_INPUT = "binary_input" +CLUSTER_HANDLER_ANALOG_INPUT = "analog_input" +CLUSTER_HANDLER_ANALOG_OUTPUT = "analog_output" +CLUSTER_HANDLER_ATTRIBUTE = "attribute" +CLUSTER_HANDLER_BASIC = "basic" +CLUSTER_HANDLER_COLOR = "light_color" +CLUSTER_HANDLER_COVER = "window_covering" +CLUSTER_HANDLER_DEVICE_TEMPERATURE = "device_temperature" +CLUSTER_HANDLER_DOORLOCK = "door_lock" +CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT = "electrical_measurement" +CLUSTER_HANDLER_EVENT_RELAY = "event_relay" +CLUSTER_HANDLER_FAN = "fan" +CLUSTER_HANDLER_HUMIDITY = "humidity" +CLUSTER_HANDLER_HUE_OCCUPANCY = "philips_occupancy" +CLUSTER_HANDLER_SOIL_MOISTURE = "soil_moisture" +CLUSTER_HANDLER_LEAF_WETNESS = "leaf_wetness" +CLUSTER_HANDLER_IAS_ACE = "ias_ace" +CLUSTER_HANDLER_IAS_WD = "ias_wd" +CLUSTER_HANDLER_IDENTIFY = "identify" +CLUSTER_HANDLER_ILLUMINANCE = "illuminance" +CLUSTER_HANDLER_LEVEL = ATTR_LEVEL +CLUSTER_HANDLER_MULTISTATE_INPUT = "multistate_input" +CLUSTER_HANDLER_OCCUPANCY = "occupancy" +CLUSTER_HANDLER_ON_OFF = "on_off" +CLUSTER_HANDLER_OTA = "ota" +CLUSTER_HANDLER_POWER_CONFIGURATION = "power" +CLUSTER_HANDLER_PRESSURE = "pressure" +CLUSTER_HANDLER_SHADE = "shade" +CLUSTER_HANDLER_SMARTENERGY_METERING = "smartenergy_metering" +CLUSTER_HANDLER_TEMPERATURE = "temperature" +CLUSTER_HANDLER_THERMOSTAT = "thermostat" +CLUSTER_HANDLER_ZDO = "zdo" +CLUSTER_HANDLER_ZONE = ZONE = "ias_zone" +CLUSTER_HANDLER_INOVELLI = "inovelli_vzm31sn_cluster" + +CLUSTER_COMMAND_SERVER = "server" +CLUSTER_COMMANDS_CLIENT = "client_commands" +CLUSTER_COMMANDS_SERVER = "server_commands" +CLUSTER_TYPE_IN = "in" +CLUSTER_TYPE_OUT = "out" + +PLATFORMS = ( + Platform.ALARM_CONTROL_PANEL, + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.CLIMATE, + Platform.COVER, + Platform.DEVICE_TRACKER, + Platform.FAN, + Platform.LIGHT, + Platform.LOCK, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SIREN, + Platform.SWITCH, + Platform.UPDATE, +) + +CONF_ALARM_MASTER_CODE = "alarm_master_code" +CONF_ALARM_FAILED_TRIES = "alarm_failed_tries" +CONF_ALARM_ARM_REQUIRES_CODE = "alarm_arm_requires_code" + +CONF_BAUDRATE = "baudrate" +CONF_FLOW_CONTROL = "flow_control" +CONF_CUSTOM_QUIRKS_PATH = "custom_quirks_path" +CONF_DEFAULT_LIGHT_TRANSITION = "default_light_transition" +CONF_DEVICE_CONFIG = "device_config" +CONF_ENABLE_ENHANCED_LIGHT_TRANSITION = "enhanced_light_transition" +CONF_ENABLE_LIGHT_TRANSITIONING_FLAG = "light_transitioning_flag" +CONF_ALWAYS_PREFER_XY_COLOR_MODE = "always_prefer_xy_color_mode" +CONF_GROUP_MEMBERS_ASSUME_STATE = "group_members_assume_state" +CONF_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join" +CONF_ENABLE_QUIRKS = "enable_quirks" +CONF_RADIO_TYPE = "radio_type" +CONF_USB_PATH = "usb_path" +CONF_USE_THREAD = "use_thread" +CONF_ZIGPY = "zigpy_config" + +CONF_CONSIDER_UNAVAILABLE_MAINS = "consider_unavailable_mains" +CONF_DEFAULT_CONSIDER_UNAVAILABLE_MAINS = 60 * 60 * 2 # 2 hours +CONF_CONSIDER_UNAVAILABLE_BATTERY = "consider_unavailable_battery" +CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY = 60 * 60 * 6 # 6 hours + +CONF_ZHA_OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_DEFAULT_LIGHT_TRANSITION, default=0): vol.All( + vol.Coerce(float), vol.Range(min=0, max=2**16 / 10) + ), + vol.Required(CONF_ENABLE_ENHANCED_LIGHT_TRANSITION, default=False): cv.boolean, + vol.Required(CONF_ENABLE_LIGHT_TRANSITIONING_FLAG, default=True): cv.boolean, + vol.Required(CONF_ALWAYS_PREFER_XY_COLOR_MODE, default=True): cv.boolean, + vol.Required(CONF_GROUP_MEMBERS_ASSUME_STATE, default=True): cv.boolean, + vol.Required(CONF_ENABLE_IDENTIFY_ON_JOIN, default=True): cv.boolean, + vol.Optional( + CONF_CONSIDER_UNAVAILABLE_MAINS, + default=CONF_DEFAULT_CONSIDER_UNAVAILABLE_MAINS, + ): cv.positive_int, + vol.Optional( + CONF_CONSIDER_UNAVAILABLE_BATTERY, + default=CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY, + ): cv.positive_int, + } +) + +CONF_ZHA_ALARM_SCHEMA = vol.Schema( + { + vol.Required(CONF_ALARM_MASTER_CODE, default="1234"): cv.string, + vol.Required(CONF_ALARM_FAILED_TRIES, default=3): cv.positive_int, + vol.Required(CONF_ALARM_ARM_REQUIRES_CODE, default=False): cv.boolean, + } +) + +CUSTOM_CONFIGURATION = "custom_configuration" + +DATA_DEVICE_CONFIG = "zha_device_config" +DATA_ZHA = "zha" +DATA_ZHA_CONFIG = "config" +DATA_ZHA_CORE_EVENTS = "zha_core_events" +DATA_ZHA_DEVICE_TRIGGER_CACHE = "zha_device_trigger_cache" +DATA_ZHA_GATEWAY = "zha_gateway" + +DEBUG_COMP_BELLOWS = "bellows" +DEBUG_COMP_ZHA = "homeassistant.components.zha" +DEBUG_COMP_ZIGPY = "zigpy" +DEBUG_COMP_ZIGPY_ZNP = "zigpy_znp" +DEBUG_COMP_ZIGPY_DECONZ = "zigpy_deconz" +DEBUG_COMP_ZIGPY_XBEE = "zigpy_xbee" +DEBUG_COMP_ZIGPY_ZIGATE = "zigpy_zigate" +DEBUG_LEVEL_CURRENT = "current" +DEBUG_LEVEL_ORIGINAL = "original" +DEBUG_LEVELS = { + DEBUG_COMP_BELLOWS: logging.DEBUG, + DEBUG_COMP_ZHA: logging.DEBUG, + DEBUG_COMP_ZIGPY: logging.DEBUG, + DEBUG_COMP_ZIGPY_ZNP: logging.DEBUG, + DEBUG_COMP_ZIGPY_DECONZ: logging.DEBUG, + DEBUG_COMP_ZIGPY_XBEE: logging.DEBUG, + DEBUG_COMP_ZIGPY_ZIGATE: logging.DEBUG, +} +DEBUG_RELAY_LOGGERS = [DEBUG_COMP_ZHA, DEBUG_COMP_ZIGPY] + +DEFAULT_RADIO_TYPE = "ezsp" +DEFAULT_BAUDRATE = 57600 +DEFAULT_DATABASE_NAME = "zigbee.db" + +DEVICE_PAIRING_STATUS = "pairing_status" + +DISCOVERY_KEY = "zha_discovery_info" + +DOMAIN = "zha" + +GROUP_ID = "group_id" +GROUP_IDS = "group_ids" +GROUP_NAME = "group_name" + +MFG_CLUSTER_ID_START = 0xFC00 + +POWER_MAINS_POWERED = "Mains" +POWER_BATTERY_OR_UNKNOWN = "Battery or Unknown" + +PRESET_SCHEDULE = "Schedule" +PRESET_COMPLEX = "Complex" +PRESET_TEMP_MANUAL = "Temporary manual" + +QUIRK_METADATA = "quirk_metadata" + +ZCL_INIT_ATTRS = "ZCL_INIT_ATTRS" + +ZHA_ALARM_OPTIONS = "zha_alarm_options" +ZHA_OPTIONS = "zha_options" + +ZHA_CONFIG_SCHEMAS = { + ZHA_OPTIONS: CONF_ZHA_OPTIONS_SCHEMA, + ZHA_ALARM_OPTIONS: CONF_ZHA_ALARM_SCHEMA, +} + +_ControllerClsType = type[zigpy.application.ControllerApplication] + + +class RadioType(enum.Enum): + """Possible options for radio type.""" + + ezsp = ( + "EZSP = Silicon Labs EmberZNet protocol: Elelabs, HUSBZB-1, Telegesis", + bellows.zigbee.application.ControllerApplication, + ) + znp = ( + "ZNP = Texas Instruments Z-Stack ZNP protocol: CC253x, CC26x2, CC13x2", + zigpy_znp.zigbee.application.ControllerApplication, + ) + deconz = ( + "deCONZ = dresden elektronik deCONZ protocol: ConBee I/II, RaspBee I/II", + zigpy_deconz.zigbee.application.ControllerApplication, + ) + zigate = ( + "ZiGate = ZiGate Zigbee radios: PiZiGate, ZiGate USB-TTL, ZiGate WiFi", + zigpy_zigate.zigbee.application.ControllerApplication, + ) + xbee = ( + "XBee = Digi XBee Zigbee radios: Digi XBee Series 2, 2C, 3", + zigpy_xbee.zigbee.application.ControllerApplication, + ) + + @classmethod + def list(cls) -> list[str]: + """Return a list of descriptions.""" + return [e.description for e in RadioType] + + @classmethod + def get_by_description(cls, description: str) -> RadioType: + """Get radio by description.""" + for radio in cls: + if radio.description == description: + return radio + raise ValueError + + def __init__(self, description: str, controller_cls: _ControllerClsType) -> None: + """Init instance.""" + self._desc = description + self._ctrl_cls = controller_cls + + @property + def controller(self) -> _ControllerClsType: + """Return controller class.""" + return self._ctrl_cls + + @property + def description(self) -> str: + """Return radio type description.""" + return self._desc + + +REPORT_CONFIG_ATTR_PER_REQ = 3 +REPORT_CONFIG_MAX_INT = 900 +REPORT_CONFIG_MAX_INT_BATTERY_SAVE = 10800 +REPORT_CONFIG_MIN_INT = 30 +REPORT_CONFIG_MIN_INT_ASAP = 1 +REPORT_CONFIG_MIN_INT_IMMEDIATE = 0 +REPORT_CONFIG_MIN_INT_OP = 5 +REPORT_CONFIG_MIN_INT_BATTERY_SAVE = 3600 +REPORT_CONFIG_RPT_CHANGE = 1 +REPORT_CONFIG_DEFAULT = ( + REPORT_CONFIG_MIN_INT, + REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_RPT_CHANGE, +) +REPORT_CONFIG_ASAP = ( + REPORT_CONFIG_MIN_INT_ASAP, + REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_RPT_CHANGE, +) +REPORT_CONFIG_BATTERY_SAVE = ( + REPORT_CONFIG_MIN_INT_BATTERY_SAVE, + REPORT_CONFIG_MAX_INT_BATTERY_SAVE, + REPORT_CONFIG_RPT_CHANGE, +) +REPORT_CONFIG_IMMEDIATE = ( + REPORT_CONFIG_MIN_INT_IMMEDIATE, + REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_RPT_CHANGE, +) +REPORT_CONFIG_OP = ( + REPORT_CONFIG_MIN_INT_OP, + REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_RPT_CHANGE, +) + +SENSOR_ACCELERATION = "acceleration" +SENSOR_BATTERY = "battery" +SENSOR_ELECTRICAL_MEASUREMENT = CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT +SENSOR_GENERIC = "generic" +SENSOR_HUMIDITY = CLUSTER_HANDLER_HUMIDITY +SENSOR_ILLUMINANCE = CLUSTER_HANDLER_ILLUMINANCE +SENSOR_METERING = "metering" +SENSOR_OCCUPANCY = CLUSTER_HANDLER_OCCUPANCY +SENSOR_OPENING = "opening" +SENSOR_PRESSURE = CLUSTER_HANDLER_PRESSURE +SENSOR_TEMPERATURE = CLUSTER_HANDLER_TEMPERATURE +SENSOR_TYPE = "sensor_type" + +SIGNAL_ADD_ENTITIES = "zha_add_new_entities" +SIGNAL_ATTR_UPDATED = "attribute_updated" +SIGNAL_AVAILABLE = "available" +SIGNAL_MOVE_LEVEL = "move_level" +SIGNAL_REMOVE = "remove" +SIGNAL_SET_LEVEL = "set_level" +SIGNAL_STATE_ATTR = "update_state_attribute" +SIGNAL_UPDATE_DEVICE = "{}_zha_update_device" +SIGNAL_GROUP_ENTITY_REMOVED = "group_entity_removed" +SIGNAL_GROUP_MEMBERSHIP_CHANGE = "group_membership_change" + +UNKNOWN = "unknown" +UNKNOWN_MANUFACTURER = "unk_manufacturer" +UNKNOWN_MODEL = "unk_model" + +WARNING_DEVICE_MODE_STOP = 0 +WARNING_DEVICE_MODE_BURGLAR = 1 +WARNING_DEVICE_MODE_FIRE = 2 +WARNING_DEVICE_MODE_EMERGENCY = 3 +WARNING_DEVICE_MODE_POLICE_PANIC = 4 +WARNING_DEVICE_MODE_FIRE_PANIC = 5 +WARNING_DEVICE_MODE_EMERGENCY_PANIC = 6 + +WARNING_DEVICE_STROBE_NO = 0 +WARNING_DEVICE_STROBE_YES = 1 + +WARNING_DEVICE_SOUND_LOW = 0 +WARNING_DEVICE_SOUND_MEDIUM = 1 +WARNING_DEVICE_SOUND_HIGH = 2 +WARNING_DEVICE_SOUND_VERY_HIGH = 3 + +WARNING_DEVICE_STROBE_LOW = 0x00 +WARNING_DEVICE_STROBE_MEDIUM = 0x01 +WARNING_DEVICE_STROBE_HIGH = 0x02 +WARNING_DEVICE_STROBE_VERY_HIGH = 0x03 + +WARNING_DEVICE_SQUAWK_MODE_ARMED = 0 +WARNING_DEVICE_SQUAWK_MODE_DISARMED = 1 + +ZHA_DISCOVERY_NEW = "zha_discovery_new_{}" +ZHA_CLUSTER_HANDLER_MSG = "zha_channel_message" +ZHA_CLUSTER_HANDLER_MSG_BIND = "zha_channel_bind" +ZHA_CLUSTER_HANDLER_MSG_CFG_RPT = "zha_channel_configure_reporting" +ZHA_CLUSTER_HANDLER_MSG_DATA = "zha_channel_msg_data" +ZHA_CLUSTER_HANDLER_CFG_DONE = "zha_channel_cfg_done" +ZHA_CLUSTER_HANDLER_READS_PER_REQ = 5 +ZHA_EVENT = "zha_event" +ZHA_GW_MSG = "zha_gateway_message" +ZHA_GW_MSG_DEVICE_FULL_INIT = "device_fully_initialized" +ZHA_GW_MSG_DEVICE_INFO = "device_info" +ZHA_GW_MSG_DEVICE_JOINED = "device_joined" +ZHA_GW_MSG_DEVICE_REMOVED = "device_removed" +ZHA_GW_MSG_GROUP_ADDED = "group_added" +ZHA_GW_MSG_GROUP_INFO = "group_info" +ZHA_GW_MSG_GROUP_MEMBER_ADDED = "group_member_added" +ZHA_GW_MSG_GROUP_MEMBER_REMOVED = "group_member_removed" +ZHA_GW_MSG_GROUP_REMOVED = "group_removed" +ZHA_GW_MSG_LOG_ENTRY = "log_entry" +ZHA_GW_MSG_LOG_OUTPUT = "log_output" +ZHA_GW_MSG_RAW_INIT = "raw_device_initialized" + + +class Strobe(t.enum8): + """Strobe enum.""" + + No_Strobe = 0x00 + Strobe = 0x01 + + +EZSP_OVERWRITE_EUI64 = ( + "i_understand_i_can_update_eui64_only_once_and_i_still_want_to_do_it" +) diff --git a/zha/core/decorators.py b/zha/core/decorators.py new file mode 100644 index 00000000..b8e15024 --- /dev/null +++ b/zha/core/decorators.py @@ -0,0 +1,54 @@ +"""Decorators for ZHA core registries.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, TypeVar + +_TypeT = TypeVar("_TypeT", bound=type[Any]) + + +class DictRegistry(dict[int | str, _TypeT]): + """Dict Registry of items.""" + + def register(self, name: int | str) -> Callable[[_TypeT], _TypeT]: + """Return decorator to register item with a specific name.""" + + def decorator(cluster_handler: _TypeT) -> _TypeT: + """Register decorated cluster handler or item.""" + self[name] = cluster_handler + return cluster_handler + + return decorator + + +class NestedDictRegistry(dict[int | str, dict[int | str | None, _TypeT]]): + """Dict Registry of multiple items per key.""" + + def register( + self, name: int | str, sub_name: int | str | None = None + ) -> Callable[[_TypeT], _TypeT]: + """Return decorator to register item with a specific and a quirk name.""" + + def decorator(cluster_handler: _TypeT) -> _TypeT: + """Register decorated cluster handler or item.""" + if name not in self: + self[name] = {} + self[name][sub_name] = cluster_handler + return cluster_handler + + return decorator + + +class SetRegistry(set[int | str]): + """Set Registry of items.""" + + def register(self, name: int | str) -> Callable[[_TypeT], _TypeT]: + """Return decorator to register item with a specific name.""" + + def decorator(cluster_handler: _TypeT) -> _TypeT: + """Register decorated cluster handler or item.""" + self.add(name) + return cluster_handler + + return decorator diff --git a/zha/core/device.py b/zha/core/device.py new file mode 100644 index 00000000..89c56254 --- /dev/null +++ b/zha/core/device.py @@ -0,0 +1,1008 @@ +"""Device for Zigbee Home Automation.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from datetime import timedelta +from enum import Enum +import logging +import random +import time +from typing import TYPE_CHECKING, Any, Self + +from homeassistant.backports.functools import cached_property +from homeassistant.const import ATTR_COMMAND, ATTR_DEVICE_ID, ATTR_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.event import async_track_time_interval +from zigpy import types +from zigpy.device import Device as ZigpyDevice +import zigpy.exceptions +from zigpy.profiles import PROFILES +import zigpy.quirks +from zigpy.quirks.v2 import CustomDeviceV2 +from zigpy.types.named import EUI64, NWK +from zigpy.zcl.clusters import Cluster +from zigpy.zcl.clusters.general import Groups, Identify +from zigpy.zcl.foundation import Status as ZclStatus, ZCLCommandDef +import zigpy.zdo.types as zdo_types + +from . import const, discovery +from .cluster_handlers import ClusterHandler, ZDOClusterHandler +from .const import ( + ATTR_ACTIVE_COORDINATOR, + ATTR_ARGS, + ATTR_ATTRIBUTE, + ATTR_AVAILABLE, + ATTR_CLUSTER_ID, + ATTR_CLUSTER_TYPE, + ATTR_COMMAND_TYPE, + ATTR_DEVICE_TYPE, + ATTR_ENDPOINT_ID, + ATTR_ENDPOINT_NAMES, + ATTR_ENDPOINTS, + ATTR_IEEE, + ATTR_LAST_SEEN, + ATTR_LQI, + ATTR_MANUFACTURER, + ATTR_MANUFACTURER_CODE, + ATTR_MODEL, + ATTR_NEIGHBORS, + ATTR_NODE_DESCRIPTOR, + ATTR_NWK, + ATTR_PARAMS, + ATTR_POWER_SOURCE, + ATTR_QUIRK_APPLIED, + ATTR_QUIRK_CLASS, + ATTR_QUIRK_ID, + ATTR_ROUTES, + ATTR_RSSI, + ATTR_SIGNATURE, + ATTR_VALUE, + CLUSTER_COMMAND_SERVER, + CLUSTER_COMMANDS_CLIENT, + CLUSTER_COMMANDS_SERVER, + CLUSTER_TYPE_IN, + CLUSTER_TYPE_OUT, + CONF_CONSIDER_UNAVAILABLE_BATTERY, + CONF_CONSIDER_UNAVAILABLE_MAINS, + CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY, + CONF_DEFAULT_CONSIDER_UNAVAILABLE_MAINS, + CONF_ENABLE_IDENTIFY_ON_JOIN, + POWER_BATTERY_OR_UNKNOWN, + POWER_MAINS_POWERED, + SIGNAL_AVAILABLE, + SIGNAL_UPDATE_DEVICE, + UNKNOWN, + UNKNOWN_MANUFACTURER, + UNKNOWN_MODEL, + ZHA_OPTIONS, +) +from .endpoint import Endpoint +from .helpers import LogMixin, async_get_zha_config_value, convert_to_zcl_values + +if TYPE_CHECKING: + from ..websocket_api import ClusterBinding + from .gateway import ZHAGateway + +_LOGGER = logging.getLogger(__name__) +_UPDATE_ALIVE_INTERVAL = (60, 90) +_CHECKIN_GRACE_PERIODS = 2 + + +def get_device_automation_triggers( + device: zigpy.device.Device, +) -> dict[tuple[str, str], dict[str, str]]: + """Get the supported device automation triggers for a zigpy device.""" + return { + ("device_offline", "device_offline"): {"device_event_type": "device_offline"}, + **getattr(device, "device_automation_triggers", {}), + } + + +class DeviceStatus(Enum): + """Status of a device.""" + + CREATED = 1 + INITIALIZED = 2 + + +class ZHADevice(LogMixin): + """ZHA Zigbee device object.""" + + _ha_device_id: str + + def __init__( + self, + hass: HomeAssistant, + zigpy_device: zigpy.device.Device, + zha_gateway: ZHAGateway, + ) -> None: + """Initialize the gateway.""" + self.hass: HomeAssistant = hass + self._zigpy_device: ZigpyDevice = zigpy_device + self._zha_gateway: ZHAGateway = zha_gateway + self._available_signal: str = f"{self.name}_{self.ieee}_{SIGNAL_AVAILABLE}" + self._checkins_missed_count: int = 0 + self.unsubs: list[Callable[[], None]] = [] + self.quirk_applied: bool = isinstance( + self._zigpy_device, zigpy.quirks.CustomDevice + ) + self.quirk_class: str = ( + f"{self._zigpy_device.__class__.__module__}." + f"{self._zigpy_device.__class__.__name__}" + ) + self.quirk_id: str | None = getattr(self._zigpy_device, ATTR_QUIRK_ID, None) + + if self.is_mains_powered: + self.consider_unavailable_time: int = async_get_zha_config_value( + self._zha_gateway.config_entry, + ZHA_OPTIONS, + CONF_CONSIDER_UNAVAILABLE_MAINS, + CONF_DEFAULT_CONSIDER_UNAVAILABLE_MAINS, + ) + else: + self.consider_unavailable_time = async_get_zha_config_value( + self._zha_gateway.config_entry, + ZHA_OPTIONS, + CONF_CONSIDER_UNAVAILABLE_BATTERY, + CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY, + ) + self._available: bool = self.is_coordinator or ( + self.last_seen is not None + and time.time() - self.last_seen < self.consider_unavailable_time + ) + self._zdo_handler: ZDOClusterHandler = ZDOClusterHandler(self) + self._power_config_ch: ClusterHandler | None = None + self._identify_ch: ClusterHandler | None = None + self._basic_ch: ClusterHandler | None = None + self.status: DeviceStatus = DeviceStatus.CREATED + + self._endpoints: dict[int, Endpoint] = {} + for ep_id, endpoint in zigpy_device.endpoints.items(): + if ep_id != 0: + self._endpoints[ep_id] = Endpoint.new(endpoint, self) + + if not self.is_coordinator: + keep_alive_interval = random.randint(*_UPDATE_ALIVE_INTERVAL) + self.debug( + "starting availability checks - interval: %s", keep_alive_interval + ) + self.unsubs.append( + async_track_time_interval( + self.hass, + self._check_available, + timedelta(seconds=keep_alive_interval), + ) + ) + + @property + def device_id(self) -> str: + """Return the HA device registry device id.""" + return self._ha_device_id + + def set_device_id(self, device_id: str) -> None: + """Set the HA device registry device id.""" + self._ha_device_id = device_id + + @property + def device(self) -> zigpy.device.Device: + """Return underlying Zigpy device.""" + return self._zigpy_device + + @property + def name(self) -> str: + """Return device name.""" + return f"{self.manufacturer} {self.model}" + + @property + def ieee(self) -> EUI64: + """Return ieee address for device.""" + return self._zigpy_device.ieee + + @property + def manufacturer(self) -> str: + """Return manufacturer for device.""" + if self._zigpy_device.manufacturer is None: + return UNKNOWN_MANUFACTURER + return self._zigpy_device.manufacturer + + @property + def model(self) -> str: + """Return model for device.""" + if self._zigpy_device.model is None: + return UNKNOWN_MODEL + return self._zigpy_device.model + + @property + def manufacturer_code(self) -> int | None: + """Return the manufacturer code for the device.""" + if self._zigpy_device.node_desc is None: + return None + + return self._zigpy_device.node_desc.manufacturer_code + + @property + def nwk(self) -> NWK: + """Return nwk for device.""" + return self._zigpy_device.nwk + + @property + def lqi(self): + """Return lqi for device.""" + return self._zigpy_device.lqi + + @property + def rssi(self): + """Return rssi for device.""" + return self._zigpy_device.rssi + + @property + def last_seen(self) -> float | None: + """Return last_seen for device.""" + return self._zigpy_device.last_seen + + @property + def is_mains_powered(self) -> bool | None: + """Return true if device is mains powered.""" + if self._zigpy_device.node_desc is None: + return None + + return self._zigpy_device.node_desc.is_mains_powered + + @property + def device_type(self) -> str: + """Return the logical device type for the device.""" + if self._zigpy_device.node_desc is None: + return UNKNOWN + + return self._zigpy_device.node_desc.logical_type.name + + @property + def power_source(self) -> str: + """Return the power source for the device.""" + return ( + POWER_MAINS_POWERED if self.is_mains_powered else POWER_BATTERY_OR_UNKNOWN + ) + + @property + def is_router(self) -> bool | None: + """Return true if this is a routing capable device.""" + if self._zigpy_device.node_desc is None: + return None + + return self._zigpy_device.node_desc.is_router + + @property + def is_coordinator(self) -> bool | None: + """Return true if this device represents a coordinator.""" + if self._zigpy_device.node_desc is None: + return None + + return self._zigpy_device.node_desc.is_coordinator + + @property + def is_active_coordinator(self) -> bool: + """Return true if this device is the active coordinator.""" + if not self.is_coordinator: + return False + + return self.ieee == self.gateway.state.node_info.ieee + + @property + def is_end_device(self) -> bool | None: + """Return true if this device is an end device.""" + if self._zigpy_device.node_desc is None: + return None + + return self._zigpy_device.node_desc.is_end_device + + @property + def is_groupable(self) -> bool: + """Return true if this device has a group cluster.""" + return self.is_coordinator or ( + self.available and bool(self.async_get_groupable_endpoints()) + ) + + @property + def skip_configuration(self) -> bool: + """Return true if the device should not issue configuration related commands.""" + return self._zigpy_device.skip_configuration or bool(self.is_coordinator) + + @property + def gateway(self): + """Return the gateway for this device.""" + return self._zha_gateway + + @cached_property + def device_automation_commands(self) -> dict[str, list[tuple[str, str]]]: + """Return the a lookup of commands to etype/sub_type.""" + commands: dict[str, list[tuple[str, str]]] = {} + for etype_subtype, trigger in self.device_automation_triggers.items(): + if command := trigger.get(ATTR_COMMAND): + commands.setdefault(command, []).append(etype_subtype) + return commands + + @cached_property + def device_automation_triggers(self) -> dict[tuple[str, str], dict[str, str]]: + """Return the device automation triggers for this device.""" + return get_device_automation_triggers(self._zigpy_device) + + @property + def available_signal(self) -> str: + """Signal to use to subscribe to device availability changes.""" + return self._available_signal + + @property + def available(self): + """Return True if device is available.""" + return self._available + + @available.setter + def available(self, new_availability: bool) -> None: + """Set device availability.""" + self._available = new_availability + + @property + def power_configuration_ch(self) -> ClusterHandler | None: + """Return power configuration cluster handler.""" + return self._power_config_ch + + @power_configuration_ch.setter + def power_configuration_ch(self, cluster_handler: ClusterHandler) -> None: + """Power configuration cluster handler setter.""" + if self._power_config_ch is None: + self._power_config_ch = cluster_handler + + @property + def basic_ch(self) -> ClusterHandler | None: + """Return basic cluster handler.""" + return self._basic_ch + + @basic_ch.setter + def basic_ch(self, cluster_handler: ClusterHandler) -> None: + """Set the basic cluster handler.""" + if self._basic_ch is None: + self._basic_ch = cluster_handler + + @property + def identify_ch(self) -> ClusterHandler | None: + """Return power configuration cluster handler.""" + return self._identify_ch + + @identify_ch.setter + def identify_ch(self, cluster_handler: ClusterHandler) -> None: + """Power configuration cluster handler setter.""" + if self._identify_ch is None: + self._identify_ch = cluster_handler + + @property + def zdo_cluster_handler(self) -> ZDOClusterHandler: + """Return ZDO cluster handler.""" + return self._zdo_handler + + @property + def endpoints(self) -> dict[int, Endpoint]: + """Return the endpoints for this device.""" + return self._endpoints + + @property + def zigbee_signature(self) -> dict[str, Any]: + """Get zigbee signature for this device.""" + return { + ATTR_NODE_DESCRIPTOR: str(self._zigpy_device.node_desc), + ATTR_ENDPOINTS: { + signature[0]: signature[1] + for signature in [ + endpoint.zigbee_signature for endpoint in self._endpoints.values() + ] + }, + ATTR_MANUFACTURER: self.manufacturer, + ATTR_MODEL: self.model, + } + + @property + def sw_version(self) -> str | None: + """Return the software version for this device.""" + device_registry = dr.async_get(self.hass) + reg_device: DeviceEntry | None = device_registry.async_get(self.device_id) + if reg_device is None: + return None + return reg_device.sw_version + + @classmethod + def new( + cls, + hass: HomeAssistant, + zigpy_dev: zigpy.device.Device, + gateway: ZHAGateway, + ) -> Self: + """Create new device.""" + zha_dev = cls(hass, zigpy_dev, gateway) + zha_dev.unsubs.append( + async_dispatcher_connect( + hass, + SIGNAL_UPDATE_DEVICE.format(str(zha_dev.ieee)), + zha_dev.async_update_sw_build_id, + ) + ) + discovery.PROBE.discover_device_entities(zha_dev) + return zha_dev + + @callback + def async_update_sw_build_id(self, sw_version: int) -> None: + """Update device sw version.""" + if self.device_id is None: + return + + device_registry = dr.async_get(self.hass) + device_registry.async_update_device( + self.device_id, sw_version=f"0x{sw_version:08x}" + ) + + async def _check_available(self, *_: Any) -> None: + # don't flip the availability state of the coordinator + if self.is_coordinator: + return + if self.last_seen is None: + self.debug("last_seen is None, marking the device unavailable") + self.update_available(False) + return + + difference = time.time() - self.last_seen + if difference < self.consider_unavailable_time: + self.debug( + "Device seen - marking the device available and resetting counter" + ) + self.update_available(True) + self._checkins_missed_count = 0 + return + + if self.hass.data[const.DATA_ZHA].allow_polling: + if ( + self._checkins_missed_count >= _CHECKIN_GRACE_PERIODS + or self.manufacturer == "LUMI" + or not self._endpoints + ): + self.debug( + ( + "last_seen is %s seconds ago and ping attempts have been exhausted," + " marking the device unavailable" + ), + difference, + ) + self.update_available(False) + return + + self._checkins_missed_count += 1 + self.debug( + "Attempting to checkin with device - missed checkins: %s", + self._checkins_missed_count, + ) + if not self.basic_ch: + self.debug("does not have a mandatory basic cluster") + self.update_available(False) + return + res = await self.basic_ch.get_attribute_value( + ATTR_MANUFACTURER, from_cache=False + ) + if res is not None: + self._checkins_missed_count = 0 + + def update_available(self, available: bool) -> None: + """Update device availability and signal entities.""" + self.debug( + ( + "Update device availability - device available: %s - new availability:" + " %s - changed: %s" + ), + self.available, + available, + self.available ^ available, + ) + availability_changed = self.available ^ available + self.available = available + if availability_changed and available: + # reinit cluster handlers then signal entities + self.debug( + "Device availability changed and device became available," + " reinitializing cluster handlers" + ) + self.hass.async_create_task(self._async_became_available()) + return + if availability_changed and not available: + self.debug("Device availability changed and device became unavailable") + self.zha_send_event( + { + "device_event_type": "device_offline", + }, + ) + async_dispatcher_send(self.hass, f"{self._available_signal}_entity") + + @callback + def zha_send_event(self, event_data: dict[str, str | int]) -> None: + """Relay events to hass.""" + self.hass.bus.async_fire( + const.ZHA_EVENT, + { + const.ATTR_DEVICE_IEEE: str(self.ieee), + const.ATTR_UNIQUE_ID: str(self.ieee), + ATTR_DEVICE_ID: self.device_id, + **event_data, + }, + ) + + async def _async_became_available(self) -> None: + """Update device availability and signal entities.""" + await self.async_initialize(False) + async_dispatcher_send(self.hass, f"{self._available_signal}_entity") + + @property + def device_info(self) -> dict[str, Any]: + """Return a device description for device.""" + ieee = str(self.ieee) + time_struct = time.localtime(self.last_seen) + update_time = time.strftime("%Y-%m-%dT%H:%M:%S", time_struct) + return { + ATTR_IEEE: ieee, + ATTR_NWK: self.nwk, + ATTR_MANUFACTURER: self.manufacturer, + ATTR_MODEL: self.model, + ATTR_NAME: self.name or ieee, + ATTR_QUIRK_APPLIED: self.quirk_applied, + ATTR_QUIRK_CLASS: self.quirk_class, + ATTR_QUIRK_ID: self.quirk_id, + ATTR_MANUFACTURER_CODE: self.manufacturer_code, + ATTR_POWER_SOURCE: self.power_source, + ATTR_LQI: self.lqi, + ATTR_RSSI: self.rssi, + ATTR_LAST_SEEN: update_time, + ATTR_AVAILABLE: self.available, + ATTR_DEVICE_TYPE: self.device_type, + ATTR_SIGNATURE: self.zigbee_signature, + } + + async def async_configure(self) -> None: + """Configure the device.""" + should_identify = async_get_zha_config_value( + self._zha_gateway.config_entry, + ZHA_OPTIONS, + CONF_ENABLE_IDENTIFY_ON_JOIN, + True, + ) + self.debug("started configuration") + await self._zdo_handler.async_configure() + self._zdo_handler.debug("'async_configure' stage succeeded") + await asyncio.gather( + *(endpoint.async_configure() for endpoint in self._endpoints.values()) + ) + if isinstance(self._zigpy_device, CustomDeviceV2): + self.debug("applying quirks v2 custom device configuration") + await self._zigpy_device.apply_custom_configuration() + async_dispatcher_send( + self.hass, + const.ZHA_CLUSTER_HANDLER_MSG, + { + const.ATTR_TYPE: const.ZHA_CLUSTER_HANDLER_CFG_DONE, + }, + ) + self.debug("completed configuration") + + if ( + should_identify + and self.identify_ch is not None + and not self.skip_configuration + ): + await self.identify_ch.trigger_effect( + effect_id=Identify.EffectIdentifier.Okay, + effect_variant=Identify.EffectVariant.Default, + ) + + async def async_initialize(self, from_cache: bool = False) -> None: + """Initialize cluster handlers.""" + self.debug("started initialization") + await self._zdo_handler.async_initialize(from_cache) + self._zdo_handler.debug("'async_initialize' stage succeeded") + + # We intentionally do not use `gather` here! This is so that if, for example, + # three `device.async_initialize()`s are spawned, only three concurrent requests + # will ever be in flight at once. Startup concurrency is managed at the device + # level. + for endpoint in self._endpoints.values(): + try: + await endpoint.async_initialize(from_cache) + except Exception: # pylint: disable=broad-exception-caught + self.debug("Failed to initialize endpoint", exc_info=True) + + self.debug("power source: %s", self.power_source) + self.status = DeviceStatus.INITIALIZED + self.debug("completed initialization") + + @callback + def async_cleanup_handles(self) -> None: + """Unsubscribe the dispatchers and timers.""" + for unsubscribe in self.unsubs: + unsubscribe() + + @property + def zha_device_info(self) -> dict[str, Any]: + """Get ZHA device information.""" + device_info: dict[str, Any] = {} + device_info.update(self.device_info) + device_info[ATTR_ACTIVE_COORDINATOR] = self.is_active_coordinator + device_info["entities"] = [ + { + "entity_id": entity_ref.reference_id, + ATTR_NAME: entity_ref.device_info[ATTR_NAME], + } + for entity_ref in self.gateway.device_registry[self.ieee] + ] + + topology = self.gateway.application_controller.topology + device_info[ATTR_NEIGHBORS] = [ + { + "device_type": neighbor.device_type.name, + "rx_on_when_idle": neighbor.rx_on_when_idle.name, + "relationship": neighbor.relationship.name, + "extended_pan_id": str(neighbor.extended_pan_id), + "ieee": str(neighbor.ieee), + "nwk": str(neighbor.nwk), + "permit_joining": neighbor.permit_joining.name, + "depth": str(neighbor.depth), + "lqi": str(neighbor.lqi), + } + for neighbor in topology.neighbors[self.ieee] + ] + + device_info[ATTR_ROUTES] = [ + { + "dest_nwk": str(route.DstNWK), + "route_status": str(route.RouteStatus.name), + "memory_constrained": bool(route.MemoryConstrained), + "many_to_one": bool(route.ManyToOne), + "route_record_required": bool(route.RouteRecordRequired), + "next_hop": str(route.NextHop), + } + for route in topology.routes[self.ieee] + ] + + # Return endpoint device type Names + names: list[dict[str, str]] = [] + for endpoint in (ep for epid, ep in self.device.endpoints.items() if epid): + profile = PROFILES.get(endpoint.profile_id) + if profile and endpoint.device_type is not None: + # DeviceType provides undefined enums + names.append({ATTR_NAME: profile.DeviceType(endpoint.device_type).name}) + else: + names.append( + { + ATTR_NAME: ( + f"unknown {endpoint.device_type} device_type " + f"of 0x{(endpoint.profile_id or 0xFFFF):04x} profile id" + ) + } + ) + device_info[ATTR_ENDPOINT_NAMES] = names + + device_registry = dr.async_get(self.hass) + reg_device = device_registry.async_get(self.device_id) + if reg_device is not None: + device_info["user_given_name"] = reg_device.name_by_user + device_info["device_reg_id"] = reg_device.id + device_info["area_id"] = reg_device.area_id + return device_info + + @callback + def async_get_clusters(self) -> dict[int, dict[str, dict[int, Cluster]]]: + """Get all clusters for this device.""" + return { + ep_id: { + CLUSTER_TYPE_IN: endpoint.in_clusters, + CLUSTER_TYPE_OUT: endpoint.out_clusters, + } + for (ep_id, endpoint) in self._zigpy_device.endpoints.items() + if ep_id != 0 + } + + @callback + def async_get_groupable_endpoints(self): + """Get device endpoints that have a group 'in' cluster.""" + return [ + ep_id + for (ep_id, clusters) in self.async_get_clusters().items() + if Groups.cluster_id in clusters[CLUSTER_TYPE_IN] + ] + + @callback + def async_get_std_clusters(self): + """Get ZHA and ZLL clusters for this device.""" + + return { + ep_id: { + CLUSTER_TYPE_IN: endpoint.in_clusters, + CLUSTER_TYPE_OUT: endpoint.out_clusters, + } + for (ep_id, endpoint) in self._zigpy_device.endpoints.items() + if ep_id != 0 and endpoint.profile_id in PROFILES + } + + @callback + def async_get_cluster( + self, endpoint_id: int, cluster_id: int, cluster_type: str = CLUSTER_TYPE_IN + ) -> Cluster: + """Get zigbee cluster from this entity.""" + clusters: dict[int, dict[str, dict[int, Cluster]]] = self.async_get_clusters() + return clusters[endpoint_id][cluster_type][cluster_id] + + @callback + def async_get_cluster_attributes( + self, endpoint_id, cluster_id, cluster_type=CLUSTER_TYPE_IN + ): + """Get zigbee attributes for specified cluster.""" + cluster = self.async_get_cluster(endpoint_id, cluster_id, cluster_type) + if cluster is None: + return None + return cluster.attributes + + @callback + def async_get_cluster_commands( + self, endpoint_id, cluster_id, cluster_type=CLUSTER_TYPE_IN + ): + """Get zigbee commands for specified cluster.""" + cluster = self.async_get_cluster(endpoint_id, cluster_id, cluster_type) + if cluster is None: + return None + return { + CLUSTER_COMMANDS_CLIENT: cluster.client_commands, + CLUSTER_COMMANDS_SERVER: cluster.server_commands, + } + + async def write_zigbee_attribute( + self, + endpoint_id, + cluster_id, + attribute, + value, + cluster_type=CLUSTER_TYPE_IN, + manufacturer=None, + ): + """Write a value to a zigbee attribute for a cluster in this entity.""" + try: + cluster: Cluster = self.async_get_cluster( + endpoint_id, cluster_id, cluster_type + ) + except KeyError as exc: + raise ValueError( + f"Cluster {cluster_id} not found on endpoint {endpoint_id} while" + f" writing attribute {attribute} with value {value}" + ) from exc + + try: + response = await cluster.write_attributes( + {attribute: value}, manufacturer=manufacturer + ) + self.debug( + "set: %s for attr: %s to cluster: %s for ept: %s - res: %s", + value, + attribute, + cluster_id, + endpoint_id, + response, + ) + return response + except zigpy.exceptions.ZigbeeException as exc: + raise HomeAssistantError( + f"Failed to set attribute: " + f"{ATTR_VALUE}: {value} " + f"{ATTR_ATTRIBUTE}: {attribute} " + f"{ATTR_CLUSTER_ID}: {cluster_id} " + f"{ATTR_ENDPOINT_ID}: {endpoint_id}" + ) from exc + + async def issue_cluster_command( + self, + endpoint_id: int, + cluster_id: int, + command: int, + command_type: str, + args: list | None, + params: dict[str, Any] | None, + cluster_type: str = CLUSTER_TYPE_IN, + manufacturer: int | None = None, + ) -> None: + """Issue a command against specified zigbee cluster on this device.""" + try: + cluster: Cluster = self.async_get_cluster( + endpoint_id, cluster_id, cluster_type + ) + except KeyError as exc: + raise ValueError( + f"Cluster {cluster_id} not found on endpoint {endpoint_id} while" + f" issuing command {command} with args {args}" + ) from exc + commands: dict[int, ZCLCommandDef] = ( + cluster.server_commands + if command_type == CLUSTER_COMMAND_SERVER + else cluster.client_commands + ) + if args is not None: + self.warning( + ( + "args [%s] are deprecated and should be passed with the params key." + " The parameter names are: %s" + ), + args, + [field.name for field in commands[command].schema.fields], + ) + response = await getattr(cluster, commands[command].name)(*args) + else: + assert params is not None + response = await getattr(cluster, commands[command].name)( + **convert_to_zcl_values(params, commands[command].schema) + ) + self.debug( + "Issued cluster command: %s %s %s %s %s %s %s %s", + f"{ATTR_CLUSTER_ID}: [{cluster_id}]", + f"{ATTR_CLUSTER_TYPE}: [{cluster_type}]", + f"{ATTR_ENDPOINT_ID}: [{endpoint_id}]", + f"{ATTR_COMMAND}: [{command}]", + f"{ATTR_COMMAND_TYPE}: [{command_type}]", + f"{ATTR_ARGS}: [{args}]", + f"{ATTR_PARAMS}: [{params}]", + f"{ATTR_MANUFACTURER}: [{manufacturer}]", + ) + if response is None: + return # client commands don't return a response + if isinstance(response, Exception): + raise HomeAssistantError("Failed to issue cluster command") from response + if response[1] is not ZclStatus.SUCCESS: + raise HomeAssistantError( + f"Failed to issue cluster command with status: {response[1]}" + ) + + async def async_add_to_group(self, group_id: int) -> None: + """Add this device to the provided zigbee group.""" + try: + # A group name is required. However, the spec also explicitly states that + # the group name can be ignored by the receiving device if a device cannot + # store it, so we cannot rely on it existing after being written. This is + # only done to make the ZCL command valid. + await self._zigpy_device.add_to_group(group_id, name=f"0x{group_id:04X}") + except (zigpy.exceptions.ZigbeeException, TimeoutError) as ex: + self.debug( + "Failed to add device '%s' to group: 0x%04x ex: %s", + self._zigpy_device.ieee, + group_id, + str(ex), + ) + + async def async_remove_from_group(self, group_id: int) -> None: + """Remove this device from the provided zigbee group.""" + try: + await self._zigpy_device.remove_from_group(group_id) + except (zigpy.exceptions.ZigbeeException, TimeoutError) as ex: + self.debug( + "Failed to remove device '%s' from group: 0x%04x ex: %s", + self._zigpy_device.ieee, + group_id, + str(ex), + ) + + async def async_add_endpoint_to_group( + self, endpoint_id: int, group_id: int + ) -> None: + """Add the device endpoint to the provided zigbee group.""" + try: + await self._zigpy_device.endpoints[endpoint_id].add_to_group( + group_id, name=f"0x{group_id:04X}" + ) + except (zigpy.exceptions.ZigbeeException, TimeoutError) as ex: + self.debug( + "Failed to add endpoint: %s for device: '%s' to group: 0x%04x ex: %s", + endpoint_id, + self._zigpy_device.ieee, + group_id, + str(ex), + ) + + async def async_remove_endpoint_from_group( + self, endpoint_id: int, group_id: int + ) -> None: + """Remove the device endpoint from the provided zigbee group.""" + try: + await self._zigpy_device.endpoints[endpoint_id].remove_from_group(group_id) + except (zigpy.exceptions.ZigbeeException, TimeoutError) as ex: + self.debug( + ( + "Failed to remove endpoint: %s for device '%s' from group: 0x%04x" + " ex: %s" + ), + endpoint_id, + self._zigpy_device.ieee, + group_id, + str(ex), + ) + + async def async_bind_to_group( + self, group_id: int, cluster_bindings: list[ClusterBinding] + ) -> None: + """Directly bind this device to a group for the given clusters.""" + await self._async_group_binding_operation( + group_id, zdo_types.ZDOCmd.Bind_req, cluster_bindings + ) + + async def async_unbind_from_group( + self, group_id: int, cluster_bindings: list[ClusterBinding] + ) -> None: + """Unbind this device from a group for the given clusters.""" + await self._async_group_binding_operation( + group_id, zdo_types.ZDOCmd.Unbind_req, cluster_bindings + ) + + async def _async_group_binding_operation( + self, + group_id: int, + operation: zdo_types.ZDOCmd, + cluster_bindings: list[ClusterBinding], + ) -> None: + """Create or remove a direct zigbee binding between a device and a group.""" + + zdo = self._zigpy_device.zdo + op_msg = "0x%04x: %s %s, ep: %s, cluster: %s to group: 0x%04x" + destination_address = zdo_types.MultiAddress() + destination_address.addrmode = types.uint8_t(1) + destination_address.nwk = types.uint16_t(group_id) + + tasks = [] + + for cluster_binding in cluster_bindings: + if cluster_binding.endpoint_id == 0: + continue + if ( + cluster_binding.id + in self._zigpy_device.endpoints[ + cluster_binding.endpoint_id + ].out_clusters + ): + op_params = ( + self.nwk, + operation.name, + str(self.ieee), + cluster_binding.endpoint_id, + cluster_binding.id, + group_id, + ) + zdo.debug(f"processing {op_msg}", *op_params) + tasks.append( + ( + zdo.request( + operation, + self.ieee, + cluster_binding.endpoint_id, + cluster_binding.id, + destination_address, + ), + op_msg, + op_params, + ) + ) + res = await asyncio.gather(*(t[0] for t in tasks), return_exceptions=True) + for outcome, log_msg in zip(res, tasks): + if isinstance(outcome, Exception): + fmt = f"{log_msg[1]} failed: %s" + else: + fmt = f"{log_msg[1]} completed: %s" + zdo.debug(fmt, *(log_msg[2] + (outcome,))) + + def log(self, level: int, msg: str, *args: Any, **kwargs: Any) -> None: + """Log a message.""" + msg = f"[%s](%s): {msg}" + args = (self.nwk, self.model) + args + _LOGGER.log(level, msg, *args, **kwargs) diff --git a/zha/core/discovery.py b/zha/core/discovery.py new file mode 100644 index 00000000..948e0c17 --- /dev/null +++ b/zha/core/discovery.py @@ -0,0 +1,653 @@ +"""Device discovery functions for Zigbee Home Automation.""" + +from __future__ import annotations + +from collections import Counter +from collections.abc import Callable +import logging +from typing import TYPE_CHECKING, Any, cast + +from homeassistant.const import CONF_TYPE, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_registry import async_entries_for_device +from homeassistant.helpers.typing import ConfigType +from slugify import slugify +from zigpy.quirks.v2 import ( + BinarySensorMetadata, + CustomDeviceV2, + EntityType, + NumberMetadata, + SwitchMetadata, + WriteAttributeButtonMetadata, + ZCLCommandButtonMetadata, + ZCLEnumMetadata, + ZCLSensorMetadata, +) +from zigpy.state import State +from zigpy.zcl import ClusterType +from zigpy.zcl.clusters.general import Ota + +from .. import ( # noqa: F401 + alarm_control_panel, + binary_sensor, + button, + climate, + cover, + device_tracker, + fan, + light, + lock, + number, + select, + sensor, + siren, + switch, + update, +) +from . import const as zha_const, registries as zha_regs + +# importing cluster handlers updates registries +from .cluster_handlers import ( # noqa: F401 + ClusterHandler, + closures, + general, + homeautomation, + hvac, + lighting, + lightlink, + manufacturerspecific, + measurement, + protocol, + security, + smartenergy, +) +from .helpers import get_zha_data, get_zha_gateway + +if TYPE_CHECKING: + from ..entity import ZhaEntity + from .device import ZHADevice + from .endpoint import Endpoint + from .group import ZHAGroup + +_LOGGER = logging.getLogger(__name__) + + +QUIRKS_ENTITY_META_TO_ENTITY_CLASS = { + ( + Platform.BUTTON, + WriteAttributeButtonMetadata, + EntityType.CONFIG, + ): button.ZHAAttributeButton, + (Platform.BUTTON, ZCLCommandButtonMetadata, EntityType.CONFIG): button.ZHAButton, + ( + Platform.BUTTON, + ZCLCommandButtonMetadata, + EntityType.DIAGNOSTIC, + ): button.ZHAButton, + ( + Platform.BINARY_SENSOR, + BinarySensorMetadata, + EntityType.CONFIG, + ): binary_sensor.BinarySensor, + ( + Platform.BINARY_SENSOR, + BinarySensorMetadata, + EntityType.DIAGNOSTIC, + ): binary_sensor.BinarySensor, + ( + Platform.BINARY_SENSOR, + BinarySensorMetadata, + EntityType.STANDARD, + ): binary_sensor.BinarySensor, + (Platform.SENSOR, ZCLEnumMetadata, EntityType.DIAGNOSTIC): sensor.EnumSensor, + (Platform.SENSOR, ZCLEnumMetadata, EntityType.STANDARD): sensor.EnumSensor, + (Platform.SENSOR, ZCLSensorMetadata, EntityType.DIAGNOSTIC): sensor.Sensor, + (Platform.SENSOR, ZCLSensorMetadata, EntityType.STANDARD): sensor.Sensor, + (Platform.SELECT, ZCLEnumMetadata, EntityType.CONFIG): select.ZCLEnumSelectEntity, + ( + Platform.SELECT, + ZCLEnumMetadata, + EntityType.DIAGNOSTIC, + ): select.ZCLEnumSelectEntity, + ( + Platform.NUMBER, + NumberMetadata, + EntityType.CONFIG, + ): number.ZHANumberConfigurationEntity, + (Platform.NUMBER, NumberMetadata, EntityType.DIAGNOSTIC): number.ZhaNumber, + (Platform.NUMBER, NumberMetadata, EntityType.STANDARD): number.ZhaNumber, + ( + Platform.SWITCH, + SwitchMetadata, + EntityType.CONFIG, + ): switch.ZHASwitchConfigurationEntity, + (Platform.SWITCH, SwitchMetadata, EntityType.STANDARD): switch.Switch, +} + + +@callback +async def async_add_entities( + _async_add_entities: AddEntitiesCallback, + entities: list[ + tuple[ + type[ZhaEntity], + tuple[str, ZHADevice, list[ClusterHandler]], + dict[str, Any], + ] + ], + **kwargs, +) -> None: + """Add entities helper.""" + if not entities: + return + + to_add = [ + ent_cls.create_entity(*args, **{**kwargs, **kw_args}) + for ent_cls, args, kw_args in entities + ] + entities_to_add = [entity for entity in to_add if entity is not None] + _async_add_entities(entities_to_add, update_before_add=False) + entities.clear() + + +class ProbeEndpoint: + """All discovered cluster handlers and entities of an endpoint.""" + + def __init__(self) -> None: + """Initialize instance.""" + self._device_configs: ConfigType = {} + + @callback + def discover_entities(self, endpoint: Endpoint) -> None: + """Process an endpoint on a zigpy device.""" + _LOGGER.debug( + "Discovering entities for endpoint: %s-%s", + str(endpoint.device.ieee), + endpoint.id, + ) + self.discover_by_device_type(endpoint) + self.discover_multi_entities(endpoint) + self.discover_by_cluster_id(endpoint) + self.discover_multi_entities(endpoint, config_diagnostic_entities=True) + zha_regs.ZHA_ENTITIES.clean_up() + + @callback + def discover_device_entities(self, device: ZHADevice) -> None: + """Discover entities for a ZHA device.""" + _LOGGER.debug( + "Discovering entities for device: %s-%s", + str(device.ieee), + device.name, + ) + + if device.is_coordinator: + self.discover_coordinator_device_entities(device) + return + + self.discover_quirks_v2_entities(device) + zha_regs.ZHA_ENTITIES.clean_up() + + @callback + def discover_quirks_v2_entities(self, device: ZHADevice) -> None: + """Discover entities for a ZHA device exposed by quirks v2.""" + _LOGGER.debug( + "Attempting to discover quirks v2 entities for device: %s-%s", + str(device.ieee), + device.name, + ) + + if not isinstance(device.device, CustomDeviceV2): + _LOGGER.debug( + "Device: %s-%s is not a quirks v2 device - skipping " + "discover_quirks_v2_entities", + str(device.ieee), + device.name, + ) + return + + zigpy_device: CustomDeviceV2 = device.device + + if not zigpy_device.exposes_metadata: + _LOGGER.debug( + "Device: %s-%s does not expose any quirks v2 entities", + str(device.ieee), + device.name, + ) + return + + for ( + cluster_details, + quirk_metadata_list, + ) in zigpy_device.exposes_metadata.items(): + endpoint_id, cluster_id, cluster_type = cluster_details + + if endpoint_id not in device.endpoints: + _LOGGER.warning( + "Device: %s-%s does not have an endpoint with id: %s - unable to " + "create entity with cluster details: %s", + str(device.ieee), + device.name, + endpoint_id, + cluster_details, + ) + continue + + endpoint: Endpoint = device.endpoints[endpoint_id] + cluster = ( + endpoint.zigpy_endpoint.in_clusters.get(cluster_id) + if cluster_type is ClusterType.Server + else endpoint.zigpy_endpoint.out_clusters.get(cluster_id) + ) + + if cluster is None: + _LOGGER.warning( + "Device: %s-%s does not have a cluster with id: %s - " + "unable to create entity with cluster details: %s", + str(device.ieee), + device.name, + cluster_id, + cluster_details, + ) + continue + + cluster_handler_id = f"{endpoint.id}:0x{cluster.cluster_id:04x}" + cluster_handler = ( + endpoint.all_cluster_handlers.get(cluster_handler_id) + if cluster_type is ClusterType.Server + else endpoint.client_cluster_handlers.get(cluster_handler_id) + ) + assert cluster_handler + + for quirk_metadata in quirk_metadata_list: + platform = Platform(quirk_metadata.entity_platform.value) + metadata_type = type(quirk_metadata.entity_metadata) + entity_class = QUIRKS_ENTITY_META_TO_ENTITY_CLASS.get( + (platform, metadata_type, quirk_metadata.entity_type) + ) + + if entity_class is None: + _LOGGER.warning( + "Device: %s-%s has an entity with details: %s that does not" + " have an entity class mapping - unable to create entity", + str(device.ieee), + device.name, + { + zha_const.CLUSTER_DETAILS: cluster_details, + zha_const.QUIRK_METADATA: quirk_metadata, + }, + ) + continue + + # automatically add the attribute to ZCL_INIT_ATTRS for the cluster + # handler if it is not already in the list + if ( + hasattr(quirk_metadata.entity_metadata, "attribute_name") + and quirk_metadata.entity_metadata.attribute_name + not in cluster_handler.ZCL_INIT_ATTRS + ): + init_attrs = cluster_handler.ZCL_INIT_ATTRS.copy() + init_attrs[ + quirk_metadata.entity_metadata.attribute_name + ] = quirk_metadata.attribute_initialized_from_cache + cluster_handler.__dict__[zha_const.ZCL_INIT_ATTRS] = init_attrs + + endpoint.async_new_entity( + platform, + entity_class, + endpoint.unique_id, + [cluster_handler], + quirk_metadata=quirk_metadata, + ) + + _LOGGER.debug( + "'%s' platform -> '%s' using %s", + platform, + entity_class.__name__, + [cluster_handler.name], + ) + + @callback + def discover_coordinator_device_entities(self, device: ZHADevice) -> None: + """Discover entities for the coordinator device.""" + _LOGGER.debug( + "Discovering entities for coordinator device: %s-%s", + str(device.ieee), + device.name, + ) + state: State = device.gateway.application_controller.state + platforms: dict[Platform, list] = get_zha_data(device.hass).platforms + + @callback + def process_counters(counter_groups: str) -> None: + for counter_group, counters in getattr(state, counter_groups).items(): + for counter in counters: + platforms[Platform.SENSOR].append( + ( + sensor.DeviceCounterSensor, + ( + f"{slugify(str(device.ieee))}_{counter_groups}_{counter_group}_{counter}", + device, + counter_groups, + counter_group, + counter, + ), + {}, + ) + ) + _LOGGER.debug( + "'%s' platform -> '%s' using %s", + Platform.SENSOR, + sensor.DeviceCounterSensor.__name__, + f"counter groups[{counter_groups}] counter group[{counter_group}] counter[{counter}]", + ) + + process_counters("counters") + process_counters("broadcast_counters") + process_counters("device_counters") + process_counters("group_counters") + + @callback + def discover_by_device_type(self, endpoint: Endpoint) -> None: + """Process an endpoint on a zigpy device.""" + + unique_id = endpoint.unique_id + + platform: str | None = self._device_configs.get(unique_id, {}).get(CONF_TYPE) + if platform is None: + ep_profile_id = endpoint.zigpy_endpoint.profile_id + ep_device_type = endpoint.zigpy_endpoint.device_type + platform = zha_regs.DEVICE_CLASS[ep_profile_id].get(ep_device_type) + + if platform and platform in zha_const.PLATFORMS: + platform = cast(Platform, platform) + + cluster_handlers = endpoint.unclaimed_cluster_handlers() + platform_entity_class, claimed = zha_regs.ZHA_ENTITIES.get_entity( + platform, + endpoint.device.manufacturer, + endpoint.device.model, + cluster_handlers, + endpoint.device.quirk_id, + ) + if platform_entity_class is None: + return + endpoint.claim_cluster_handlers(claimed) + endpoint.async_new_entity( + platform, platform_entity_class, unique_id, claimed + ) + + @callback + def discover_by_cluster_id(self, endpoint: Endpoint) -> None: + """Process an endpoint on a zigpy device.""" + + items = zha_regs.SINGLE_INPUT_CLUSTER_DEVICE_CLASS.items() + single_input_clusters = { + cluster_class: match + for cluster_class, match in items + if not isinstance(cluster_class, int) + } + remaining_cluster_handlers = endpoint.unclaimed_cluster_handlers() + for cluster_handler in remaining_cluster_handlers: + if ( + cluster_handler.cluster.cluster_id + in zha_regs.CLUSTER_HANDLER_ONLY_CLUSTERS + ): + endpoint.claim_cluster_handlers([cluster_handler]) + continue + + platform = zha_regs.SINGLE_INPUT_CLUSTER_DEVICE_CLASS.get( + cluster_handler.cluster.cluster_id + ) + if platform is None: + for cluster_class, match in single_input_clusters.items(): + if isinstance(cluster_handler.cluster, cluster_class): + platform = match + break + + self.probe_single_cluster(platform, cluster_handler, endpoint) + + # until we can get rid of registries + self.handle_on_off_output_cluster_exception(endpoint) + + @staticmethod + def probe_single_cluster( + platform: Platform | None, + cluster_handler: ClusterHandler, + endpoint: Endpoint, + ) -> None: + """Probe specified cluster for specific component.""" + if platform is None or platform not in zha_const.PLATFORMS: + return + cluster_handler_list = [cluster_handler] + unique_id = f"{endpoint.unique_id}-{cluster_handler.cluster.cluster_id}" + + entity_class, claimed = zha_regs.ZHA_ENTITIES.get_entity( + platform, + endpoint.device.manufacturer, + endpoint.device.model, + cluster_handler_list, + endpoint.device.quirk_id, + ) + if entity_class is None: + return + endpoint.claim_cluster_handlers(claimed) + endpoint.async_new_entity(platform, entity_class, unique_id, claimed) + + def handle_on_off_output_cluster_exception(self, endpoint: Endpoint) -> None: + """Process output clusters of the endpoint.""" + + profile_id = endpoint.zigpy_endpoint.profile_id + device_type = endpoint.zigpy_endpoint.device_type + if device_type in zha_regs.REMOTE_DEVICE_TYPES.get(profile_id, []): + return + + for cluster_id, cluster in endpoint.zigpy_endpoint.out_clusters.items(): + platform = zha_regs.SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS.get( + cluster.cluster_id + ) + if platform is None: + continue + + cluster_handler_classes = zha_regs.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( + cluster_id, {None: ClusterHandler} + ) + + quirk_id = ( + endpoint.device.quirk_id + if endpoint.device.quirk_id in cluster_handler_classes + else None + ) + + cluster_handler_class = cluster_handler_classes.get( + quirk_id, ClusterHandler + ) + + cluster_handler = cluster_handler_class(cluster, endpoint) + self.probe_single_cluster(platform, cluster_handler, endpoint) + + @staticmethod + @callback + def discover_multi_entities( + endpoint: Endpoint, + config_diagnostic_entities: bool = False, + ) -> None: + """Process an endpoint on and discover multiple entities.""" + + ep_profile_id = endpoint.zigpy_endpoint.profile_id + ep_device_type = endpoint.zigpy_endpoint.device_type + cmpt_by_dev_type = zha_regs.DEVICE_CLASS[ep_profile_id].get(ep_device_type) + + if config_diagnostic_entities: + cluster_handlers = list(endpoint.all_cluster_handlers.values()) + ota_handler_id = f"{endpoint.id}:0x{Ota.cluster_id:04x}" + if ota_handler_id in endpoint.client_cluster_handlers: + cluster_handlers.append( + endpoint.client_cluster_handlers[ota_handler_id] + ) + matches, claimed = zha_regs.ZHA_ENTITIES.get_config_diagnostic_entity( + endpoint.device.manufacturer, + endpoint.device.model, + cluster_handlers, + endpoint.device.quirk_id, + ) + else: + matches, claimed = zha_regs.ZHA_ENTITIES.get_multi_entity( + endpoint.device.manufacturer, + endpoint.device.model, + endpoint.unclaimed_cluster_handlers(), + endpoint.device.quirk_id, + ) + + endpoint.claim_cluster_handlers(claimed) + for platform, ent_n_handler_list in matches.items(): + for entity_and_handler in ent_n_handler_list: + _LOGGER.debug( + "'%s' platform -> '%s' using %s", + platform, + entity_and_handler.entity_class.__name__, + [ch.name for ch in entity_and_handler.claimed_cluster_handlers], + ) + for platform, ent_n_handler_list in matches.items(): + for entity_and_handler in ent_n_handler_list: + if platform == cmpt_by_dev_type: + # for well known device types, + # like thermostats we'll take only 1st class + endpoint.async_new_entity( + platform, + entity_and_handler.entity_class, + endpoint.unique_id, + entity_and_handler.claimed_cluster_handlers, + ) + break + first_ch = entity_and_handler.claimed_cluster_handlers[0] + endpoint.async_new_entity( + platform, + entity_and_handler.entity_class, + f"{endpoint.unique_id}-{first_ch.cluster.cluster_id}", + entity_and_handler.claimed_cluster_handlers, + ) + + def initialize(self, hass: HomeAssistant) -> None: + """Update device overrides config.""" + zha_config = get_zha_data(hass).yaml_config + if overrides := zha_config.get(zha_const.CONF_DEVICE_CONFIG): + self._device_configs.update(overrides) + + +class GroupProbe: + """Determine the appropriate component for a group.""" + + _hass: HomeAssistant + + def __init__(self) -> None: + """Initialize instance.""" + self._unsubs: list[Callable[[], None]] = [] + + def initialize(self, hass: HomeAssistant) -> None: + """Initialize the group probe.""" + self._hass = hass + self._unsubs.append( + async_dispatcher_connect( + hass, zha_const.SIGNAL_GROUP_ENTITY_REMOVED, self._reprobe_group + ) + ) + + def cleanup(self) -> None: + """Clean up on when ZHA shuts down.""" + for unsub in self._unsubs[:]: + unsub() + self._unsubs.remove(unsub) + + @callback + def _reprobe_group(self, group_id: int) -> None: + """Reprobe a group for entities after its members change.""" + zha_gateway = get_zha_gateway(self._hass) + if (zha_group := zha_gateway.groups.get(group_id)) is None: + return + self.discover_group_entities(zha_group) + + @callback + def discover_group_entities(self, group: ZHAGroup) -> None: + """Process a group and create any entities that are needed.""" + # only create a group entity if there are 2 or more members in a group + if len(group.members) < 2: + _LOGGER.debug( + "Group: %s:0x%04x has less than 2 members - skipping entity discovery", + group.name, + group.group_id, + ) + return + + entity_domains = GroupProbe.determine_entity_domains(self._hass, group) + + if not entity_domains: + return + + zha_data = get_zha_data(self._hass) + zha_gateway = get_zha_gateway(self._hass) + + for domain in entity_domains: + entity_class = zha_regs.ZHA_ENTITIES.get_group_entity(domain) + if entity_class is None: + continue + zha_data.platforms[domain].append( + ( + entity_class, + ( + group.get_domain_entity_ids(domain), + f"{domain}_zha_group_0x{group.group_id:04x}", + group.group_id, + zha_gateway.coordinator_zha_device, + ), + {}, + ) + ) + async_dispatcher_send(self._hass, zha_const.SIGNAL_ADD_ENTITIES) + + @staticmethod + def determine_entity_domains( + hass: HomeAssistant, group: ZHAGroup + ) -> list[Platform]: + """Determine the entity domains for this group.""" + entity_registry = er.async_get(hass) + + entity_domains: list[Platform] = [] + all_domain_occurrences: list[Platform] = [] + + for member in group.members: + if member.device.is_coordinator: + continue + entities = async_entries_for_device( + entity_registry, + member.device.device_id, + include_disabled_entities=True, + ) + all_domain_occurrences.extend( + [ + cast(Platform, entity.domain) + for entity in entities + if entity.domain in zha_regs.GROUP_ENTITY_DOMAINS + ] + ) + if not all_domain_occurrences: + return entity_domains + # get all domains we care about if there are more than 2 entities of this domain + counts = Counter(all_domain_occurrences) + entity_domains = [domain[0] for domain in counts.items() if domain[1] >= 2] + _LOGGER.debug( + "The entity domains are: %s for group: %s:0x%04x", + entity_domains, + group.name, + group.group_id, + ) + return entity_domains + + +PROBE = ProbeEndpoint() +GROUP_PROBE = GroupProbe() diff --git a/zha/core/endpoint.py b/zha/core/endpoint.py new file mode 100644 index 00000000..b0d617eb --- /dev/null +++ b/zha/core/endpoint.py @@ -0,0 +1,254 @@ +"""Representation of a Zigbee endpoint for zha.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +import functools +import logging +from typing import TYPE_CHECKING, Any, Final, TypeVar + +from homeassistant.const import Platform +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.util.async_ import gather_with_limited_concurrency + +from . import const, discovery, registries +from .cluster_handlers import ClusterHandler +from .helpers import get_zha_data + +if TYPE_CHECKING: + from zigpy import Endpoint as ZigpyEndpoint + + from .cluster_handlers import ClientClusterHandler + from .device import ZHADevice + +ATTR_DEVICE_TYPE: Final[str] = "device_type" +ATTR_PROFILE_ID: Final[str] = "profile_id" +ATTR_IN_CLUSTERS: Final[str] = "input_clusters" +ATTR_OUT_CLUSTERS: Final[str] = "output_clusters" + +_LOGGER = logging.getLogger(__name__) +CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) + + +class Endpoint: + """Endpoint for a zha device.""" + + def __init__(self, zigpy_endpoint: ZigpyEndpoint, device: ZHADevice) -> None: + """Initialize instance.""" + assert zigpy_endpoint is not None + assert device is not None + self._zigpy_endpoint: ZigpyEndpoint = zigpy_endpoint + self._device: ZHADevice = device + self._all_cluster_handlers: dict[str, ClusterHandler] = {} + self._claimed_cluster_handlers: dict[str, ClusterHandler] = {} + self._client_cluster_handlers: dict[str, ClientClusterHandler] = {} + self._unique_id: str = f"{str(device.ieee)}-{zigpy_endpoint.endpoint_id}" + + @property + def device(self) -> ZHADevice: + """Return the device this endpoint belongs to.""" + return self._device + + @property + def all_cluster_handlers(self) -> dict[str, ClusterHandler]: + """All server cluster handlers of an endpoint.""" + return self._all_cluster_handlers + + @property + def claimed_cluster_handlers(self) -> dict[str, ClusterHandler]: + """Cluster handlers in use.""" + return self._claimed_cluster_handlers + + @property + def client_cluster_handlers(self) -> dict[str, ClientClusterHandler]: + """Return a dict of client cluster handlers.""" + return self._client_cluster_handlers + + @property + def zigpy_endpoint(self) -> ZigpyEndpoint: + """Return endpoint of zigpy device.""" + return self._zigpy_endpoint + + @property + def id(self) -> int: + """Return endpoint id.""" + return self._zigpy_endpoint.endpoint_id + + @property + def unique_id(self) -> str: + """Return the unique id for this endpoint.""" + return self._unique_id + + @property + def zigbee_signature(self) -> tuple[int, dict[str, Any]]: + """Get the zigbee signature for the endpoint this pool represents.""" + return ( + self.id, + { + ATTR_PROFILE_ID: f"0x{self._zigpy_endpoint.profile_id:04x}" + if self._zigpy_endpoint.profile_id is not None + else "", + ATTR_DEVICE_TYPE: f"0x{self._zigpy_endpoint.device_type:04x}" + if self._zigpy_endpoint.device_type is not None + else "", + ATTR_IN_CLUSTERS: [ + f"0x{cluster_id:04x}" + for cluster_id in sorted(self._zigpy_endpoint.in_clusters) + ], + ATTR_OUT_CLUSTERS: [ + f"0x{cluster_id:04x}" + for cluster_id in sorted(self._zigpy_endpoint.out_clusters) + ], + }, + ) + + @classmethod + def new(cls, zigpy_endpoint: ZigpyEndpoint, device: ZHADevice) -> Endpoint: + """Create new endpoint and populate cluster handlers.""" + endpoint = cls(zigpy_endpoint, device) + endpoint.add_all_cluster_handlers() + endpoint.add_client_cluster_handlers() + if not device.is_coordinator: + discovery.PROBE.discover_entities(endpoint) + return endpoint + + def add_all_cluster_handlers(self) -> None: + """Create and add cluster handlers for all input clusters.""" + for cluster_id, cluster in self.zigpy_endpoint.in_clusters.items(): + cluster_handler_classes = registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( + cluster_id, {None: ClusterHandler} + ) + quirk_id = ( + self.device.quirk_id + if self.device.quirk_id in cluster_handler_classes + else None + ) + cluster_handler_class = cluster_handler_classes.get( + quirk_id, ClusterHandler + ) + + # Allow cluster handler to filter out bad matches + if not cluster_handler_class.matches(cluster, self): + cluster_handler_class = ClusterHandler + + _LOGGER.debug( + "Creating cluster handler for cluster id: %s class: %s", + cluster_id, + cluster_handler_class, + ) + + try: + cluster_handler = cluster_handler_class(cluster, self) + except KeyError as err: + _LOGGER.warning( + "Cluster handler %s for cluster %s on endpoint %s is invalid: %s", + cluster_handler_class, + cluster, + self, + err, + ) + continue + + if cluster_handler.name == const.CLUSTER_HANDLER_POWER_CONFIGURATION: + self._device.power_configuration_ch = cluster_handler + elif cluster_handler.name == const.CLUSTER_HANDLER_IDENTIFY: + self._device.identify_ch = cluster_handler + elif cluster_handler.name == const.CLUSTER_HANDLER_BASIC: + self._device.basic_ch = cluster_handler + self._all_cluster_handlers[cluster_handler.id] = cluster_handler + + def add_client_cluster_handlers(self) -> None: + """Create client cluster handlers for all output clusters if in the registry.""" + for ( + cluster_id, + cluster_handler_class, + ) in registries.CLIENT_CLUSTER_HANDLER_REGISTRY.items(): + cluster = self.zigpy_endpoint.out_clusters.get(cluster_id) + if cluster is not None: + cluster_handler = cluster_handler_class(cluster, self) + self.client_cluster_handlers[cluster_handler.id] = cluster_handler + + async def async_initialize(self, from_cache: bool = False) -> None: + """Initialize claimed cluster handlers.""" + await self._execute_handler_tasks( + "async_initialize", from_cache, max_concurrency=1 + ) + + async def async_configure(self) -> None: + """Configure claimed cluster handlers.""" + await self._execute_handler_tasks("async_configure") + + async def _execute_handler_tasks( + self, func_name: str, *args: Any, max_concurrency: int | None = None + ) -> None: + """Add a throttled cluster handler task and swallow exceptions.""" + cluster_handlers = [ + *self.claimed_cluster_handlers.values(), + *self.client_cluster_handlers.values(), + ] + tasks = [getattr(ch, func_name)(*args) for ch in cluster_handlers] + + gather: Callable[..., Awaitable] + + if max_concurrency is None: + gather = asyncio.gather + else: + gather = functools.partial(gather_with_limited_concurrency, max_concurrency) + + results = await gather(*tasks, return_exceptions=True) + for cluster_handler, outcome in zip(cluster_handlers, results): + if isinstance(outcome, Exception): + cluster_handler.debug( + "'%s' stage failed: %s", func_name, str(outcome), exc_info=outcome + ) + else: + cluster_handler.debug("'%s' stage succeeded", func_name) + + def async_new_entity( + self, + platform: Platform, + entity_class: CALLABLE_T, + unique_id: str, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> None: + """Create a new entity.""" + from .device import DeviceStatus # pylint: disable=import-outside-toplevel + + if self.device.status == DeviceStatus.INITIALIZED: + return + + zha_data = get_zha_data(self.device.hass) + zha_data.platforms[platform].append( + (entity_class, (unique_id, self.device, cluster_handlers), kwargs or {}) + ) + + @callback + def async_send_signal(self, signal: str, *args: Any) -> None: + """Send a signal through hass dispatcher.""" + async_dispatcher_send(self.device.hass, signal, *args) + + def send_event(self, signal: dict[str, Any]) -> None: + """Broadcast an event from this endpoint.""" + self.device.zha_send_event( + { + const.ATTR_UNIQUE_ID: self.unique_id, + const.ATTR_ENDPOINT_ID: self.id, + **signal, + } + ) + + def claim_cluster_handlers(self, cluster_handlers: list[ClusterHandler]) -> None: + """Claim cluster handlers.""" + self.claimed_cluster_handlers.update({ch.id: ch for ch in cluster_handlers}) + + def unclaimed_cluster_handlers(self) -> list[ClusterHandler]: + """Return a list of available (unclaimed) cluster handlers.""" + claimed = set(self.claimed_cluster_handlers) + available = set(self.all_cluster_handlers) + return [ + self.all_cluster_handlers[cluster_id] + for cluster_id in (available - claimed) + ] diff --git a/zha/core/gateway.py b/zha/core/gateway.py new file mode 100644 index 00000000..3636776d --- /dev/null +++ b/zha/core/gateway.py @@ -0,0 +1,881 @@ +"""Virtual gateway for Zigbee Home Automation.""" + +from __future__ import annotations + +import asyncio +import collections +from collections.abc import Callable +from contextlib import suppress +from datetime import timedelta +from enum import Enum +import itertools +import logging +import re +import time +from typing import TYPE_CHECKING, Any, NamedTuple, Self, cast + +from homeassistant import __path__ as HOMEASSISTANT_PATH +from homeassistant.components.system_log import LogEntry, _figure_out_source +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import ConfigType +from homeassistant.util.async_ import gather_with_limited_concurrency +from zigpy.application import ControllerApplication +from zigpy.config import ( + CONF_DATABASE, + CONF_DEVICE, + CONF_DEVICE_PATH, + CONF_NWK, + CONF_NWK_CHANNEL, + CONF_NWK_VALIDATE_SETTINGS, +) +import zigpy.device +import zigpy.endpoint +import zigpy.group +from zigpy.state import State +from zigpy.types.named import EUI64 + +from . import discovery +from .const import ( + ATTR_IEEE, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NWK, + ATTR_SIGNATURE, + ATTR_TYPE, + CONF_RADIO_TYPE, + CONF_USE_THREAD, + CONF_ZIGPY, + DATA_ZHA, + DEBUG_COMP_BELLOWS, + DEBUG_COMP_ZHA, + DEBUG_COMP_ZIGPY, + DEBUG_COMP_ZIGPY_DECONZ, + DEBUG_COMP_ZIGPY_XBEE, + DEBUG_COMP_ZIGPY_ZIGATE, + DEBUG_COMP_ZIGPY_ZNP, + DEBUG_LEVEL_CURRENT, + DEBUG_LEVEL_ORIGINAL, + DEBUG_LEVELS, + DEBUG_RELAY_LOGGERS, + DEFAULT_DATABASE_NAME, + DEVICE_PAIRING_STATUS, + DOMAIN, + SIGNAL_ADD_ENTITIES, + SIGNAL_GROUP_MEMBERSHIP_CHANGE, + SIGNAL_REMOVE, + UNKNOWN_MANUFACTURER, + UNKNOWN_MODEL, + ZHA_GW_MSG, + ZHA_GW_MSG_DEVICE_FULL_INIT, + ZHA_GW_MSG_DEVICE_INFO, + ZHA_GW_MSG_DEVICE_JOINED, + ZHA_GW_MSG_DEVICE_REMOVED, + ZHA_GW_MSG_GROUP_ADDED, + ZHA_GW_MSG_GROUP_INFO, + ZHA_GW_MSG_GROUP_MEMBER_ADDED, + ZHA_GW_MSG_GROUP_MEMBER_REMOVED, + ZHA_GW_MSG_GROUP_REMOVED, + ZHA_GW_MSG_LOG_ENTRY, + ZHA_GW_MSG_LOG_OUTPUT, + ZHA_GW_MSG_RAW_INIT, + RadioType, +) +from .device import DeviceStatus, ZHADevice +from .group import GroupMember, ZHAGroup +from .helpers import get_zha_data +from .registries import GROUP_ENTITY_DOMAINS + +if TYPE_CHECKING: + from logging import Filter, LogRecord + + from ..entity import ZhaEntity + from .cluster_handlers import ClusterHandler + + _LogFilterType = Filter | Callable[[LogRecord], bool] + +_LOGGER = logging.getLogger(__name__) + + +class EntityReference(NamedTuple): + """Describes an entity reference.""" + + reference_id: str + zha_device: ZHADevice + cluster_handlers: dict[str, ClusterHandler] + device_info: DeviceInfo + remove_future: asyncio.Future[Any] + + +class DevicePairingStatus(Enum): + """Status of a device.""" + + PAIRED = 1 + INTERVIEW_COMPLETE = 2 + CONFIGURED = 3 + INITIALIZED = 4 + + +class ZHAGateway: + """Gateway that handles events that happen on the ZHA Zigbee network.""" + + def __init__( + self, hass: HomeAssistant, config: ConfigType, config_entry: ConfigEntry + ) -> None: + """Initialize the gateway.""" + self.hass = hass + self._config = config + self._devices: dict[EUI64, ZHADevice] = {} + self._groups: dict[int, ZHAGroup] = {} + self.application_controller: ControllerApplication = None + self.coordinator_zha_device: ZHADevice = None # type: ignore[assignment] + self._device_registry: collections.defaultdict[ + EUI64, list[EntityReference] + ] = collections.defaultdict(list) + self._log_levels: dict[str, dict[str, int]] = { + DEBUG_LEVEL_ORIGINAL: async_capture_log_levels(), + DEBUG_LEVEL_CURRENT: async_capture_log_levels(), + } + self.debug_enabled = False + self._log_relay_handler = LogRelayHandler(hass, self) + self.config_entry = config_entry + self._unsubs: list[Callable[[], None]] = [] + + self.shutting_down = False + self._reload_task: asyncio.Task | None = None + + def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: + """Get an uninitialized instance of a zigpy `ControllerApplication`.""" + radio_type = RadioType[self.config_entry.data[CONF_RADIO_TYPE]] + + app_config = self._config.get(CONF_ZIGPY, {}) + database = self._config.get( + CONF_DATABASE, + self.hass.config.path(DEFAULT_DATABASE_NAME), + ) + app_config[CONF_DATABASE] = database + app_config[CONF_DEVICE] = self.config_entry.data[CONF_DEVICE] + + if CONF_NWK_VALIDATE_SETTINGS not in app_config: + app_config[CONF_NWK_VALIDATE_SETTINGS] = True + + # The bellows UART thread sometimes propagates a cancellation into the main Core + # event loop, when a connection to a TCP coordinator fails in a specific way + if ( + CONF_USE_THREAD not in app_config + and radio_type is RadioType.ezsp + and app_config[CONF_DEVICE][CONF_DEVICE_PATH].startswith("socket://") + ): + app_config[CONF_USE_THREAD] = False + + # Local import to avoid circular dependencies + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + is_multiprotocol_url, + ) + + # Until we have a way to coordinate channels with the Thread half of multi-PAN, + # stick to the old zigpy default of channel 15 instead of dynamically scanning + if ( + is_multiprotocol_url(app_config[CONF_DEVICE][CONF_DEVICE_PATH]) + and app_config.get(CONF_NWK, {}).get(CONF_NWK_CHANNEL) is None + ): + app_config.setdefault(CONF_NWK, {})[CONF_NWK_CHANNEL] = 15 + + return radio_type.controller, radio_type.controller.SCHEMA(app_config) + + @classmethod + async def async_from_config( + cls, hass: HomeAssistant, config: ConfigType, config_entry: ConfigEntry + ) -> Self: + """Create an instance of a gateway from config objects.""" + instance = cls(hass, config, config_entry) + await instance.async_initialize() + return instance + + async def async_initialize(self) -> None: + """Initialize controller and connect radio.""" + discovery.PROBE.initialize(self.hass) + discovery.GROUP_PROBE.initialize(self.hass) + + self.shutting_down = False + + app_controller_cls, app_config = self.get_application_controller_data() + app = await app_controller_cls.new( + config=app_config, + auto_form=False, + start_radio=False, + ) + + try: + await app.startup(auto_form=True) + except Exception: + # Explicitly shut down the controller application on failure + await app.shutdown() + raise + + self.application_controller = app + + zha_data = get_zha_data(self.hass) + zha_data.gateway = self + + self.coordinator_zha_device = self._async_get_or_create_device( + self._find_coordinator_device() + ) + + self.async_load_devices() + self.async_load_groups() + + self.application_controller.add_listener(self) + self.application_controller.groups.add_listener(self) + + def connection_lost(self, exc: Exception) -> None: + """Handle connection lost event.""" + _LOGGER.debug("Connection to the radio was lost: %r", exc) + + if self.shutting_down: + return + + # Ensure we do not queue up multiple resets + if self._reload_task is not None: + _LOGGER.debug("Ignoring reset, one is already running") + return + + self._reload_task = self.hass.async_create_task( + self.hass.config_entries.async_reload(self.config_entry.entry_id) + ) + + def _find_coordinator_device(self) -> zigpy.device.Device: + zigpy_coordinator = self.application_controller.get_device(nwk=0x0000) + + if last_backup := self.application_controller.backups.most_recent_backup(): + with suppress(KeyError): + zigpy_coordinator = self.application_controller.get_device( + ieee=last_backup.node_info.ieee + ) + + return zigpy_coordinator + + @callback + def async_load_devices(self) -> None: + """Restore ZHA devices from zigpy application state.""" + + for zigpy_device in self.application_controller.devices.values(): + zha_device = self._async_get_or_create_device(zigpy_device) + delta_msg = "not known" + if zha_device.last_seen is not None: + delta = round(time.time() - zha_device.last_seen) + delta_msg = f"{str(timedelta(seconds=delta))} ago" + _LOGGER.debug( + ( + "[%s](%s) restored as '%s', last seen: %s," + " consider_unavailable_time: %s seconds" + ), + zha_device.nwk, + zha_device.name, + "available" if zha_device.available else "unavailable", + delta_msg, + zha_device.consider_unavailable_time, + ) + + @callback + def async_load_groups(self) -> None: + """Initialize ZHA groups.""" + + for group_id in self.application_controller.groups: + group = self.application_controller.groups[group_id] + zha_group = self._async_get_or_create_group(group) + # we can do this here because the entities are in the + # entity registry tied to the devices + discovery.GROUP_PROBE.discover_group_entities(zha_group) + + @property + def radio_concurrency(self) -> int: + """Maximum configured radio concurrency.""" + return self.application_controller._concurrent_requests_semaphore.max_value # pylint: disable=protected-access + + async def async_fetch_updated_state_mains(self) -> None: + """Fetch updated state for mains powered devices.""" + _LOGGER.debug("Fetching current state for mains powered devices") + + now = time.time() + + # Only delay startup to poll mains-powered devices that are online + online_devices = [ + dev + for dev in self.devices.values() + if dev.is_mains_powered + and dev.last_seen is not None + and (now - dev.last_seen) < dev.consider_unavailable_time + ] + + # Prioritize devices that have recently been contacted + online_devices.sort(key=lambda dev: cast(float, dev.last_seen), reverse=True) + + # Make sure that we always leave slots for non-startup requests + max_poll_concurrency = max(1, self.radio_concurrency - 4) + + await gather_with_limited_concurrency( + max_poll_concurrency, + *(dev.async_initialize(from_cache=False) for dev in online_devices), + ) + + _LOGGER.debug("completed fetching current state for mains powered devices") + + async def async_initialize_devices_and_entities(self) -> None: + """Initialize devices and load entities.""" + + _LOGGER.debug("Initializing all devices from Zigpy cache") + await asyncio.gather( + *(dev.async_initialize(from_cache=True) for dev in self.devices.values()) + ) + + async def fetch_updated_state() -> None: + """Fetch updated state for mains powered devices.""" + await self.async_fetch_updated_state_mains() + _LOGGER.debug("Allowing polled requests") + self.hass.data[DATA_ZHA].allow_polling = True + + # background the fetching of state for mains powered devices + self.config_entry.async_create_background_task( + self.hass, fetch_updated_state(), "zha.gateway-fetch_updated_state" + ) + + def device_joined(self, device: zigpy.device.Device) -> None: + """Handle device joined. + + At this point, no information about the device is known other than its + address + """ + async_dispatcher_send( + self.hass, + ZHA_GW_MSG, + { + ATTR_TYPE: ZHA_GW_MSG_DEVICE_JOINED, + ZHA_GW_MSG_DEVICE_INFO: { + ATTR_NWK: device.nwk, + ATTR_IEEE: str(device.ieee), + DEVICE_PAIRING_STATUS: DevicePairingStatus.PAIRED.name, + }, + }, + ) + + def raw_device_initialized(self, device: zigpy.device.Device) -> None: + """Handle a device initialization without quirks loaded.""" + manuf = device.manufacturer + async_dispatcher_send( + self.hass, + ZHA_GW_MSG, + { + ATTR_TYPE: ZHA_GW_MSG_RAW_INIT, + ZHA_GW_MSG_DEVICE_INFO: { + ATTR_NWK: device.nwk, + ATTR_IEEE: str(device.ieee), + DEVICE_PAIRING_STATUS: DevicePairingStatus.INTERVIEW_COMPLETE.name, + ATTR_MODEL: device.model if device.model else UNKNOWN_MODEL, + ATTR_MANUFACTURER: manuf if manuf else UNKNOWN_MANUFACTURER, + ATTR_SIGNATURE: device.get_signature(), + }, + }, + ) + + def device_initialized(self, device: zigpy.device.Device) -> None: + """Handle device joined and basic information discovered.""" + self.hass.async_create_task(self.async_device_initialized(device)) + + def device_left(self, device: zigpy.device.Device) -> None: + """Handle device leaving the network.""" + self.async_update_device(device, False) + + def group_member_removed( + self, zigpy_group: zigpy.group.Group, endpoint: zigpy.endpoint.Endpoint + ) -> None: + """Handle zigpy group member removed event.""" + # need to handle endpoint correctly on groups + zha_group = self._async_get_or_create_group(zigpy_group) + zha_group.info("group_member_removed - endpoint: %s", endpoint) + self._send_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_MEMBER_REMOVED) + async_dispatcher_send( + self.hass, f"{SIGNAL_GROUP_MEMBERSHIP_CHANGE}_0x{zigpy_group.group_id:04x}" + ) + + def group_member_added( + self, zigpy_group: zigpy.group.Group, endpoint: zigpy.endpoint.Endpoint + ) -> None: + """Handle zigpy group member added event.""" + # need to handle endpoint correctly on groups + zha_group = self._async_get_or_create_group(zigpy_group) + zha_group.info("group_member_added - endpoint: %s", endpoint) + self._send_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_MEMBER_ADDED) + async_dispatcher_send( + self.hass, f"{SIGNAL_GROUP_MEMBERSHIP_CHANGE}_0x{zigpy_group.group_id:04x}" + ) + if len(zha_group.members) == 2: + # we need to do this because there wasn't already + # a group entity to remove and re-add + discovery.GROUP_PROBE.discover_group_entities(zha_group) + + def group_added(self, zigpy_group: zigpy.group.Group) -> None: + """Handle zigpy group added event.""" + zha_group = self._async_get_or_create_group(zigpy_group) + zha_group.info("group_added") + # need to dispatch for entity creation here + self._send_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_ADDED) + + def group_removed(self, zigpy_group: zigpy.group.Group) -> None: + """Handle zigpy group removed event.""" + self._send_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_REMOVED) + zha_group = self._groups.pop(zigpy_group.group_id) + zha_group.info("group_removed") + self._cleanup_group_entity_registry_entries(zigpy_group) + + def _send_group_gateway_message( + self, zigpy_group: zigpy.group.Group, gateway_message_type: str + ) -> None: + """Send the gateway event for a zigpy group event.""" + zha_group = self._groups.get(zigpy_group.group_id) + if zha_group is not None: + async_dispatcher_send( + self.hass, + ZHA_GW_MSG, + { + ATTR_TYPE: gateway_message_type, + ZHA_GW_MSG_GROUP_INFO: zha_group.group_info, + }, + ) + + async def _async_remove_device( + self, device: ZHADevice, entity_refs: list[EntityReference] | None + ) -> None: + if entity_refs is not None: + remove_tasks: list[asyncio.Future[Any]] = [] + for entity_ref in entity_refs: + remove_tasks.append(entity_ref.remove_future) + if remove_tasks: + await asyncio.wait(remove_tasks) + + device_registry = dr.async_get(self.hass) + reg_device = device_registry.async_get(device.device_id) + if reg_device is not None: + device_registry.async_remove_device(reg_device.id) + + def device_removed(self, device: zigpy.device.Device) -> None: + """Handle device being removed from the network.""" + zha_device = self._devices.pop(device.ieee, None) + entity_refs = self._device_registry.pop(device.ieee, None) + if zha_device is not None: + device_info = zha_device.zha_device_info + zha_device.async_cleanup_handles() + async_dispatcher_send(self.hass, f"{SIGNAL_REMOVE}_{str(zha_device.ieee)}") + self.hass.async_create_task( + self._async_remove_device(zha_device, entity_refs), + "ZHAGateway._async_remove_device", + ) + if device_info is not None: + async_dispatcher_send( + self.hass, + ZHA_GW_MSG, + { + ATTR_TYPE: ZHA_GW_MSG_DEVICE_REMOVED, + ZHA_GW_MSG_DEVICE_INFO: device_info, + }, + ) + + def get_device(self, ieee: EUI64) -> ZHADevice | None: + """Return ZHADevice for given ieee.""" + return self._devices.get(ieee) + + def get_group(self, group_id: int) -> ZHAGroup | None: + """Return Group for given group id.""" + return self.groups.get(group_id) + + @callback + def async_get_group_by_name(self, group_name: str) -> ZHAGroup | None: + """Get ZHA group by name.""" + for group in self.groups.values(): + if group.name == group_name: + return group + return None + + def get_entity_reference(self, entity_id: str) -> EntityReference | None: + """Return entity reference for given entity_id if found.""" + for entity_reference in itertools.chain.from_iterable( + self.device_registry.values() + ): + if entity_id == entity_reference.reference_id: + return entity_reference + return None + + def remove_entity_reference(self, entity: ZhaEntity) -> None: + """Remove entity reference for given entity_id if found.""" + if entity.zha_device.ieee in self.device_registry: + entity_refs = self.device_registry.get(entity.zha_device.ieee) + self.device_registry[entity.zha_device.ieee] = [ + e + for e in entity_refs # type: ignore[union-attr] + if e.reference_id != entity.entity_id + ] + + def _cleanup_group_entity_registry_entries( + self, zigpy_group: zigpy.group.Group + ) -> None: + """Remove entity registry entries for group entities when the groups are removed from HA.""" + # first we collect the potential unique ids for entities that could be created from this group + possible_entity_unique_ids = [ + f"{domain}_zha_group_0x{zigpy_group.group_id:04x}" + for domain in GROUP_ENTITY_DOMAINS + ] + + # then we get all group entity entries tied to the coordinator + entity_registry = er.async_get(self.hass) + assert self.coordinator_zha_device + all_group_entity_entries = er.async_entries_for_device( + entity_registry, + self.coordinator_zha_device.device_id, + include_disabled_entities=True, + ) + + # then we get the entity entries for this specific group + # by getting the entries that match + entries_to_remove = [ + entry + for entry in all_group_entity_entries + if entry.unique_id in possible_entity_unique_ids + ] + + # then we remove the entries from the entity registry + for entry in entries_to_remove: + _LOGGER.debug( + "cleaning up entity registry entry for entity: %s", entry.entity_id + ) + entity_registry.async_remove(entry.entity_id) + + @property + def state(self) -> State: + """Return the active coordinator's network state.""" + return self.application_controller.state + + @property + def devices(self) -> dict[EUI64, ZHADevice]: + """Return devices.""" + return self._devices + + @property + def groups(self) -> dict[int, ZHAGroup]: + """Return groups.""" + return self._groups + + @property + def device_registry(self) -> collections.defaultdict[EUI64, list[EntityReference]]: + """Return entities by ieee.""" + return self._device_registry + + def register_entity_reference( + self, + ieee: EUI64, + reference_id: str, + zha_device: ZHADevice, + cluster_handlers: dict[str, ClusterHandler], + device_info: DeviceInfo, + remove_future: asyncio.Future[Any], + ): + """Record the creation of a hass entity associated with ieee.""" + self._device_registry[ieee].append( + EntityReference( + reference_id=reference_id, + zha_device=zha_device, + cluster_handlers=cluster_handlers, + device_info=device_info, + remove_future=remove_future, + ) + ) + + @callback + def async_enable_debug_mode(self, filterer: _LogFilterType | None = None) -> None: + """Enable debug mode for ZHA.""" + self._log_levels[DEBUG_LEVEL_ORIGINAL] = async_capture_log_levels() + async_set_logger_levels(DEBUG_LEVELS) + self._log_levels[DEBUG_LEVEL_CURRENT] = async_capture_log_levels() + + if filterer: + self._log_relay_handler.addFilter(filterer) + + for logger_name in DEBUG_RELAY_LOGGERS: + logging.getLogger(logger_name).addHandler(self._log_relay_handler) + + self.debug_enabled = True + + @callback + def async_disable_debug_mode(self, filterer: _LogFilterType | None = None) -> None: + """Disable debug mode for ZHA.""" + async_set_logger_levels(self._log_levels[DEBUG_LEVEL_ORIGINAL]) + self._log_levels[DEBUG_LEVEL_CURRENT] = async_capture_log_levels() + for logger_name in DEBUG_RELAY_LOGGERS: + logging.getLogger(logger_name).removeHandler(self._log_relay_handler) + if filterer: + self._log_relay_handler.removeFilter(filterer) + self.debug_enabled = False + + @callback + def _async_get_or_create_device( + self, zigpy_device: zigpy.device.Device + ) -> ZHADevice: + """Get or create a ZHA device.""" + if (zha_device := self._devices.get(zigpy_device.ieee)) is None: + zha_device = ZHADevice.new(self.hass, zigpy_device, self) + self._devices[zigpy_device.ieee] = zha_device + + device_registry = dr.async_get(self.hass) + device_registry_device = device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + connections={(dr.CONNECTION_ZIGBEE, str(zha_device.ieee))}, + identifiers={(DOMAIN, str(zha_device.ieee))}, + name=zha_device.name, + manufacturer=zha_device.manufacturer, + model=zha_device.model, + ) + zha_device.set_device_id(device_registry_device.id) + return zha_device + + @callback + def _async_get_or_create_group(self, zigpy_group: zigpy.group.Group) -> ZHAGroup: + """Get or create a ZHA group.""" + zha_group = self._groups.get(zigpy_group.group_id) + if zha_group is None: + zha_group = ZHAGroup(self.hass, self, zigpy_group) + self._groups[zigpy_group.group_id] = zha_group + return zha_group + + @callback + def async_update_device( + self, sender: zigpy.device.Device, available: bool = True + ) -> None: + """Update device that has just become available.""" + if sender.ieee in self.devices: + device = self.devices[sender.ieee] + # avoid a race condition during new joins + if device.status is DeviceStatus.INITIALIZED: + device.update_available(available) + + async def async_device_initialized(self, device: zigpy.device.Device) -> None: + """Handle device joined and basic information discovered (async).""" + zha_device = self._async_get_or_create_device(device) + _LOGGER.debug( + "device - %s:%s entering async_device_initialized - is_new_join: %s", + device.nwk, + device.ieee, + zha_device.status is not DeviceStatus.INITIALIZED, + ) + + if zha_device.status is DeviceStatus.INITIALIZED: + # ZHA already has an initialized device so either the device was assigned a + # new nwk or device was physically reset and added again without being removed + _LOGGER.debug( + "device - %s:%s has been reset and re-added or its nwk address changed", + device.nwk, + device.ieee, + ) + await self._async_device_rejoined(zha_device) + else: + _LOGGER.debug( + "device - %s:%s has joined the ZHA zigbee network", + device.nwk, + device.ieee, + ) + await self._async_device_joined(zha_device) + + device_info = zha_device.zha_device_info + device_info[DEVICE_PAIRING_STATUS] = DevicePairingStatus.INITIALIZED.name + async_dispatcher_send( + self.hass, + ZHA_GW_MSG, + { + ATTR_TYPE: ZHA_GW_MSG_DEVICE_FULL_INIT, + ZHA_GW_MSG_DEVICE_INFO: device_info, + }, + ) + + async def _async_device_joined(self, zha_device: ZHADevice) -> None: + zha_device.available = True + device_info = zha_device.device_info + await zha_device.async_configure() + device_info[DEVICE_PAIRING_STATUS] = DevicePairingStatus.CONFIGURED.name + async_dispatcher_send( + self.hass, + ZHA_GW_MSG, + { + ATTR_TYPE: ZHA_GW_MSG_DEVICE_FULL_INIT, + ZHA_GW_MSG_DEVICE_INFO: device_info, + }, + ) + await zha_device.async_initialize(from_cache=False) + async_dispatcher_send(self.hass, SIGNAL_ADD_ENTITIES) + + async def _async_device_rejoined(self, zha_device: ZHADevice) -> None: + _LOGGER.debug( + "skipping discovery for previously discovered device - %s:%s", + zha_device.nwk, + zha_device.ieee, + ) + # we don't have to do this on a nwk swap + # but we don't have a way to tell currently + await zha_device.async_configure() + device_info = zha_device.device_info + device_info[DEVICE_PAIRING_STATUS] = DevicePairingStatus.CONFIGURED.name + async_dispatcher_send( + self.hass, + ZHA_GW_MSG, + { + ATTR_TYPE: ZHA_GW_MSG_DEVICE_FULL_INIT, + ZHA_GW_MSG_DEVICE_INFO: device_info, + }, + ) + # force async_initialize() to fire so don't explicitly call it + zha_device.available = False + zha_device.update_available(True) + + async def async_create_zigpy_group( + self, + name: str, + members: list[GroupMember] | None, + group_id: int | None = None, + ) -> ZHAGroup | None: + """Create a new Zigpy Zigbee group.""" + + # we start with two to fill any gaps from a user removing existing groups + + if group_id is None: + group_id = 2 + while group_id in self.groups: + group_id += 1 + + # guard against group already existing + if self.async_get_group_by_name(name) is None: + self.application_controller.groups.add_group(group_id, name) + if members is not None: + tasks = [] + for member in members: + _LOGGER.debug( + ( + "Adding member with IEEE: %s and endpoint ID: %s to group:" + " %s:0x%04x" + ), + member.ieee, + member.endpoint_id, + name, + group_id, + ) + tasks.append( + self.devices[member.ieee].async_add_endpoint_to_group( + member.endpoint_id, group_id + ) + ) + await asyncio.gather(*tasks) + return self.groups.get(group_id) + + async def async_remove_zigpy_group(self, group_id: int) -> None: + """Remove a Zigbee group from Zigpy.""" + if not (group := self.groups.get(group_id)): + _LOGGER.debug("Group: 0x%04x could not be found", group_id) + return + if group.members: + tasks = [] + for member in group.members: + tasks.append(member.async_remove_from_group()) + if tasks: + await asyncio.gather(*tasks) + self.application_controller.groups.pop(group_id) + + async def shutdown(self) -> None: + """Stop ZHA Controller Application.""" + if self.shutting_down: + _LOGGER.debug("Ignoring duplicate shutdown event") + return + + _LOGGER.debug("Shutting down ZHA ControllerApplication") + self.shutting_down = True + + for unsubscribe in self._unsubs: + unsubscribe() + for device in self.devices.values(): + device.async_cleanup_handles() + await self.application_controller.shutdown() + + def handle_message( + self, + sender: zigpy.device.Device, + profile: int, + cluster: int, + src_ep: int, + dst_ep: int, + message: bytes, + ) -> None: + """Handle message from a device Event handler.""" + if sender.ieee in self.devices and not self.devices[sender.ieee].available: + self.async_update_device(sender, available=True) + + +@callback +def async_capture_log_levels() -> dict[str, int]: + """Capture current logger levels for ZHA.""" + return { + DEBUG_COMP_BELLOWS: logging.getLogger(DEBUG_COMP_BELLOWS).getEffectiveLevel(), + DEBUG_COMP_ZHA: logging.getLogger(DEBUG_COMP_ZHA).getEffectiveLevel(), + DEBUG_COMP_ZIGPY: logging.getLogger(DEBUG_COMP_ZIGPY).getEffectiveLevel(), + DEBUG_COMP_ZIGPY_ZNP: logging.getLogger( + DEBUG_COMP_ZIGPY_ZNP + ).getEffectiveLevel(), + DEBUG_COMP_ZIGPY_DECONZ: logging.getLogger( + DEBUG_COMP_ZIGPY_DECONZ + ).getEffectiveLevel(), + DEBUG_COMP_ZIGPY_XBEE: logging.getLogger( + DEBUG_COMP_ZIGPY_XBEE + ).getEffectiveLevel(), + DEBUG_COMP_ZIGPY_ZIGATE: logging.getLogger( + DEBUG_COMP_ZIGPY_ZIGATE + ).getEffectiveLevel(), + } + + +@callback +def async_set_logger_levels(levels: dict[str, int]) -> None: + """Set logger levels for ZHA.""" + logging.getLogger(DEBUG_COMP_BELLOWS).setLevel(levels[DEBUG_COMP_BELLOWS]) + logging.getLogger(DEBUG_COMP_ZHA).setLevel(levels[DEBUG_COMP_ZHA]) + logging.getLogger(DEBUG_COMP_ZIGPY).setLevel(levels[DEBUG_COMP_ZIGPY]) + logging.getLogger(DEBUG_COMP_ZIGPY_ZNP).setLevel(levels[DEBUG_COMP_ZIGPY_ZNP]) + logging.getLogger(DEBUG_COMP_ZIGPY_DECONZ).setLevel(levels[DEBUG_COMP_ZIGPY_DECONZ]) + logging.getLogger(DEBUG_COMP_ZIGPY_XBEE).setLevel(levels[DEBUG_COMP_ZIGPY_XBEE]) + logging.getLogger(DEBUG_COMP_ZIGPY_ZIGATE).setLevel(levels[DEBUG_COMP_ZIGPY_ZIGATE]) + + +class LogRelayHandler(logging.Handler): + """Log handler for error messages.""" + + def __init__(self, hass: HomeAssistant, gateway: ZHAGateway) -> None: + """Initialize a new LogErrorHandler.""" + super().__init__() + self.hass = hass + self.gateway = gateway + hass_path: str = HOMEASSISTANT_PATH[0] + config_dir = self.hass.config.config_dir + self.paths_re = re.compile( + r"(?:{})/(.*)".format( + "|".join([re.escape(x) for x in (hass_path, config_dir)]) + ) + ) + + def emit(self, record: LogRecord) -> None: + """Relay log message via dispatcher.""" + if record.levelno >= logging.WARN: + entry = LogEntry(record, _figure_out_source(record, self.paths_re)) + else: + entry = LogEntry(record, (record.pathname, record.lineno)) + async_dispatcher_send( + self.hass, + ZHA_GW_MSG, + {ATTR_TYPE: ZHA_GW_MSG_LOG_OUTPUT, ZHA_GW_MSG_LOG_ENTRY: entry.to_dict()}, + ) diff --git a/zha/core/group.py b/zha/core/group.py new file mode 100644 index 00000000..4d26a7d9 --- /dev/null +++ b/zha/core/group.py @@ -0,0 +1,250 @@ +"""Group for Zigbee Home Automation.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, Any, NamedTuple + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_registry import async_entries_for_device +import zigpy.endpoint +import zigpy.exceptions +import zigpy.group +from zigpy.types.named import EUI64 + +from .helpers import LogMixin + +if TYPE_CHECKING: + from .device import ZHADevice + from .gateway import ZHAGateway + +_LOGGER = logging.getLogger(__name__) + + +class GroupMember(NamedTuple): + """Describes a group member.""" + + ieee: EUI64 + endpoint_id: int + + +class GroupEntityReference(NamedTuple): + """Reference to a group entity.""" + + name: str | None + original_name: str | None + entity_id: int + + +class ZHAGroupMember(LogMixin): + """Composite object that represents a device endpoint in a Zigbee group.""" + + def __init__( + self, zha_group: ZHAGroup, zha_device: ZHADevice, endpoint_id: int + ) -> None: + """Initialize the group member.""" + self._zha_group = zha_group + self._zha_device = zha_device + self._endpoint_id = endpoint_id + + @property + def group(self) -> ZHAGroup: + """Return the group this member belongs to.""" + return self._zha_group + + @property + def endpoint_id(self) -> int: + """Return the endpoint id for this group member.""" + return self._endpoint_id + + @property + def endpoint(self) -> zigpy.endpoint.Endpoint: + """Return the endpoint for this group member.""" + return self._zha_device.device.endpoints.get(self.endpoint_id) + + @property + def device(self) -> ZHADevice: + """Return the ZHA device for this group member.""" + return self._zha_device + + @property + def member_info(self) -> dict[str, Any]: + """Get ZHA group info.""" + member_info: dict[str, Any] = {} + member_info["endpoint_id"] = self.endpoint_id + member_info["device"] = self.device.zha_device_info + member_info["entities"] = self.associated_entities + return member_info + + @property + def associated_entities(self) -> list[dict[str, Any]]: + """Return the list of entities that were derived from this endpoint.""" + entity_registry = er.async_get(self._zha_device.hass) + zha_device_registry = self.device.gateway.device_registry + + entity_info = [] + + for entity_ref in zha_device_registry.get(self.device.ieee): + # We have device entities now that don't leverage cluster handlers + if not entity_ref.cluster_handlers: + continue + entity = entity_registry.async_get(entity_ref.reference_id) + handler = list(entity_ref.cluster_handlers.values())[0] + + if ( + entity is None + or handler.cluster.endpoint.endpoint_id != self.endpoint_id + ): + continue + + entity_info.append( + GroupEntityReference( + name=entity.name, + original_name=entity.original_name, + entity_id=entity_ref.reference_id, + )._asdict() + ) + + return entity_info + + async def async_remove_from_group(self) -> None: + """Remove the device endpoint from the provided zigbee group.""" + try: + await self._zha_device.device.endpoints[ + self._endpoint_id + ].remove_from_group(self._zha_group.group_id) + except (zigpy.exceptions.ZigbeeException, TimeoutError) as ex: + self.debug( + ( + "Failed to remove endpoint: %s for device '%s' from group: 0x%04x" + " ex: %s" + ), + self._endpoint_id, + self._zha_device.ieee, + self._zha_group.group_id, + str(ex), + ) + + def log(self, level: int, msg: str, *args: Any, **kwargs) -> None: + """Log a message.""" + msg = f"[%s](%s): {msg}" + args = (f"0x{self._zha_group.group_id:04x}", self.endpoint_id) + args + _LOGGER.log(level, msg, *args, **kwargs) + + +class ZHAGroup(LogMixin): + """ZHA Zigbee group object.""" + + def __init__( + self, + hass: HomeAssistant, + zha_gateway: ZHAGateway, + zigpy_group: zigpy.group.Group, + ) -> None: + """Initialize the group.""" + self.hass = hass + self._zha_gateway = zha_gateway + self._zigpy_group = zigpy_group + + @property + def name(self) -> str: + """Return group name.""" + return self._zigpy_group.name + + @property + def group_id(self) -> int: + """Return group name.""" + return self._zigpy_group.group_id + + @property + def endpoint(self) -> zigpy.endpoint.Endpoint: + """Return the endpoint for this group.""" + return self._zigpy_group.endpoint + + @property + def members(self) -> list[ZHAGroupMember]: + """Return the ZHA devices that are members of this group.""" + return [ + ZHAGroupMember(self, self._zha_gateway.devices[member_ieee], endpoint_id) + for (member_ieee, endpoint_id) in self._zigpy_group.members + if member_ieee in self._zha_gateway.devices + ] + + async def async_add_members(self, members: list[GroupMember]) -> None: + """Add members to this group.""" + if len(members) > 1: + tasks = [] + for member in members: + tasks.append( + self._zha_gateway.devices[member.ieee].async_add_endpoint_to_group( + member.endpoint_id, self.group_id + ) + ) + await asyncio.gather(*tasks) + else: + await self._zha_gateway.devices[ + members[0].ieee + ].async_add_endpoint_to_group(members[0].endpoint_id, self.group_id) + + async def async_remove_members(self, members: list[GroupMember]) -> None: + """Remove members from this group.""" + if len(members) > 1: + tasks = [] + for member in members: + tasks.append( + self._zha_gateway.devices[ + member.ieee + ].async_remove_endpoint_from_group( + member.endpoint_id, self.group_id + ) + ) + await asyncio.gather(*tasks) + else: + await self._zha_gateway.devices[ + members[0].ieee + ].async_remove_endpoint_from_group(members[0].endpoint_id, self.group_id) + + @property + def member_entity_ids(self) -> list[str]: + """Return the ZHA entity ids for all entities for the members of this group.""" + all_entity_ids: list[str] = [] + for member in self.members: + entity_references = member.associated_entities + for entity_reference in entity_references: + all_entity_ids.append(entity_reference["entity_id"]) + return all_entity_ids + + def get_domain_entity_ids(self, domain: str) -> list[str]: + """Return entity ids from the entity domain for this group.""" + entity_registry = er.async_get(self.hass) + domain_entity_ids: list[str] = [] + + for member in self.members: + if member.device.is_coordinator: + continue + entities = async_entries_for_device( + entity_registry, + member.device.device_id, + include_disabled_entities=True, + ) + domain_entity_ids.extend( + [entity.entity_id for entity in entities if entity.domain == domain] + ) + return domain_entity_ids + + @property + def group_info(self) -> dict[str, Any]: + """Get ZHA group info.""" + group_info: dict[str, Any] = {} + group_info["group_id"] = self.group_id + group_info["name"] = self.name + group_info["members"] = [member.member_info for member in self.members] + return group_info + + def log(self, level: int, msg: str, *args: Any, **kwargs) -> None: + """Log a message.""" + msg = f"[%s](%s): {msg}" + args = (self.name, self.group_id) + args + _LOGGER.log(level, msg, *args, **kwargs) diff --git a/zha/core/helpers.py b/zha/core/helpers.py new file mode 100644 index 00000000..cb5e36ca --- /dev/null +++ b/zha/core/helpers.py @@ -0,0 +1,425 @@ +"""Helpers for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/integrations/zha/ +""" + +from __future__ import annotations + +import binascii +import collections +from collections.abc import Callable, Iterator +import dataclasses +from dataclasses import dataclass +import enum +import logging +import re +from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, State, callback +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.typing import ConfigType +import voluptuous as vol +import zigpy.exceptions +import zigpy.types +import zigpy.util +import zigpy.zcl +from zigpy.zcl.foundation import CommandSchema +import zigpy.zdo.types as zdo_types + +from .const import CLUSTER_TYPE_IN, CLUSTER_TYPE_OUT, CUSTOM_CONFIGURATION, DATA_ZHA +from .registries import BINDABLE_CLUSTERS + +if TYPE_CHECKING: + from .cluster_handlers import ClusterHandler + from .device import ZHADevice + from .gateway import ZHAGateway + +_ClusterHandlerT = TypeVar("_ClusterHandlerT", bound="ClusterHandler") +_T = TypeVar("_T") +_R = TypeVar("_R") +_P = ParamSpec("_P") +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class BindingPair: + """Information for binding.""" + + source_cluster: zigpy.zcl.Cluster + target_ieee: zigpy.types.EUI64 + target_ep_id: int + + @property + def destination_address(self) -> zdo_types.MultiAddress: + """Return a ZDO multi address instance.""" + return zdo_types.MultiAddress( + addrmode=3, ieee=self.target_ieee, endpoint=self.target_ep_id + ) + + +async def safe_read( + cluster, attributes, allow_cache=True, only_cache=False, manufacturer=None +): + """Swallow all exceptions from network read. + + If we throw during initialization, setup fails. Rather have an entity that + exists, but is in a maybe wrong state, than no entity. This method should + probably only be used during initialization. + """ + try: + result, _ = await cluster.read_attributes( + attributes, + allow_cache=allow_cache, + only_cache=only_cache, + manufacturer=manufacturer, + ) + return result + except Exception: # pylint: disable=broad-except + return {} + + +async def get_matched_clusters( + source_zha_device: ZHADevice, target_zha_device: ZHADevice +) -> list[BindingPair]: + """Get matched input/output cluster pairs for 2 devices.""" + source_clusters = source_zha_device.async_get_std_clusters() + target_clusters = target_zha_device.async_get_std_clusters() + clusters_to_bind = [] + + for endpoint_id in source_clusters: + for cluster_id in source_clusters[endpoint_id][CLUSTER_TYPE_OUT]: + if cluster_id not in BINDABLE_CLUSTERS: + continue + if target_zha_device.nwk == 0x0000: + cluster_pair = BindingPair( + source_cluster=source_clusters[endpoint_id][CLUSTER_TYPE_OUT][ + cluster_id + ], + target_ieee=target_zha_device.ieee, + target_ep_id=target_zha_device.device.application.get_endpoint_id( + cluster_id, is_server_cluster=True + ), + ) + clusters_to_bind.append(cluster_pair) + continue + for t_endpoint_id in target_clusters: + if cluster_id in target_clusters[t_endpoint_id][CLUSTER_TYPE_IN]: + cluster_pair = BindingPair( + source_cluster=source_clusters[endpoint_id][CLUSTER_TYPE_OUT][ + cluster_id + ], + target_ieee=target_zha_device.ieee, + target_ep_id=t_endpoint_id, + ) + clusters_to_bind.append(cluster_pair) + return clusters_to_bind + + +def cluster_command_schema_to_vol_schema(schema: CommandSchema) -> vol.Schema: + """Convert a cluster command schema to a voluptuous schema.""" + return vol.Schema( + { + vol.Optional(field.name) + if field.optional + else vol.Required(field.name): schema_type_to_vol(field.type) + for field in schema.fields + } + ) + + +def schema_type_to_vol(field_type: Any) -> Any: + """Convert a schema type to a voluptuous type.""" + if issubclass(field_type, enum.Flag) and field_type.__members__: + return cv.multi_select( + [key.replace("_", " ") for key in field_type.__members__] + ) + if issubclass(field_type, enum.Enum) and field_type.__members__: + return vol.In([key.replace("_", " ") for key in field_type.__members__]) + if ( + issubclass(field_type, zigpy.types.FixedIntType) + or issubclass(field_type, enum.Flag) + or issubclass(field_type, enum.Enum) + ): + return vol.All( + vol.Coerce(int), vol.Range(field_type.min_value, field_type.max_value) + ) + return str + + +def convert_to_zcl_values( + fields: dict[str, Any], schema: CommandSchema +) -> dict[str, Any]: + """Convert user input to ZCL values.""" + converted_fields: dict[str, Any] = {} + for field in schema.fields: + if field.name not in fields: + continue + value = fields[field.name] + if issubclass(field.type, enum.Flag) and isinstance(value, list): + new_value = 0 + + for flag in value: + if isinstance(flag, str): + new_value |= field.type[flag.replace(" ", "_")] + else: + new_value |= flag + + value = field.type(new_value) + elif issubclass(field.type, enum.Enum): + value = ( + field.type[value.replace(" ", "_")] + if isinstance(value, str) + else field.type(value) + ) + else: + value = field.type(value) + _LOGGER.debug( + "Converted ZCL schema field(%s) value from: %s to: %s", + field.name, + fields[field.name], + value, + ) + converted_fields[field.name] = value + return converted_fields + + +@callback +def async_is_bindable_target(source_zha_device, target_zha_device): + """Determine if target is bindable to source.""" + if target_zha_device.nwk == 0x0000: + return True + + source_clusters = source_zha_device.async_get_std_clusters() + target_clusters = target_zha_device.async_get_std_clusters() + + for endpoint_id in source_clusters: + for t_endpoint_id in target_clusters: + matches = set( + source_clusters[endpoint_id][CLUSTER_TYPE_OUT].keys() + ).intersection(target_clusters[t_endpoint_id][CLUSTER_TYPE_IN].keys()) + if any(bindable in BINDABLE_CLUSTERS for bindable in matches): + return True + return False + + +@callback +def async_get_zha_config_value( + config_entry: ConfigEntry, section: str, config_key: str, default: _T +) -> _T: + """Get the value for the specified configuration from the ZHA config entry.""" + return ( + config_entry.options.get(CUSTOM_CONFIGURATION, {}) + .get(section, {}) + .get(config_key, default) + ) + + +def async_cluster_exists(hass, cluster_id, skip_coordinator=True): + """Determine if a device containing the specified in cluster is paired.""" + zha_gateway = get_zha_gateway(hass) + zha_devices = zha_gateway.devices.values() + for zha_device in zha_devices: + if skip_coordinator and zha_device.is_coordinator: + continue + clusters_by_endpoint = zha_device.async_get_clusters() + for clusters in clusters_by_endpoint.values(): + if ( + cluster_id in clusters[CLUSTER_TYPE_IN] + or cluster_id in clusters[CLUSTER_TYPE_OUT] + ): + return True + return False + + +@callback +def async_get_zha_device(hass: HomeAssistant, device_id: str) -> ZHADevice: + """Get a ZHA device for the given device registry id.""" + device_registry = dr.async_get(hass) + registry_device = device_registry.async_get(device_id) + if not registry_device: + _LOGGER.error("Device id `%s` not found in registry", device_id) + raise KeyError(f"Device id `{device_id}` not found in registry.") + zha_gateway = get_zha_gateway(hass) + try: + ieee_address = list(registry_device.identifiers)[0][1] + ieee = zigpy.types.EUI64.convert(ieee_address) + except (IndexError, ValueError) as ex: + _LOGGER.error( + "Unable to determine device IEEE for device with device id `%s`", device_id + ) + raise KeyError( + f"Unable to determine device IEEE for device with device id `{device_id}`." + ) from ex + return zha_gateway.devices[ieee] + + +def find_state_attributes(states: list[State], key: str) -> Iterator[Any]: + """Find attributes with matching key from states.""" + for state in states: + if (value := state.attributes.get(key)) is not None: + yield value + + +def mean_int(*args): + """Return the mean of the supplied values.""" + return int(sum(args) / len(args)) + + +def mean_tuple(*args): + """Return the mean values along the columns of the supplied values.""" + return tuple(sum(x) / len(x) for x in zip(*args)) + + +def reduce_attribute( + states: list[State], + key: str, + default: Any | None = None, + reduce: Callable[..., Any] = mean_int, +) -> Any: + """Find the first attribute matching key from states. + + If none are found, return default. + """ + attrs = list(find_state_attributes(states, key)) + + if not attrs: + return default + + if len(attrs) == 1: + return attrs[0] + + return reduce(*attrs) + + +class LogMixin: + """Log helper.""" + + def log(self, level, msg, *args, **kwargs): + """Log with level.""" + raise NotImplementedError + + def debug(self, msg, *args, **kwargs): + """Debug level log.""" + return self.log(logging.DEBUG, msg, *args, **kwargs) + + def info(self, msg, *args, **kwargs): + """Info level log.""" + return self.log(logging.INFO, msg, *args, **kwargs) + + def warning(self, msg, *args, **kwargs): + """Warning method log.""" + return self.log(logging.WARNING, msg, *args, **kwargs) + + def error(self, msg, *args, **kwargs): + """Error level log.""" + return self.log(logging.ERROR, msg, *args, **kwargs) + + +def convert_install_code(value: str) -> zigpy.types.KeyData: + """Convert string to install code bytes and validate length.""" + + try: + code = binascii.unhexlify(value.replace("-", "").lower()) + except binascii.Error as exc: + raise vol.Invalid(f"invalid hex string: {value}") from exc + + if len(code) != 18: # 16 byte code + 2 crc bytes + raise vol.Invalid("invalid length of the install code") + + link_key = zigpy.util.convert_install_code(code) + if link_key is None: + raise vol.Invalid("invalid install code") + + return link_key + + +QR_CODES = ( + # Consciot + r"^([\da-fA-F]{16})\|([\da-fA-F]{36})$", + # Enbrighten + r""" + ^Z: + ([0-9a-fA-F]{16}) # IEEE address + \$I: + ([0-9a-fA-F]{36}) # install code + $ + """, + # Aqara + r""" + \$A: + ([0-9a-fA-F]{16}) # IEEE address + \$I: + ([0-9a-fA-F]{36}) # install code + $ + """, + # Bosch + r""" + ^RB01SG + [0-9a-fA-F]{34} + ([0-9a-fA-F]{16}) # IEEE address + DLK + ([0-9a-fA-F]{36}|[0-9a-fA-F]{32}) # install code / link key + $ + """, +) + + +def qr_to_install_code(qr_code: str) -> tuple[zigpy.types.EUI64, zigpy.types.KeyData]: + """Try to parse the QR code. + + if successful, return a tuple of a EUI64 address and install code. + """ + + for code_pattern in QR_CODES: + match = re.search(code_pattern, qr_code, re.VERBOSE) + if match is None: + continue + + ieee_hex = binascii.unhexlify(match[1]) + ieee = zigpy.types.EUI64(ieee_hex[::-1]) + + # Bosch supplies (A) device specific link key (DSLK) or (A) install code + crc + if "RB01SG" in code_pattern and len(match[2]) == 32: + link_key_hex = binascii.unhexlify(match[2]) + link_key = zigpy.types.KeyData(link_key_hex) + return ieee, link_key + install_code = match[2] + # install_code sanity check + link_key = convert_install_code(install_code) + return ieee, link_key + + raise vol.Invalid(f"couldn't convert qr code: {qr_code}") + + +@dataclasses.dataclass(kw_only=True, slots=True) +class ZHAData: + """ZHA component data stored in `hass.data`.""" + + yaml_config: ConfigType = dataclasses.field(default_factory=dict) + platforms: collections.defaultdict[Platform, list] = dataclasses.field( + default_factory=lambda: collections.defaultdict(list) + ) + gateway: ZHAGateway | None = dataclasses.field(default=None) + device_trigger_cache: dict[str, tuple[str, dict]] = dataclasses.field( + default_factory=dict + ) + allow_polling: bool = dataclasses.field(default=False) + + +def get_zha_data(hass: HomeAssistant) -> ZHAData: + """Get the global ZHA data object.""" + if DATA_ZHA not in hass.data: + hass.data[DATA_ZHA] = ZHAData() + + return hass.data[DATA_ZHA] + + +def get_zha_gateway(hass: HomeAssistant) -> ZHAGateway: + """Get the ZHA gateway object.""" + if (zha_gateway := get_zha_data(hass).gateway) is None: + raise ValueError("No gateway object exists") + + return zha_gateway diff --git a/zha/core/registries.py b/zha/core/registries.py new file mode 100644 index 00000000..40ef27a3 --- /dev/null +++ b/zha/core/registries.py @@ -0,0 +1,518 @@ +"""Mapping registries for Zigbee Home Automation.""" + +from __future__ import annotations + +import collections +from collections.abc import Callable +import dataclasses +from operator import attrgetter +from typing import TYPE_CHECKING, TypeVar + +import attr +from homeassistant.const import Platform +from zigpy import zcl +import zigpy.profiles.zha +import zigpy.profiles.zll +from zigpy.types.named import EUI64 + +from .decorators import DictRegistry, NestedDictRegistry, SetRegistry + +if TYPE_CHECKING: + from ..entity import ZhaEntity, ZhaGroupEntity + from .cluster_handlers import ClientClusterHandler, ClusterHandler + + +_ZhaEntityT = TypeVar("_ZhaEntityT", bound=type["ZhaEntity"]) +_ZhaGroupEntityT = TypeVar("_ZhaGroupEntityT", bound=type["ZhaGroupEntity"]) + +GROUP_ENTITY_DOMAINS = [Platform.LIGHT, Platform.SWITCH, Platform.FAN] + +IKEA_AIR_PURIFIER_CLUSTER = 0xFC7D +PHILLIPS_REMOTE_CLUSTER = 0xFC00 +SMARTTHINGS_ACCELERATION_CLUSTER = 0xFC02 +SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE = 0x8000 +SMARTTHINGS_HUMIDITY_CLUSTER = 0xFC45 +TUYA_MANUFACTURER_CLUSTER = 0xEF00 +VOC_LEVEL_CLUSTER = 0x042E + +REMOTE_DEVICE_TYPES = { + zigpy.profiles.zha.PROFILE_ID: [ + zigpy.profiles.zha.DeviceType.COLOR_CONTROLLER, + zigpy.profiles.zha.DeviceType.COLOR_DIMMER_SWITCH, + zigpy.profiles.zha.DeviceType.COLOR_SCENE_CONTROLLER, + zigpy.profiles.zha.DeviceType.DIMMER_SWITCH, + zigpy.profiles.zha.DeviceType.LEVEL_CONTROL_SWITCH, + zigpy.profiles.zha.DeviceType.NON_COLOR_CONTROLLER, + zigpy.profiles.zha.DeviceType.NON_COLOR_SCENE_CONTROLLER, + zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + zigpy.profiles.zha.DeviceType.ON_OFF_LIGHT_SWITCH, + zigpy.profiles.zha.DeviceType.REMOTE_CONTROL, + zigpy.profiles.zha.DeviceType.SCENE_SELECTOR, + ], + zigpy.profiles.zll.PROFILE_ID: [ + zigpy.profiles.zll.DeviceType.COLOR_CONTROLLER, + zigpy.profiles.zll.DeviceType.COLOR_SCENE_CONTROLLER, + zigpy.profiles.zll.DeviceType.CONTROL_BRIDGE, + zigpy.profiles.zll.DeviceType.CONTROLLER, + zigpy.profiles.zll.DeviceType.SCENE_CONTROLLER, + ], +} +REMOTE_DEVICE_TYPES = collections.defaultdict(list, REMOTE_DEVICE_TYPES) + +SINGLE_INPUT_CLUSTER_DEVICE_CLASS = { + # this works for now but if we hit conflicts we can break it out to + # a different dict that is keyed by manufacturer + zcl.clusters.general.AnalogOutput.cluster_id: Platform.NUMBER, + zcl.clusters.general.MultistateInput.cluster_id: Platform.SENSOR, + zcl.clusters.general.OnOff.cluster_id: Platform.SWITCH, + zcl.clusters.hvac.Fan.cluster_id: Platform.FAN, +} + +SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS = { + zcl.clusters.general.OnOff.cluster_id: Platform.BINARY_SENSOR, + zcl.clusters.security.IasAce.cluster_id: Platform.ALARM_CONTROL_PANEL, +} + +BINDABLE_CLUSTERS = SetRegistry() + +DEVICE_CLASS = { + zigpy.profiles.zha.PROFILE_ID: { + SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE: Platform.DEVICE_TRACKER, + zigpy.profiles.zha.DeviceType.THERMOSTAT: Platform.CLIMATE, + zigpy.profiles.zha.DeviceType.COLOR_DIMMABLE_LIGHT: Platform.LIGHT, + zigpy.profiles.zha.DeviceType.COLOR_TEMPERATURE_LIGHT: Platform.LIGHT, + zigpy.profiles.zha.DeviceType.DIMMABLE_BALLAST: Platform.LIGHT, + zigpy.profiles.zha.DeviceType.DIMMABLE_LIGHT: Platform.LIGHT, + zigpy.profiles.zha.DeviceType.DIMMABLE_PLUG_IN_UNIT: Platform.LIGHT, + zigpy.profiles.zha.DeviceType.EXTENDED_COLOR_LIGHT: Platform.LIGHT, + zigpy.profiles.zha.DeviceType.LEVEL_CONTROLLABLE_OUTPUT: Platform.COVER, + zigpy.profiles.zha.DeviceType.ON_OFF_BALLAST: Platform.SWITCH, + zigpy.profiles.zha.DeviceType.ON_OFF_LIGHT: Platform.LIGHT, + zigpy.profiles.zha.DeviceType.ON_OFF_PLUG_IN_UNIT: Platform.SWITCH, + zigpy.profiles.zha.DeviceType.SHADE: Platform.COVER, + zigpy.profiles.zha.DeviceType.SMART_PLUG: Platform.SWITCH, + zigpy.profiles.zha.DeviceType.IAS_ANCILLARY_CONTROL: Platform.ALARM_CONTROL_PANEL, + zigpy.profiles.zha.DeviceType.IAS_WARNING_DEVICE: Platform.SIREN, + }, + zigpy.profiles.zll.PROFILE_ID: { + zigpy.profiles.zll.DeviceType.COLOR_LIGHT: Platform.LIGHT, + zigpy.profiles.zll.DeviceType.COLOR_TEMPERATURE_LIGHT: Platform.LIGHT, + zigpy.profiles.zll.DeviceType.DIMMABLE_LIGHT: Platform.LIGHT, + zigpy.profiles.zll.DeviceType.DIMMABLE_PLUGIN_UNIT: Platform.LIGHT, + zigpy.profiles.zll.DeviceType.EXTENDED_COLOR_LIGHT: Platform.LIGHT, + zigpy.profiles.zll.DeviceType.ON_OFF_LIGHT: Platform.LIGHT, + zigpy.profiles.zll.DeviceType.ON_OFF_PLUGIN_UNIT: Platform.SWITCH, + }, +} +DEVICE_CLASS = collections.defaultdict(dict, DEVICE_CLASS) + +CLUSTER_HANDLER_ONLY_CLUSTERS = SetRegistry() +CLIENT_CLUSTER_HANDLER_REGISTRY: DictRegistry[ + type[ClientClusterHandler] +] = DictRegistry() +ZIGBEE_CLUSTER_HANDLER_REGISTRY: NestedDictRegistry[ + type[ClusterHandler] +] = NestedDictRegistry() + +WEIGHT_ATTR = attrgetter("weight") + + +def set_or_callable(value) -> frozenset[str] | Callable: + """Convert single str or None to a set. Pass through callables and sets.""" + if value is None: + return frozenset() + if callable(value): + return value + if isinstance(value, (frozenset, set, list)): + return frozenset(value) + return frozenset([str(value)]) + + +def _get_empty_frozenset() -> frozenset[str]: + return frozenset() + + +@attr.s(frozen=True) +class MatchRule: + """Match a ZHA Entity to a cluster handler name or generic id.""" + + cluster_handler_names: frozenset[str] = attr.ib( + factory=frozenset, converter=set_or_callable + ) + generic_ids: frozenset[str] = attr.ib(factory=frozenset, converter=set_or_callable) + manufacturers: frozenset[str] | Callable = attr.ib( + factory=_get_empty_frozenset, converter=set_or_callable + ) + models: frozenset[str] | Callable = attr.ib( + factory=_get_empty_frozenset, converter=set_or_callable + ) + aux_cluster_handlers: frozenset[str] | Callable = attr.ib( + factory=_get_empty_frozenset, converter=set_or_callable + ) + quirk_ids: frozenset[str] | Callable = attr.ib( + factory=_get_empty_frozenset, converter=set_or_callable + ) + + @property + def weight(self) -> int: + """Return the weight of the matching rule. + + More specific matches should be preferred over less specific. Quirk class + matching rules have priority over model matching rules + and have a priority over manufacturer matching rules and rules matching a + single model/manufacturer get a better priority over rules matching multiple + models/manufacturers. And any model or manufacturers matching rules get better + priority over rules matching only cluster handlers. + But in case of a cluster handler name/cluster handler id matching, we give rules matching + multiple cluster handlers a better priority over rules matching a single cluster handler. + """ + weight = 0 + if self.quirk_ids: + weight += 501 - (1 if callable(self.quirk_ids) else len(self.quirk_ids)) + + if self.models: + weight += 401 - (1 if callable(self.models) else len(self.models)) + + if self.manufacturers: + weight += 301 - ( + 1 if callable(self.manufacturers) else len(self.manufacturers) + ) + + weight += 10 * len(self.cluster_handler_names) + weight += 5 * len(self.generic_ids) + if isinstance(self.aux_cluster_handlers, frozenset): + weight += 1 * len(self.aux_cluster_handlers) + return weight + + def claim_cluster_handlers( + self, cluster_handlers: list[ClusterHandler] + ) -> list[ClusterHandler]: + """Return a list of cluster handlers this rule matches + aux cluster handlers.""" + claimed = [] + if isinstance(self.cluster_handler_names, frozenset): + claimed.extend( + [ch for ch in cluster_handlers if ch.name in self.cluster_handler_names] + ) + if isinstance(self.generic_ids, frozenset): + claimed.extend( + [ch for ch in cluster_handlers if ch.generic_id in self.generic_ids] + ) + if isinstance(self.aux_cluster_handlers, frozenset): + claimed.extend( + [ch for ch in cluster_handlers if ch.name in self.aux_cluster_handlers] + ) + return claimed + + def strict_matched( + self, + manufacturer: str, + model: str, + cluster_handlers: list, + quirk_id: str | None, + ) -> bool: + """Return True if this device matches the criteria.""" + return all(self._matched(manufacturer, model, cluster_handlers, quirk_id)) + + def loose_matched( + self, + manufacturer: str, + model: str, + cluster_handlers: list, + quirk_id: str | None, + ) -> bool: + """Return True if this device matches the criteria.""" + return any(self._matched(manufacturer, model, cluster_handlers, quirk_id)) + + def _matched( + self, + manufacturer: str, + model: str, + cluster_handlers: list, + quirk_id: str | None, + ) -> list: + """Return a list of field matches.""" + if not any(attr.asdict(self).values()): + return [False] + + matches = [] + if self.cluster_handler_names: + cluster_handler_names = {ch.name for ch in cluster_handlers} + matches.append(self.cluster_handler_names.issubset(cluster_handler_names)) + + if self.generic_ids: + all_generic_ids = {ch.generic_id for ch in cluster_handlers} + matches.append(self.generic_ids.issubset(all_generic_ids)) + + if self.manufacturers: + if callable(self.manufacturers): + matches.append(self.manufacturers(manufacturer)) + else: + matches.append(manufacturer in self.manufacturers) + + if self.models: + if callable(self.models): + matches.append(self.models(model)) + else: + matches.append(model in self.models) + + if self.quirk_ids: + if callable(self.quirk_ids): + matches.append(self.quirk_ids(quirk_id)) + else: + matches.append(quirk_id in self.quirk_ids) + + return matches + + +@dataclasses.dataclass +class EntityClassAndClusterHandlers: + """Container for entity class and corresponding cluster handlers.""" + + entity_class: type[ZhaEntity] + claimed_cluster_handlers: list[ClusterHandler] + + +class ZHAEntityRegistry: + """Cluster handler to ZHA Entity mapping.""" + + def __init__(self) -> None: + """Initialize Registry instance.""" + self._strict_registry: dict[ + Platform, dict[MatchRule, type[ZhaEntity]] + ] = collections.defaultdict(dict) + self._multi_entity_registry: dict[ + Platform, dict[int | str | None, dict[MatchRule, list[type[ZhaEntity]]]] + ] = collections.defaultdict( + lambda: collections.defaultdict(lambda: collections.defaultdict(list)) + ) + self._config_diagnostic_entity_registry: dict[ + Platform, dict[int | str | None, dict[MatchRule, list[type[ZhaEntity]]]] + ] = collections.defaultdict( + lambda: collections.defaultdict(lambda: collections.defaultdict(list)) + ) + self._group_registry: dict[str, type[ZhaGroupEntity]] = {} + self.single_device_matches: dict[ + Platform, dict[EUI64, list[str]] + ] = collections.defaultdict(lambda: collections.defaultdict(list)) + + def get_entity( + self, + component: Platform, + manufacturer: str, + model: str, + cluster_handlers: list[ClusterHandler], + quirk_id: str | None, + default: type[ZhaEntity] | None = None, + ) -> tuple[type[ZhaEntity] | None, list[ClusterHandler]]: + """Match a ZHA ClusterHandler to a ZHA Entity class.""" + matches = self._strict_registry[component] + for match in sorted(matches, key=WEIGHT_ATTR, reverse=True): + if match.strict_matched(manufacturer, model, cluster_handlers, quirk_id): + claimed = match.claim_cluster_handlers(cluster_handlers) + return self._strict_registry[component][match], claimed + + return default, [] + + def get_multi_entity( + self, + manufacturer: str, + model: str, + cluster_handlers: list[ClusterHandler], + quirk_id: str | None, + ) -> tuple[ + dict[Platform, list[EntityClassAndClusterHandlers]], list[ClusterHandler] + ]: + """Match ZHA cluster handlers to potentially multiple ZHA Entity classes.""" + result: dict[ + Platform, list[EntityClassAndClusterHandlers] + ] = collections.defaultdict(list) + all_claimed: set[ClusterHandler] = set() + for component, stop_match_groups in self._multi_entity_registry.items(): + for stop_match_grp, matches in stop_match_groups.items(): + sorted_matches = sorted(matches, key=WEIGHT_ATTR, reverse=True) + for match in sorted_matches: + if match.strict_matched( + manufacturer, model, cluster_handlers, quirk_id + ): + claimed = match.claim_cluster_handlers(cluster_handlers) + for ent_class in stop_match_groups[stop_match_grp][match]: + ent_n_cluster_handlers = EntityClassAndClusterHandlers( + ent_class, claimed + ) + result[component].append(ent_n_cluster_handlers) + all_claimed |= set(claimed) + if stop_match_grp: + break + + return result, list(all_claimed) + + def get_config_diagnostic_entity( + self, + manufacturer: str, + model: str, + cluster_handlers: list[ClusterHandler], + quirk_id: str | None, + ) -> tuple[ + dict[Platform, list[EntityClassAndClusterHandlers]], list[ClusterHandler] + ]: + """Match ZHA cluster handlers to potentially multiple ZHA Entity classes.""" + result: dict[ + Platform, list[EntityClassAndClusterHandlers] + ] = collections.defaultdict(list) + all_claimed: set[ClusterHandler] = set() + for ( + component, + stop_match_groups, + ) in self._config_diagnostic_entity_registry.items(): + for stop_match_grp, matches in stop_match_groups.items(): + sorted_matches = sorted(matches, key=WEIGHT_ATTR, reverse=True) + for match in sorted_matches: + if match.strict_matched( + manufacturer, model, cluster_handlers, quirk_id + ): + claimed = match.claim_cluster_handlers(cluster_handlers) + for ent_class in stop_match_groups[stop_match_grp][match]: + ent_n_cluster_handlers = EntityClassAndClusterHandlers( + ent_class, claimed + ) + result[component].append(ent_n_cluster_handlers) + all_claimed |= set(claimed) + if stop_match_grp: + break + + return result, list(all_claimed) + + def get_group_entity(self, component: str) -> type[ZhaGroupEntity] | None: + """Match a ZHA group to a ZHA Entity class.""" + return self._group_registry.get(component) + + def strict_match( + self, + component: Platform, + cluster_handler_names: set[str] | str | None = None, + generic_ids: set[str] | str | None = None, + manufacturers: Callable | set[str] | str | None = None, + models: Callable | set[str] | str | None = None, + aux_cluster_handlers: Callable | set[str] | str | None = None, + quirk_ids: set[str] | str | None = None, + ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: + """Decorate a strict match rule.""" + + rule = MatchRule( + cluster_handler_names, + generic_ids, + manufacturers, + models, + aux_cluster_handlers, + quirk_ids, + ) + + def decorator(zha_ent: _ZhaEntityT) -> _ZhaEntityT: + """Register a strict match rule. + + All non-empty fields of a match rule must match. + """ + self._strict_registry[component][rule] = zha_ent + return zha_ent + + return decorator + + def multipass_match( + self, + component: Platform, + cluster_handler_names: set[str] | str | None = None, + generic_ids: set[str] | str | None = None, + manufacturers: Callable | set[str] | str | None = None, + models: Callable | set[str] | str | None = None, + aux_cluster_handlers: Callable | set[str] | str | None = None, + stop_on_match_group: int | str | None = None, + quirk_ids: set[str] | str | None = None, + ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: + """Decorate a loose match rule.""" + + rule = MatchRule( + cluster_handler_names, + generic_ids, + manufacturers, + models, + aux_cluster_handlers, + quirk_ids, + ) + + def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: + """Register a loose match rule. + + All non empty fields of a match rule must match. + """ + # group the rules by cluster handlers + self._multi_entity_registry[component][stop_on_match_group][rule].append( + zha_entity + ) + return zha_entity + + return decorator + + def config_diagnostic_match( + self, + component: Platform, + cluster_handler_names: set[str] | str | None = None, + generic_ids: set[str] | str | None = None, + manufacturers: Callable | set[str] | str | None = None, + models: Callable | set[str] | str | None = None, + aux_cluster_handlers: Callable | set[str] | str | None = None, + stop_on_match_group: int | str | None = None, + quirk_ids: set[str] | str | None = None, + ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: + """Decorate a loose match rule.""" + + rule = MatchRule( + cluster_handler_names, + generic_ids, + manufacturers, + models, + aux_cluster_handlers, + quirk_ids, + ) + + def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: + """Register a loose match rule. + + All non-empty fields of a match rule must match. + """ + # group the rules by cluster handlers + self._config_diagnostic_entity_registry[component][stop_on_match_group][ + rule + ].append(zha_entity) + return zha_entity + + return decorator + + def group_match( + self, component: Platform + ) -> Callable[[_ZhaGroupEntityT], _ZhaGroupEntityT]: + """Decorate a group match rule.""" + + def decorator(zha_ent: _ZhaGroupEntityT) -> _ZhaGroupEntityT: + """Register a group match rule.""" + self._group_registry[component] = zha_ent + return zha_ent + + return decorator + + def prevent_entity_creation(self, platform: Platform, ieee: EUI64, key: str): + """Return True if the entity should not be created.""" + platform_restrictions = self.single_device_matches[platform] + device_restrictions = platform_restrictions[ieee] + if key in device_restrictions: + return True + device_restrictions.append(key) + return False + + def clean_up(self) -> None: + """Clean up post discovery.""" + self.single_device_matches = collections.defaultdict( + lambda: collections.defaultdict(list) + ) + + +ZHA_ENTITIES = ZHAEntityRegistry() diff --git a/zha/cover.py b/zha/cover.py new file mode 100644 index 00000000..1c1868ca --- /dev/null +++ b/zha/cover.py @@ -0,0 +1,487 @@ +"""Support for ZHA covers.""" + +from __future__ import annotations + +import asyncio +import functools +import logging +from typing import TYPE_CHECKING, Any, cast + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + ATTR_TILT_POSITION, + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + Platform, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from zigpy.zcl.clusters.closures import WindowCovering as WindowCoveringCluster +from zigpy.zcl.foundation import Status + +from .core import discovery +from .core.cluster_handlers.closures import WindowCoveringClusterHandler +from .core.const import ( + CLUSTER_HANDLER_COVER, + CLUSTER_HANDLER_LEVEL, + CLUSTER_HANDLER_ON_OFF, + CLUSTER_HANDLER_SHADE, + SIGNAL_ADD_ENTITIES, + SIGNAL_ATTR_UPDATED, + SIGNAL_SET_LEVEL, +) +from .core.helpers import get_zha_data +from .core.registries import ZHA_ENTITIES +from .entity import ZhaEntity + +if TYPE_CHECKING: + from .core.cluster_handlers import ClusterHandler + from .core.device import ZHADevice + +_LOGGER = logging.getLogger(__name__) + +MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.COVER) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Zigbee Home Automation cover from config entry.""" + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.COVER] + + unsub = async_dispatcher_connect( + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), + ) + config_entry.async_on_unload(unsub) + + +WCAttrs = WindowCoveringCluster.AttributeDefs +WCT = WindowCoveringCluster.WindowCoveringType +WCCS = WindowCoveringCluster.ConfigStatus + +ZCL_TO_COVER_DEVICE_CLASS = { + WCT.Awning: CoverDeviceClass.AWNING, + WCT.Drapery: CoverDeviceClass.CURTAIN, + WCT.Projector_screen: CoverDeviceClass.SHADE, + WCT.Rollershade: CoverDeviceClass.SHADE, + WCT.Rollershade_two_motors: CoverDeviceClass.SHADE, + WCT.Rollershade_exterior: CoverDeviceClass.SHADE, + WCT.Rollershade_exterior_two_motors: CoverDeviceClass.SHADE, + WCT.Shutter: CoverDeviceClass.SHUTTER, + WCT.Tilt_blind_tilt_only: CoverDeviceClass.BLIND, + WCT.Tilt_blind_tilt_and_lift: CoverDeviceClass.BLIND, +} + + +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_COVER) +class ZhaCover(ZhaEntity, CoverEntity): + """Representation of a ZHA cover.""" + + _attr_translation_key: str = "cover" + + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> None: + """Init this cover.""" + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + cluster_handler = self.cluster_handlers.get(CLUSTER_HANDLER_COVER) + assert cluster_handler + self._cover_cluster_handler: WindowCoveringClusterHandler = cast( + WindowCoveringClusterHandler, cluster_handler + ) + if self._cover_cluster_handler.window_covering_type: + self._attr_device_class: CoverDeviceClass | None = ( + ZCL_TO_COVER_DEVICE_CLASS.get( + self._cover_cluster_handler.window_covering_type + ) + ) + self._attr_supported_features: CoverEntityFeature = ( + self._determine_supported_features() + ) + self._target_lift_position: int | None = None + self._target_tilt_position: int | None = None + self._determine_initial_state() + + def _determine_supported_features(self) -> CoverEntityFeature: + """Determine the supported cover features.""" + supported_features: CoverEntityFeature = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + if ( + self._cover_cluster_handler.window_covering_type + and self._cover_cluster_handler.window_covering_type + in ( + WCT.Shutter, + WCT.Tilt_blind_tilt_only, + WCT.Tilt_blind_tilt_and_lift, + ) + ): + supported_features |= CoverEntityFeature.SET_TILT_POSITION + supported_features |= CoverEntityFeature.OPEN_TILT + supported_features |= CoverEntityFeature.CLOSE_TILT + supported_features |= CoverEntityFeature.STOP_TILT + return supported_features + + def _determine_initial_state(self) -> None: + """Determine the initial state of the cover.""" + if ( + self._cover_cluster_handler.window_covering_type + and self._cover_cluster_handler.window_covering_type + in ( + WCT.Shutter, + WCT.Tilt_blind_tilt_only, + WCT.Tilt_blind_tilt_and_lift, + ) + ): + self._determine_state( + self.current_cover_tilt_position, is_lift_update=False + ) + if ( + self._cover_cluster_handler.window_covering_type + == WCT.Tilt_blind_tilt_and_lift + ): + state = self._state + self._determine_state(self.current_cover_position) + if state == STATE_OPEN and self._state == STATE_CLOSED: + # let the tilt state override the lift state + self._state = STATE_OPEN + else: + self._determine_state(self.current_cover_position) + + def _determine_state(self, position_or_tilt, is_lift_update=True) -> None: + """Determine the state of the cover. + + In HA None is unknown, 0 is closed, 100 is fully open. + In ZCL 0 is fully open, 100 is fully closed. + Keep in mind the values have already been flipped to match HA + in the WindowCovering cluster handler + """ + if is_lift_update: + target = self._target_lift_position + current = self.current_cover_position + else: + target = self._target_tilt_position + current = self.current_cover_tilt_position + + if position_or_tilt == 100: + self._state = STATE_CLOSED + return + if target is not None and target != current: + # we are mid transition and shouldn't update the state + return + self._state = STATE_OPEN + + async def async_added_to_hass(self) -> None: + """Run when the cover entity is about to be added to hass.""" + await super().async_added_to_hass() + self.async_accept_signal( + self._cover_cluster_handler, SIGNAL_ATTR_UPDATED, self.zcl_attribute_updated + ) + + @property + def is_closed(self) -> bool | None: + """Return True if the cover is closed. + + In HA None is unknown, 0 is closed, 100 is fully open. + In ZCL 0 is fully open, 100 is fully closed. + Keep in mind the values have already been flipped to match HA + in the WindowCovering cluster handler + """ + if self.current_cover_position is None: + return None + return self.current_cover_position == 0 + + @property + def is_opening(self) -> bool: + """Return if the cover is opening or not.""" + return self._state == STATE_OPENING + + @property + def is_closing(self) -> bool: + """Return if the cover is closing or not.""" + return self._state == STATE_CLOSING + + @property + def current_cover_position(self) -> int | None: + """Return the current position of ZHA cover. + + In HA None is unknown, 0 is closed, 100 is fully open. + In ZCL 0 is fully open, 100 is fully closed. + Keep in mind the values have already been flipped to match HA + in the WindowCovering cluster handler + """ + return self._cover_cluster_handler.current_position_lift_percentage + + @property + def current_cover_tilt_position(self) -> int | None: + """Return the current tilt position of the cover.""" + return self._cover_cluster_handler.current_position_tilt_percentage + + @callback + def zcl_attribute_updated(self, attr_id, attr_name, value): + """Handle position update from cluster handler.""" + if attr_id in ( + WCAttrs.current_position_lift_percentage.id, + WCAttrs.current_position_tilt_percentage.id, + ): + value = ( + self.current_cover_position + if attr_id == WCAttrs.current_position_lift_percentage.id + else self.current_cover_tilt_position + ) + self._determine_state( + value, + is_lift_update=attr_id == WCAttrs.current_position_lift_percentage.id, + ) + self.async_write_ha_state() + + @callback + def async_update_state(self, state): + """Handle state update from HA operations below.""" + _LOGGER.debug("async_update_state=%s", state) + self._state = state + self.async_write_ha_state() + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + res = await self._cover_cluster_handler.up_open() + if res[1] is not Status.SUCCESS: + raise HomeAssistantError(f"Failed to open cover: {res[1]}") + self.async_update_state(STATE_OPENING) + + async def async_open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover tilt.""" + # 0 is open in ZCL + res = await self._cover_cluster_handler.go_to_tilt_percentage(0) + if res[1] is not Status.SUCCESS: + raise HomeAssistantError(f"Failed to open cover tilt: {res[1]}") + self.async_update_state(STATE_OPENING) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + res = await self._cover_cluster_handler.down_close() + if res[1] is not Status.SUCCESS: + raise HomeAssistantError(f"Failed to close cover: {res[1]}") + self.async_update_state(STATE_CLOSING) + + async def async_close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover tilt.""" + # 100 is closed in ZCL + res = await self._cover_cluster_handler.go_to_tilt_percentage(100) + if res[1] is not Status.SUCCESS: + raise HomeAssistantError(f"Failed to close cover tilt: {res[1]}") + self.async_update_state(STATE_CLOSING) + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + self._target_lift_position = kwargs[ATTR_POSITION] + assert self._target_lift_position is not None + assert self.current_cover_position is not None + # the 100 - value is because we need to invert the value before giving it to ZCL + res = await self._cover_cluster_handler.go_to_lift_percentage( + 100 - self._target_lift_position + ) + if res[1] is not Status.SUCCESS: + raise HomeAssistantError(f"Failed to set cover position: {res[1]}") + self.async_update_state( + STATE_CLOSING + if self._target_lift_position < self.current_cover_position + else STATE_OPENING + ) + + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the cover tilt to a specific position.""" + self._target_tilt_position = kwargs[ATTR_TILT_POSITION] + assert self._target_tilt_position is not None + assert self.current_cover_tilt_position is not None + # the 100 - value is because we need to invert the value before giving it to ZCL + res = await self._cover_cluster_handler.go_to_tilt_percentage( + 100 - self._target_tilt_position + ) + if res[1] is not Status.SUCCESS: + raise HomeAssistantError(f"Failed to set cover tilt position: {res[1]}") + self.async_update_state( + STATE_CLOSING + if self._target_tilt_position < self.current_cover_tilt_position + else STATE_OPENING + ) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + res = await self._cover_cluster_handler.stop() + if res[1] is not Status.SUCCESS: + raise HomeAssistantError(f"Failed to stop cover: {res[1]}") + self._target_lift_position = self.current_cover_position + self._determine_state(self.current_cover_position) + self.async_write_ha_state() + + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: + """Stop the cover tilt.""" + res = await self._cover_cluster_handler.stop() + if res[1] is not Status.SUCCESS: + raise HomeAssistantError(f"Failed to stop cover: {res[1]}") + self._target_tilt_position = self.current_cover_tilt_position + self._determine_state(self.current_cover_tilt_position, is_lift_update=False) + self.async_write_ha_state() + + +@MULTI_MATCH( + cluster_handler_names={ + CLUSTER_HANDLER_LEVEL, + CLUSTER_HANDLER_ON_OFF, + CLUSTER_HANDLER_SHADE, + } +) +class Shade(ZhaEntity, CoverEntity): + """ZHA Shade.""" + + _attr_device_class = CoverDeviceClass.SHADE + _attr_translation_key: str = "shade" + + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs, + ) -> None: + """Initialize the ZHA light.""" + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._on_off_cluster_handler = self.cluster_handlers[CLUSTER_HANDLER_ON_OFF] + self._level_cluster_handler = self.cluster_handlers[CLUSTER_HANDLER_LEVEL] + self._position: int | None = None + self._is_open: bool | None = None + + @property + def current_cover_position(self) -> int | None: + """Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + return self._position + + @property + def is_closed(self) -> bool | None: + """Return True if shade is closed.""" + if self._is_open is None: + return None + return not self._is_open + + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + await super().async_added_to_hass() + self.async_accept_signal( + self._on_off_cluster_handler, + SIGNAL_ATTR_UPDATED, + self.async_set_open_closed, + ) + self.async_accept_signal( + self._level_cluster_handler, SIGNAL_SET_LEVEL, self.async_set_level + ) + + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + self._is_open = last_state.state == STATE_OPEN + if ATTR_CURRENT_POSITION in last_state.attributes: + self._position = last_state.attributes[ATTR_CURRENT_POSITION] + + @callback + def async_set_open_closed(self, attr_id: int, attr_name: str, value: bool) -> None: + """Set open/closed state.""" + self._is_open = bool(value) + self.async_write_ha_state() + + @callback + def async_set_level(self, value: int) -> None: + """Set the reported position.""" + value = max(0, min(255, value)) + self._position = int(value * 100 / 255) + self.async_write_ha_state() + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the window cover.""" + res = await self._on_off_cluster_handler.on() + if res[1] != Status.SUCCESS: + raise HomeAssistantError(f"Failed to open cover: {res[1]}") + + self._is_open = True + self.async_write_ha_state() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the window cover.""" + res = await self._on_off_cluster_handler.off() + if res[1] != Status.SUCCESS: + raise HomeAssistantError(f"Failed to close cover: {res[1]}") + + self._is_open = False + self.async_write_ha_state() + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the roller shutter to a specific position.""" + new_pos = kwargs[ATTR_POSITION] + res = await self._level_cluster_handler.move_to_level_with_on_off( + new_pos * 255 / 100, 1 + ) + + if res[1] != Status.SUCCESS: + raise HomeAssistantError(f"Failed to set cover position: {res[1]}") + + self._position = new_pos + self.async_write_ha_state() + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + res = await self._level_cluster_handler.stop() + if res[1] != Status.SUCCESS: + raise HomeAssistantError(f"Failed to stop cover: {res[1]}") + + +@MULTI_MATCH( + cluster_handler_names={CLUSTER_HANDLER_LEVEL, CLUSTER_HANDLER_ON_OFF}, + manufacturers="Keen Home Inc", +) +class KeenVent(Shade): + """Keen vent cover.""" + + _attr_device_class = CoverDeviceClass.DAMPER + _attr_translation_key: str = "keen_vent" + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + position = self._position or 100 + await asyncio.gather( + self._level_cluster_handler.move_to_level_with_on_off( + position * 255 / 100, 1 + ), + self._on_off_cluster_handler.on(), + ) + + self._is_open = True + self._position = position + self.async_write_ha_state() diff --git a/zha/device_action.py b/zha/device_action.py new file mode 100644 index 00000000..b52f5a47 --- /dev/null +++ b/zha/device_action.py @@ -0,0 +1,232 @@ +"""Provides device actions for ZHA devices.""" + +from __future__ import annotations + +from typing import Any + +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 +from homeassistant.helpers.typing import ConfigType, TemplateVarsType +import voluptuous as vol + +from . import DOMAIN +from .core.cluster_handlers.manufacturerspecific import ( + AllLEDEffectType, + SingleLEDEffectType, +) +from .core.const import CLUSTER_HANDLER_IAS_WD, CLUSTER_HANDLER_INOVELLI +from .core.helpers import async_get_zha_device +from .websocket_api import SERVICE_WARNING_DEVICE_SQUAWK, SERVICE_WARNING_DEVICE_WARN + +# mypy: disallow-any-generics + +ACTION_SQUAWK = "squawk" +ACTION_WARN = "warn" +ATTR_DATA = "data" +ATTR_IEEE = "ieee" +CONF_ZHA_ACTION_TYPE = "zha_action_type" +ZHA_ACTION_TYPE_SERVICE_CALL = "service_call" +ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND = "cluster_handler_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"): AllLEDEffectType.__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("effect_type"): SingleLEDEffectType.__getitem__, + vol.Required("led_number"): vol.All(vol.Coerce(int), vol.Range(0, 6)), + } +) + +ACTION_SCHEMA_MAP = { + INOVELLI_ALL_LED_EFFECT: INOVELLI_ALL_LED_EFFECT_SCHEMA, + INOVELLI_INDIVIDUAL_LED_EFFECT: INOVELLI_INDIVIDUAL_LED_EFFECT_SCHEMA, +} + +ACTION_SCHEMA = vol.Any( + INOVELLI_ALL_LED_EFFECT_SCHEMA, + INOVELLI_INDIVIDUAL_LED_EFFECT_SCHEMA, + DEFAULT_ACTION_SCHEMA, +) + +DEVICE_ACTIONS = { + CLUSTER_HANDLER_IAS_WD: [ + {CONF_TYPE: ACTION_SQUAWK, CONF_DOMAIN: DOMAIN}, + {CONF_TYPE: ACTION_WARN, CONF_DOMAIN: DOMAIN}, + ], + CLUSTER_HANDLER_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_CLUSTER_HANDLER_COMMAND, + INOVELLI_INDIVIDUAL_LED_EFFECT: ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND, +} + +DEVICE_ACTION_SCHEMAS = { + INOVELLI_ALL_LED_EFFECT: vol.Schema( + { + vol.Required("effect_type"): vol.In(AllLEDEffectType.__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(0, 6)), + vol.Required("effect_type"): vol.In(SingleLEDEffectType.__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 = { + ACTION_SQUAWK: SERVICE_WARNING_DEVICE_SQUAWK, + ACTION_WARN: SERVICE_WARNING_DEVICE_WARN, +} + +CLUSTER_HANDLER_MAPPINGS = { + INOVELLI_ALL_LED_EFFECT: CLUSTER_HANDLER_INOVELLI, + INOVELLI_INDIVIDUAL_LED_EFFECT: CLUSTER_HANDLER_INOVELLI, +} + + +async def async_call_action_from_config( + hass: HomeAssistant, + config: ConfigType, + variables: TemplateVarsType, + context: Context | None, +) -> None: + """Perform an action based on configuration.""" + await ZHA_ACTION_TYPES[DEVICE_ACTION_TYPES[config[CONF_TYPE]]]( + hass, config, variables, context + ) + + +async def async_validate_action_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + schema = ACTION_SCHEMA_MAP.get(config[CONF_TYPE], DEFAULT_ACTION_SCHEMA) + config = schema(config) + return config + + +async def async_get_actions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: + """List device actions.""" + try: + zha_device = async_get_zha_device(hass, device_id) + except (KeyError, AttributeError): + return [] + cluster_handlers = [ + ch.name + for endpoint in zha_device.endpoints.values() + for ch in endpoint.claimed_cluster_handlers.values() + ] + actions = [ + action + for cluster_handler, cluster_handler_actions in DEVICE_ACTIONS.items() + for action in cluster_handler_actions + if cluster_handler in cluster_handlers + ] + for action in actions: + action[CONF_DEVICE_ID] = device_id + 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], + variables: TemplateVarsType, + context: Context | None, +) -> None: + action_type = config[CONF_TYPE] + service_name = SERVICE_NAMES[action_type] + try: + zha_device = async_get_zha_device(hass, config[CONF_DEVICE_ID]) + except (KeyError, AttributeError): + return + + service_data = {ATTR_IEEE: str(zha_device.ieee)} + + await hass.services.async_call( + DOMAIN, service_name, service_data, blocking=True, context=context + ) + + +async def _execute_cluster_handler_command_based_action( + hass: HomeAssistant, + config: dict[str, Any], + variables: TemplateVarsType, + context: Context | None, +) -> None: + action_type = config[CONF_TYPE] + cluster_handler_name = CLUSTER_HANDLER_MAPPINGS[action_type] + try: + zha_device = async_get_zha_device(hass, config[CONF_DEVICE_ID]) + except (KeyError, AttributeError): + return + + action_cluster_handler = None + for endpoint in zha_device.endpoints.values(): + for cluster_handler in endpoint.all_cluster_handlers.values(): + if cluster_handler.name == cluster_handler_name: + action_cluster_handler = cluster_handler + break + + if action_cluster_handler is None: + raise InvalidDeviceAutomationConfig( + f"Unable to execute cluster handler action - cluster handler: {cluster_handler_name} action:" + f" {action_type}" + ) + + if not hasattr(action_cluster_handler, action_type): + raise InvalidDeviceAutomationConfig( + f"Unable to execute cluster handler - cluster handler: {cluster_handler_name} action:" + f" {action_type}" + ) + + await getattr(action_cluster_handler, action_type)(**config) + + +ZHA_ACTION_TYPES = { + ZHA_ACTION_TYPE_SERVICE_CALL: _execute_service_based_action, + ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND: _execute_cluster_handler_command_based_action, +} diff --git a/zha/device_tracker.py b/zha/device_tracker.py new file mode 100644 index 00000000..9c96fd0e --- /dev/null +++ b/zha/device_tracker.py @@ -0,0 +1,131 @@ +"""Support for the ZHA platform.""" + +from __future__ import annotations + +import functools +import time + +from homeassistant.components.device_tracker import ScannerEntity, SourceType +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .core import discovery +from .core.const import ( + CLUSTER_HANDLER_POWER_CONFIGURATION, + SIGNAL_ADD_ENTITIES, + SIGNAL_ATTR_UPDATED, +) +from .core.helpers import get_zha_data +from .core.registries import ZHA_ENTITIES +from .entity import ZhaEntity +from .sensor import Battery + +STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.DEVICE_TRACKER) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Zigbee Home Automation device tracker from config entry.""" + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.DEVICE_TRACKER] + + unsub = async_dispatcher_connect( + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), + ) + config_entry.async_on_unload(unsub) + + +@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_POWER_CONFIGURATION) +class ZHADeviceScannerEntity(ScannerEntity, ZhaEntity): + """Represent a tracked device.""" + + _attr_should_poll = True # BaseZhaEntity defaults to False + _attr_name: str = "Device scanner" + + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): + """Initialize the ZHA device tracker.""" + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._battery_cluster_handler = self.cluster_handlers.get( + CLUSTER_HANDLER_POWER_CONFIGURATION + ) + self._connected = False + self._keepalive_interval = 60 + self._battery_level = None + + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + await super().async_added_to_hass() + if self._battery_cluster_handler: + self.async_accept_signal( + self._battery_cluster_handler, + SIGNAL_ATTR_UPDATED, + self.async_battery_percentage_remaining_updated, + ) + + async def async_update(self) -> None: + """Handle polling.""" + if self.zha_device.last_seen is None: + self._connected = False + else: + difference = time.time() - self.zha_device.last_seen + if difference > self._keepalive_interval: + self._connected = False + else: + self._connected = True + + @property + def is_connected(self): + """Return true if the device is connected to the network.""" + return self._connected + + @property + def source_type(self) -> SourceType: + """Return the source type, eg gps or router, of the device.""" + return SourceType.ROUTER + + @callback + def async_battery_percentage_remaining_updated(self, attr_id, attr_name, value): + """Handle tracking.""" + if attr_name != "battery_percentage_remaining": + return + self.debug("battery_percentage_remaining updated: %s", value) + self._connected = True + self._battery_level = Battery.formatter(value) + self.async_write_ha_state() + + @property + def battery_level(self): + """Return the battery level of the device. + + Percentage from 0-100. + """ + return self._battery_level + + @property # type: ignore[misc] + def device_info( + self, + ) -> DeviceInfo: + """Return device info.""" + # We opt ZHA device tracker back into overriding this method because + # it doesn't track IP-based devices. + # Call Super because ScannerEntity overrode it. + # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 + return ZhaEntity.device_info.fget(self) # type: ignore[attr-defined] + + @property + def unique_id(self) -> str: + """Return unique ID.""" + # Call Super because ScannerEntity overrode it. + # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 + return ZhaEntity.unique_id.fget(self) # type: ignore[attr-defined] diff --git a/zha/device_trigger.py b/zha/device_trigger.py new file mode 100644 index 00000000..355c7207 --- /dev/null +++ b/zha/device_trigger.py @@ -0,0 +1,112 @@ +"""Provides device automations for ZHA devices that emit events.""" + +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.components.homeassistant.triggers import event as event_trigger +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.typing import ConfigType +import voluptuous as vol + +from . import DOMAIN as ZHA_DOMAIN +from .core.const import ZHA_EVENT +from .core.helpers import async_get_zha_device, get_zha_data + +CONF_SUBTYPE = "subtype" +DEVICE = "device" +DEVICE_IEEE = "device_ieee" + +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + {vol.Required(CONF_TYPE): str, vol.Required(CONF_SUBTYPE): str} +) + + +def _get_device_trigger_data(hass: HomeAssistant, device_id: str) -> tuple[str, dict]: + """Get device trigger data for a device, falling back to the cache if possible.""" + + # First, try checking to see if the device itself is accessible + try: + zha_device = async_get_zha_device(hass, device_id) + except ValueError: + pass + else: + return str(zha_device.ieee), zha_device.device_automation_triggers + + # If not, check the trigger cache but allow any `KeyError`s to propagate + return get_zha_data(hass).device_trigger_cache[device_id] + + +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + config = TRIGGER_SCHEMA(config) + + # Trigger validation will not occur if the config entry is not loaded + _, triggers = _get_device_trigger_data(hass, config[CONF_DEVICE_ID]) + + trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) + if trigger not in triggers: + raise InvalidDeviceAutomationConfig(f"device does not have trigger {trigger}") + + return config + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Listen for state changes based on configuration.""" + + try: + ieee, triggers = _get_device_trigger_data(hass, config[CONF_DEVICE_ID]) + except KeyError as err: + raise HomeAssistantError( + f"Unable to get zha device {config[CONF_DEVICE_ID]}" + ) from err + + trigger_key: tuple[str, str] = (config[CONF_TYPE], config[CONF_SUBTYPE]) + + if trigger_key not in triggers: + raise HomeAssistantError(f"Unable to find trigger {trigger_key}") + + event_config = event_trigger.TRIGGER_SCHEMA( + { + event_trigger.CONF_PLATFORM: "event", + event_trigger.CONF_EVENT_TYPE: ZHA_EVENT, + event_trigger.CONF_EVENT_DATA: {DEVICE_IEEE: ieee, **triggers[trigger_key]}, + } + ) + return await event_trigger.async_attach_trigger( + hass, event_config, action, trigger_info, platform_type="device" + ) + + +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: + """List device triggers. + + Make sure the device supports device automations and return the trigger list. + """ + try: + _, triggers = _get_device_trigger_data(hass, device_id) + except KeyError as err: + raise InvalidDeviceAutomationConfig from err + + return [ + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: ZHA_DOMAIN, + CONF_PLATFORM: DEVICE, + CONF_TYPE: trigger, + CONF_SUBTYPE: subtype, + } + for trigger, subtype in triggers + ] diff --git a/zha/diagnostics.py b/zha/diagnostics.py new file mode 100644 index 00000000..8a63ee52 --- /dev/null +++ b/zha/diagnostics.py @@ -0,0 +1,175 @@ +"""Provides diagnostics for ZHA.""" + +from __future__ import annotations + +import dataclasses +from importlib.metadata import version +from typing import Any + +from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ID, CONF_NAME, CONF_UNIQUE_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from zigpy.config import CONF_NWK_EXTENDED_PAN_ID +from zigpy.profiles import PROFILES +from zigpy.types import Channels +from zigpy.zcl import Cluster + +from .core.const import ( + ATTR_ATTRIBUTE_NAME, + ATTR_DEVICE_TYPE, + ATTR_IEEE, + ATTR_IN_CLUSTERS, + ATTR_OUT_CLUSTERS, + ATTR_PROFILE_ID, + ATTR_VALUE, + CONF_ALARM_MASTER_CODE, + UNKNOWN, +) +from .core.device import ZHADevice +from .core.gateway import ZHAGateway +from .core.helpers import async_get_zha_device, get_zha_data, get_zha_gateway + +KEYS_TO_REDACT = { + ATTR_IEEE, + CONF_UNIQUE_ID, + CONF_ALARM_MASTER_CODE, + "network_key", + CONF_NWK_EXTENDED_PAN_ID, + "partner_ieee", +} + +ATTRIBUTES = "attributes" +CLUSTER_DETAILS = "cluster_details" +UNSUPPORTED_ATTRIBUTES = "unsupported_attributes" + + +def shallow_asdict(obj: Any) -> dict: + """Return a shallow copy of a dataclass as a dict.""" + if hasattr(obj, "__dataclass_fields__"): + result = {} + + for field in dataclasses.fields(obj): + result[field.name] = shallow_asdict(getattr(obj, field.name)) + + return result + if hasattr(obj, "as_dict"): + return obj.as_dict() + return obj + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + zha_data = get_zha_data(hass) + gateway: ZHAGateway = get_zha_gateway(hass) + app = gateway.application_controller + + energy_scan = await app.energy_scan( + channels=Channels.ALL_CHANNELS, duration_exp=4, count=1 + ) + + return async_redact_data( + { + "config": zha_data.yaml_config, + "config_entry": config_entry.as_dict(), + "application_state": shallow_asdict(app.state), + "energy_scan": { + channel: 100 * energy / 255 for channel, energy in energy_scan.items() + }, + "versions": { + "bellows": version("bellows"), + "zigpy": version("zigpy"), + "zigpy_deconz": version("zigpy-deconz"), + "zigpy_xbee": version("zigpy-xbee"), + "zigpy_znp": version("zigpy_znp"), + "zigpy_zigate": version("zigpy-zigate"), + "zhaquirks": version("zha-quirks"), + }, + "devices": [ + { + "manufacturer": device.manufacturer, + "model": device.model, + "logical_type": device.device_type, + } + for device in gateway.devices.values() + ], + }, + KEYS_TO_REDACT, + ) + + +async def async_get_device_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry, device: dr.DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device.""" + zha_device: ZHADevice = async_get_zha_device(hass, device.id) + device_info: dict[str, Any] = zha_device.zha_device_info + device_info[CLUSTER_DETAILS] = get_endpoint_cluster_attr_data(zha_device) + return async_redact_data(device_info, KEYS_TO_REDACT) + + +def get_endpoint_cluster_attr_data(zha_device: ZHADevice) -> dict: + """Return endpoint cluster attribute data.""" + cluster_details = {} + for ep_id, endpoint in zha_device.device.endpoints.items(): + if ep_id == 0: + continue + endpoint_key = ( + f"{PROFILES.get(endpoint.profile_id).DeviceType(endpoint.device_type).name}" + if PROFILES.get(endpoint.profile_id) is not None + and endpoint.device_type is not None + else UNKNOWN + ) + cluster_details[ep_id] = { + ATTR_DEVICE_TYPE: { + CONF_NAME: endpoint_key, + CONF_ID: endpoint.device_type, + }, + ATTR_PROFILE_ID: endpoint.profile_id, + ATTR_IN_CLUSTERS: { + f"0x{cluster_id:04x}": { + "endpoint_attribute": cluster.ep_attribute, + **get_cluster_attr_data(cluster), + } + for cluster_id, cluster in endpoint.in_clusters.items() + }, + ATTR_OUT_CLUSTERS: { + f"0x{cluster_id:04x}": { + "endpoint_attribute": cluster.ep_attribute, + **get_cluster_attr_data(cluster), + } + for cluster_id, cluster in endpoint.out_clusters.items() + }, + } + return cluster_details + + +def get_cluster_attr_data(cluster: Cluster) -> dict: + """Return cluster attribute data.""" + unsupported_attributes = {} + for u_attr in cluster.unsupported_attributes: + try: + u_attr_def = cluster.find_attribute(u_attr) + unsupported_attributes[f"0x{u_attr_def.id:04x}"] = { + ATTR_ATTRIBUTE_NAME: u_attr_def.name + } + except KeyError: + if isinstance(u_attr, int): + unsupported_attributes[f"0x{u_attr:04x}"] = {} + else: + unsupported_attributes[u_attr] = {} + + return { + ATTRIBUTES: { + f"0x{attr_id:04x}": { + ATTR_ATTRIBUTE_NAME: attr_def.name, + ATTR_VALUE: attr_value, + } + for attr_id, attr_def in cluster.attributes.items() + if (attr_value := cluster.get(attr_def.name)) is not None + }, + UNSUPPORTED_ATTRIBUTES: unsupported_attributes, + } diff --git a/zha/entity.py b/zha/entity.py new file mode 100644 index 00000000..a302bc4d --- /dev/null +++ b/zha/entity.py @@ -0,0 +1,361 @@ +"""Entity for Zigbee Home Automation.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import functools +import logging +from typing import TYPE_CHECKING, Any, Self + +from homeassistant.const import ATTR_NAME, EntityCategory +from homeassistant.core import CALLBACK_TYPE, Event, callback +from homeassistant.helpers import entity +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE, DeviceInfo +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.restore_state import RestoreEntity +from zigpy.quirks.v2 import EntityMetadata, EntityType + +from .core.const import ( + ATTR_MANUFACTURER, + ATTR_MODEL, + DOMAIN, + SIGNAL_GROUP_ENTITY_REMOVED, + SIGNAL_GROUP_MEMBERSHIP_CHANGE, + SIGNAL_REMOVE, +) +from .core.helpers import LogMixin, get_zha_gateway + +if TYPE_CHECKING: + from .core.cluster_handlers import ClusterHandler + from .core.device import ZHADevice + +_LOGGER = logging.getLogger(__name__) + +ENTITY_SUFFIX = "entity_suffix" +DEFAULT_UPDATE_GROUP_FROM_CHILD_DELAY = 0.5 + + +class BaseZhaEntity(LogMixin, entity.Entity): + """A base class for ZHA entities.""" + + _unique_id_suffix: str | None = None + """suffix to add to the unique_id of the entity. Used for multi + entities using the same cluster handler/cluster id for the entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, unique_id: str, zha_device: ZHADevice, **kwargs: Any) -> None: + """Init ZHA entity.""" + self._unique_id: str = unique_id + if self._unique_id_suffix: + self._unique_id += f"-{self._unique_id_suffix}" + self._state: Any = None + self._extra_state_attributes: dict[str, Any] = {} + self._zha_device = zha_device + self._unsubs: list[Callable[[], None]] = [] + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._unique_id + + @property + def zha_device(self) -> ZHADevice: + """Return the ZHA device this entity is attached to.""" + return self._zha_device + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return device specific state attributes.""" + return self._extra_state_attributes + + @property + def device_info(self) -> DeviceInfo: + """Return a device description for device registry.""" + zha_device_info = self._zha_device.device_info + ieee = zha_device_info["ieee"] + + zha_gateway = get_zha_gateway(self.hass) + + return DeviceInfo( + connections={(CONNECTION_ZIGBEE, ieee)}, + identifiers={(DOMAIN, ieee)}, + manufacturer=zha_device_info[ATTR_MANUFACTURER], + model=zha_device_info[ATTR_MODEL], + name=zha_device_info[ATTR_NAME], + via_device=(DOMAIN, zha_gateway.state.node_info.ieee), + ) + + @callback + def async_state_changed(self) -> None: + """Entity state changed.""" + self.async_write_ha_state() + + @callback + def async_update_state_attribute(self, key: str, value: Any) -> None: + """Update a single device state attribute.""" + self._extra_state_attributes.update({key: value}) + self.async_write_ha_state() + + @callback + def async_set_state(self, attr_id: int, attr_name: str, value: Any) -> None: + """Set the entity state.""" + + async def async_will_remove_from_hass(self) -> None: + """Disconnect entity object when removed.""" + for unsub in self._unsubs[:]: + unsub() + self._unsubs.remove(unsub) + + @callback + def async_accept_signal( + self, + cluster_handler: ClusterHandler | None, + signal: str, + func: Callable[..., Any], + signal_override=False, + ): + """Accept a signal from a cluster handler.""" + unsub = None + if signal_override: + unsub = async_dispatcher_connect(self.hass, signal, func) + else: + assert cluster_handler + unsub = async_dispatcher_connect( + self.hass, f"{cluster_handler.unique_id}_{signal}", func + ) + self._unsubs.append(unsub) + + def log(self, level: int, msg: str, *args, **kwargs): + """Log a message.""" + msg = f"%s: {msg}" + args = (self.entity_id,) + args + _LOGGER.log(level, msg, *args, **kwargs) + + +class ZhaEntity(BaseZhaEntity, RestoreEntity): + """A base class for non group ZHA entities.""" + + remove_future: asyncio.Future[Any] + + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> None: + """Init ZHA entity.""" + super().__init__(unique_id, zha_device, **kwargs) + + self.cluster_handlers: dict[str, ClusterHandler] = {} + for cluster_handler in cluster_handlers: + self.cluster_handlers[cluster_handler.name] = cluster_handler + + @classmethod + def create_entity( + cls, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> Self | None: + """Entity Factory. + + Return entity if it is a supported configuration, otherwise return None + """ + return cls(unique_id, zha_device, cluster_handlers, **kwargs) + + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + if entity_metadata.initially_disabled: + self._attr_entity_registry_enabled_default = False + + if entity_metadata.translation_key: + self._attr_translation_key = entity_metadata.translation_key + + if hasattr(entity_metadata.entity_metadata, "attribute_name"): + if not entity_metadata.translation_key: + self._attr_translation_key = ( + entity_metadata.entity_metadata.attribute_name + ) + self._unique_id_suffix = entity_metadata.entity_metadata.attribute_name + elif hasattr(entity_metadata.entity_metadata, "command_name"): + if not entity_metadata.translation_key: + self._attr_translation_key = ( + entity_metadata.entity_metadata.command_name + ) + self._unique_id_suffix = entity_metadata.entity_metadata.command_name + if entity_metadata.entity_type is EntityType.CONFIG: + self._attr_entity_category = EntityCategory.CONFIG + elif entity_metadata.entity_type is EntityType.DIAGNOSTIC: + self._attr_entity_category = EntityCategory.DIAGNOSTIC + + @property + def available(self) -> bool: + """Return entity availability.""" + return self._zha_device.available + + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + self.remove_future = self.hass.loop.create_future() + self.async_accept_signal( + None, + f"{SIGNAL_REMOVE}_{self.zha_device.ieee}", + functools.partial(self.async_remove, force_remove=True), + signal_override=True, + ) + + if last_state := await self.async_get_last_state(): + self.async_restore_last_state(last_state) + + self.async_accept_signal( + None, + f"{self.zha_device.available_signal}_entity", + self.async_state_changed, + signal_override=True, + ) + self._zha_device.gateway.register_entity_reference( + self._zha_device.ieee, + self.entity_id, + self._zha_device, + self.cluster_handlers, + self.device_info, + self.remove_future, + ) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect entity object when removed.""" + await super().async_will_remove_from_hass() + self.zha_device.gateway.remove_entity_reference(self) + self.remove_future.set_result(True) + + @callback + def async_restore_last_state(self, last_state) -> None: + """Restore previous state.""" + + async def async_update(self) -> None: + """Retrieve latest state.""" + tasks = [ + cluster_handler.async_update() + for cluster_handler in self.cluster_handlers.values() + if hasattr(cluster_handler, "async_update") + ] + if tasks: + await asyncio.gather(*tasks) + + +class ZhaGroupEntity(BaseZhaEntity): + """A base class for ZHA group entities.""" + + # The group name is set in the initializer + _attr_name: str + + def __init__( + self, + entity_ids: list[str], + unique_id: str, + group_id: int, + zha_device: ZHADevice, + **kwargs: Any, + ) -> None: + """Initialize a ZHA group.""" + super().__init__(unique_id, zha_device, **kwargs) + self._available = False + self._group = zha_device.gateway.groups.get(group_id) + self._group_id: int = group_id + self._entity_ids: list[str] = entity_ids + self._async_unsub_state_changed: CALLBACK_TYPE | None = None + self._handled_group_membership = False + self._change_listener_debouncer: Debouncer | None = None + self._update_group_from_child_delay = DEFAULT_UPDATE_GROUP_FROM_CHILD_DELAY + + self._attr_name = self._group.name + + @property + def available(self) -> bool: + """Return entity availability.""" + return self._available + + @classmethod + def create_entity( + cls, + entity_ids: list[str], + unique_id: str, + group_id: int, + zha_device: ZHADevice, + **kwargs: Any, + ) -> Self | None: + """Group Entity Factory. + + Return entity if it is a supported configuration, otherwise return None + """ + return cls(entity_ids, unique_id, group_id, zha_device, **kwargs) + + async def _handle_group_membership_changed(self): + """Handle group membership changed.""" + # Make sure we don't call remove twice as members are removed + if self._handled_group_membership: + return + + self._handled_group_membership = True + await self.async_remove(force_remove=True) + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() + await self.async_update() + + self.async_accept_signal( + None, + f"{SIGNAL_GROUP_MEMBERSHIP_CHANGE}_0x{self._group_id:04x}", + self._handle_group_membership_changed, + signal_override=True, + ) + + if self._change_listener_debouncer is None: + self._change_listener_debouncer = Debouncer( + self.hass, + _LOGGER, + cooldown=self._update_group_from_child_delay, + immediate=False, + function=functools.partial(self.async_update_ha_state, True), + ) + self.async_on_remove(self._change_listener_debouncer.async_cancel) + self._async_unsub_state_changed = async_track_state_change_event( + self.hass, self._entity_ids, self.async_state_changed_listener + ) + + def send_removed_signal(): + async_dispatcher_send( + self.hass, SIGNAL_GROUP_ENTITY_REMOVED, self._group_id + ) + + self.async_on_remove(send_removed_signal) + + @callback + def async_state_changed_listener(self, event: Event[EventStateChangedData]) -> None: + """Handle child updates.""" + # Delay to ensure that we get updates from all members before updating the group + assert self._change_listener_debouncer + self._change_listener_debouncer.async_schedule_call() + + async def async_will_remove_from_hass(self) -> None: + """Handle removal from Home Assistant.""" + await super().async_will_remove_from_hass() + if self._async_unsub_state_changed is not None: + self._async_unsub_state_changed() + self._async_unsub_state_changed = None + + async def async_update(self) -> None: + """Update the state of the group entity.""" diff --git a/zha/fan.py b/zha/fan.py new file mode 100644 index 00000000..35dda778 --- /dev/null +++ b/zha/fan.py @@ -0,0 +1,315 @@ +"""Fans on Zigbee Home Automation networks.""" + +from __future__ import annotations + +from abc import abstractmethod +import functools +import math +from typing import Any + +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + FanEntity, + FanEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant, State, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) +from homeassistant.util.scaling import int_states_in_range +from zigpy.zcl.clusters import hvac + +from .core import discovery +from .core.cluster_handlers import wrap_zigpy_exceptions +from .core.const import CLUSTER_HANDLER_FAN, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED +from .core.helpers import get_zha_data +from .core.registries import ZHA_ENTITIES +from .entity import ZhaEntity, ZhaGroupEntity + +# Additional speeds in zigbee's ZCL +# Spec is unclear as to what this value means. On King Of Fans HBUniversal +# receiver, this means Very High. +PRESET_MODE_ON = "on" +# The fan speed is self-regulated +PRESET_MODE_AUTO = "auto" +# When the heated/cooled space is occupied, the fan is always on +PRESET_MODE_SMART = "smart" + +SPEED_RANGE = (1, 3) # off is not included +PRESET_MODES_TO_NAME = {4: PRESET_MODE_ON, 5: PRESET_MODE_AUTO, 6: PRESET_MODE_SMART} + +DEFAULT_ON_PERCENTAGE = 50 + +STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.FAN) +GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, Platform.FAN) +MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.FAN) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Zigbee Home Automation fan from config entry.""" + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.FAN] + + unsub = async_dispatcher_connect( + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, + async_add_entities, + entities_to_create, + ), + ) + config_entry.async_on_unload(unsub) + + +class BaseFan(FanEntity): + """Base representation of a ZHA fan.""" + + _attr_supported_features = FanEntityFeature.SET_SPEED + _attr_translation_key: str = "fan" + + @property + def preset_modes(self) -> list[str]: + """Return the available preset modes.""" + return list(self.preset_modes_to_name.values()) + + @property + def preset_modes_to_name(self) -> dict[int, str]: + """Return a dict from preset mode to name.""" + return PRESET_MODES_TO_NAME + + @property + def preset_name_to_mode(self) -> dict[str, int]: + """Return a dict from preset name to mode.""" + return {v: k for k, v in self.preset_modes_to_name.items()} + + @property + def default_on_percentage(self) -> int: + """Return the default on percentage.""" + return DEFAULT_ON_PERCENTAGE + + @property + def speed_range(self) -> tuple[int, int]: + """Return the range of speeds the fan supports. Off is not included.""" + return SPEED_RANGE + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range(self.speed_range) + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn the entity on.""" + if percentage is None: + percentage = self.default_on_percentage + await self.async_set_percentage(percentage) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self.async_set_percentage(0) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + fan_mode = math.ceil(percentage_to_ranged_value(self.speed_range, percentage)) + await self._async_set_fan_mode(fan_mode) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode for the fan.""" + await self._async_set_fan_mode(self.preset_name_to_mode[preset_mode]) + + @abstractmethod + async def _async_set_fan_mode(self, fan_mode: int) -> None: + """Set the fan mode for the fan.""" + + @callback + def async_set_state(self, attr_id, attr_name, value): + """Handle state update from cluster handler.""" + + +@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_FAN) +class ZhaFan(BaseFan, ZhaEntity): + """Representation of a ZHA fan.""" + + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): + """Init this sensor.""" + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._fan_cluster_handler = self.cluster_handlers.get(CLUSTER_HANDLER_FAN) + + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + await super().async_added_to_hass() + self.async_accept_signal( + self._fan_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state + ) + + @property + def percentage(self) -> int | None: + """Return the current speed percentage.""" + if ( + self._fan_cluster_handler.fan_mode is None + or self._fan_cluster_handler.fan_mode > self.speed_range[1] + ): + return None + if self._fan_cluster_handler.fan_mode == 0: + return 0 + return ranged_value_to_percentage( + self.speed_range, self._fan_cluster_handler.fan_mode + ) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + return self.preset_modes_to_name.get(self._fan_cluster_handler.fan_mode) + + @callback + def async_set_state(self, attr_id, attr_name, value): + """Handle state update from cluster handler.""" + self.async_write_ha_state() + + async def _async_set_fan_mode(self, fan_mode: int) -> None: + """Set the fan mode for the fan.""" + await self._fan_cluster_handler.async_set_speed(fan_mode) + self.async_set_state(0, "fan_mode", fan_mode) + + +@GROUP_MATCH() +class FanGroup(BaseFan, ZhaGroupEntity): + """Representation of a fan group.""" + + _attr_translation_key: str = "fan_group" + + def __init__( + self, entity_ids: list[str], unique_id: str, group_id: int, zha_device, **kwargs + ) -> None: + """Initialize a fan group.""" + super().__init__(entity_ids, unique_id, group_id, zha_device, **kwargs) + self._available: bool = False + group = self.zha_device.gateway.get_group(self._group_id) + self._fan_cluster_handler = group.endpoint[hvac.Fan.cluster_id] + self._percentage = None + self._preset_mode = None + + @property + def percentage(self) -> int | None: + """Return the current speed percentage.""" + return self._percentage + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + return self._preset_mode + + async def _async_set_fan_mode(self, fan_mode: int) -> None: + """Set the fan mode for the group.""" + + with wrap_zigpy_exceptions(): + await self._fan_cluster_handler.write_attributes({"fan_mode": fan_mode}) + + self.async_set_state(0, "fan_mode", fan_mode) + + async def async_update(self) -> None: + """Attempt to retrieve on off state from the fan.""" + all_states = [self.hass.states.get(x) for x in self._entity_ids] + states: list[State] = list(filter(None, all_states)) + percentage_states: list[State] = [ + state for state in states if state.attributes.get(ATTR_PERCENTAGE) + ] + preset_mode_states: list[State] = [ + state for state in states if state.attributes.get(ATTR_PRESET_MODE) + ] + self._available = any(state.state != STATE_UNAVAILABLE for state in states) + + if percentage_states: + self._percentage = percentage_states[0].attributes[ATTR_PERCENTAGE] + self._preset_mode = None + elif preset_mode_states: + self._preset_mode = preset_mode_states[0].attributes[ATTR_PRESET_MODE] + self._percentage = None + else: + self._percentage = None + self._preset_mode = None + + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + await self.async_update() + await super().async_added_to_hass() + + +IKEA_SPEED_RANGE = (1, 10) # off is not included +IKEA_PRESET_MODES_TO_NAME = { + 1: PRESET_MODE_AUTO, + 2: "Speed 1", + 3: "Speed 1.5", + 4: "Speed 2", + 5: "Speed 2.5", + 6: "Speed 3", + 7: "Speed 3.5", + 8: "Speed 4", + 9: "Speed 4.5", + 10: "Speed 5", +} + + +@MULTI_MATCH( + cluster_handler_names="ikea_airpurifier", + models={"STARKVIND Air purifier", "STARKVIND Air purifier table"}, +) +class IkeaFan(ZhaFan): + """Representation of an Ikea fan.""" + + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs) -> None: + """Init this sensor.""" + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._fan_cluster_handler = self.cluster_handlers.get("ikea_airpurifier") + + @property + def preset_modes_to_name(self) -> dict[int, str]: + """Return a dict from preset mode to name.""" + return IKEA_PRESET_MODES_TO_NAME + + @property + def speed_range(self) -> tuple[int, int]: + """Return the range of speeds the fan supports. Off is not included.""" + return IKEA_SPEED_RANGE + + @property + def default_on_percentage(self) -> int: + """Return the default on percentage.""" + return int( + (100 / self.speed_count) * self.preset_name_to_mode[PRESET_MODE_AUTO] + ) + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_FAN, + models={"HBUniversalCFRemote", "HDC52EastwindFan"}, +) +class KofFan(ZhaFan): + """Representation of a fan made by King Of Fans.""" + + _attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE + + @property + def speed_range(self) -> tuple[int, int]: + """Return the range of speeds the fan supports. Off is not included.""" + return (1, 4) + + @property + def preset_modes_to_name(self) -> dict[int, str]: + """Return a dict from preset mode to name.""" + return {6: PRESET_MODE_SMART} diff --git a/zha/light.py b/zha/light.py new file mode 100644 index 00000000..e60b2883 --- /dev/null +++ b/zha/light.py @@ -0,0 +1,1383 @@ +"""Lights on Zigbee Home Automation networks.""" + +from __future__ import annotations + +from collections import Counter +from collections.abc import Callable +from datetime import timedelta +import functools +import itertools +import logging +import random +from typing import TYPE_CHECKING, Any + +from homeassistant.components import light +from homeassistant.components.light import ( + ColorMode, + LightEntityFeature, + brightness_supported, + filter_supported_color_modes, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, + STATE_ON, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_call_later, async_track_time_interval +from zigpy.zcl.clusters.general import Identify, LevelControl, OnOff +from zigpy.zcl.clusters.lighting import Color +from zigpy.zcl.foundation import Status + +from .core import discovery, helpers +from .core.const import ( + CLUSTER_HANDLER_COLOR, + CLUSTER_HANDLER_LEVEL, + CLUSTER_HANDLER_ON_OFF, + CONF_ALWAYS_PREFER_XY_COLOR_MODE, + CONF_DEFAULT_LIGHT_TRANSITION, + CONF_ENABLE_ENHANCED_LIGHT_TRANSITION, + CONF_ENABLE_LIGHT_TRANSITIONING_FLAG, + CONF_GROUP_MEMBERS_ASSUME_STATE, + DATA_ZHA, + SIGNAL_ADD_ENTITIES, + SIGNAL_ATTR_UPDATED, + SIGNAL_SET_LEVEL, + ZHA_OPTIONS, +) +from .core.helpers import LogMixin, async_get_zha_config_value, get_zha_data +from .core.registries import ZHA_ENTITIES +from .entity import ZhaEntity, ZhaGroupEntity + +if TYPE_CHECKING: + from .core.device import ZHADevice + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_ON_OFF_TRANSITION = 1 # most bulbs default to a 1-second turn on/off transition +DEFAULT_EXTRA_TRANSITION_DELAY_SHORT = 0.25 +DEFAULT_EXTRA_TRANSITION_DELAY_LONG = 2.0 +DEFAULT_LONG_TRANSITION_TIME = 10 +DEFAULT_MIN_BRIGHTNESS = 2 +ASSUME_UPDATE_GROUP_FROM_CHILD_DELAY = 0.05 + +FLASH_EFFECTS = { + light.FLASH_SHORT: Identify.EffectIdentifier.Blink, + light.FLASH_LONG: Identify.EffectIdentifier.Breathe, +} + +STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.LIGHT) +GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, Platform.LIGHT) +SIGNAL_LIGHT_GROUP_STATE_CHANGED = "zha_light_group_state_changed" +SIGNAL_LIGHT_GROUP_TRANSITION_START = "zha_light_group_transition_start" +SIGNAL_LIGHT_GROUP_TRANSITION_FINISHED = "zha_light_group_transition_finished" +SIGNAL_LIGHT_GROUP_ASSUME_GROUP_STATE = "zha_light_group_assume_group_state" +DEFAULT_MIN_TRANSITION_MANUFACTURERS = {"sengled"} + +COLOR_MODES_GROUP_LIGHT = {ColorMode.COLOR_TEMP, ColorMode.XY} +SUPPORT_GROUP_LIGHT = ( + light.LightEntityFeature.EFFECT + | light.LightEntityFeature.FLASH + | light.LightEntityFeature.TRANSITION +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Zigbee Home Automation light from config entry.""" + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.LIGHT] + + unsub = async_dispatcher_connect( + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), + ) + config_entry.async_on_unload(unsub) + + +class BaseLight(LogMixin, light.LightEntity): + """Operations common to all light entities.""" + + _FORCE_ON = False + _DEFAULT_MIN_TRANSITION_TIME: float = 0 + + def __init__(self, *args, **kwargs): + """Initialize the light.""" + self._zha_device: ZHADevice = None + super().__init__(*args, **kwargs) + self._attr_min_mireds: int | None = 153 + self._attr_max_mireds: int | None = 500 + self._attr_color_mode = ColorMode.UNKNOWN # Set by subclasses + self._attr_supported_features: int = 0 + self._attr_state: bool | None + self._off_with_transition: bool = False + self._off_brightness: int | None = None + self._zha_config_transition = self._DEFAULT_MIN_TRANSITION_TIME + self._zha_config_enhanced_light_transition: bool = False + self._zha_config_enable_light_transitioning_flag: bool = True + self._zha_config_always_prefer_xy_color_mode: bool = True + self._on_off_cluster_handler = None + self._level_cluster_handler = None + self._color_cluster_handler = None + self._identify_cluster_handler = None + self._transitioning_individual: bool = False + self._transitioning_group: bool = False + self._transition_listener: Callable[[], None] | None = None + + async def async_will_remove_from_hass(self) -> None: + """Disconnect entity object when removed.""" + self._async_unsub_transition_listener() + await super().async_will_remove_from_hass() + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return state attributes.""" + attributes = { + "off_with_transition": self._off_with_transition, + "off_brightness": self._off_brightness, + } + return attributes + + @property + def is_on(self) -> bool: + """Return true if entity is on.""" + if self._attr_state is None: + return False + return self._attr_state + + @callback + def set_level(self, value: int) -> None: + """Set the brightness of this light between 0..254. + + brightness level 255 is a special value instructing the device to come + on at `on_level` Zigbee attribute value, regardless of the last set + level + """ + if self.is_transitioning: + self.debug( + "received level %s while transitioning - skipping update", + value, + ) + return + value = max(0, min(254, value)) + self._attr_brightness = value + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + transition = kwargs.get(light.ATTR_TRANSITION) + duration = ( + transition if transition is not None else self._zha_config_transition + ) or ( + # if 0 is passed in some devices still need the minimum default + self._DEFAULT_MIN_TRANSITION_TIME + ) + brightness = kwargs.get(light.ATTR_BRIGHTNESS) + effect = kwargs.get(light.ATTR_EFFECT) + flash = kwargs.get(light.ATTR_FLASH) + temperature = kwargs.get(light.ATTR_COLOR_TEMP) + xy_color = kwargs.get(light.ATTR_XY_COLOR) + hs_color = kwargs.get(light.ATTR_HS_COLOR) + + execute_if_off_supported = ( + self._GROUP_SUPPORTS_EXECUTE_IF_OFF + if isinstance(self, LightGroup) + else self._color_cluster_handler + and self._color_cluster_handler.execute_if_off_supported + ) + + set_transition_flag = ( + brightness_supported(self._attr_supported_color_modes) + or temperature is not None + or xy_color is not None + or hs_color is not None + ) and self._zha_config_enable_light_transitioning_flag + transition_time = ( + ( + duration + DEFAULT_EXTRA_TRANSITION_DELAY_SHORT + if ( + (brightness is not None or transition is not None) + and brightness_supported(self._attr_supported_color_modes) + or (self._off_with_transition and self._off_brightness is not None) + or temperature is not None + or xy_color is not None + or hs_color is not None + ) + else DEFAULT_ON_OFF_TRANSITION + DEFAULT_EXTRA_TRANSITION_DELAY_SHORT + ) + if set_transition_flag + else 0 + ) + + # If we need to pause attribute report parsing, we'll do so here. + # After successful calls, we later start a timer to unset the flag after + # transition_time. + # - On an error on the first move to level call, we unset the flag immediately + # if no previous timer is running. + # - On an error on subsequent calls, we start the transition timer, + # as a brightness call might have come through. + if set_transition_flag: + self.async_transition_set_flag() + + # If the light is currently off but a turn_on call with a color/temperature is + # sent, the light needs to be turned on first at a low brightness level where + # the light is immediately transitioned to the correct color. Afterwards, the + # transition is only from the low brightness to the new brightness. + # Otherwise, the transition is from the color the light had before being turned + # on to the new color. This can look especially bad with transitions longer than + # a second. We do not want to do this for devices that need to be forced to use + # the on command because we would end up with 4 commands sent: + # move to level, on, color, move to level... We also will not set this + # if the bulb is already in the desired color mode with the desired color + # or color temperature. + new_color_provided_while_off = ( + self._zha_config_enhanced_light_transition + and not self._FORCE_ON + and not self._attr_state + and ( + ( + temperature is not None + and ( + self._attr_color_temp != temperature + or self._attr_color_mode != ColorMode.COLOR_TEMP + ) + ) + or ( + xy_color is not None + and ( + self._attr_xy_color != xy_color + or self._attr_color_mode != ColorMode.XY + ) + ) + or ( + hs_color is not None + and ( + self._attr_hs_color != hs_color + or self._attr_color_mode != ColorMode.HS + ) + ) + ) + and brightness_supported(self._attr_supported_color_modes) + and not execute_if_off_supported + ) + + if ( + brightness is None + and (self._off_with_transition or new_color_provided_while_off) + and self._off_brightness is not None + ): + brightness = self._off_brightness + + if brightness is not None: + level = min(254, brightness) + else: + level = self._attr_brightness or 254 + + t_log = {} + + if new_color_provided_while_off: + # If the light is currently off, we first need to turn it on at a low + # brightness level with no transition. + # After that, we set it to the desired color/temperature with no transition. + result = await self._level_cluster_handler.move_to_level_with_on_off( + level=DEFAULT_MIN_BRIGHTNESS, + transition_time=int(10 * self._DEFAULT_MIN_TRANSITION_TIME), + ) + t_log["move_to_level_with_on_off"] = result + if result[1] is not Status.SUCCESS: + # First 'move to level' call failed, so if the transitioning delay + # isn't running from a previous call, + # the flag can be unset immediately + if set_transition_flag and not self._transition_listener: + self.async_transition_complete() + self.debug("turned on: %s", t_log) + return + # Currently only setting it to "on", as the correct level state will + # be set at the second move_to_level call + self._attr_state = True + + if execute_if_off_supported: + self.debug("handling color commands before turning on/level") + if not await self.async_handle_color_commands( + temperature, + duration, # duration is ignored by lights when off + hs_color, + xy_color, + new_color_provided_while_off, + t_log, + ): + # Color calls before on/level calls failed, + # so if the transitioning delay isn't running from a previous call, + # the flag can be unset immediately + if set_transition_flag and not self._transition_listener: + self.async_transition_complete() + self.debug("turned on: %s", t_log) + return + + if ( + (brightness is not None or transition is not None) + and not new_color_provided_while_off + and brightness_supported(self._attr_supported_color_modes) + ): + result = await self._level_cluster_handler.move_to_level_with_on_off( + level=level, + transition_time=int(10 * duration), + ) + t_log["move_to_level_with_on_off"] = result + if result[1] is not Status.SUCCESS: + # First 'move to level' call failed, so if the transitioning delay + # isn't running from a previous call, the flag can be unset immediately + if set_transition_flag and not self._transition_listener: + self.async_transition_complete() + self.debug("turned on: %s", t_log) + return + self._attr_state = bool(level) + if level: + self._attr_brightness = level + + if ( + (brightness is None and transition is None) + and not new_color_provided_while_off + or (self._FORCE_ON and brightness != 0) + ): + # since FORCE_ON lights don't turn on with move_to_level_with_on_off, + # we should call the on command on the on_off cluster + # if brightness is not 0. + result = await self._on_off_cluster_handler.on() + t_log["on_off"] = result + if result[1] is not Status.SUCCESS: + # 'On' call failed, but as brightness may still transition + # (for FORCE_ON lights), we start the timer to unset the flag after + # the transition_time if necessary. + self.async_transition_start_timer(transition_time) + self.debug("turned on: %s", t_log) + return + self._attr_state = True + + if not execute_if_off_supported: + self.debug("handling color commands after turning on/level") + if not await self.async_handle_color_commands( + temperature, + duration, + hs_color, + xy_color, + new_color_provided_while_off, + t_log, + ): + # Color calls failed, but as brightness may still transition, + # we start the timer to unset the flag + self.async_transition_start_timer(transition_time) + self.debug("turned on: %s", t_log) + return + + if new_color_provided_while_off: + # The light has the correct color, so we can now transition + # it to the correct brightness level. + result = await self._level_cluster_handler.move_to_level( + level=level, transition_time=int(10 * duration) + ) + t_log["move_to_level_if_color"] = result + if result[1] is not Status.SUCCESS: + self.debug("turned on: %s", t_log) + return + self._attr_state = bool(level) + if level: + self._attr_brightness = level + + # Our light is guaranteed to have just started the transitioning process + # if necessary, so we start the delay for the transition (to stop parsing + # attribute reports after the completed transition). + self.async_transition_start_timer(transition_time) + + if effect == light.EFFECT_COLORLOOP: + result = await self._color_cluster_handler.color_loop_set( + update_flags=( + Color.ColorLoopUpdateFlags.Action + | Color.ColorLoopUpdateFlags.Direction + | Color.ColorLoopUpdateFlags.Time + ), + action=Color.ColorLoopAction.Activate_from_current_hue, + direction=Color.ColorLoopDirection.Increment, + time=transition if transition else 7, + start_hue=0, + ) + t_log["color_loop_set"] = result + self._attr_effect = light.EFFECT_COLORLOOP + elif ( + self._attr_effect == light.EFFECT_COLORLOOP + and effect != light.EFFECT_COLORLOOP + ): + result = await self._color_cluster_handler.color_loop_set( + update_flags=Color.ColorLoopUpdateFlags.Action, + action=Color.ColorLoopAction.Deactivate, + direction=Color.ColorLoopDirection.Decrement, + time=0, + start_hue=0, + ) + t_log["color_loop_set"] = result + self._attr_effect = None + + if flash is not None: + result = await self._identify_cluster_handler.trigger_effect( + effect_id=FLASH_EFFECTS[flash], + effect_variant=Identify.EffectVariant.Default, + ) + t_log["trigger_effect"] = result + + self._off_with_transition = False + self._off_brightness = None + self.debug("turned on: %s", t_log) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + transition = kwargs.get(light.ATTR_TRANSITION) + supports_level = brightness_supported(self._attr_supported_color_modes) + + transition_time = ( + transition or self._DEFAULT_MIN_TRANSITION_TIME + if transition is not None + else DEFAULT_ON_OFF_TRANSITION + ) + DEFAULT_EXTRA_TRANSITION_DELAY_SHORT + + # Start pausing attribute report parsing + if self._zha_config_enable_light_transitioning_flag: + self.async_transition_set_flag() + + # is not none looks odd here, but it will override built in bulb + # transition times if we pass 0 in here + if transition is not None and supports_level: + result = await self._level_cluster_handler.move_to_level_with_on_off( + level=0, + transition_time=int( + 10 * (transition or self._DEFAULT_MIN_TRANSITION_TIME) + ), + ) + else: + result = await self._on_off_cluster_handler.off() + + # Pause parsing attribute reports until transition is complete + if self._zha_config_enable_light_transitioning_flag: + self.async_transition_start_timer(transition_time) + self.debug("turned off: %s", result) + if result[1] is not Status.SUCCESS: + return + self._attr_state = False + + if supports_level and not self._off_with_transition: + # store current brightness so that the next turn_on uses it: + # when using "enhanced turn on" + self._off_brightness = self._attr_brightness + if transition is not None: + # save for when calling turn_on without a brightness: + # current_level is set to 1 after transitioning to level 0, + # needed for correct state with light groups + self._attr_brightness = 1 + self._off_with_transition = transition is not None + + self.async_write_ha_state() + + async def async_handle_color_commands( + self, + temperature, + duration, + hs_color, + xy_color, + new_color_provided_while_off, + t_log, + ): + """Process ZCL color commands.""" + + transition_time = ( + self._DEFAULT_MIN_TRANSITION_TIME + if new_color_provided_while_off + else duration + ) + + if temperature is not None: + result = await self._color_cluster_handler.move_to_color_temp( + color_temp_mireds=temperature, + transition_time=int(10 * transition_time), + ) + t_log["move_to_color_temp"] = result + if result[1] is not Status.SUCCESS: + return False + self._attr_color_mode = ColorMode.COLOR_TEMP + self._attr_color_temp = temperature + self._attr_xy_color = None + self._attr_hs_color = None + + if hs_color is not None: + if ( + not isinstance(self, LightGroup) + and self._color_cluster_handler.enhanced_hue_supported + ): + result = await self._color_cluster_handler.enhanced_move_to_hue_and_saturation( + enhanced_hue=int(hs_color[0] * 65535 / 360), + saturation=int(hs_color[1] * 2.54), + transition_time=int(10 * transition_time), + ) + t_log["enhanced_move_to_hue_and_saturation"] = result + else: + result = await self._color_cluster_handler.move_to_hue_and_saturation( + hue=int(hs_color[0] * 254 / 360), + saturation=int(hs_color[1] * 2.54), + transition_time=int(10 * transition_time), + ) + t_log["move_to_hue_and_saturation"] = result + if result[1] is not Status.SUCCESS: + return False + self._attr_color_mode = ColorMode.HS + self._attr_hs_color = hs_color + self._attr_xy_color = None + self._attr_color_temp = None + xy_color = None # don't set xy_color if it is also present + + if xy_color is not None: + result = await self._color_cluster_handler.move_to_color( + color_x=int(xy_color[0] * 65535), + color_y=int(xy_color[1] * 65535), + transition_time=int(10 * transition_time), + ) + t_log["move_to_color"] = result + if result[1] is not Status.SUCCESS: + return False + self._attr_color_mode = ColorMode.XY + self._attr_xy_color = xy_color + self._attr_color_temp = None + self._attr_hs_color = None + + return True + + @property + def is_transitioning(self) -> bool: + """Return if the light is transitioning.""" + return self._transitioning_individual or self._transitioning_group + + @callback + def async_transition_set_flag(self) -> None: + """Set _transitioning to True.""" + self.debug("setting transitioning flag to True") + self._transitioning_individual = True + self._transitioning_group = False + if isinstance(self, LightGroup): + async_dispatcher_send( + self.hass, + SIGNAL_LIGHT_GROUP_TRANSITION_START, + {"entity_ids": self._entity_ids}, + ) + self._async_unsub_transition_listener() + + @callback + def async_transition_start_timer(self, transition_time) -> None: + """Start a timer to unset _transitioning_individual after transition_time. + + If necessary. + """ + if not transition_time: + return + # For longer transitions, we want to extend the timer a bit more + if transition_time >= DEFAULT_LONG_TRANSITION_TIME: + transition_time += DEFAULT_EXTRA_TRANSITION_DELAY_LONG + self.debug("starting transitioning timer for %s", transition_time) + self._transition_listener = async_call_later( + self._zha_device.hass, + transition_time, + self.async_transition_complete, + ) + + @callback + def _async_unsub_transition_listener(self) -> None: + """Unsubscribe transition listener.""" + if self._transition_listener: + self._transition_listener() + self._transition_listener = None + + @callback + def async_transition_complete(self, _=None) -> None: + """Set _transitioning_individual to False and write HA state.""" + self.debug("transition complete - future attribute reports will write HA state") + self._transitioning_individual = False + self._async_unsub_transition_listener() + self.async_write_ha_state() + if isinstance(self, LightGroup): + async_dispatcher_send( + self.hass, + SIGNAL_LIGHT_GROUP_TRANSITION_FINISHED, + {"entity_ids": self._entity_ids}, + ) + if self._debounced_member_refresh is not None: + self.debug("transition complete - refreshing group member states") + assert self.platform.config_entry + self.platform.config_entry.async_create_background_task( + self.hass, + self._debounced_member_refresh.async_call(), + "zha.light-refresh-debounced-member", + ) + + +@STRICT_MATCH( + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, + aux_cluster_handlers={CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_LEVEL}, +) +class Light(BaseLight, ZhaEntity): + """Representation of a ZHA or ZLL light.""" + + _attr_supported_color_modes: set[ColorMode] + _attr_translation_key: str = "light" + _REFRESH_INTERVAL = (45, 75) + + def __init__( + self, unique_id, zha_device: ZHADevice, cluster_handlers, **kwargs + ) -> None: + """Initialize the ZHA light.""" + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._on_off_cluster_handler = self.cluster_handlers[CLUSTER_HANDLER_ON_OFF] + self._attr_state = bool(self._on_off_cluster_handler.on_off) + self._level_cluster_handler = self.cluster_handlers.get(CLUSTER_HANDLER_LEVEL) + self._color_cluster_handler = self.cluster_handlers.get(CLUSTER_HANDLER_COLOR) + self._identify_cluster_handler = zha_device.identify_ch + if self._color_cluster_handler: + self._attr_min_mireds: int = self._color_cluster_handler.min_mireds + self._attr_max_mireds: int = self._color_cluster_handler.max_mireds + self._cancel_refresh_handle: CALLBACK_TYPE | None = None + effect_list = [] + + self._zha_config_always_prefer_xy_color_mode = async_get_zha_config_value( + zha_device.gateway.config_entry, + ZHA_OPTIONS, + CONF_ALWAYS_PREFER_XY_COLOR_MODE, + True, + ) + + self._attr_supported_color_modes = {ColorMode.ONOFF} + if self._level_cluster_handler: + self._attr_supported_color_modes.add(ColorMode.BRIGHTNESS) + self._attr_supported_features |= light.LightEntityFeature.TRANSITION + self._attr_brightness = self._level_cluster_handler.current_level + + if self._color_cluster_handler: + if self._color_cluster_handler.color_temp_supported: + self._attr_supported_color_modes.add(ColorMode.COLOR_TEMP) + self._attr_color_temp = self._color_cluster_handler.color_temperature + + if self._color_cluster_handler.xy_supported and ( + self._zha_config_always_prefer_xy_color_mode + or not self._color_cluster_handler.hs_supported + ): + self._attr_supported_color_modes.add(ColorMode.XY) + curr_x = self._color_cluster_handler.current_x + curr_y = self._color_cluster_handler.current_y + if curr_x is not None and curr_y is not None: + self._attr_xy_color = (curr_x / 65535, curr_y / 65535) + else: + self._attr_xy_color = (0, 0) + + if ( + self._color_cluster_handler.hs_supported + and not self._zha_config_always_prefer_xy_color_mode + ): + self._attr_supported_color_modes.add(ColorMode.HS) + if ( + self._color_cluster_handler.enhanced_hue_supported + and self._color_cluster_handler.enhanced_current_hue is not None + ): + curr_hue = ( + self._color_cluster_handler.enhanced_current_hue * 65535 / 360 + ) + elif self._color_cluster_handler.current_hue is not None: + curr_hue = self._color_cluster_handler.current_hue * 254 / 360 + else: + curr_hue = 0 + + if ( + curr_saturation := self._color_cluster_handler.current_saturation + ) is None: + curr_saturation = 0 + + self._attr_hs_color = ( + int(curr_hue), + int(curr_saturation * 2.54), + ) + + if self._color_cluster_handler.color_loop_supported: + self._attr_supported_features |= light.LightEntityFeature.EFFECT + effect_list.append(light.EFFECT_COLORLOOP) + if self._color_cluster_handler.color_loop_active == 1: + self._attr_effect = light.EFFECT_COLORLOOP + self._attr_supported_color_modes = filter_supported_color_modes( + self._attr_supported_color_modes + ) + if len(self._attr_supported_color_modes) == 1: + self._attr_color_mode = next(iter(self._attr_supported_color_modes)) + else: # Light supports color_temp + hs, determine which mode the light is in + assert self._color_cluster_handler + if ( + self._color_cluster_handler.color_mode + == Color.ColorMode.Color_temperature + ): + self._attr_color_mode = ColorMode.COLOR_TEMP + else: + self._attr_color_mode = ColorMode.XY + + if self._identify_cluster_handler: + self._attr_supported_features |= light.LightEntityFeature.FLASH + + if effect_list: + self._attr_effect_list = effect_list + + self._zha_config_transition = async_get_zha_config_value( + zha_device.gateway.config_entry, + ZHA_OPTIONS, + CONF_DEFAULT_LIGHT_TRANSITION, + 0, + ) + self._zha_config_enhanced_light_transition = async_get_zha_config_value( + zha_device.gateway.config_entry, + ZHA_OPTIONS, + CONF_ENABLE_ENHANCED_LIGHT_TRANSITION, + False, + ) + self._zha_config_enable_light_transitioning_flag = async_get_zha_config_value( + zha_device.gateway.config_entry, + ZHA_OPTIONS, + CONF_ENABLE_LIGHT_TRANSITIONING_FLAG, + True, + ) + + @callback + def async_set_state(self, attr_id, attr_name, value): + """Set the state.""" + if self.is_transitioning: + self.debug( + "received onoff %s while transitioning - skipping update", + value, + ) + return + self._attr_state = bool(value) + if value: + self._off_with_transition = False + self._off_brightness = None + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + await super().async_added_to_hass() + self.async_accept_signal( + self._on_off_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state + ) + if self._level_cluster_handler: + self.async_accept_signal( + self._level_cluster_handler, SIGNAL_SET_LEVEL, self.set_level + ) + refresh_interval = random.randint(*(x * 60 for x in self._REFRESH_INTERVAL)) + self._cancel_refresh_handle = async_track_time_interval( + self.hass, self._refresh, timedelta(seconds=refresh_interval) + ) + self.debug("started polling with refresh interval of %s", refresh_interval) + self.async_accept_signal( + None, + SIGNAL_LIGHT_GROUP_STATE_CHANGED, + self._maybe_force_refresh, + signal_override=True, + ) + + @callback + def transition_on(signal): + """Handle a transition start event from a group.""" + if self.entity_id in signal["entity_ids"]: + self.debug( + "group transition started - setting member transitioning flag" + ) + self._transitioning_group = True + + self.async_accept_signal( + None, + SIGNAL_LIGHT_GROUP_TRANSITION_START, + transition_on, + signal_override=True, + ) + + @callback + def transition_off(signal): + """Handle a transition finished event from a group.""" + if self.entity_id in signal["entity_ids"]: + self.debug( + "group transition completed - unsetting member transitioning flag" + ) + self._transitioning_group = False + + self.async_accept_signal( + None, + SIGNAL_LIGHT_GROUP_TRANSITION_FINISHED, + transition_off, + signal_override=True, + ) + + self.async_accept_signal( + None, + SIGNAL_LIGHT_GROUP_ASSUME_GROUP_STATE, + self._assume_group_state, + signal_override=True, + ) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect entity object when removed.""" + assert self._cancel_refresh_handle + self._cancel_refresh_handle() + self._cancel_refresh_handle = None + self.debug("stopped polling during device removal") + await super().async_will_remove_from_hass() + + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + self._attr_state = last_state.state == STATE_ON + if "brightness" in last_state.attributes: + self._attr_brightness = last_state.attributes["brightness"] + if "off_with_transition" in last_state.attributes: + self._off_with_transition = last_state.attributes["off_with_transition"] + if "off_brightness" in last_state.attributes: + self._off_brightness = last_state.attributes["off_brightness"] + if (color_mode := last_state.attributes.get("color_mode")) is not None: + self._attr_color_mode = ColorMode(color_mode) + if "color_temp" in last_state.attributes: + self._attr_color_temp = last_state.attributes["color_temp"] + if "xy_color" in last_state.attributes: + self._attr_xy_color = last_state.attributes["xy_color"] + if "hs_color" in last_state.attributes: + self._attr_hs_color = last_state.attributes["hs_color"] + if "effect" in last_state.attributes: + self._attr_effect = last_state.attributes["effect"] + + async def async_get_state(self) -> None: + """Attempt to retrieve the state from the light.""" + if not self._attr_available: + return + self.debug("polling current state") + + if self._on_off_cluster_handler: + state = await self._on_off_cluster_handler.get_attribute_value( + "on_off", from_cache=False + ) + # check if transition started whilst waiting for polled state + if self.is_transitioning: + return + + if state is not None: + self._attr_state = state + if state: # reset "off with transition" flag if the light is on + self._off_with_transition = False + self._off_brightness = None + + if self._level_cluster_handler: + level = await self._level_cluster_handler.get_attribute_value( + "current_level", from_cache=False + ) + # check if transition started whilst waiting for polled state + if self.is_transitioning: + return + if level is not None: + self._attr_brightness = level + + if self._color_cluster_handler: + attributes = [ + "color_mode", + "current_x", + "current_y", + ] + if ( + not self._zha_config_always_prefer_xy_color_mode + and self._color_cluster_handler.enhanced_hue_supported + ): + attributes.append("enhanced_current_hue") + attributes.append("current_saturation") + if ( + self._color_cluster_handler.hs_supported + and not self._color_cluster_handler.enhanced_hue_supported + and not self._zha_config_always_prefer_xy_color_mode + ): + attributes.append("current_hue") + attributes.append("current_saturation") + if self._color_cluster_handler.color_temp_supported: + attributes.append("color_temperature") + if self._color_cluster_handler.color_loop_supported: + attributes.append("color_loop_active") + + results = await self._color_cluster_handler.get_attributes( + attributes, from_cache=False, only_cache=False + ) + + # although rare, a transition might have been started while we were waiting + # for the polled attributes, so abort if we are transitioning, + # as that state will not be accurate + if self.is_transitioning: + return + + if (color_mode := results.get("color_mode")) is not None: + if color_mode == Color.ColorMode.Color_temperature: + self._attr_color_mode = ColorMode.COLOR_TEMP + color_temp = results.get("color_temperature") + if color_temp is not None and color_mode: + self._attr_color_temp = color_temp + self._attr_xy_color = None + self._attr_hs_color = None + elif ( + color_mode == Color.ColorMode.Hue_and_saturation + and not self._zha_config_always_prefer_xy_color_mode + ): + self._attr_color_mode = ColorMode.HS + if self._color_cluster_handler.enhanced_hue_supported: + current_hue = results.get("enhanced_current_hue") + else: + current_hue = results.get("current_hue") + current_saturation = results.get("current_saturation") + if current_hue is not None and current_saturation is not None: + self._attr_hs_color = ( + int(current_hue * 360 / 65535) + if self._color_cluster_handler.enhanced_hue_supported + else int(current_hue * 360 / 254), + int(current_saturation / 2.54), + ) + self._attr_xy_color = None + self._attr_color_temp = None + else: + self._attr_color_mode = ColorMode.XY + color_x = results.get("current_x") + color_y = results.get("current_y") + if color_x is not None and color_y is not None: + self._attr_xy_color = (color_x / 65535, color_y / 65535) + self._attr_color_temp = None + self._attr_hs_color = None + + color_loop_active = results.get("color_loop_active") + if color_loop_active is not None: + if color_loop_active == 1: + self._attr_effect = light.EFFECT_COLORLOOP + else: + self._attr_effect = None + + async def async_update(self) -> None: + """Update to the latest state.""" + if self.is_transitioning: + self.debug("skipping async_update while transitioning") + return + await self.async_get_state() + + async def _refresh(self, time): + """Call async_get_state at an interval.""" + if self.is_transitioning: + self.debug("skipping _refresh while transitioning") + return + if self._zha_device.available and self.hass.data[DATA_ZHA].allow_polling: + self.debug("polling for updated state") + await self.async_get_state() + self.async_write_ha_state() + else: + self.debug( + "skipping polling for updated state, available: %s, allow polled requests: %s", + self._zha_device.available, + self.hass.data[DATA_ZHA].allow_polling, + ) + + async def _maybe_force_refresh(self, signal): + """Force update the state if the signal contains the entity id for this entity.""" + if self.entity_id in signal["entity_ids"]: + if self.is_transitioning: + self.debug("skipping _maybe_force_refresh while transitioning") + return + if self._zha_device.available and self.hass.data[DATA_ZHA].allow_polling: + self.debug("forcing polling for updated state") + await self.async_get_state() + self.async_write_ha_state() + else: + self.debug( + "skipping _maybe_force_refresh, available: %s, allow polled requests: %s", + self._zha_device.available, + self.hass.data[DATA_ZHA].allow_polling, + ) + + @callback + def _assume_group_state(self, signal, update_params) -> None: + """Handle an assume group state event from a group.""" + if self.entity_id in signal["entity_ids"] and self._attr_available: + self.debug("member assuming group state with: %s", update_params) + + state = update_params["state"] + brightness = update_params.get(light.ATTR_BRIGHTNESS) + color_mode = update_params.get(light.ATTR_COLOR_MODE) + color_temp = update_params.get(light.ATTR_COLOR_TEMP) + xy_color = update_params.get(light.ATTR_XY_COLOR) + hs_color = update_params.get(light.ATTR_HS_COLOR) + effect = update_params.get(light.ATTR_EFFECT) + + supported_modes = self._attr_supported_color_modes + + # unset "off brightness" and "off with transition" + # if group turned on this light + if state and not self._attr_state: + self._off_with_transition = False + self._off_brightness = None + + # set "off brightness" and "off with transition" + # if group turned off this light, and the light was not already off + # (to not override _off_with_transition) + elif ( + not state and self._attr_state and brightness_supported(supported_modes) + ): + # use individual brightness, instead of possibly averaged + # brightness from group + self._off_brightness = self._attr_brightness + self._off_with_transition = update_params["off_with_transition"] + + # Note: If individual lights have off_with_transition set, but not the + # group, and the group is then turned on without a level, individual lights + # might fall back to brightness level 1. + # Since all lights might need different brightness levels to be turned on, + # we can't use one group call. And making individual calls when turning on + # a ZHA group would cause a lot of traffic. In this case, + # turn_on should either just be called with a level or individual turn_on + # calls can be used. + + # state is always set (light.turn_on/light.turn_off) + self._attr_state = state + + # before assuming a group state attribute, check if the attribute + # was actually set in that call + if brightness is not None and brightness_supported(supported_modes): + self._attr_brightness = brightness + if color_mode is not None and color_mode in supported_modes: + self._attr_color_mode = color_mode + if color_temp is not None and ColorMode.COLOR_TEMP in supported_modes: + self._attr_color_temp = color_temp + if xy_color is not None and ColorMode.XY in supported_modes: + self._attr_xy_color = xy_color + if hs_color is not None and ColorMode.HS in supported_modes: + self._attr_hs_color = hs_color + # the effect is always deactivated in async_turn_on if not provided + if effect is None: + self._attr_effect = None + elif self._attr_effect_list and effect in self._attr_effect_list: + self._attr_effect = effect + + self.async_write_ha_state() + + +@STRICT_MATCH( + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, + aux_cluster_handlers={CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_LEVEL}, + manufacturers={"Philips", "Signify Netherlands B.V."}, +) +class HueLight(Light): + """Representation of a HUE light which does not report attributes.""" + + _REFRESH_INTERVAL = (3, 5) + + +@STRICT_MATCH( + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, + aux_cluster_handlers={CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_LEVEL}, + manufacturers={"Jasco", "Jasco Products", "Quotra-Vision", "eWeLight", "eWeLink"}, +) +class ForceOnLight(Light): + """Representation of a light which does not respect on/off for move_to_level_with_on_off commands.""" + + _FORCE_ON = True + + +@STRICT_MATCH( + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, + aux_cluster_handlers={CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_LEVEL}, + manufacturers=DEFAULT_MIN_TRANSITION_MANUFACTURERS, +) +class MinTransitionLight(Light): + """Representation of a light which does not react to any "move to" calls with 0 as a transition.""" + + # Transitions are counted in 1/10th of a second increments, so this is the smallest + _DEFAULT_MIN_TRANSITION_TIME = 0.1 + + +@GROUP_MATCH() +class LightGroup(BaseLight, ZhaGroupEntity): + """Representation of a light group.""" + + _attr_translation_key: str = "light_group" + + def __init__( + self, + entity_ids: list[str], + unique_id: str, + group_id: int, + zha_device: ZHADevice, + **kwargs: Any, + ) -> None: + """Initialize a light group.""" + super().__init__(entity_ids, unique_id, group_id, zha_device, **kwargs) + group = self.zha_device.gateway.get_group(self._group_id) + + self._GROUP_SUPPORTS_EXECUTE_IF_OFF = True + + for member in group.members: + # Ensure we do not send group commands that violate the minimum transition + # time of any members. + if member.device.manufacturer in DEFAULT_MIN_TRANSITION_MANUFACTURERS: + self._DEFAULT_MIN_TRANSITION_TIME = ( + MinTransitionLight._DEFAULT_MIN_TRANSITION_TIME + ) + + # Check all group members to see if they support execute_if_off. + # If at least one member has a color cluster and doesn't support it, + # it's not used. + for endpoint in member.device._endpoints.values(): + for cluster_handler in endpoint.all_cluster_handlers.values(): + if ( + cluster_handler.name == CLUSTER_HANDLER_COLOR + and not cluster_handler.execute_if_off_supported + ): + self._GROUP_SUPPORTS_EXECUTE_IF_OFF = False + break + + self._on_off_cluster_handler = group.endpoint[OnOff.cluster_id] + self._level_cluster_handler = group.endpoint[LevelControl.cluster_id] + self._color_cluster_handler = group.endpoint[Color.cluster_id] + self._identify_cluster_handler = group.endpoint[Identify.cluster_id] + self._debounced_member_refresh: Debouncer | None = None + self._zha_config_transition = async_get_zha_config_value( + zha_device.gateway.config_entry, + ZHA_OPTIONS, + CONF_DEFAULT_LIGHT_TRANSITION, + 0, + ) + self._zha_config_enable_light_transitioning_flag = async_get_zha_config_value( + zha_device.gateway.config_entry, + ZHA_OPTIONS, + CONF_ENABLE_LIGHT_TRANSITIONING_FLAG, + True, + ) + self._zha_config_always_prefer_xy_color_mode = async_get_zha_config_value( + zha_device.gateway.config_entry, + ZHA_OPTIONS, + CONF_ALWAYS_PREFER_XY_COLOR_MODE, + True, + ) + self._zha_config_group_members_assume_state = async_get_zha_config_value( + zha_device.gateway.config_entry, + ZHA_OPTIONS, + CONF_GROUP_MEMBERS_ASSUME_STATE, + True, + ) + if self._zha_config_group_members_assume_state: + self._update_group_from_child_delay = ASSUME_UPDATE_GROUP_FROM_CHILD_DELAY + self._zha_config_enhanced_light_transition = False + + self._attr_color_mode = ColorMode.UNKNOWN + self._attr_supported_color_modes = {ColorMode.ONOFF} + + # remove this when all ZHA platforms and base entities are updated + @property + def available(self) -> bool: + """Return entity availability.""" + return self._attr_available + + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + await super().async_added_to_hass() + if self._debounced_member_refresh is None: + force_refresh_debouncer = Debouncer( + self.hass, + _LOGGER, + cooldown=3, + immediate=True, + function=self._force_member_updates, + ) + self._debounced_member_refresh = force_refresh_debouncer + self.async_on_remove(force_refresh_debouncer.async_cancel) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + # "off with transition" and "off brightness" will get overridden when + # turning on the group, but they are needed for setting the assumed + # member state correctly, so save them here + off_brightness = self._off_brightness if self._off_with_transition else None + await super().async_turn_on(**kwargs) + if self._zha_config_group_members_assume_state: + self._send_member_assume_state_event(True, kwargs, off_brightness) + if self.is_transitioning: # when transitioning, state is refreshed at the end + return + if self._debounced_member_refresh: + await self._debounced_member_refresh.async_call() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await super().async_turn_off(**kwargs) + if self._zha_config_group_members_assume_state: + self._send_member_assume_state_event(False, kwargs) + if self.is_transitioning: + return + if self._debounced_member_refresh: + await self._debounced_member_refresh.async_call() + + async def async_update(self) -> None: + """Query all members and determine the light group state.""" + self.debug("updating group state") + all_states = [self.hass.states.get(x) for x in self._entity_ids] + states: list[State] = list(filter(None, all_states)) + on_states = [state for state in states if state.state == STATE_ON] + + self._attr_state = len(on_states) > 0 + + # reset "off with transition" flag if any member is on + if self._attr_state: + self._off_with_transition = False + self._off_brightness = None + + self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states) + + self._attr_brightness = helpers.reduce_attribute( + on_states, light.ATTR_BRIGHTNESS + ) + + self._attr_xy_color = helpers.reduce_attribute( + on_states, light.ATTR_XY_COLOR, reduce=helpers.mean_tuple + ) + + if not self._zha_config_always_prefer_xy_color_mode: + self._attr_hs_color = helpers.reduce_attribute( + on_states, light.ATTR_HS_COLOR, reduce=helpers.mean_tuple + ) + + self._attr_color_temp = helpers.reduce_attribute( + on_states, light.ATTR_COLOR_TEMP + ) + self._attr_min_mireds = helpers.reduce_attribute( + states, light.ATTR_MIN_MIREDS, default=153, reduce=min + ) + self._attr_max_mireds = helpers.reduce_attribute( + states, light.ATTR_MAX_MIREDS, default=500, reduce=max + ) + + self._attr_effect_list = None + all_effect_lists = list( + helpers.find_state_attributes(states, light.ATTR_EFFECT_LIST) + ) + if all_effect_lists: + # Merge all effects from all effect_lists with a union merge. + self._attr_effect_list = list(set().union(*all_effect_lists)) + + self._attr_effect = None + all_effects = list(helpers.find_state_attributes(on_states, light.ATTR_EFFECT)) + if all_effects: + # Report the most common effect. + effects_count = Counter(itertools.chain(all_effects)) + self._attr_effect = effects_count.most_common(1)[0][0] + + supported_color_modes = {ColorMode.ONOFF} + all_supported_color_modes: list[set[ColorMode]] = list( + helpers.find_state_attributes(states, light.ATTR_SUPPORTED_COLOR_MODES) + ) + if all_supported_color_modes: + # Merge all color modes. + supported_color_modes = filter_supported_color_modes( + set().union(*all_supported_color_modes) + ) + + self._attr_supported_color_modes = supported_color_modes + + self._attr_color_mode = ColorMode.UNKNOWN + all_color_modes = list( + helpers.find_state_attributes(on_states, light.ATTR_COLOR_MODE) + ) + if all_color_modes: + # Report the most common color mode, select brightness and onoff last + color_mode_count = Counter(itertools.chain(all_color_modes)) + if ColorMode.ONOFF in color_mode_count: + if ColorMode.ONOFF in supported_color_modes: + color_mode_count[ColorMode.ONOFF] = -1 + else: + color_mode_count.pop(ColorMode.ONOFF) + if ColorMode.BRIGHTNESS in color_mode_count: + if ColorMode.BRIGHTNESS in supported_color_modes: + color_mode_count[ColorMode.BRIGHTNESS] = 0 + else: + color_mode_count.pop(ColorMode.BRIGHTNESS) + if color_mode_count: + self._attr_color_mode = color_mode_count.most_common(1)[0][0] + else: + self._attr_color_mode = next(iter(supported_color_modes)) + + if self._attr_color_mode == ColorMode.HS and ( + color_mode_count[ColorMode.HS] != len(self._group.members) + or self._zha_config_always_prefer_xy_color_mode + ): # switch to XY if all members do not support HS + self._attr_color_mode = ColorMode.XY + + self._attr_supported_features = LightEntityFeature(0) + for support in helpers.find_state_attributes(states, ATTR_SUPPORTED_FEATURES): + # Merge supported features by emulating support for every feature + # we find. + self._attr_supported_features |= support + # Bitwise-and the supported features with the GroupedLight's features + # so that we don't break in the future when a new feature is added. + self._attr_supported_features &= SUPPORT_GROUP_LIGHT + + async def _force_member_updates(self) -> None: + """Force the update of member entities to ensure the states are correct for bulbs that don't report their state.""" + async_dispatcher_send( + self.hass, + SIGNAL_LIGHT_GROUP_STATE_CHANGED, + {"entity_ids": self._entity_ids}, + ) + + def _send_member_assume_state_event( + self, state, service_kwargs, off_brightness=None + ) -> None: + """Send an assume event to all members of the group.""" + update_params = { + "state": state, + "off_with_transition": self._off_with_transition, + } + + # check if the parameters were actually updated + # in the service call before updating members + if light.ATTR_BRIGHTNESS in service_kwargs: # or off brightness + update_params[light.ATTR_BRIGHTNESS] = self._attr_brightness + elif off_brightness is not None: + # if we turn on the group light with "off brightness", + # pass that to the members + update_params[light.ATTR_BRIGHTNESS] = off_brightness + + if light.ATTR_COLOR_TEMP in service_kwargs: + update_params[light.ATTR_COLOR_MODE] = self._attr_color_mode + update_params[light.ATTR_COLOR_TEMP] = self._attr_color_temp + + if light.ATTR_XY_COLOR in service_kwargs: + update_params[light.ATTR_COLOR_MODE] = self._attr_color_mode + update_params[light.ATTR_XY_COLOR] = self._attr_xy_color + + if light.ATTR_HS_COLOR in service_kwargs: + update_params[light.ATTR_COLOR_MODE] = self._attr_color_mode + update_params[light.ATTR_HS_COLOR] = self._attr_hs_color + + if light.ATTR_EFFECT in service_kwargs: + update_params[light.ATTR_EFFECT] = self._attr_effect + + async_dispatcher_send( + self.hass, + SIGNAL_LIGHT_GROUP_ASSUME_GROUP_STATE, + {"entity_ids": self._entity_ids}, + update_params, + ) diff --git a/zha/lock.py b/zha/lock.py new file mode 100644 index 00000000..77398a05 --- /dev/null +++ b/zha/lock.py @@ -0,0 +1,197 @@ +"""Locks on Zigbee Home Automation networks.""" + +import functools +from typing import Any + +from homeassistant.components.lock import STATE_LOCKED, STATE_UNLOCKED, LockEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) +from homeassistant.helpers.typing import StateType +import voluptuous as vol +from zigpy.zcl.foundation import Status + +from .core import discovery +from .core.const import ( + CLUSTER_HANDLER_DOORLOCK, + SIGNAL_ADD_ENTITIES, + SIGNAL_ATTR_UPDATED, +) +from .core.helpers import get_zha_data +from .core.registries import ZHA_ENTITIES +from .entity import ZhaEntity + +# The first state is Zigbee 'Not fully locked' +STATE_LIST = [STATE_UNLOCKED, STATE_LOCKED, STATE_UNLOCKED] +MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.LOCK) + +VALUE_TO_STATE = dict(enumerate(STATE_LIST)) + +SERVICE_SET_LOCK_USER_CODE = "set_lock_user_code" +SERVICE_ENABLE_LOCK_USER_CODE = "enable_lock_user_code" +SERVICE_DISABLE_LOCK_USER_CODE = "disable_lock_user_code" +SERVICE_CLEAR_LOCK_USER_CODE = "clear_lock_user_code" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Zigbee Home Automation Door Lock from config entry.""" + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.LOCK] + + unsub = async_dispatcher_connect( + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), + ) + config_entry.async_on_unload(unsub) + + platform = async_get_current_platform() + + platform.async_register_entity_service( + SERVICE_SET_LOCK_USER_CODE, + { + vol.Required("code_slot"): vol.Coerce(int), + vol.Required("user_code"): cv.string, + }, + "async_set_lock_user_code", + ) + + platform.async_register_entity_service( + SERVICE_ENABLE_LOCK_USER_CODE, + { + vol.Required("code_slot"): vol.Coerce(int), + }, + "async_enable_lock_user_code", + ) + + platform.async_register_entity_service( + SERVICE_DISABLE_LOCK_USER_CODE, + { + vol.Required("code_slot"): vol.Coerce(int), + }, + "async_disable_lock_user_code", + ) + + platform.async_register_entity_service( + SERVICE_CLEAR_LOCK_USER_CODE, + { + vol.Required("code_slot"): vol.Coerce(int), + }, + "async_clear_lock_user_code", + ) + + +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_DOORLOCK) +class ZhaDoorLock(ZhaEntity, LockEntity): + """Representation of a ZHA lock.""" + + _attr_translation_key: str = "door_lock" + + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): + """Init this sensor.""" + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._doorlock_cluster_handler = self.cluster_handlers.get( + CLUSTER_HANDLER_DOORLOCK + ) + + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + await super().async_added_to_hass() + self.async_accept_signal( + self._doorlock_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state + ) + + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + self._state = VALUE_TO_STATE.get(last_state.state, last_state.state) + + @property + def is_locked(self) -> bool: + """Return true if entity is locked.""" + if self._state is None: + return False + return self._state == STATE_LOCKED + + @property + def extra_state_attributes(self) -> dict[str, StateType]: + """Return state attributes.""" + return self.state_attributes + + async def async_lock(self, **kwargs: Any) -> None: + """Lock the lock.""" + result = await self._doorlock_cluster_handler.lock_door() + if result[0] is not Status.SUCCESS: + self.error("Error with lock_door: %s", result) + return + self.async_write_ha_state() + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock the lock.""" + result = await self._doorlock_cluster_handler.unlock_door() + if result[0] is not Status.SUCCESS: + self.error("Error with unlock_door: %s", result) + return + self.async_write_ha_state() + + async def async_update(self) -> None: + """Attempt to retrieve state from the lock.""" + await super().async_update() + await self.async_get_state() + + @callback + def async_set_state(self, attr_id, attr_name, value): + """Handle state update from cluster handler.""" + self._state = VALUE_TO_STATE.get(value, self._state) + self.async_write_ha_state() + + async def async_get_state(self, from_cache=True): + """Attempt to retrieve state from the lock.""" + if self._doorlock_cluster_handler: + state = await self._doorlock_cluster_handler.get_attribute_value( + "lock_state", from_cache=from_cache + ) + if state is not None: + self._state = VALUE_TO_STATE.get(state, self._state) + + async def refresh(self, time): + """Call async_get_state at an interval.""" + await self.async_get_state(from_cache=False) + + async def async_set_lock_user_code(self, code_slot: int, user_code: str) -> None: + """Set the user_code to index X on the lock.""" + if self._doorlock_cluster_handler: + await self._doorlock_cluster_handler.async_set_user_code( + code_slot, user_code + ) + self.debug("User code at slot %s set", code_slot) + + async def async_enable_lock_user_code(self, code_slot: int) -> None: + """Enable user_code at index X on the lock.""" + if self._doorlock_cluster_handler: + await self._doorlock_cluster_handler.async_enable_user_code(code_slot) + self.debug("User code at slot %s enabled", code_slot) + + async def async_disable_lock_user_code(self, code_slot: int) -> None: + """Disable user_code at index X on the lock.""" + if self._doorlock_cluster_handler: + await self._doorlock_cluster_handler.async_disable_user_code(code_slot) + self.debug("User code at slot %s disabled", code_slot) + + async def async_clear_lock_user_code(self, code_slot: int) -> None: + """Clear the user_code at index X on the lock.""" + if self._doorlock_cluster_handler: + await self._doorlock_cluster_handler.async_clear_user_code(code_slot) + self.debug("User code at slot %s cleared", code_slot) diff --git a/zha/logbook.py b/zha/logbook.py new file mode 100644 index 00000000..e63ef565 --- /dev/null +++ b/zha/logbook.py @@ -0,0 +1,83 @@ +"""Describe ZHA logbook events.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING + +from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME +from homeassistant.const import ATTR_COMMAND, ATTR_DEVICE_ID +from homeassistant.core import Event, HomeAssistant, callback +import homeassistant.helpers.device_registry as dr + +from .core.const import DOMAIN as ZHA_DOMAIN, ZHA_EVENT +from .core.helpers import async_get_zha_device + +if TYPE_CHECKING: + from .core.device import ZHADevice + + +@callback +def async_describe_events( + hass: HomeAssistant, + async_describe_event: Callable[[str, str, Callable[[Event], dict[str, str]]], None], +) -> None: + """Describe logbook events.""" + device_registry = dr.async_get(hass) + + @callback + def async_describe_zha_event(event: Event) -> dict[str, str]: + """Describe ZHA logbook event.""" + device: dr.DeviceEntry | None = None + device_name: str = "Unknown device" + zha_device: ZHADevice | None = None + event_data = event.data + event_type: str | None = None + event_subtype: str | None = None + + try: + device = device_registry.devices[event.data[ATTR_DEVICE_ID]] + if device: + device_name = device.name_by_user or device.name or "Unknown device" + zha_device = async_get_zha_device(hass, event.data[ATTR_DEVICE_ID]) + except (KeyError, AttributeError): + pass + + if ( + zha_device + and (command := event_data.get(ATTR_COMMAND)) + and (command_to_etype_subtype := zha_device.device_automation_commands) + and (etype_subtypes := command_to_etype_subtype.get(command)) + ): + all_triggers = zha_device.device_automation_triggers + for etype_subtype in etype_subtypes: + trigger = all_triggers[etype_subtype] + if not all( + event_data.get(key) == value for key, value in trigger.items() + ): + continue + event_type, event_subtype = etype_subtype + break + + if event_type is None: + event_type = event_data.get(ATTR_COMMAND, ZHA_EVENT) + + if event_subtype is not None and event_subtype != event_type: + event_type = f"{event_type} - {event_subtype}" + + if event_type is not None: + event_type = event_type.replace("_", " ").title() + if "event" in event_type.lower(): + message = f"{event_type} was fired" + else: + message = f"{event_type} event was fired" + + if params := event_data.get("params"): + message = f"{message} with parameters: {params}" + + return { + LOGBOOK_ENTRY_NAME: device_name, + LOGBOOK_ENTRY_MESSAGE: message, + } + + async_describe_event(ZHA_DOMAIN, ZHA_EVENT, async_describe_zha_event) diff --git a/zha/number.py b/zha/number.py new file mode 100644 index 00000000..ece90f57 --- /dev/null +++ b/zha/number.py @@ -0,0 +1,1093 @@ +"""Support for ZHA AnalogOutput cluster.""" + +from __future__ import annotations + +import functools +import logging +from typing import TYPE_CHECKING, Any, Self + +from homeassistant.components.number import NumberEntity, NumberMode +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, Platform, UnitOfMass, UnitOfTemperature +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 UndefinedType +from zigpy.quirks.v2 import EntityMetadata, NumberMetadata +from zigpy.zcl.clusters.hvac import Thermostat + +from .core import discovery +from .core.const import ( + CLUSTER_HANDLER_ANALOG_OUTPUT, + CLUSTER_HANDLER_BASIC, + CLUSTER_HANDLER_COLOR, + CLUSTER_HANDLER_INOVELLI, + CLUSTER_HANDLER_LEVEL, + CLUSTER_HANDLER_OCCUPANCY, + CLUSTER_HANDLER_THERMOSTAT, + QUIRK_METADATA, + SIGNAL_ADD_ENTITIES, + SIGNAL_ATTR_UPDATED, +) +from .core.helpers import get_zha_data +from .core.registries import ZHA_ENTITIES +from .entity import ZhaEntity + +if TYPE_CHECKING: + from .core.cluster_handlers import ClusterHandler + from .core.device import ZHADevice + +_LOGGER = logging.getLogger(__name__) + +STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.NUMBER) +CONFIG_DIAGNOSTIC_MATCH = functools.partial( + ZHA_ENTITIES.config_diagnostic_match, Platform.NUMBER +) + + +UNITS = { + 0: "Square-meters", + 1: "Square-feet", + 2: "Milliamperes", + 3: "Amperes", + 4: "Ohms", + 5: "Volts", + 6: "Kilo-volts", + 7: "Mega-volts", + 8: "Volt-amperes", + 9: "Kilo-volt-amperes", + 10: "Mega-volt-amperes", + 11: "Volt-amperes-reactive", + 12: "Kilo-volt-amperes-reactive", + 13: "Mega-volt-amperes-reactive", + 14: "Degrees-phase", + 15: "Power-factor", + 16: "Joules", + 17: "Kilojoules", + 18: "Watt-hours", + 19: "Kilowatt-hours", + 20: "BTUs", + 21: "Therms", + 22: "Ton-hours", + 23: "Joules-per-kilogram-dry-air", + 24: "BTUs-per-pound-dry-air", + 25: "Cycles-per-hour", + 26: "Cycles-per-minute", + 27: "Hertz", + 28: "Grams-of-water-per-kilogram-dry-air", + 29: "Percent-relative-humidity", + 30: "Millimeters", + 31: "Meters", + 32: "Inches", + 33: "Feet", + 34: "Watts-per-square-foot", + 35: "Watts-per-square-meter", + 36: "Lumens", + 37: "Luxes", + 38: "Foot-candles", + 39: "Kilograms", + 40: "Pounds-mass", + 41: "Tons", + 42: "Kilograms-per-second", + 43: "Kilograms-per-minute", + 44: "Kilograms-per-hour", + 45: "Pounds-mass-per-minute", + 46: "Pounds-mass-per-hour", + 47: "Watts", + 48: "Kilowatts", + 49: "Megawatts", + 50: "BTUs-per-hour", + 51: "Horsepower", + 52: "Tons-refrigeration", + 53: "Pascals", + 54: "Kilopascals", + 55: "Bars", + 56: "Pounds-force-per-square-inch", + 57: "Centimeters-of-water", + 58: "Inches-of-water", + 59: "Millimeters-of-mercury", + 60: "Centimeters-of-mercury", + 61: "Inches-of-mercury", + 62: "°C", + 63: "°K", + 64: "°F", + 65: "Degree-days-Celsius", + 66: "Degree-days-Fahrenheit", + 67: "Years", + 68: "Months", + 69: "Weeks", + 70: "Days", + 71: "Hours", + 72: "Minutes", + 73: "Seconds", + 74: "Meters-per-second", + 75: "Kilometers-per-hour", + 76: "Feet-per-second", + 77: "Feet-per-minute", + 78: "Miles-per-hour", + 79: "Cubic-feet", + 80: "Cubic-meters", + 81: "Imperial-gallons", + 82: "Liters", + 83: "Us-gallons", + 84: "Cubic-feet-per-minute", + 85: "Cubic-meters-per-second", + 86: "Imperial-gallons-per-minute", + 87: "Liters-per-second", + 88: "Liters-per-minute", + 89: "Us-gallons-per-minute", + 90: "Degrees-angular", + 91: "Degrees-Celsius-per-hour", + 92: "Degrees-Celsius-per-minute", + 93: "Degrees-Fahrenheit-per-hour", + 94: "Degrees-Fahrenheit-per-minute", + 95: None, + 96: "Parts-per-million", + 97: "Parts-per-billion", + 98: "%", + 99: "Percent-per-second", + 100: "Per-minute", + 101: "Per-second", + 102: "Psi-per-Degree-Fahrenheit", + 103: "Radians", + 104: "Revolutions-per-minute", + 105: "Currency1", + 106: "Currency2", + 107: "Currency3", + 108: "Currency4", + 109: "Currency5", + 110: "Currency6", + 111: "Currency7", + 112: "Currency8", + 113: "Currency9", + 114: "Currency10", + 115: "Square-inches", + 116: "Square-centimeters", + 117: "BTUs-per-pound", + 118: "Centimeters", + 119: "Pounds-mass-per-second", + 120: "Delta-Degrees-Fahrenheit", + 121: "Delta-Degrees-Kelvin", + 122: "Kilohms", + 123: "Megohms", + 124: "Millivolts", + 125: "Kilojoules-per-kilogram", + 126: "Megajoules", + 127: "Joules-per-degree-Kelvin", + 128: "Joules-per-kilogram-degree-Kelvin", + 129: "Kilohertz", + 130: "Megahertz", + 131: "Per-hour", + 132: "Milliwatts", + 133: "Hectopascals", + 134: "Millibars", + 135: "Cubic-meters-per-hour", + 136: "Liters-per-hour", + 137: "Kilowatt-hours-per-square-meter", + 138: "Kilowatt-hours-per-square-foot", + 139: "Megajoules-per-square-meter", + 140: "Megajoules-per-square-foot", + 141: "Watts-per-square-meter-Degree-Kelvin", + 142: "Cubic-feet-per-second", + 143: "Percent-obscuration-per-foot", + 144: "Percent-obscuration-per-meter", + 145: "Milliohms", + 146: "Megawatt-hours", + 147: "Kilo-BTUs", + 148: "Mega-BTUs", + 149: "Kilojoules-per-kilogram-dry-air", + 150: "Megajoules-per-kilogram-dry-air", + 151: "Kilojoules-per-degree-Kelvin", + 152: "Megajoules-per-degree-Kelvin", + 153: "Newton", + 154: "Grams-per-second", + 155: "Grams-per-minute", + 156: "Tons-per-hour", + 157: "Kilo-BTUs-per-hour", + 158: "Hundredths-seconds", + 159: "Milliseconds", + 160: "Newton-meters", + 161: "Millimeters-per-second", + 162: "Millimeters-per-minute", + 163: "Meters-per-minute", + 164: "Meters-per-hour", + 165: "Cubic-meters-per-minute", + 166: "Meters-per-second-per-second", + 167: "Amperes-per-meter", + 168: "Amperes-per-square-meter", + 169: "Ampere-square-meters", + 170: "Farads", + 171: "Henrys", + 172: "Ohm-meters", + 173: "Siemens", + 174: "Siemens-per-meter", + 175: "Teslas", + 176: "Volts-per-degree-Kelvin", + 177: "Volts-per-meter", + 178: "Webers", + 179: "Candelas", + 180: "Candelas-per-square-meter", + 181: "Kelvins-per-hour", + 182: "Kelvins-per-minute", + 183: "Joule-seconds", + 185: "Square-meters-per-Newton", + 186: "Kilogram-per-cubic-meter", + 187: "Newton-seconds", + 188: "Newtons-per-meter", + 189: "Watts-per-meter-per-degree-Kelvin", +} + +ICONS = { + 0: "mdi:temperature-celsius", + 1: "mdi:water-percent", + 2: "mdi:gauge", + 3: "mdi:speedometer", + 4: "mdi:percent", + 5: "mdi:air-filter", + 6: "mdi:fan", + 7: "mdi:flash", + 8: "mdi:current-ac", + 9: "mdi:flash", + 10: "mdi:flash", + 11: "mdi:flash", + 12: "mdi:counter", + 13: "mdi:thermometer-lines", + 14: "mdi:timer", + 15: "mdi:palette", + 16: "mdi:brightness-percent", +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Zigbee Home Automation Analog Output from config entry.""" + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.NUMBER] + + unsub = async_dispatcher_connect( + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, + async_add_entities, + entities_to_create, + ), + ) + config_entry.async_on_unload(unsub) + + +@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_ANALOG_OUTPUT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class ZhaNumber(ZhaEntity, NumberEntity): + """Representation of a ZHA Number entity.""" + + _attr_translation_key: str = "number" + + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> None: + """Init this entity.""" + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._analog_output_cluster_handler = self.cluster_handlers[ + CLUSTER_HANDLER_ANALOG_OUTPUT + ] + + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + await super().async_added_to_hass() + self.async_accept_signal( + self._analog_output_cluster_handler, + SIGNAL_ATTR_UPDATED, + self.async_set_state, + ) + + @property + def native_value(self) -> float | None: + """Return the current value.""" + return self._analog_output_cluster_handler.present_value + + @property + def native_min_value(self) -> float: + """Return the minimum value.""" + min_present_value = self._analog_output_cluster_handler.min_present_value + if min_present_value is not None: + return min_present_value + return 0 + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + max_present_value = self._analog_output_cluster_handler.max_present_value + if max_present_value is not None: + return max_present_value + return 1023 + + @property + def native_step(self) -> float | None: + """Return the value step.""" + resolution = self._analog_output_cluster_handler.resolution + if resolution is not None: + return resolution + return super().native_step + + @property + def name(self) -> str | UndefinedType | None: + """Return the name of the number entity.""" + description = self._analog_output_cluster_handler.description + if description is not None and len(description) > 0: + return f"{super().name} {description}" + return super().name + + @property + def icon(self) -> str | None: + """Return the icon to be used for this entity.""" + application_type = self._analog_output_cluster_handler.application_type + if application_type is not None: + return ICONS.get(application_type >> 16, super().icon) + return super().icon + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit the value is expressed in.""" + engineering_units = self._analog_output_cluster_handler.engineering_units + return UNITS.get(engineering_units) + + @callback + def async_set_state(self, attr_id, attr_name, value): + """Handle value update from cluster handler.""" + self.async_write_ha_state() + + async def async_set_native_value(self, value: float) -> None: + """Update the current value from HA.""" + await self._analog_output_cluster_handler.async_set_present_value(float(value)) + self.async_write_ha_state() + + async def async_update(self) -> None: + """Attempt to retrieve the state of the entity.""" + await super().async_update() + _LOGGER.debug("polling current state") + if self._analog_output_cluster_handler: + value = await self._analog_output_cluster_handler.get_attribute_value( + "present_value", from_cache=False + ) + _LOGGER.debug("read value=%s", value) + + +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): + """Representation of a ZHA number configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_native_step: float = 1.0 + _attr_multiplier: float = 1 + _attribute_name: str + + @classmethod + def create_entity( + cls, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> Self | None: + """Entity Factory. + + Return entity if it is a supported configuration, otherwise return None + """ + cluster_handler = cluster_handlers[0] + if QUIRK_METADATA not in kwargs and ( + cls._attribute_name in cluster_handler.cluster.unsupported_attributes + or cls._attribute_name not in cluster_handler.cluster.attributes_by_name + or cluster_handler.cluster.get(cls._attribute_name) is None + ): + _LOGGER.debug( + "%s is not supported - skipping %s entity creation", + cls._attribute_name, + cls.__name__, + ) + return None + + return cls(unique_id, zha_device, cluster_handlers, **kwargs) + + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> None: + """Init this number configuration entity.""" + self._cluster_handler: ClusterHandler = cluster_handlers[0] + if QUIRK_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + super()._init_from_quirks_metadata(entity_metadata) + number_metadata: NumberMetadata = entity_metadata.entity_metadata + self._attribute_name = number_metadata.attribute_name + + if number_metadata.min is not None: + self._attr_native_min_value = number_metadata.min + if number_metadata.max is not None: + self._attr_native_max_value = number_metadata.max + if number_metadata.step is not None: + self._attr_native_step = number_metadata.step + if number_metadata.unit is not None: + self._attr_native_unit_of_measurement = number_metadata.unit + if number_metadata.multiplier is not None: + self._attr_multiplier = number_metadata.multiplier + + @property + def native_value(self) -> float: + """Return the current value.""" + return ( + self._cluster_handler.cluster.get(self._attribute_name) + * self._attr_multiplier + ) + + async def async_set_native_value(self, value: float) -> None: + """Update the current value from HA.""" + await self._cluster_handler.write_attributes_safe( + {self._attribute_name: int(value / self._attr_multiplier)} + ) + self.async_write_ha_state() + + async def async_update(self) -> None: + """Attempt to retrieve the state of the entity.""" + await super().async_update() + _LOGGER.debug("polling current state") + if self._cluster_handler: + value = await self._cluster_handler.get_attribute_value( + self._attribute_name, from_cache=False + ) + _LOGGER.debug("read value=%s", value) + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", + models={"lumi.motion.ac02", "lumi.motion.agl04"}, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class AqaraMotionDetectionInterval(ZHANumberConfigurationEntity): + """Representation of a ZHA motion detection interval configuration entity.""" + + _unique_id_suffix = "detection_interval" + _attr_native_min_value: float = 2 + _attr_native_max_value: float = 65535 + _attribute_name = "detection_interval" + _attr_translation_key: str = "detection_interval" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class OnOffTransitionTimeConfigurationEntity(ZHANumberConfigurationEntity): + """Representation of a ZHA on off transition time configuration entity.""" + + _unique_id_suffix = "on_off_transition_time" + _attr_native_min_value: float = 0x0000 + _attr_native_max_value: float = 0xFFFF + _attribute_name = "on_off_transition_time" + _attr_translation_key: str = "on_off_transition_time" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class OnLevelConfigurationEntity(ZHANumberConfigurationEntity): + """Representation of a ZHA on level configuration entity.""" + + _unique_id_suffix = "on_level" + _attr_native_min_value: float = 0x00 + _attr_native_max_value: float = 0xFF + _attribute_name = "on_level" + _attr_translation_key: str = "on_level" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class OnTransitionTimeConfigurationEntity(ZHANumberConfigurationEntity): + """Representation of a ZHA on transition time configuration entity.""" + + _unique_id_suffix = "on_transition_time" + _attr_native_min_value: float = 0x0000 + _attr_native_max_value: float = 0xFFFE + _attribute_name = "on_transition_time" + _attr_translation_key: str = "on_transition_time" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class OffTransitionTimeConfigurationEntity(ZHANumberConfigurationEntity): + """Representation of a ZHA off transition time configuration entity.""" + + _unique_id_suffix = "off_transition_time" + _attr_native_min_value: float = 0x0000 + _attr_native_max_value: float = 0xFFFE + _attribute_name = "off_transition_time" + _attr_translation_key: str = "off_transition_time" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class DefaultMoveRateConfigurationEntity(ZHANumberConfigurationEntity): + """Representation of a ZHA default move rate configuration entity.""" + + _unique_id_suffix = "default_move_rate" + _attr_native_min_value: float = 0x00 + _attr_native_max_value: float = 0xFE + _attribute_name = "default_move_rate" + _attr_translation_key: str = "default_move_rate" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class StartUpCurrentLevelConfigurationEntity(ZHANumberConfigurationEntity): + """Representation of a ZHA startup current level configuration entity.""" + + _unique_id_suffix = "start_up_current_level" + _attr_native_min_value: float = 0x00 + _attr_native_max_value: float = 0xFF + _attribute_name = "start_up_current_level" + _attr_translation_key: str = "start_up_current_level" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_COLOR) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class StartUpColorTemperatureConfigurationEntity(ZHANumberConfigurationEntity): + """Representation of a ZHA startup color temperature configuration entity.""" + + _unique_id_suffix = "start_up_color_temperature" + _attr_native_min_value: float = 153 + _attr_native_max_value: float = 500 + _attribute_name = "start_up_color_temperature" + _attr_translation_key: str = "start_up_color_temperature" + + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> None: + """Init this ZHA startup color temperature entity.""" + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + if self._cluster_handler: + self._attr_native_min_value: float = self._cluster_handler.min_mireds + self._attr_native_max_value: float = self._cluster_handler.max_mireds + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="tuya_manufacturer", + manufacturers={ + "_TZE200_htnnfasr", + }, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class TimerDurationMinutes(ZHANumberConfigurationEntity): + """Representation of a ZHA timer duration configuration entity.""" + + _unique_id_suffix = "timer_duration" + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[14] + _attr_native_min_value: float = 0x00 + _attr_native_max_value: float = 0x257 + _attr_native_unit_of_measurement: str | None = UNITS[72] + _attribute_name = "timer_duration" + _attr_translation_key: str = "timer_duration" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names="ikea_airpurifier") +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class FilterLifeTime(ZHANumberConfigurationEntity): + """Representation of a ZHA filter lifetime configuration entity.""" + + _unique_id_suffix = "filter_life_time" + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[14] + _attr_native_min_value: float = 0x00 + _attr_native_max_value: float = 0xFFFFFFFF + _attr_native_unit_of_measurement: str | None = UNITS[72] + _attribute_name = "filter_life_time" + _attr_translation_key: str = "filter_life_time" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_BASIC, + manufacturers={"TexasInstruments"}, + models={"ti.router"}, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class TiRouterTransmitPower(ZHANumberConfigurationEntity): + """Representation of a ZHA TI transmit power configuration entity.""" + + _unique_id_suffix = "transmit_power" + _attr_native_min_value: float = -20 + _attr_native_max_value: float = 20 + _attribute_name = "transmit_power" + _attr_translation_key: str = "transmit_power" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class InovelliRemoteDimmingUpSpeed(ZHANumberConfigurationEntity): + """Inovelli remote dimming up speed configuration entity.""" + + _unique_id_suffix = "dimming_speed_up_remote" + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[3] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 126 + _attribute_name = "dimming_speed_up_remote" + _attr_translation_key: str = "dimming_speed_up_remote" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class InovelliButtonDelay(ZHANumberConfigurationEntity): + """Inovelli button delay configuration entity.""" + + _unique_id_suffix = "button_delay" + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[3] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 9 + _attribute_name = "button_delay" + _attr_translation_key: str = "button_delay" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class InovelliLocalDimmingUpSpeed(ZHANumberConfigurationEntity): + """Inovelli local dimming up speed configuration entity.""" + + _unique_id_suffix = "dimming_speed_up_local" + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[3] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 127 + _attribute_name = "dimming_speed_up_local" + _attr_translation_key: str = "dimming_speed_up_local" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class InovelliLocalRampRateOffToOn(ZHANumberConfigurationEntity): + """Inovelli off to on local ramp rate configuration entity.""" + + _unique_id_suffix = "ramp_rate_off_to_on_local" + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[3] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 127 + _attribute_name = "ramp_rate_off_to_on_local" + _attr_translation_key: str = "ramp_rate_off_to_on_local" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class InovelliRemoteDimmingSpeedOffToOn(ZHANumberConfigurationEntity): + """Inovelli off to on remote ramp rate configuration entity.""" + + _unique_id_suffix = "ramp_rate_off_to_on_remote" + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[3] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 127 + _attribute_name = "ramp_rate_off_to_on_remote" + _attr_translation_key: str = "ramp_rate_off_to_on_remote" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class InovelliRemoteDimmingDownSpeed(ZHANumberConfigurationEntity): + """Inovelli remote dimming down speed configuration entity.""" + + _unique_id_suffix = "dimming_speed_down_remote" + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[3] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 127 + _attribute_name = "dimming_speed_down_remote" + _attr_translation_key: str = "dimming_speed_down_remote" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class InovelliLocalDimmingDownSpeed(ZHANumberConfigurationEntity): + """Inovelli local dimming down speed configuration entity.""" + + _unique_id_suffix = "dimming_speed_down_local" + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[3] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 127 + _attribute_name = "dimming_speed_down_local" + _attr_translation_key: str = "dimming_speed_down_local" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class InovelliLocalRampRateOnToOff(ZHANumberConfigurationEntity): + """Inovelli local on to off ramp rate configuration entity.""" + + _unique_id_suffix = "ramp_rate_on_to_off_local" + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[3] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 127 + _attribute_name = "ramp_rate_on_to_off_local" + _attr_translation_key: str = "ramp_rate_on_to_off_local" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class InovelliRemoteDimmingSpeedOnToOff(ZHANumberConfigurationEntity): + """Inovelli remote on to off ramp rate configuration entity.""" + + _unique_id_suffix = "ramp_rate_on_to_off_remote" + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[3] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 127 + _attribute_name = "ramp_rate_on_to_off_remote" + _attr_translation_key: str = "ramp_rate_on_to_off_remote" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class InovelliMinimumLoadDimmingLevel(ZHANumberConfigurationEntity): + """Inovelli minimum load dimming level configuration entity.""" + + _unique_id_suffix = "minimum_level" + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[16] + _attr_native_min_value: float = 1 + _attr_native_max_value: float = 254 + _attribute_name = "minimum_level" + _attr_translation_key: str = "minimum_level" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class InovelliMaximumLoadDimmingLevel(ZHANumberConfigurationEntity): + """Inovelli maximum load dimming level configuration entity.""" + + _unique_id_suffix = "maximum_level" + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[16] + _attr_native_min_value: float = 2 + _attr_native_max_value: float = 255 + _attribute_name = "maximum_level" + _attr_translation_key: str = "maximum_level" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class InovelliAutoShutoffTimer(ZHANumberConfigurationEntity): + """Inovelli automatic switch shutoff timer configuration entity.""" + + _unique_id_suffix = "auto_off_timer" + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[14] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 32767 + _attribute_name = "auto_off_timer" + _attr_translation_key: str = "auto_off_timer" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, models={"VZM35-SN"} +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class InovelliQuickStartTime(ZHANumberConfigurationEntity): + """Inovelli fan quick start time configuration entity.""" + + _unique_id_suffix = "quick_start_time" + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[3] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 10 + _attribute_name = "quick_start_time" + _attr_translation_key: str = "quick_start_time" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class InovelliLoadLevelIndicatorTimeout(ZHANumberConfigurationEntity): + """Inovelli load level indicator timeout configuration entity.""" + + _unique_id_suffix = "load_level_indicator_timeout" + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[14] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 11 + _attribute_name = "load_level_indicator_timeout" + _attr_translation_key: str = "load_level_indicator_timeout" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class InovelliDefaultAllLEDOnColor(ZHANumberConfigurationEntity): + """Inovelli default all led color when on configuration entity.""" + + _unique_id_suffix = "led_color_when_on" + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[15] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 255 + _attribute_name = "led_color_when_on" + _attr_translation_key: str = "led_color_when_on" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class InovelliDefaultAllLEDOffColor(ZHANumberConfigurationEntity): + """Inovelli default all led color when off configuration entity.""" + + _unique_id_suffix = "led_color_when_off" + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[15] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 255 + _attribute_name = "led_color_when_off" + _attr_translation_key: str = "led_color_when_off" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class InovelliDefaultAllLEDOnIntensity(ZHANumberConfigurationEntity): + """Inovelli default all led intensity when on configuration entity.""" + + _unique_id_suffix = "led_intensity_when_on" + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[16] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 100 + _attribute_name = "led_intensity_when_on" + _attr_translation_key: str = "led_intensity_when_on" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class InovelliDefaultAllLEDOffIntensity(ZHANumberConfigurationEntity): + """Inovelli default all led intensity when off configuration entity.""" + + _unique_id_suffix = "led_intensity_when_off" + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[16] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 100 + _attribute_name = "led_intensity_when_off" + _attr_translation_key: str = "led_intensity_when_off" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class InovelliDoubleTapUpLevel(ZHANumberConfigurationEntity): + """Inovelli double tap up level configuration entity.""" + + _unique_id_suffix = "double_tap_up_level" + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[16] + _attr_native_min_value: float = 2 + _attr_native_max_value: float = 254 + _attribute_name = "double_tap_up_level" + _attr_translation_key: str = "double_tap_up_level" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class InovelliDoubleTapDownLevel(ZHANumberConfigurationEntity): + """Inovelli double tap down level configuration entity.""" + + _unique_id_suffix = "double_tap_down_level" + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[16] + _attr_native_min_value: float = 0 + _attr_native_max_value: float = 254 + _attribute_name = "double_tap_down_level" + _attr_translation_key: str = "double_tap_down_level" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class AqaraPetFeederServingSize(ZHANumberConfigurationEntity): + """Aqara pet feeder serving size configuration entity.""" + + _unique_id_suffix = "serving_size" + _attr_entity_category = EntityCategory.CONFIG + _attr_native_min_value: float = 1 + _attr_native_max_value: float = 10 + _attribute_name = "serving_size" + _attr_translation_key: str = "serving_size" + + _attr_mode: NumberMode = NumberMode.BOX + _attr_icon: str = "mdi:counter" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class AqaraPetFeederPortionWeight(ZHANumberConfigurationEntity): + """Aqara pet feeder portion weight configuration entity.""" + + _unique_id_suffix = "portion_weight" + _attr_entity_category = EntityCategory.CONFIG + _attr_native_min_value: float = 1 + _attr_native_max_value: float = 100 + _attribute_name = "portion_weight" + _attr_translation_key: str = "portion_weight" + + _attr_mode: NumberMode = NumberMode.BOX + _attr_native_unit_of_measurement: str = UnitOfMass.GRAMS + _attr_icon: str = "mdi:weight-gram" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class AqaraThermostatAwayTemp(ZHANumberConfigurationEntity): + """Aqara away preset temperature configuration entity.""" + + _unique_id_suffix = "away_preset_temperature" + _attr_entity_category = EntityCategory.CONFIG + _attr_native_min_value: float = 5 + _attr_native_max_value: float = 30 + _attr_multiplier: float = 0.01 + _attribute_name = "away_preset_temperature" + _attr_translation_key: str = "away_preset_temperature" + + _attr_mode: NumberMode = NumberMode.SLIDER + _attr_native_unit_of_measurement: str = UnitOfTemperature.CELSIUS + _attr_icon: str = ICONS[0] + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class ThermostatLocalTempCalibration(ZHANumberConfigurationEntity): + """Local temperature calibration.""" + + _unique_id_suffix = "local_temperature_calibration" + _attr_native_min_value: float = -2.5 + _attr_native_max_value: float = 2.5 + _attr_native_step: float = 0.1 + _attr_multiplier: float = 0.1 + _attribute_name = "local_temperature_calibration" + _attr_translation_key: str = "local_temperature_calibration" + + _attr_mode: NumberMode = NumberMode.SLIDER + _attr_native_unit_of_measurement: str = UnitOfTemperature.CELSIUS + _attr_icon: str = ICONS[0] + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + models={"TRVZB"}, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class SonoffThermostatLocalTempCalibration(ThermostatLocalTempCalibration): + """Local temperature calibration for the Sonoff TRVZB.""" + + _attr_native_min_value: float = -7 + _attr_native_max_value: float = 7 + _attr_native_step: float = 0.2 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_OCCUPANCY, models={"SNZB-06P"} +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class SonoffPresenceSenorTimeout(ZHANumberConfigurationEntity): + """Configuration of Sonoff sensor presence detection timeout.""" + + _unique_id_suffix = "presence_detection_timeout" + _attr_entity_category = EntityCategory.CONFIG + _attr_native_min_value: int = 15 + _attr_native_max_value: int = 60 + _attribute_name = "ultrasonic_o_to_u_delay" + _attr_translation_key: str = "presence_detection_timeout" + + _attr_mode: NumberMode = NumberMode.BOX + _attr_icon: str = "mdi:timer-edit" + + +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class ZCLTemperatureEntity(ZHANumberConfigurationEntity): + """Common entity class for ZCL temperature input.""" + + _attr_native_unit_of_measurement: str = UnitOfTemperature.CELSIUS + _attr_mode: NumberMode = NumberMode.BOX + _attr_native_step: float = 0.01 + _attr_multiplier: float = 0.01 + + +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class ZCLHeatSetpointLimitEntity(ZCLTemperatureEntity): + """Min or max heat setpoint setting on thermostats.""" + + _attr_icon: str = "mdi:thermostat" + _attr_native_step: float = 0.5 + + _min_source = Thermostat.AttributeDefs.abs_min_heat_setpoint_limit.name + _max_source = Thermostat.AttributeDefs.abs_max_heat_setpoint_limit.name + + @property + def native_min_value(self) -> float: + """Return the minimum value.""" + # The spec says 0x954D, which is a signed integer, therefore the value is in decimals + min_present_value = self._cluster_handler.cluster.get(self._min_source, -27315) + return min_present_value * self._attr_multiplier + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + max_present_value = self._cluster_handler.cluster.get(self._max_source, 0x7FFF) + return max_present_value * self._attr_multiplier + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class MaxHeatSetpointLimit(ZCLHeatSetpointLimitEntity): + """Max heat setpoint setting on thermostats. + + Optional thermostat attribute. + """ + + _unique_id_suffix = "max_heat_setpoint_limit" + _attribute_name: str = "max_heat_setpoint_limit" + _attr_translation_key: str = "max_heat_setpoint_limit" + _attr_entity_category = EntityCategory.CONFIG + + _min_source = Thermostat.AttributeDefs.min_heat_setpoint_limit.name + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class MinHeatSetpointLimit(ZCLHeatSetpointLimitEntity): + """Min heat setpoint setting on thermostats. + + Optional thermostat attribute. + """ + + _unique_id_suffix = "min_heat_setpoint_limit" + _attribute_name: str = "min_heat_setpoint_limit" + _attr_translation_key: str = "min_heat_setpoint_limit" + _attr_entity_category = EntityCategory.CONFIG + + _max_source = Thermostat.AttributeDefs.max_heat_setpoint_limit.name diff --git a/zha/radio_manager.py b/zha/radio_manager.py new file mode 100644 index 00000000..d64e8059 --- /dev/null +++ b/zha/radio_manager.py @@ -0,0 +1,464 @@ +"""Config flow for ZHA.""" + +from __future__ import annotations + +import asyncio +import contextlib +from contextlib import suppress +import copy +import enum +import logging +import os +from typing import Any, Self + +from bellows.config import CONF_USE_THREAD +from homeassistant import config_entries +from homeassistant.components import usb +from homeassistant.core import HomeAssistant +import voluptuous as vol +from zigpy.application import ControllerApplication +import zigpy.backups +from zigpy.config import ( + CONF_DATABASE, + CONF_DEVICE, + CONF_DEVICE_PATH, + CONF_NWK_BACKUP_ENABLED, + SCHEMA_DEVICE, +) +from zigpy.exceptions import NetworkNotFormed + +from . import repairs +from .core.const import ( + CONF_RADIO_TYPE, + CONF_ZIGPY, + DEFAULT_DATABASE_NAME, + EZSP_OVERWRITE_EUI64, + RadioType, +) +from .core.helpers import get_zha_data + +# Only the common radio types will be autoprobed, ordered by new device popularity. +# XBee takes too long to probe since it scans through all possible bauds and likely has +# very few users to begin with. +AUTOPROBE_RADIOS = ( + RadioType.ezsp, + RadioType.znp, + RadioType.deconz, + RadioType.zigate, +) + +RECOMMENDED_RADIOS = ( + RadioType.ezsp, + RadioType.znp, + RadioType.deconz, +) + +CONNECT_DELAY_S = 1.0 +RETRY_DELAY_S = 1.0 + +BACKUP_RETRIES = 5 +MIGRATION_RETRIES = 100 + + +DEVICE_SCHEMA = vol.Schema( + { + vol.Required("path"): str, + vol.Optional("baudrate", default=115200): int, + vol.Optional("flow_control", default=None): vol.In( + ["hardware", "software", None] + ), + } +) + +HARDWARE_DISCOVERY_SCHEMA = vol.Schema( + { + vol.Required("name"): str, + vol.Required("port"): DEVICE_SCHEMA, + vol.Required("radio_type"): str, + } +) + +HARDWARE_MIGRATION_SCHEMA = vol.Schema( + { + vol.Required("new_discovery_info"): HARDWARE_DISCOVERY_SCHEMA, + vol.Required("old_discovery_info"): vol.Schema( + { + vol.Exclusive("hw", "discovery"): HARDWARE_DISCOVERY_SCHEMA, + vol.Exclusive("usb", "discovery"): usb.UsbServiceInfo, + } + ), + } +) + +_LOGGER = logging.getLogger(__name__) + + +class ProbeResult(enum.StrEnum): + """Radio firmware probing result.""" + + RADIO_TYPE_DETECTED = "radio_type_detected" + WRONG_FIRMWARE_INSTALLED = "wrong_firmware_installed" + PROBING_FAILED = "probing_failed" + + +def _allow_overwrite_ezsp_ieee( + backup: zigpy.backups.NetworkBackup, +) -> zigpy.backups.NetworkBackup: + """Return a new backup with the flag to allow overwriting the EZSP EUI64.""" + new_stack_specific = copy.deepcopy(backup.network_info.stack_specific) + new_stack_specific.setdefault("ezsp", {})[EZSP_OVERWRITE_EUI64] = True + + return backup.replace( + network_info=backup.network_info.replace(stack_specific=new_stack_specific) + ) + + +def _prevent_overwrite_ezsp_ieee( + backup: zigpy.backups.NetworkBackup, +) -> zigpy.backups.NetworkBackup: + """Return a new backup without the flag to allow overwriting the EZSP EUI64.""" + if "ezsp" not in backup.network_info.stack_specific: + return backup + + new_stack_specific = copy.deepcopy(backup.network_info.stack_specific) + new_stack_specific.setdefault("ezsp", {}).pop(EZSP_OVERWRITE_EUI64, None) + + return backup.replace( + network_info=backup.network_info.replace(stack_specific=new_stack_specific) + ) + + +class ZhaRadioManager: + """Helper class with radio related functionality.""" + + hass: HomeAssistant + + def __init__(self) -> None: + """Initialize ZhaRadioManager instance.""" + self.device_path: str | None = None + self.device_settings: dict[str, Any] | None = None + self.radio_type: RadioType | None = None + self.current_settings: zigpy.backups.NetworkBackup | None = None + self.backups: list[zigpy.backups.NetworkBackup] = [] + self.chosen_backup: zigpy.backups.NetworkBackup | None = None + + @classmethod + def from_config_entry( + cls, hass: HomeAssistant, config_entry: config_entries.ConfigEntry + ) -> Self: + """Create an instance from a config entry.""" + mgr = cls() + mgr.hass = hass + mgr.device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] + mgr.device_settings = config_entry.data[CONF_DEVICE] + mgr.radio_type = RadioType[config_entry.data[CONF_RADIO_TYPE]] + + return mgr + + @contextlib.asynccontextmanager + async def connect_zigpy_app(self) -> ControllerApplication: + """Connect to the radio with the current config and then clean up.""" + assert self.radio_type is not None + + config = get_zha_data(self.hass).yaml_config + app_config = config.get(CONF_ZIGPY, {}).copy() + + database_path = config.get( + CONF_DATABASE, + self.hass.config.path(DEFAULT_DATABASE_NAME), + ) + + # Don't create `zigbee.db` if it doesn't already exist + if not await self.hass.async_add_executor_job(os.path.exists, database_path): + database_path = None + + app_config[CONF_DATABASE] = database_path + app_config[CONF_DEVICE] = self.device_settings + app_config[CONF_NWK_BACKUP_ENABLED] = False + app_config[CONF_USE_THREAD] = False + app_config = self.radio_type.controller.SCHEMA(app_config) + + app = await self.radio_type.controller.new( + app_config, auto_form=False, start_radio=False + ) + + try: + yield app + finally: + await app.shutdown() + await asyncio.sleep(CONNECT_DELAY_S) + + async def restore_backup( + self, backup: zigpy.backups.NetworkBackup, **kwargs: Any + ) -> None: + """Restore the provided network backup, passing through kwargs.""" + if self.current_settings is not None and self.current_settings.supersedes( + self.chosen_backup + ): + return + + async with self.connect_zigpy_app() as app: + await app.connect() + await app.backups.restore_backup(backup, **kwargs) + + @staticmethod + def parse_radio_type(radio_type: str) -> RadioType: + """Parse a radio type name, accounting for past aliases.""" + if radio_type == "efr32": + return RadioType.ezsp + + return RadioType[radio_type] + + async def detect_radio_type(self) -> ProbeResult: + """Probe all radio types on the current port.""" + assert self.device_path is not None + + for radio in AUTOPROBE_RADIOS: + _LOGGER.debug("Attempting to probe radio type %s", radio) + + dev_config = SCHEMA_DEVICE({CONF_DEVICE_PATH: self.device_path}) + probe_result = await radio.controller.probe(dev_config) + + if not probe_result: + continue + + # Radio library probing can succeed and return new device settings + if isinstance(probe_result, dict): + dev_config = probe_result + + self.radio_type = radio + self.device_settings = dev_config + + repairs.async_delete_blocking_issues(self.hass) + return ProbeResult.RADIO_TYPE_DETECTED + + with suppress(repairs.wrong_silabs_firmware.AlreadyRunningEZSP): + if await repairs.wrong_silabs_firmware.warn_on_wrong_silabs_firmware( + self.hass, self.device_path + ): + return ProbeResult.WRONG_FIRMWARE_INSTALLED + + return ProbeResult.PROBING_FAILED + + async def async_load_network_settings( + self, *, create_backup: bool = False + ) -> zigpy.backups.NetworkBackup | None: + """Connect to the radio and load its current network settings.""" + backup = None + + async with self.connect_zigpy_app() as app: + await app.connect() + + # Check if the stick has any settings and load them + try: + await app.load_network_info() + except NetworkNotFormed: + pass + else: + self.current_settings = zigpy.backups.NetworkBackup( + network_info=app.state.network_info, + node_info=app.state.node_info, + ) + + if create_backup: + backup = await app.backups.create_backup() + + # The list of backups will always exist + self.backups = app.backups.backups.copy() + self.backups.sort(reverse=True, key=lambda b: b.backup_time) + + return backup + + async def async_form_network(self) -> None: + """Form a brand-new network.""" + async with self.connect_zigpy_app() as app: + await app.connect() + await app.form_network() + + async def async_reset_adapter(self) -> None: + """Reset the current adapter.""" + async with self.connect_zigpy_app() as app: + await app.connect() + await app.reset_network_info() + + async def async_restore_backup_step_1(self) -> bool: + """Prepare restoring backup. + + Returns True if async_restore_backup_step_2 should be called. + """ + assert self.chosen_backup is not None + + if self.radio_type != RadioType.ezsp: + await self.restore_backup(self.chosen_backup) + return False + + # We have no way to partially load network settings if no network is formed + if self.current_settings is None: + # Since we are going to be restoring the backup anyways, write it to the + # radio without overwriting the IEEE but don't take a backup with these + # temporary settings + temp_backup = _prevent_overwrite_ezsp_ieee(self.chosen_backup) + await self.restore_backup(temp_backup, create_new=False) + await self.async_load_network_settings() + + assert self.current_settings is not None + + metadata = self.current_settings.network_info.metadata["ezsp"] + + if ( + self.current_settings.node_info.ieee == self.chosen_backup.node_info.ieee + or metadata["can_rewrite_custom_eui64"] + or not metadata["can_burn_userdata_custom_eui64"] + ): + # No point in prompting the user if the backup doesn't have a new IEEE + # address or if there is no way to overwrite the IEEE address a second time + await self.restore_backup(self.chosen_backup) + + return False + + return True + + async def async_restore_backup_step_2(self, overwrite_ieee: bool) -> None: + """Restore backup and optionally overwrite IEEE.""" + assert self.chosen_backup is not None + + backup = self.chosen_backup + + if overwrite_ieee: + backup = _allow_overwrite_ezsp_ieee(backup) + + # If the user declined to overwrite the IEEE *and* we wrote the backup to + # their empty radio above, restoring it again would be redundant. + await self.restore_backup(backup) + + +class ZhaMultiPANMigrationHelper: + """Helper class for automatic migration when upgrading the firmware of a radio. + + This class is currently only intended to be used when changing the firmware on the + radio used in the Home Assistant SkyConnect USB stick and the Home Assistant Yellow + from Zigbee only firmware to firmware supporting both Zigbee and Thread. + """ + + def __init__( + self, hass: HomeAssistant, config_entry: config_entries.ConfigEntry + ) -> None: + """Initialize MigrationHelper instance.""" + self._config_entry = config_entry + self._hass = hass + self._radio_mgr = ZhaRadioManager() + self._radio_mgr.hass = hass + + async def async_initiate_migration(self, data: dict[str, Any]) -> bool: + """Initiate ZHA migration. + + The passed data should contain: + - Discovery data identifying the device being firmware updated + - Discovery data for connecting to the device after the firmware update is + completed. + + Returns True if async_finish_migration should be called after the firmware + update is completed. + """ + migration_data = HARDWARE_MIGRATION_SCHEMA(data) + + name = migration_data["new_discovery_info"]["name"] + new_radio_type = ZhaRadioManager.parse_radio_type( + migration_data["new_discovery_info"]["radio_type"] + ) + + new_device_settings = SCHEMA_DEVICE( + migration_data["new_discovery_info"]["port"] + ) + + if "hw" in migration_data["old_discovery_info"]: + old_device_path = migration_data["old_discovery_info"]["hw"]["port"]["path"] + else: # usb + device = migration_data["old_discovery_info"]["usb"].device + old_device_path = await self._hass.async_add_executor_job( + usb.get_serial_by_id, device + ) + + if self._config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] != old_device_path: + # ZHA is using another radio, do nothing + return False + + # OperationNotAllowed: ZHA is not running + with suppress(config_entries.OperationNotAllowed): + await self._hass.config_entries.async_unload(self._config_entry.entry_id) + + # Temporarily connect to the old radio to read its settings + config_entry_data = self._config_entry.data + old_radio_mgr = ZhaRadioManager() + old_radio_mgr.hass = self._hass + old_radio_mgr.device_path = config_entry_data[CONF_DEVICE][CONF_DEVICE_PATH] + old_radio_mgr.device_settings = config_entry_data[CONF_DEVICE] + old_radio_mgr.radio_type = RadioType[config_entry_data[CONF_RADIO_TYPE]] + + for retry in range(BACKUP_RETRIES): + try: + backup = await old_radio_mgr.async_load_network_settings( + create_backup=True + ) + break + except OSError as err: + if retry >= BACKUP_RETRIES - 1: + raise + + _LOGGER.debug( + "Failed to create backup %r, retrying in %s seconds", + err, + RETRY_DELAY_S, + ) + + await asyncio.sleep(RETRY_DELAY_S) + + # Then configure the radio manager for the new radio to use the new settings + self._radio_mgr.chosen_backup = backup + self._radio_mgr.radio_type = new_radio_type + self._radio_mgr.device_path = new_device_settings[CONF_DEVICE_PATH] + self._radio_mgr.device_settings = new_device_settings + device_settings = self._radio_mgr.device_settings.copy() # type: ignore[union-attr] + + # Update the config entry settings + self._hass.config_entries.async_update_entry( + entry=self._config_entry, + data={ + CONF_DEVICE: device_settings, + CONF_RADIO_TYPE: self._radio_mgr.radio_type.name, + }, + options=self._config_entry.options, + title=name, + ) + return True + + async def async_finish_migration(self) -> None: + """Finish ZHA migration. + + Throws an exception if the migration did not succeed. + """ + # Restore the backup, permanently overwriting the device IEEE address + for retry in range(MIGRATION_RETRIES): + try: + if await self._radio_mgr.async_restore_backup_step_1(): + await self._radio_mgr.async_restore_backup_step_2(True) + + break + except OSError as err: + if retry >= MIGRATION_RETRIES - 1: + raise + + _LOGGER.debug( + "Failed to restore backup %r, retrying in %s seconds", + err, + RETRY_DELAY_S, + ) + + await asyncio.sleep(RETRY_DELAY_S) + + _LOGGER.debug("Restored backup after %s retries", retry) + + # Launch ZHA again + # OperationNotAllowed: ZHA is not unloaded + with suppress(config_entries.OperationNotAllowed): + await self._hass.config_entries.async_setup(self._config_entry.entry_id) diff --git a/zha/select.py b/zha/select.py new file mode 100644 index 00000000..264617e1 --- /dev/null +++ b/zha/select.py @@ -0,0 +1,692 @@ +"""Support for ZHA controls using the select platform.""" + +from __future__ import annotations + +from enum import Enum +import functools +import logging +from typing import TYPE_CHECKING, Any, Self + +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNKNOWN, EntityCategory, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from zhaquirks.quirk_ids import TUYA_PLUG_MANUFACTURER, TUYA_PLUG_ONOFF +from zhaquirks.xiaomi.aqara.magnet_ac01 import OppleCluster as MagnetAC01OppleCluster +from zhaquirks.xiaomi.aqara.switch_acn047 import OppleCluster as T2RelayOppleCluster +from zigpy import types +from zigpy.quirks.v2 import EntityMetadata, ZCLEnumMetadata +from zigpy.zcl.clusters.general import OnOff +from zigpy.zcl.clusters.security import IasWd + +from .core import discovery +from .core.const import ( + CLUSTER_HANDLER_HUE_OCCUPANCY, + CLUSTER_HANDLER_IAS_WD, + CLUSTER_HANDLER_INOVELLI, + CLUSTER_HANDLER_OCCUPANCY, + CLUSTER_HANDLER_ON_OFF, + QUIRK_METADATA, + SIGNAL_ADD_ENTITIES, + SIGNAL_ATTR_UPDATED, + Strobe, +) +from .core.helpers import get_zha_data +from .core.registries import ZHA_ENTITIES +from .entity import ZhaEntity + +if TYPE_CHECKING: + from .core.cluster_handlers import ClusterHandler + from .core.device import ZHADevice + + +CONFIG_DIAGNOSTIC_MATCH = functools.partial( + ZHA_ENTITIES.config_diagnostic_match, Platform.SELECT +) +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Zigbee Home Automation siren from config entry.""" + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.SELECT] + + unsub = async_dispatcher_connect( + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, + async_add_entities, + entities_to_create, + ), + ) + config_entry.async_on_unload(unsub) + + +class ZHAEnumSelectEntity(ZhaEntity, SelectEntity): + """Representation of a ZHA select entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attribute_name: str + _enum: type[Enum] + + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> None: + """Init this select entity.""" + self._cluster_handler: ClusterHandler = cluster_handlers[0] + self._attribute_name = self._enum.__name__ + self._attr_options = [entry.name.replace("_", " ") for entry in self._enum] + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + option = self._cluster_handler.data_cache.get(self._attribute_name) + if option is None: + return None + return option.name.replace("_", " ") + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + self._cluster_handler.data_cache[self._attribute_name] = 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._cluster_handler.data_cache[self._attribute_name] = self._enum[ + last_state.state.replace(" ", "_") + ] + + +class ZHANonZCLSelectEntity(ZHAEnumSelectEntity): + """Representation of a ZHA select entity with no ZCL interaction.""" + + @property + def available(self) -> bool: + """Return entity availability.""" + return True + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) +class ZHADefaultToneSelectEntity(ZHANonZCLSelectEntity): + """Representation of a ZHA default siren tone select entity.""" + + _unique_id_suffix = IasWd.Warning.WarningMode.__name__ + _enum = IasWd.Warning.WarningMode + _attr_translation_key: str = "default_siren_tone" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) +class ZHADefaultSirenLevelSelectEntity(ZHANonZCLSelectEntity): + """Representation of a ZHA default siren level select entity.""" + + _unique_id_suffix = IasWd.Warning.SirenLevel.__name__ + _enum = IasWd.Warning.SirenLevel + _attr_translation_key: str = "default_siren_level" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) +class ZHADefaultStrobeLevelSelectEntity(ZHANonZCLSelectEntity): + """Representation of a ZHA default siren strobe level select entity.""" + + _unique_id_suffix = IasWd.StrobeLevel.__name__ + _enum = IasWd.StrobeLevel + _attr_translation_key: str = "default_strobe_level" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) +class ZHADefaultStrobeSelectEntity(ZHANonZCLSelectEntity): + """Representation of a ZHA default siren strobe select entity.""" + + _unique_id_suffix = Strobe.__name__ + _enum = Strobe + _attr_translation_key: str = "default_strobe" + + +class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): + """Representation of a ZHA ZCL enum select entity.""" + + _attribute_name: str + _attr_entity_category = EntityCategory.CONFIG + _enum: type[Enum] + + @classmethod + def create_entity( + cls, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> Self | None: + """Entity Factory. + + Return entity if it is a supported configuration, otherwise return None + """ + cluster_handler = cluster_handlers[0] + if QUIRK_METADATA not in kwargs and ( + cls._attribute_name in cluster_handler.cluster.unsupported_attributes + or cls._attribute_name not in cluster_handler.cluster.attributes_by_name + or cluster_handler.cluster.get(cls._attribute_name) is None + ): + _LOGGER.debug( + "%s is not supported - skipping %s entity creation", + cls._attribute_name, + cls.__name__, + ) + return None + + return cls(unique_id, zha_device, cluster_handlers, **kwargs) + + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> None: + """Init this select entity.""" + self._cluster_handler: ClusterHandler = cluster_handlers[0] + if QUIRK_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) + self._attr_options = [entry.name.replace("_", " ") for entry in self._enum] + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + super()._init_from_quirks_metadata(entity_metadata) + zcl_enum_metadata: ZCLEnumMetadata = entity_metadata.entity_metadata + self._attribute_name = zcl_enum_metadata.attribute_name + self._enum = zcl_enum_metadata.enum + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + option = self._cluster_handler.cluster.get(self._attribute_name) + if option is None: + return None + option = self._enum(option) + return option.name.replace("_", " ") + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self._cluster_handler.write_attributes_safe( + {self._attribute_name: self._enum[option.replace(" ", "_")]} + ) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + await super().async_added_to_hass() + self.async_accept_signal( + self._cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state + ) + + @callback + def async_set_state(self, attr_id: int, attr_name: str, value: Any): + """Handle state update from cluster handler.""" + self.async_write_ha_state() + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_ON_OFF) +class ZHAStartupOnOffSelectEntity(ZCLEnumSelectEntity): + """Representation of a ZHA startup onoff select entity.""" + + _unique_id_suffix = OnOff.StartUpOnOff.__name__ + _attribute_name = "start_up_on_off" + _enum = OnOff.StartUpOnOff + _attr_translation_key: str = "start_up_on_off" + + +class TuyaPowerOnState(types.enum8): + """Tuya power on state enum.""" + + Off = 0x00 + On = 0x01 + LastState = 0x02 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, quirk_ids=TUYA_PLUG_ONOFF +) +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="tuya_manufacturer", quirk_ids=TUYA_PLUG_MANUFACTURER +) +class TuyaPowerOnStateSelectEntity(ZCLEnumSelectEntity): + """Representation of a ZHA power on state select entity.""" + + _unique_id_suffix = "power_on_state" + _attribute_name = "power_on_state" + _enum = TuyaPowerOnState + _attr_translation_key: str = "power_on_state" + + +class TuyaBacklightMode(types.enum8): + """Tuya switch backlight mode enum.""" + + Off = 0x00 + LightWhenOn = 0x01 + LightWhenOff = 0x02 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, quirk_ids=TUYA_PLUG_ONOFF +) +class TuyaBacklightModeSelectEntity(ZCLEnumSelectEntity): + """Representation of a ZHA backlight mode select entity.""" + + _unique_id_suffix = "backlight_mode" + _attribute_name = "backlight_mode" + _enum = TuyaBacklightMode + _attr_translation_key: str = "backlight_mode" + + +class MoesBacklightMode(types.enum8): + """MOES switch backlight mode enum.""" + + Off = 0x00 + LightWhenOn = 0x01 + LightWhenOff = 0x02 + Freeze = 0x03 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="tuya_manufacturer", quirk_ids=TUYA_PLUG_MANUFACTURER +) +class MoesBacklightModeSelectEntity(ZCLEnumSelectEntity): + """Moes devices have a different backlight mode select options.""" + + _unique_id_suffix = "backlight_mode" + _attribute_name = "backlight_mode" + _enum = MoesBacklightMode + _attr_translation_key: str = "backlight_mode" + + +class AqaraMotionSensitivities(types.enum8): + """Aqara motion sensitivities.""" + + Low = 0x01 + Medium = 0x02 + High = 0x03 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", + models={"lumi.motion.ac01", "lumi.motion.ac02", "lumi.motion.agl04"}, +) +class AqaraMotionSensitivity(ZCLEnumSelectEntity): + """Representation of a ZHA motion sensitivity configuration entity.""" + + _unique_id_suffix = "motion_sensitivity" + _attribute_name = "motion_sensitivity" + _enum = AqaraMotionSensitivities + _attr_translation_key: str = "motion_sensitivity" + + +class HueV1MotionSensitivities(types.enum8): + """Hue v1 motion sensitivities.""" + + Low = 0x00 + Medium = 0x01 + High = 0x02 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_HUE_OCCUPANCY, + manufacturers={"Philips", "Signify Netherlands B.V."}, + models={"SML001"}, +) +class HueV1MotionSensitivity(ZCLEnumSelectEntity): + """Representation of a ZHA motion sensitivity configuration entity.""" + + _unique_id_suffix = "motion_sensitivity" + _attribute_name = "sensitivity" + _enum = HueV1MotionSensitivities + _attr_translation_key: str = "motion_sensitivity" + + +class HueV2MotionSensitivities(types.enum8): + """Hue v2 motion sensitivities.""" + + Lowest = 0x00 + Low = 0x01 + Medium = 0x02 + High = 0x03 + Highest = 0x04 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_HUE_OCCUPANCY, + manufacturers={"Philips", "Signify Netherlands B.V."}, + models={"SML002", "SML003", "SML004"}, +) +class HueV2MotionSensitivity(ZCLEnumSelectEntity): + """Representation of a ZHA motion sensitivity configuration entity.""" + + _unique_id_suffix = "motion_sensitivity" + _attribute_name = "sensitivity" + _enum = HueV2MotionSensitivities + _attr_translation_key: str = "motion_sensitivity" + + +class AqaraMonitoringModess(types.enum8): + """Aqara monitoring modes.""" + + Undirected = 0x00 + Left_Right = 0x01 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.motion.ac01"} +) +class AqaraMonitoringMode(ZCLEnumSelectEntity): + """Representation of a ZHA monitoring mode configuration entity.""" + + _unique_id_suffix = "monitoring_mode" + _attribute_name = "monitoring_mode" + _enum = AqaraMonitoringModess + _attr_translation_key: str = "monitoring_mode" + + +class AqaraApproachDistances(types.enum8): + """Aqara approach distances.""" + + Far = 0x00 + Medium = 0x01 + Near = 0x02 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.motion.ac01"} +) +class AqaraApproachDistance(ZCLEnumSelectEntity): + """Representation of a ZHA approach distance configuration entity.""" + + _unique_id_suffix = "approach_distance" + _attribute_name = "approach_distance" + _enum = AqaraApproachDistances + _attr_translation_key: str = "approach_distance" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.magnet.ac01"} +) +class AqaraMagnetAC01DetectionDistance(ZCLEnumSelectEntity): + """Representation of a ZHA detection distance configuration entity.""" + + _unique_id_suffix = "detection_distance" + _attribute_name = "detection_distance" + _enum = MagnetAC01OppleCluster.DetectionDistance + _attr_translation_key: str = "detection_distance" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.switch.acn047"} +) +class AqaraT2RelaySwitchMode(ZCLEnumSelectEntity): + """Representation of a ZHA switch mode configuration entity.""" + + _unique_id_suffix = "switch_mode" + _attribute_name = "switch_mode" + _enum = T2RelayOppleCluster.SwitchMode + _attr_translation_key: str = "switch_mode" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.switch.acn047"} +) +class AqaraT2RelaySwitchType(ZCLEnumSelectEntity): + """Representation of a ZHA switch type configuration entity.""" + + _unique_id_suffix = "switch_type" + _attribute_name = "switch_type" + _enum = T2RelayOppleCluster.SwitchType + _attr_translation_key: str = "switch_type" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.switch.acn047"} +) +class AqaraT2RelayStartupOnOff(ZCLEnumSelectEntity): + """Representation of a ZHA startup on off configuration entity.""" + + _unique_id_suffix = "startup_on_off" + _attribute_name = "startup_on_off" + _enum = T2RelayOppleCluster.StartupOnOff + _attr_translation_key: str = "start_up_on_off" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.switch.acn047"} +) +class AqaraT2RelayDecoupledMode(ZCLEnumSelectEntity): + """Representation of a ZHA switch decoupled mode configuration entity.""" + + _unique_id_suffix = "decoupled_mode" + _attribute_name = "decoupled_mode" + _enum = T2RelayOppleCluster.DecoupledMode + _attr_translation_key: str = "decoupled_mode" + + +class InovelliOutputMode(types.enum1): + """Inovelli output mode.""" + + Dimmer = 0x00 + OnOff = 0x01 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, +) +class InovelliOutputModeEntity(ZCLEnumSelectEntity): + """Inovelli output mode control.""" + + _unique_id_suffix = "output_mode" + _attribute_name = "output_mode" + _enum = InovelliOutputMode + _attr_translation_key: str = "output_mode" + + +class InovelliSwitchType(types.enum8): + """Inovelli switch mode.""" + + Single_Pole = 0x00 + Three_Way_Dumb = 0x01 + Three_Way_AUX = 0x02 + Single_Pole_Full_Sine = 0x03 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, models={"VZM31-SN"} +) +class InovelliSwitchTypeEntity(ZCLEnumSelectEntity): + """Inovelli switch type control.""" + + _unique_id_suffix = "switch_type" + _attribute_name = "switch_type" + _enum = InovelliSwitchType + _attr_translation_key: str = "switch_type" + + +class InovelliFanSwitchType(types.enum1): + """Inovelli fan switch mode.""" + + Load_Only = 0x00 + Three_Way_AUX = 0x01 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, models={"VZM35-SN"} +) +class InovelliFanSwitchTypeEntity(ZCLEnumSelectEntity): + """Inovelli fan switch type control.""" + + _unique_id_suffix = "switch_type" + _attribute_name = "switch_type" + _enum = InovelliFanSwitchType + _attr_translation_key: str = "switch_type" + + +class InovelliLedScalingMode(types.enum1): + """Inovelli led mode.""" + + VZM31SN = 0x00 + LZW31SN = 0x01 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, +) +class InovelliLedScalingModeEntity(ZCLEnumSelectEntity): + """Inovelli led mode control.""" + + _unique_id_suffix = "led_scaling_mode" + _attribute_name = "led_scaling_mode" + _enum = InovelliLedScalingMode + _attr_translation_key: str = "led_scaling_mode" + + +class InovelliFanLedScalingMode(types.enum8): + """Inovelli fan led mode.""" + + VZM31SN = 0x00 + Grade_1 = 0x01 + Grade_2 = 0x02 + Grade_3 = 0x03 + Grade_4 = 0x04 + Grade_5 = 0x05 + Grade_6 = 0x06 + Grade_7 = 0x07 + Grade_8 = 0x08 + Grade_9 = 0x09 + Adaptive = 0x0A + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, models={"VZM35-SN"} +) +class InovelliFanLedScalingModeEntity(ZCLEnumSelectEntity): + """Inovelli fan switch led mode control.""" + + _unique_id_suffix = "smart_fan_led_display_levels" + _attribute_name = "smart_fan_led_display_levels" + _enum = InovelliFanLedScalingMode + _attr_translation_key: str = "smart_fan_led_display_levels" + + +class InovelliNonNeutralOutput(types.enum1): + """Inovelli non neutral output selection.""" + + Low = 0x00 + High = 0x01 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, +) +class InovelliNonNeutralOutputEntity(ZCLEnumSelectEntity): + """Inovelli non neutral output control.""" + + _unique_id_suffix = "increased_non_neutral_output" + _attribute_name = "increased_non_neutral_output" + _enum = InovelliNonNeutralOutput + _attr_translation_key: str = "increased_non_neutral_output" + + +class AqaraFeedingMode(types.enum8): + """Feeding mode.""" + + Manual = 0x00 + Schedule = 0x01 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} +) +class AqaraPetFeederMode(ZCLEnumSelectEntity): + """Representation of an Aqara pet feeder mode configuration entity.""" + + _unique_id_suffix = "feeding_mode" + _attribute_name = "feeding_mode" + _enum = AqaraFeedingMode + _attr_translation_key: str = "feeding_mode" + _attr_icon: str = "mdi:wrench-clock" + + +class AqaraThermostatPresetMode(types.enum8): + """Thermostat preset mode.""" + + Manual = 0x00 + Auto = 0x01 + Away = 0x02 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} +) +class AqaraThermostatPreset(ZCLEnumSelectEntity): + """Representation of an Aqara thermostat preset configuration entity.""" + + _unique_id_suffix = "preset" + _attribute_name = "preset" + _enum = AqaraThermostatPresetMode + _attr_translation_key: str = "preset" + + +class SonoffPresenceDetectionSensitivityEnum(types.enum8): + """Enum for detection sensitivity select entity.""" + + Low = 0x01 + Medium = 0x02 + High = 0x03 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_OCCUPANCY, models={"SNZB-06P"} +) +class SonoffPresenceDetectionSensitivity(ZCLEnumSelectEntity): + """Entity to set the detection sensitivity of the Sonoff SNZB-06P.""" + + _unique_id_suffix = "detection_sensitivity" + _attribute_name = "ultrasonic_u_to_o_threshold" + _enum = SonoffPresenceDetectionSensitivityEnum + _attr_translation_key: str = "detection_sensitivity" + + +class KeypadLockoutEnum(types.enum8): + """Keypad lockout options.""" + + Unlock = 0x00 + Lock1 = 0x01 + Lock2 = 0x02 + Lock3 = 0x03 + Lock4 = 0x04 + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names="thermostat_ui") +class KeypadLockout(ZCLEnumSelectEntity): + """Mandatory attribute for thermostat_ui cluster. + + Often only the first two are implemented, and Lock2 to Lock4 should map to Lock1 in the firmware. + This however covers all bases. + """ + + _unique_id_suffix = "keypad_lockout" + _attribute_name: str = "keypad_lockout" + _enum = KeypadLockoutEnum + _attr_translation_key: str = "keypad_lockout" + _attr_icon: str = "mdi:lock" diff --git a/zha/sensor.py b/zha/sensor.py new file mode 100644 index 00000000..d4962fb9 --- /dev/null +++ b/zha/sensor.py @@ -0,0 +1,1494 @@ +"""Sensors on Zigbee Home Automation networks.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from datetime import timedelta +import enum +import functools +import logging +import numbers +import random +from typing import TYPE_CHECKING, Any, Self + +from homeassistant.components.climate import HVACAction +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + LIGHT_LUX, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + Platform, + UnitOfApparentPower, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfFrequency, + UnitOfMass, + UnitOfPower, + UnitOfPressure, + UnitOfTemperature, + UnitOfTime, + UnitOfVolume, + UnitOfVolumeFlowRate, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import StateType +from zigpy import types +from zigpy.quirks.v2 import EntityMetadata, ZCLEnumMetadata, ZCLSensorMetadata +from zigpy.state import Counter, State +from zigpy.zcl.clusters.closures import WindowCovering +from zigpy.zcl.clusters.general import Basic + +from .core import discovery +from .core.const import ( + CLUSTER_HANDLER_ANALOG_INPUT, + CLUSTER_HANDLER_BASIC, + CLUSTER_HANDLER_COVER, + CLUSTER_HANDLER_DEVICE_TEMPERATURE, + CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, + CLUSTER_HANDLER_HUMIDITY, + CLUSTER_HANDLER_ILLUMINANCE, + CLUSTER_HANDLER_LEAF_WETNESS, + CLUSTER_HANDLER_POWER_CONFIGURATION, + CLUSTER_HANDLER_PRESSURE, + CLUSTER_HANDLER_SMARTENERGY_METERING, + CLUSTER_HANDLER_SOIL_MOISTURE, + CLUSTER_HANDLER_TEMPERATURE, + CLUSTER_HANDLER_THERMOSTAT, + DATA_ZHA, + QUIRK_METADATA, + SIGNAL_ADD_ENTITIES, + SIGNAL_ATTR_UPDATED, +) +from .core.helpers import get_zha_data +from .core.registries import SMARTTHINGS_HUMIDITY_CLUSTER, ZHA_ENTITIES +from .entity import BaseZhaEntity, ZhaEntity + +if TYPE_CHECKING: + from .core.cluster_handlers import ClusterHandler + from .core.device import ZHADevice + +BATTERY_SIZES = { + 0: "No battery", + 1: "Built in", + 2: "Other", + 3: "AA", + 4: "AAA", + 5: "C", + 6: "D", + 7: "CR2", + 8: "CR123A", + 9: "CR2450", + 10: "CR2032", + 11: "CR1632", + 255: "Unknown", +} + +_LOGGER = logging.getLogger(__name__) + +CLUSTER_HANDLER_ST_HUMIDITY_CLUSTER = ( + f"cluster_handler_0x{SMARTTHINGS_HUMIDITY_CLUSTER:04x}" +) +STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.SENSOR) +MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.SENSOR) +CONFIG_DIAGNOSTIC_MATCH = functools.partial( + ZHA_ENTITIES.config_diagnostic_match, Platform.SENSOR +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Zigbee Home Automation sensor from config entry.""" + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.SENSOR] + + unsub = async_dispatcher_connect( + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, + async_add_entities, + entities_to_create, + ), + ) + config_entry.async_on_unload(unsub) + + +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class Sensor(ZhaEntity, SensorEntity): + """Base ZHA sensor.""" + + _attribute_name: int | str | None = None + _decimals: int = 1 + _divisor: int = 1 + _multiplier: int | float = 1 + + @classmethod + def create_entity( + cls, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> Self | None: + """Entity Factory. + + Return entity if it is a supported configuration, otherwise return None + """ + cluster_handler = cluster_handlers[0] + if QUIRK_METADATA not in kwargs and ( + cls._attribute_name in cluster_handler.cluster.unsupported_attributes + or cls._attribute_name not in cluster_handler.cluster.attributes_by_name + ): + _LOGGER.debug( + "%s is not supported - skipping %s entity creation", + cls._attribute_name, + cls.__name__, + ) + return None + + return cls(unique_id, zha_device, cluster_handlers, **kwargs) + + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> None: + """Init this sensor.""" + self._cluster_handler: ClusterHandler = cluster_handlers[0] + if QUIRK_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + super()._init_from_quirks_metadata(entity_metadata) + sensor_metadata: ZCLSensorMetadata = entity_metadata.entity_metadata + self._attribute_name = sensor_metadata.attribute_name + if sensor_metadata.divisor is not None: + self._divisor = sensor_metadata.divisor + if sensor_metadata.multiplier is not None: + self._multiplier = sensor_metadata.multiplier + if sensor_metadata.unit is not None: + self._attr_native_unit_of_measurement = sensor_metadata.unit + + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + await super().async_added_to_hass() + self.async_accept_signal( + self._cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state + ) + + @property + def native_value(self) -> StateType: + """Return the state of the entity.""" + assert self._attribute_name is not None + raw_state = self._cluster_handler.cluster.get(self._attribute_name) + if raw_state is None: + return None + return self.formatter(raw_state) + + @callback + def async_set_state(self, attr_id: int, attr_name: str, value: Any) -> None: + """Handle state update from cluster handler.""" + self.async_write_ha_state() + + def formatter(self, value: int | enum.IntEnum) -> int | float | str | None: + """Numeric pass-through formatter.""" + if self._decimals > 0: + return round( + float(value * self._multiplier) / self._divisor, self._decimals + ) + return round(float(value * self._multiplier) / self._divisor) + + +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class PollableSensor(Sensor): + """Base ZHA sensor that polls for state.""" + + _use_custom_polling: bool = True + + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> None: + """Init this sensor.""" + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._cancel_refresh_handle: CALLBACK_TYPE | None = None + + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + await super().async_added_to_hass() + if self._use_custom_polling: + refresh_interval = random.randint(30, 60) + self._cancel_refresh_handle = async_track_time_interval( + self.hass, self._refresh, timedelta(seconds=refresh_interval) + ) + self.debug("started polling with refresh interval of %s", refresh_interval) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect entity object when removed.""" + if self._cancel_refresh_handle is not None: + self._cancel_refresh_handle() + self._cancel_refresh_handle = None + self.debug("stopped polling during device removal") + await super().async_will_remove_from_hass() + + async def _refresh(self, time): + """Call async_update at a constrained random interval.""" + if self._zha_device.available and self.hass.data[DATA_ZHA].allow_polling: + self.debug("polling for updated state") + await self.async_update() + self.async_write_ha_state() + else: + self.debug( + "skipping polling for updated state, available: %s, allow polled requests: %s", + self._zha_device.available, + self.hass.data[DATA_ZHA].allow_polling, + ) + + +class DeviceCounterSensor(BaseZhaEntity, SensorEntity): + """Device counter sensor.""" + + _attr_should_poll = True + _attr_state_class: SensorStateClass = SensorStateClass.TOTAL + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False + + @classmethod + def create_entity( + cls, + unique_id: str, + zha_device: ZHADevice, + counter_groups: str, + counter_group: str, + counter: str, + **kwargs: Any, + ) -> Self | None: + """Entity Factory. + + Return entity if it is a supported configuration, otherwise return None + """ + return cls( + unique_id, zha_device, counter_groups, counter_group, counter, **kwargs + ) + + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + counter_groups: str, + counter_group: str, + counter: str, + **kwargs: Any, + ) -> None: + """Init this sensor.""" + super().__init__(unique_id, zha_device, **kwargs) + state: State = self._zha_device.gateway.application_controller.state + self._zigpy_counter: Counter = ( + getattr(state, counter_groups).get(counter_group, {}).get(counter, None) + ) + self._attr_name: str = self._zigpy_counter.name + self.remove_future: asyncio.Future + + @property + def available(self) -> bool: + """Return entity availability.""" + return self._zha_device.available + + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + self.remove_future = self.hass.loop.create_future() + self._zha_device.gateway.register_entity_reference( + self._zha_device.ieee, + self.entity_id, + self._zha_device, + {}, + self.device_info, + self.remove_future, + ) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect entity object when removed.""" + await super().async_will_remove_from_hass() + self.zha_device.gateway.remove_entity_reference(self) + self.remove_future.set_result(True) + + @property + def native_value(self) -> StateType: + """Return the state of the entity.""" + return self._zigpy_counter.value + + async def async_update(self) -> None: + """Retrieve latest state.""" + self.async_write_ha_state() + + +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class EnumSensor(Sensor): + """Sensor with value from enum.""" + + _attr_device_class: SensorDeviceClass = SensorDeviceClass.ENUM + _enum: type[enum.Enum] + + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + ZhaEntity._init_from_quirks_metadata(self, entity_metadata) # pylint: disable=protected-access + sensor_metadata: ZCLEnumMetadata = entity_metadata.entity_metadata + self._attribute_name = sensor_metadata.attribute_name + self._enum = sensor_metadata.enum + + def formatter(self, value: int) -> str | None: + """Use name of enum.""" + assert self._enum is not None + return self._enum(value).name + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_ANALOG_INPUT, + manufacturers="Digi", + stop_on_match_group=CLUSTER_HANDLER_ANALOG_INPUT, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class AnalogInput(Sensor): + """Sensor that displays analog input values.""" + + _attribute_name = "present_value" + _attr_translation_key: str = "analog_input" + + +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_POWER_CONFIGURATION) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class Battery(Sensor): + """Battery sensor of power configuration cluster.""" + + _attribute_name = "battery_percentage_remaining" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.BATTERY + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_native_unit_of_measurement = PERCENTAGE + + @classmethod + def create_entity( + cls, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> Self | None: + """Entity Factory. + + Unlike any other entity, PowerConfiguration cluster may not support + battery_percent_remaining attribute, but zha-device-handlers takes care of it + so create the entity regardless + """ + if zha_device.is_mains_powered: + return None + return cls(unique_id, zha_device, cluster_handlers, **kwargs) + + @staticmethod + def formatter(value: int) -> int | None: + """Return the state of the entity.""" + # per zcl specs battery percent is reported at 200% ¯\_(ツ)_/¯ + if not isinstance(value, numbers.Number) or value == -1 or value == 255: + return None + value = round(value / 2) + return value + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return device state attrs for battery sensors.""" + state_attrs = {} + battery_size = self._cluster_handler.cluster.get("battery_size") + if battery_size is not None: + state_attrs["battery_size"] = BATTERY_SIZES.get(battery_size, "Unknown") + battery_quantity = self._cluster_handler.cluster.get("battery_quantity") + if battery_quantity is not None: + state_attrs["battery_quantity"] = battery_quantity + battery_voltage = self._cluster_handler.cluster.get("battery_voltage") + if battery_voltage is not None: + state_attrs["battery_voltage"] = round(battery_voltage / 10, 2) + return state_attrs + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, + stop_on_match_group=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, + models={"VZM31-SN", "SP 234", "outletv4"}, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class ElectricalMeasurement(PollableSensor): + """Active power measurement.""" + + _use_custom_polling: bool = False + _attribute_name = "active_power" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement: str = UnitOfPower.WATT + _div_mul_prefix: str | None = "ac_power" + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return device state attrs for sensor.""" + attrs = {} + if self._cluster_handler.measurement_type is not None: + attrs["measurement_type"] = self._cluster_handler.measurement_type + + max_attr_name = f"{self._attribute_name}_max" + + try: + max_v = self._cluster_handler.cluster.get(max_attr_name) + except KeyError: + pass + else: + if max_v is not None: + attrs[max_attr_name] = str(self.formatter(max_v)) + + return attrs + + def formatter(self, value: int) -> int | float: + """Return 'normalized' value.""" + if self._div_mul_prefix: + multiplier = getattr( + self._cluster_handler, f"{self._div_mul_prefix}_multiplier" + ) + divisor = getattr(self._cluster_handler, f"{self._div_mul_prefix}_divisor") + else: + multiplier = self._multiplier + divisor = self._divisor + value = float(value * multiplier) / divisor + if value < 100 and divisor > 1: + return round(value, self._decimals) + return round(value) + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, + stop_on_match_group=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class PolledElectricalMeasurement(ElectricalMeasurement): + """Polled active power measurement.""" + + _use_custom_polling: bool = True + + +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class ElectricalMeasurementApparentPower(PolledElectricalMeasurement): + """Apparent power measurement.""" + + _attribute_name = "apparent_power" + _unique_id_suffix = "apparent_power" + _use_custom_polling = False # Poll indirectly by ElectricalMeasurementSensor + _attr_device_class: SensorDeviceClass = SensorDeviceClass.APPARENT_POWER + _attr_native_unit_of_measurement = UnitOfApparentPower.VOLT_AMPERE + _div_mul_prefix = "ac_power" + + +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class ElectricalMeasurementRMSCurrent(PolledElectricalMeasurement): + """RMS current measurement.""" + + _attribute_name = "rms_current" + _unique_id_suffix = "rms_current" + _use_custom_polling = False # Poll indirectly by ElectricalMeasurementSensor + _attr_device_class: SensorDeviceClass = SensorDeviceClass.CURRENT + _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE + _div_mul_prefix = "ac_current" + + +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class ElectricalMeasurementRMSVoltage(PolledElectricalMeasurement): + """RMS Voltage measurement.""" + + _attribute_name = "rms_voltage" + _unique_id_suffix = "rms_voltage" + _use_custom_polling = False # Poll indirectly by ElectricalMeasurementSensor + _attr_device_class: SensorDeviceClass = SensorDeviceClass.VOLTAGE + _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT + _div_mul_prefix = "ac_voltage" + + +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class ElectricalMeasurementFrequency(PolledElectricalMeasurement): + """Frequency measurement.""" + + _attribute_name = "ac_frequency" + _unique_id_suffix = "ac_frequency" + _use_custom_polling = False # Poll indirectly by ElectricalMeasurementSensor + _attr_device_class: SensorDeviceClass = SensorDeviceClass.FREQUENCY + _attr_translation_key: str = "ac_frequency" + _attr_native_unit_of_measurement = UnitOfFrequency.HERTZ + _div_mul_prefix = "ac_frequency" + + +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class ElectricalMeasurementPowerFactor(PolledElectricalMeasurement): + """Power Factor measurement.""" + + _attribute_name = "power_factor" + _unique_id_suffix = "power_factor" + _use_custom_polling = False # Poll indirectly by ElectricalMeasurementSensor + _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER_FACTOR + _attr_native_unit_of_measurement = PERCENTAGE + _div_mul_prefix = None + + +@MULTI_MATCH( + generic_ids=CLUSTER_HANDLER_ST_HUMIDITY_CLUSTER, + stop_on_match_group=CLUSTER_HANDLER_HUMIDITY, +) +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_HUMIDITY, + stop_on_match_group=CLUSTER_HANDLER_HUMIDITY, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class Humidity(Sensor): + """Humidity sensor.""" + + _attribute_name = "measured_value" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.HUMIDITY + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _divisor = 100 + _attr_native_unit_of_measurement = PERCENTAGE + + +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_SOIL_MOISTURE) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class SoilMoisture(Sensor): + """Soil Moisture sensor.""" + + _attribute_name = "measured_value" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.HUMIDITY + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_translation_key: str = "soil_moisture" + _divisor = 100 + _attr_native_unit_of_measurement = PERCENTAGE + + +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEAF_WETNESS) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class LeafWetness(Sensor): + """Leaf Wetness sensor.""" + + _attribute_name = "measured_value" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.HUMIDITY + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_translation_key: str = "leaf_wetness" + _divisor = 100 + _attr_native_unit_of_measurement = PERCENTAGE + + +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ILLUMINANCE) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class Illuminance(Sensor): + """Illuminance Sensor.""" + + _attribute_name = "measured_value" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.ILLUMINANCE + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = LIGHT_LUX + + def formatter(self, value: int) -> int | None: + """Convert illumination data.""" + if value == 0: + return 0 + if value == 0xFFFF: + return None + return round(pow(10, ((value - 1) / 10000))) + + +@dataclass(frozen=True, kw_only=True) +class SmartEnergyMeteringEntityDescription(SensorEntityDescription): + """Dataclass that describes a Zigbee smart energy metering entity.""" + + key: str = "instantaneous_demand" + state_class: SensorStateClass | None = SensorStateClass.MEASUREMENT + scale: int = 1 + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, + stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class SmartEnergyMetering(PollableSensor): + """Metering sensor.""" + + entity_description: SmartEnergyMeteringEntityDescription + _use_custom_polling: bool = False + _attribute_name = "instantaneous_demand" + _attr_translation_key: str = "instantaneous_demand" + + _ENTITY_DESCRIPTION_MAP = { + 0x00: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + ), + 0x01: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + device_class=None, # volume flow rate is not supported yet + ), + 0x02: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, + device_class=None, # volume flow rate is not supported yet + ), + 0x03: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + device_class=None, # volume flow rate is not supported yet + scale=100, + ), + 0x04: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/{UnitOfTime.HOURS}", # US gallons per hour + device_class=None, # volume flow rate is not supported yet + ), + 0x05: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=f"IMP {UnitOfVolume.GALLONS}/{UnitOfTime.HOURS}", # IMP gallons per hour + device_class=None, # needs to be None as imperial gallons are not supported + ), + 0x06: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=UnitOfPower.BTU_PER_HOUR, + device_class=None, + state_class=None, + ), + 0x07: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=f"l/{UnitOfTime.HOURS}", + device_class=None, # volume flow rate is not supported yet + ), + 0x08: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=UnitOfPressure.KPA, + device_class=SensorDeviceClass.PRESSURE, + ), # gauge + 0x09: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=UnitOfPressure.KPA, + device_class=SensorDeviceClass.PRESSURE, + ), # absolute + 0x0A: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=f"{UnitOfVolume.CUBIC_FEET}/{UnitOfTime.HOURS}", # cubic feet per hour + device_class=None, # volume flow rate is not supported yet + scale=1000, + ), + 0x0B: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement="unitless", device_class=None, state_class=None + ), + 0x0C: SmartEnergyMeteringEntityDescription( + native_unit_of_measurement=f"{UnitOfEnergy.MEGA_JOULE}/{UnitOfTime.SECONDS}", + device_class=None, # needs to be None as MJ/s is not supported + ), + } + + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> None: + """Init.""" + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + + entity_description = self._ENTITY_DESCRIPTION_MAP.get( + self._cluster_handler.unit_of_measurement + ) + if entity_description is not None: + self.entity_description = entity_description + + def formatter(self, value: int) -> int | float: + """Pass through cluster handler formatter.""" + return self._cluster_handler.demand_formatter(value) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return device state attrs for battery sensors.""" + attrs = {} + if self._cluster_handler.device_type is not None: + attrs["device_type"] = self._cluster_handler.device_type + if (status := self._cluster_handler.status) is not None: + if isinstance(status, enum.IntFlag): + attrs["status"] = str( + status.name if status.name is not None else status.value + ) + else: + attrs["status"] = str(status)[len(status.__class__.__name__) + 1 :] + return attrs + + @property + def native_value(self) -> StateType: + """Return the state of the entity.""" + state = super().native_value + if hasattr(self, "entity_description") and state is not None: + return float(state) * self.entity_description.scale + + return state + + +@dataclass(frozen=True, kw_only=True) +class SmartEnergySummationEntityDescription(SmartEnergyMeteringEntityDescription): + """Dataclass that describes a Zigbee smart energy summation entity.""" + + key: str = "summation_delivered" + state_class: SensorStateClass | None = SensorStateClass.TOTAL_INCREASING + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, + stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class SmartEnergySummation(SmartEnergyMetering): + """Smart Energy Metering summation sensor.""" + + entity_description: SmartEnergySummationEntityDescription + _attribute_name = "current_summ_delivered" + _unique_id_suffix = "summation_delivered" + _attr_translation_key: str = "summation_delivered" + + _ENTITY_DESCRIPTION_MAP = { + 0x00: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + 0x01: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + device_class=SensorDeviceClass.VOLUME, + ), + 0x02: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfVolume.CUBIC_FEET, + device_class=SensorDeviceClass.VOLUME, + ), + 0x03: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfVolume.CUBIC_FEET, + device_class=SensorDeviceClass.VOLUME, + scale=100, + ), + 0x04: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfVolume.GALLONS, # US gallons + device_class=SensorDeviceClass.VOLUME, + ), + 0x05: SmartEnergySummationEntityDescription( + native_unit_of_measurement=f"IMP {UnitOfVolume.GALLONS}", + device_class=None, # needs to be None as imperial gallons are not supported + ), + 0x06: SmartEnergySummationEntityDescription( + native_unit_of_measurement="BTU", device_class=None, state_class=None + ), + 0x07: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfVolume.LITERS, + device_class=SensorDeviceClass.VOLUME, + ), + 0x08: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfPressure.KPA, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + ), # gauge + 0x09: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfPressure.KPA, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + ), # absolute + 0x0A: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfVolume.CUBIC_FEET, + device_class=SensorDeviceClass.VOLUME, + scale=1000, + ), + 0x0B: SmartEnergySummationEntityDescription( + native_unit_of_measurement="unitless", device_class=None, state_class=None + ), + 0x0C: SmartEnergySummationEntityDescription( + native_unit_of_measurement=UnitOfEnergy.MEGA_JOULE, + device_class=SensorDeviceClass.ENERGY, + ), + } + + def formatter(self, value: int) -> int | float: + """Numeric pass-through formatter.""" + if self._cluster_handler.unit_of_measurement != 0: + return self._cluster_handler.summa_formatter(value) + + cooked = ( + float(self._cluster_handler.multiplier * value) + / self._cluster_handler.divisor + ) + return round(cooked, 3) + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, + models={"TS011F", "ZLinky_TIC", "TICMeter"}, + stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class PolledSmartEnergySummation(SmartEnergySummation): + """Polled Smart Energy Metering summation sensor.""" + + _use_custom_polling: bool = True + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, + models={"ZLinky_TIC", "TICMeter"}, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class Tier1SmartEnergySummation(PolledSmartEnergySummation): + """Tier 1 Smart Energy Metering summation sensor.""" + + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation + _attribute_name = "current_tier1_summ_delivered" + _unique_id_suffix = "tier1_summation_delivered" + _attr_translation_key: str = "tier1_summation_delivered" + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, + models={"ZLinky_TIC", "TICMeter"}, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class Tier2SmartEnergySummation(PolledSmartEnergySummation): + """Tier 2 Smart Energy Metering summation sensor.""" + + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation + _attribute_name = "current_tier2_summ_delivered" + _unique_id_suffix = "tier2_summation_delivered" + _attr_translation_key: str = "tier2_summation_delivered" + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, + models={"ZLinky_TIC", "TICMeter"}, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class Tier3SmartEnergySummation(PolledSmartEnergySummation): + """Tier 3 Smart Energy Metering summation sensor.""" + + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation + _attribute_name = "current_tier3_summ_delivered" + _unique_id_suffix = "tier3_summation_delivered" + _attr_translation_key: str = "tier3_summation_delivered" + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, + models={"ZLinky_TIC", "TICMeter"}, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class Tier4SmartEnergySummation(PolledSmartEnergySummation): + """Tier 4 Smart Energy Metering summation sensor.""" + + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation + _attribute_name = "current_tier4_summ_delivered" + _unique_id_suffix = "tier4_summation_delivered" + _attr_translation_key: str = "tier4_summation_delivered" + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, + models={"ZLinky_TIC", "TICMeter"}, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class Tier5SmartEnergySummation(PolledSmartEnergySummation): + """Tier 5 Smart Energy Metering summation sensor.""" + + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation + _attribute_name = "current_tier5_summ_delivered" + _unique_id_suffix = "tier5_summation_delivered" + _attr_translation_key: str = "tier5_summation_delivered" + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, + models={"ZLinky_TIC", "TICMeter"}, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class Tier6SmartEnergySummation(PolledSmartEnergySummation): + """Tier 6 Smart Energy Metering summation sensor.""" + + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation + _attribute_name = "current_tier6_summ_delivered" + _unique_id_suffix = "tier6_summation_delivered" + _attr_translation_key: str = "tier6_summation_delivered" + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class SmartEnergySummationReceived(PolledSmartEnergySummation): + """Smart Energy Metering summation received sensor.""" + + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation + _attribute_name = "current_summ_received" + _unique_id_suffix = "summation_received" + _attr_translation_key: str = "summation_received" + + @classmethod + def create_entity( + cls, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> Self | None: + """Entity Factory. + + This attribute only started to be initialized in HA 2024.2.0, + so the entity would be created on the first HA start after the + upgrade for existing devices, as the initialization to see if + an attribute is unsupported happens later in the background. + To avoid creating unnecessary entities for existing devices, + wait until the attribute was properly initialized once for now. + """ + if cluster_handlers[0].cluster.get(cls._attribute_name) is None: + return None + return super().create_entity(unique_id, zha_device, cluster_handlers, **kwargs) + + +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_PRESSURE) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class Pressure(Sensor): + """Pressure sensor.""" + + _attribute_name = "measured_value" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.PRESSURE + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _decimals = 0 + _attr_native_unit_of_measurement = UnitOfPressure.HPA + + +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_TEMPERATURE) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class Temperature(Sensor): + """Temperature Sensor.""" + + _attribute_name = "measured_value" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.TEMPERATURE + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _divisor = 100 + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + + +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_DEVICE_TEMPERATURE) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class DeviceTemperature(Sensor): + """Device Temperature Sensor.""" + + _attribute_name = "current_temperature" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.TEMPERATURE + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_translation_key: str = "device_temperature" + _divisor = 100 + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + _attr_entity_category = EntityCategory.DIAGNOSTIC + + +@MULTI_MATCH(cluster_handler_names="carbon_dioxide_concentration") +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class CarbonDioxideConcentration(Sensor): + """Carbon Dioxide Concentration sensor.""" + + _attribute_name = "measured_value" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.CO2 + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _decimals = 0 + _multiplier = 1e6 + _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION + + +@MULTI_MATCH(cluster_handler_names="carbon_monoxide_concentration") +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class CarbonMonoxideConcentration(Sensor): + """Carbon Monoxide Concentration sensor.""" + + _attribute_name = "measured_value" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.CO + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _decimals = 0 + _multiplier = 1e6 + _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION + + +@MULTI_MATCH(generic_ids="cluster_handler_0x042e", stop_on_match_group="voc_level") +@MULTI_MATCH(cluster_handler_names="voc_level", stop_on_match_group="voc_level") +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class VOCLevel(Sensor): + """VOC Level sensor.""" + + _attribute_name = "measured_value" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _decimals = 0 + _multiplier = 1e6 + _attr_native_unit_of_measurement = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + + +@MULTI_MATCH( + cluster_handler_names="voc_level", + models="lumi.airmonitor.acn01", + stop_on_match_group="voc_level", +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class PPBVOCLevel(Sensor): + """VOC Level sensor.""" + + _attribute_name = "measured_value" + _attr_device_class: SensorDeviceClass = ( + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS + ) + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _decimals = 0 + _multiplier = 1 + _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_BILLION + + +@MULTI_MATCH(cluster_handler_names="pm25") +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class PM25(Sensor): + """Particulate Matter 2.5 microns or less sensor.""" + + _attribute_name = "measured_value" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.PM25 + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _decimals = 0 + _multiplier = 1 + _attr_native_unit_of_measurement = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + + +@MULTI_MATCH(cluster_handler_names="formaldehyde_concentration") +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class FormaldehydeConcentration(Sensor): + """Formaldehyde Concentration sensor.""" + + _attribute_name = "measured_value" + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_translation_key: str = "formaldehyde" + _decimals = 0 + _multiplier = 1e6 + _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class ThermostatHVACAction(Sensor): + """Thermostat HVAC action sensor.""" + + _unique_id_suffix = "hvac_action" + _attr_translation_key: str = "hvac_action" + + @classmethod + def create_entity( + cls, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> Self | None: + """Entity Factory. + + Return entity if it is a supported configuration, otherwise return None + """ + + return cls(unique_id, zha_device, cluster_handlers, **kwargs) + + @property + def native_value(self) -> str | None: + """Return the current HVAC action.""" + if ( + self._cluster_handler.pi_heating_demand is None + and self._cluster_handler.pi_cooling_demand is None + ): + return self._rm_rs_action + return self._pi_demand_action + + @property + def _rm_rs_action(self) -> HVACAction | None: + """Return the current HVAC action based on running mode and running state.""" + + if (running_state := self._cluster_handler.running_state) is None: + return None + + rs_heat = ( + self._cluster_handler.RunningState.Heat_State_On + | self._cluster_handler.RunningState.Heat_2nd_Stage_On + ) + if running_state & rs_heat: + return HVACAction.HEATING + + rs_cool = ( + self._cluster_handler.RunningState.Cool_State_On + | self._cluster_handler.RunningState.Cool_2nd_Stage_On + ) + if running_state & rs_cool: + return HVACAction.COOLING + + running_state = self._cluster_handler.running_state + if running_state and running_state & ( + self._cluster_handler.RunningState.Fan_State_On + | self._cluster_handler.RunningState.Fan_2nd_Stage_On + | self._cluster_handler.RunningState.Fan_3rd_Stage_On + ): + return HVACAction.FAN + + running_state = self._cluster_handler.running_state + if running_state and running_state & self._cluster_handler.RunningState.Idle: + return HVACAction.IDLE + + if self._cluster_handler.system_mode != self._cluster_handler.SystemMode.Off: + return HVACAction.IDLE + return HVACAction.OFF + + @property + def _pi_demand_action(self) -> HVACAction: + """Return the current HVAC action based on pi_demands.""" + + heating_demand = self._cluster_handler.pi_heating_demand + if heating_demand is not None and heating_demand > 0: + return HVACAction.HEATING + cooling_demand = self._cluster_handler.pi_cooling_demand + if cooling_demand is not None and cooling_demand > 0: + return HVACAction.COOLING + + if self._cluster_handler.system_mode != self._cluster_handler.SystemMode.Off: + return HVACAction.IDLE + return HVACAction.OFF + + +@MULTI_MATCH( + cluster_handler_names={CLUSTER_HANDLER_THERMOSTAT}, + manufacturers="Sinope Technologies", + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class SinopeHVACAction(ThermostatHVACAction): + """Sinope Thermostat HVAC action sensor.""" + + @property + def _rm_rs_action(self) -> HVACAction: + """Return the current HVAC action based on running mode and running state.""" + + running_mode = self._cluster_handler.running_mode + if running_mode == self._cluster_handler.RunningMode.Heat: + return HVACAction.HEATING + if running_mode == self._cluster_handler.RunningMode.Cool: + return HVACAction.COOLING + + running_state = self._cluster_handler.running_state + if running_state and running_state & ( + self._cluster_handler.RunningState.Fan_State_On + | self._cluster_handler.RunningState.Fan_2nd_Stage_On + | self._cluster_handler.RunningState.Fan_3rd_Stage_On + ): + return HVACAction.FAN + if ( + self._cluster_handler.system_mode != self._cluster_handler.SystemMode.Off + and running_mode == self._cluster_handler.SystemMode.Off + ): + return HVACAction.IDLE + return HVACAction.OFF + + +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_BASIC) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class RSSISensor(Sensor): + """RSSI sensor for a device.""" + + _attribute_name = "rssi" + _unique_id_suffix = "rssi" + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_device_class: SensorDeviceClass | None = SensorDeviceClass.SIGNAL_STRENGTH + _attr_native_unit_of_measurement: str | None = SIGNAL_STRENGTH_DECIBELS_MILLIWATT + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False + _attr_should_poll = True # BaseZhaEntity defaults to False + _attr_translation_key: str = "rssi" + + @classmethod + def create_entity( + cls, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> Self | None: + """Entity Factory. + + Return entity if it is a supported configuration, otherwise return None + """ + key = f"{CLUSTER_HANDLER_BASIC}_{cls._unique_id_suffix}" + if ZHA_ENTITIES.prevent_entity_creation(Platform.SENSOR, zha_device.ieee, key): + return None + return cls(unique_id, zha_device, cluster_handlers, **kwargs) + + @property + def native_value(self) -> StateType: + """Return the state of the entity.""" + return getattr(self._zha_device.device, self._attribute_name) + + +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_BASIC) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class LQISensor(RSSISensor): + """LQI sensor for a device.""" + + _attribute_name = "lqi" + _unique_id_suffix = "lqi" + _attr_device_class = None + _attr_native_unit_of_measurement = None + _attr_translation_key = "lqi" + + +@MULTI_MATCH( + cluster_handler_names="tuya_manufacturer", + manufacturers={ + "_TZE200_htnnfasr", + }, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class TimeLeft(Sensor): + """Sensor that displays time left value.""" + + _attribute_name = "timer_time_left" + _unique_id_suffix = "time_left" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION + _attr_icon = "mdi:timer" + _attr_translation_key: str = "timer_time_left" + _attr_native_unit_of_measurement = UnitOfTime.MINUTES + + +@MULTI_MATCH(cluster_handler_names="ikea_airpurifier") +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class IkeaDeviceRunTime(Sensor): + """Sensor that displays device run time (in minutes).""" + + _attribute_name = "device_run_time" + _unique_id_suffix = "device_run_time" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION + _attr_icon = "mdi:timer" + _attr_translation_key: str = "device_run_time" + _attr_native_unit_of_measurement = UnitOfTime.MINUTES + _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC + + +@MULTI_MATCH(cluster_handler_names="ikea_airpurifier") +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class IkeaFilterRunTime(Sensor): + """Sensor that displays run time of the current filter (in minutes).""" + + _attribute_name = "filter_run_time" + _unique_id_suffix = "filter_run_time" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION + _attr_icon = "mdi:timer" + _attr_translation_key: str = "filter_run_time" + _attr_native_unit_of_measurement = UnitOfTime.MINUTES + _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC + + +class AqaraFeedingSource(types.enum8): + """Aqara pet feeder feeding source.""" + + Feeder = 0x01 + HomeAssistant = 0x02 + + +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class AqaraPetFeederLastFeedingSource(EnumSensor): + """Sensor that displays the last feeding source of pet feeder.""" + + _attribute_name = "last_feeding_source" + _unique_id_suffix = "last_feeding_source" + _attr_translation_key: str = "last_feeding_source" + _attr_icon = "mdi:devices" + _enum = AqaraFeedingSource + + +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class AqaraPetFeederLastFeedingSize(Sensor): + """Sensor that displays the last feeding size of the pet feeder.""" + + _attribute_name = "last_feeding_size" + _unique_id_suffix = "last_feeding_size" + _attr_translation_key: str = "last_feeding_size" + _attr_icon: str = "mdi:counter" + + +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class AqaraPetFeederPortionsDispensed(Sensor): + """Sensor that displays the number of portions dispensed by the pet feeder.""" + + _attribute_name = "portions_dispensed" + _unique_id_suffix = "portions_dispensed" + _attr_translation_key: str = "portions_dispensed_today" + _attr_state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING + _attr_icon: str = "mdi:counter" + + +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class AqaraPetFeederWeightDispensed(Sensor): + """Sensor that displays the weight dispensed by the pet feeder.""" + + _attribute_name = "weight_dispensed" + _unique_id_suffix = "weight_dispensed" + _attr_translation_key: str = "weight_dispensed_today" + _attr_native_unit_of_measurement = UnitOfMass.GRAMS + _attr_state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING + _attr_icon: str = "mdi:weight-gram" + + +@MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"}) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class AqaraSmokeDensityDbm(Sensor): + """Sensor that displays the smoke density of an Aqara smoke sensor in dB/m.""" + + _attribute_name = "smoke_density_dbm" + _unique_id_suffix = "smoke_density_dbm" + _attr_translation_key: str = "smoke_density" + _attr_native_unit_of_measurement = "dB/m" + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_icon: str = "mdi:google-circles-communities" + _attr_suggested_display_precision: int = 3 + + +class SonoffIlluminationStates(types.enum8): + """Enum for displaying last Illumination state.""" + + Dark = 0x00 + Light = 0x01 + + +@MULTI_MATCH(cluster_handler_names="sonoff_manufacturer", models={"SNZB-06P"}) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class SonoffPresenceSenorIlluminationStatus(EnumSensor): + """Sensor that displays the illumination status the last time peresence was detected.""" + + _attribute_name = "last_illumination_state" + _unique_id_suffix = "last_illumination" + _attr_translation_key: str = "last_illumination_state" + _attr_icon: str = "mdi:theme-light-dark" + _enum = SonoffIlluminationStates + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class PiHeatingDemand(Sensor): + """Sensor that displays the percentage of heating power demanded. + + Optional thermostat attribute. + """ + + _unique_id_suffix = "pi_heating_demand" + _attribute_name = "pi_heating_demand" + _attr_translation_key: str = "pi_heating_demand" + _attr_icon: str = "mdi:radiator" + _attr_native_unit_of_measurement = PERCENTAGE + _decimals = 0 + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_entity_category = EntityCategory.DIAGNOSTIC + + +class SetpointChangeSourceEnum(types.enum8): + """The source of the setpoint change.""" + + Manual = 0x00 + Schedule = 0x01 + External = 0x02 + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class SetpointChangeSource(EnumSensor): + """Sensor that displays the source of the setpoint change. + + Optional thermostat attribute. + """ + + _unique_id_suffix = "setpoint_change_source" + _attribute_name = "setpoint_change_source" + _attr_translation_key: str = "setpoint_change_source" + _attr_icon: str = "mdi:thermostat" + _attr_entity_category = EntityCategory.DIAGNOSTIC + _enum = SetpointChangeSourceEnum + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_COVER) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class WindowCoveringTypeSensor(EnumSensor): + """Sensor that displays the type of a cover device.""" + + _attribute_name: str = WindowCovering.AttributeDefs.window_covering_type.name + _enum = WindowCovering.WindowCoveringType + _unique_id_suffix: str = WindowCovering.AttributeDefs.window_covering_type.name + _attr_translation_key: str = WindowCovering.AttributeDefs.window_covering_type.name + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_icon = "mdi:curtains" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_BASIC, models={"lumi.curtain.agl001"} +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class AqaraCurtainMotorPowerSourceSensor(EnumSensor): + """Sensor that displays the power source of the Aqara E1 curtain motor device.""" + + _attribute_name: str = Basic.AttributeDefs.power_source.name + _enum = Basic.PowerSource + _unique_id_suffix: str = Basic.AttributeDefs.power_source.name + _attr_translation_key: str = Basic.AttributeDefs.power_source.name + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_icon = "mdi:battery-positive" + + +class AqaraE1HookState(types.enum8): + """Aqara hook state.""" + + Unlocked = 0x00 + Locked = 0x01 + Locking = 0x02 + Unlocking = 0x03 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.curtain.agl001"} +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class AqaraCurtainHookStateSensor(EnumSensor): + """Representation of a ZHA curtain mode configuration entity.""" + + _attribute_name = "hooks_state" + _enum = AqaraE1HookState + _unique_id_suffix = "hooks_state" + _attr_translation_key: str = "hooks_state" + _attr_icon: str = "mdi:hook" + _attr_entity_category = EntityCategory.DIAGNOSTIC diff --git a/zha/silabs_multiprotocol.py b/zha/silabs_multiprotocol.py new file mode 100644 index 00000000..aec52b4a --- /dev/null +++ b/zha/silabs_multiprotocol.py @@ -0,0 +1,81 @@ +"""Silicon Labs Multiprotocol support.""" + +from __future__ import annotations + +import asyncio +import contextlib + +from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + is_multiprotocol_url, +) +from homeassistant.core import HomeAssistant + +from . import api + +# The approximate time it takes ZHA to change channels on SiLabs coordinators +ZHA_CHANNEL_CHANGE_TIME_S = 10.27 + + +def _get_zha_url(hass: HomeAssistant) -> str | None: + """Return the ZHA radio path, or None if there's no ZHA config entry.""" + with contextlib.suppress(ValueError): + return api.async_get_radio_path(hass) + return None + + +async def _get_zha_channel(hass: HomeAssistant) -> int | None: + """Get ZHA channel, or None if there's no ZHA config entry.""" + zha_network_settings: api.NetworkBackup | None + with contextlib.suppress(ValueError): + zha_network_settings = await api.async_get_network_settings(hass) + if not zha_network_settings: + return None + channel: int = zha_network_settings.network_info.channel + # ZHA uses channel 0 when no channel is set + return channel or None + + +async def async_change_channel( + hass: HomeAssistant, channel: int, delay: float = 0 +) -> asyncio.Task | None: + """Set the channel to be used. + + Does nothing if not configured. + """ + zha_url = _get_zha_url(hass) + if not zha_url: + # ZHA is not configured + return None + + async def finish_migration() -> None: + """Finish the channel migration.""" + await asyncio.sleep(max(0, delay - ZHA_CHANNEL_CHANGE_TIME_S)) + return await api.async_change_channel(hass, channel) + + return hass.async_create_task(finish_migration()) + + +async def async_get_channel(hass: HomeAssistant) -> int | None: + """Return the channel. + + Returns None if not configured. + """ + zha_url = _get_zha_url(hass) + if not zha_url: + # ZHA is not configured + return None + + return await _get_zha_channel(hass) + + +async def async_using_multipan(hass: HomeAssistant) -> bool: + """Return if the multiprotocol device is used. + + Returns False if not configured. + """ + zha_url = _get_zha_url(hass) + if not zha_url: + # ZHA is not configured + return False + + return is_multiprotocol_url(zha_url) diff --git a/zha/siren.py b/zha/siren.py new file mode 100644 index 00000000..59a468d2 --- /dev/null +++ b/zha/siren.py @@ -0,0 +1,177 @@ +"""Support for ZHA sirens.""" + +from __future__ import annotations + +from collections.abc import Callable +import functools +from typing import TYPE_CHECKING, Any, cast + +from homeassistant.components.siren import ( + ATTR_DURATION, + ATTR_TONE, + ATTR_VOLUME_LEVEL, + SirenEntity, + SirenEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_call_later +from zigpy.zcl.clusters.security import IasWd as WD + +from .core import discovery +from .core.cluster_handlers.security import IasWdClusterHandler +from .core.const import ( + CLUSTER_HANDLER_IAS_WD, + SIGNAL_ADD_ENTITIES, + WARNING_DEVICE_MODE_BURGLAR, + WARNING_DEVICE_MODE_EMERGENCY, + WARNING_DEVICE_MODE_EMERGENCY_PANIC, + WARNING_DEVICE_MODE_FIRE, + WARNING_DEVICE_MODE_FIRE_PANIC, + WARNING_DEVICE_MODE_POLICE_PANIC, + WARNING_DEVICE_MODE_STOP, + WARNING_DEVICE_SOUND_HIGH, + WARNING_DEVICE_STROBE_HIGH, + WARNING_DEVICE_STROBE_NO, + Strobe, +) +from .core.helpers import get_zha_data +from .core.registries import ZHA_ENTITIES +from .entity import ZhaEntity + +if TYPE_CHECKING: + from .core.cluster_handlers import ClusterHandler + from .core.device import ZHADevice + +MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.SIREN) +DEFAULT_DURATION = 5 # seconds + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Zigbee Home Automation siren from config entry.""" + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.SIREN] + + unsub = async_dispatcher_connect( + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, + async_add_entities, + entities_to_create, + ), + ) + config_entry.async_on_unload(unsub) + + +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) +class ZHASiren(ZhaEntity, SirenEntity): + """Representation of a ZHA siren.""" + + _attr_name: str = "Siren" + + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs, + ) -> None: + """Init this siren.""" + self._attr_supported_features = ( + SirenEntityFeature.TURN_ON + | SirenEntityFeature.TURN_OFF + | SirenEntityFeature.DURATION + | SirenEntityFeature.VOLUME_SET + | SirenEntityFeature.TONES + ) + self._attr_available_tones: list[int | str] | dict[int, str] | None = { + WARNING_DEVICE_MODE_BURGLAR: "Burglar", + WARNING_DEVICE_MODE_FIRE: "Fire", + WARNING_DEVICE_MODE_EMERGENCY: "Emergency", + WARNING_DEVICE_MODE_POLICE_PANIC: "Police Panic", + WARNING_DEVICE_MODE_FIRE_PANIC: "Fire Panic", + WARNING_DEVICE_MODE_EMERGENCY_PANIC: "Emergency Panic", + } + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._cluster_handler: IasWdClusterHandler = cast( + IasWdClusterHandler, cluster_handlers[0] + ) + self._attr_is_on: bool = False + self._off_listener: Callable[[], None] | None = None + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on siren.""" + if self._off_listener: + self._off_listener() + self._off_listener = None + tone_cache = self._cluster_handler.data_cache.get( + WD.Warning.WarningMode.__name__ + ) + siren_tone = ( + tone_cache.value + if tone_cache is not None + else WARNING_DEVICE_MODE_EMERGENCY + ) + siren_duration = DEFAULT_DURATION + level_cache = self._cluster_handler.data_cache.get( + WD.Warning.SirenLevel.__name__ + ) + siren_level = ( + level_cache.value if level_cache is not None else WARNING_DEVICE_SOUND_HIGH + ) + strobe_cache = self._cluster_handler.data_cache.get(Strobe.__name__) + should_strobe = ( + strobe_cache.value if strobe_cache is not None else Strobe.No_Strobe + ) + strobe_level_cache = self._cluster_handler.data_cache.get( + WD.StrobeLevel.__name__ + ) + strobe_level = ( + strobe_level_cache.value + if strobe_level_cache is not None + else WARNING_DEVICE_STROBE_HIGH + ) + if (duration := kwargs.get(ATTR_DURATION)) is not None: + siren_duration = duration + if (tone := kwargs.get(ATTR_TONE)) is not None: + siren_tone = tone + if (level := kwargs.get(ATTR_VOLUME_LEVEL)) is not None: + siren_level = int(level) + await self._cluster_handler.issue_start_warning( + mode=siren_tone, + warning_duration=siren_duration, + siren_level=siren_level, + strobe=should_strobe, + strobe_duty_cycle=50 if should_strobe else 0, + strobe_intensity=strobe_level, + ) + self._attr_is_on = True + self._off_listener = async_call_later( + self._zha_device.hass, siren_duration, self.async_set_off + ) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off siren.""" + await self._cluster_handler.issue_start_warning( + mode=WARNING_DEVICE_MODE_STOP, strobe=WARNING_DEVICE_STROBE_NO + ) + self._attr_is_on = False + self.async_write_ha_state() + + @callback + def async_set_off(self, _) -> None: + """Set is_on to False and write HA state.""" + self._attr_is_on = False + if self._off_listener: + self._off_listener() + self._off_listener = None + self.async_write_ha_state() diff --git a/zha/switch.py b/zha/switch.py new file mode 100644 index 00000000..5edd8aaf --- /dev/null +++ b/zha/switch.py @@ -0,0 +1,728 @@ +"""Switches on Zigbee Home Automation networks.""" + +from __future__ import annotations + +import functools +import logging +from typing import TYPE_CHECKING, Any, Self + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, EntityCategory, Platform +from homeassistant.core import HomeAssistant, State, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from zhaquirks.quirk_ids import TUYA_PLUG_ONOFF +from zigpy.quirks.v2 import EntityMetadata, SwitchMetadata +from zigpy.zcl.clusters.closures import ConfigStatus, WindowCovering, WindowCoveringMode +from zigpy.zcl.clusters.general import OnOff +from zigpy.zcl.foundation import Status + +from .core import discovery +from .core.const import ( + CLUSTER_HANDLER_BASIC, + CLUSTER_HANDLER_COVER, + CLUSTER_HANDLER_INOVELLI, + CLUSTER_HANDLER_ON_OFF, + QUIRK_METADATA, + SIGNAL_ADD_ENTITIES, + SIGNAL_ATTR_UPDATED, +) +from .core.helpers import get_zha_data +from .core.registries import ZHA_ENTITIES +from .entity import ZhaEntity, ZhaGroupEntity + +if TYPE_CHECKING: + from .core.cluster_handlers import ClusterHandler + from .core.device import ZHADevice + +STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.SWITCH) +GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, Platform.SWITCH) +CONFIG_DIAGNOSTIC_MATCH = functools.partial( + ZHA_ENTITIES.config_diagnostic_match, Platform.SWITCH +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Zigbee Home Automation switch from config entry.""" + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.SWITCH] + + unsub = async_dispatcher_connect( + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), + ) + config_entry.async_on_unload(unsub) + + +@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_ON_OFF) +class Switch(ZhaEntity, SwitchEntity): + """ZHA switch.""" + + _attr_translation_key = "switch" + + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> None: + """Initialize the ZHA switch.""" + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._on_off_cluster_handler = self.cluster_handlers[CLUSTER_HANDLER_ON_OFF] + + @property + def is_on(self) -> bool: + """Return if the switch is on based on the statemachine.""" + if self._on_off_cluster_handler.on_off is None: + return False + return self._on_off_cluster_handler.on_off + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self._on_off_cluster_handler.turn_on() + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self._on_off_cluster_handler.turn_off() + self.async_write_ha_state() + + @callback + def async_set_state(self, attr_id: int, attr_name: str, value: Any): + """Handle state update from cluster handler.""" + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + await super().async_added_to_hass() + self.async_accept_signal( + self._on_off_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state + ) + + async def async_update(self) -> None: + """Attempt to retrieve on off state from the switch.""" + self.debug("Polling current state") + await self._on_off_cluster_handler.get_attribute_value( + "on_off", from_cache=False + ) + + +@GROUP_MATCH() +class SwitchGroup(ZhaGroupEntity, SwitchEntity): + """Representation of a switch group.""" + + def __init__( + self, + entity_ids: list[str], + unique_id: str, + group_id: int, + zha_device: ZHADevice, + **kwargs: Any, + ) -> None: + """Initialize a switch group.""" + super().__init__(entity_ids, unique_id, group_id, zha_device, **kwargs) + self._available: bool + self._state: bool + group = self.zha_device.gateway.get_group(self._group_id) + self._on_off_cluster_handler = group.endpoint[OnOff.cluster_id] + + @property + def is_on(self) -> bool: + """Return if the switch is on based on the statemachine.""" + return bool(self._state) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + result = await self._on_off_cluster_handler.on() + if result[1] is not Status.SUCCESS: + return + self._state = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + result = await self._on_off_cluster_handler.off() + if result[1] is not Status.SUCCESS: + return + self._state = False + self.async_write_ha_state() + + async def async_update(self) -> None: + """Query all members and determine the switch group state.""" + all_states = [self.hass.states.get(x) for x in self._entity_ids] + states: list[State] = list(filter(None, all_states)) + on_states = [state for state in states if state.state == STATE_ON] + + self._state = len(on_states) > 0 + self._available = any(state.state != STATE_UNAVAILABLE for state in states) + + +class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): + """Representation of a ZHA switch configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attribute_name: str + _inverter_attribute_name: str | None = None + _force_inverted: bool = False + _off_value: int = 0 + _on_value: int = 1 + + @classmethod + def create_entity( + cls, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> Self | None: + """Entity Factory. + + Return entity if it is a supported configuration, otherwise return None + """ + cluster_handler = cluster_handlers[0] + if QUIRK_METADATA not in kwargs and ( + cls._attribute_name in cluster_handler.cluster.unsupported_attributes + or cls._attribute_name not in cluster_handler.cluster.attributes_by_name + or cluster_handler.cluster.get(cls._attribute_name) is None + ): + _LOGGER.debug( + "%s is not supported - skipping %s entity creation", + cls._attribute_name, + cls.__name__, + ) + return None + + return cls(unique_id, zha_device, cluster_handlers, **kwargs) + + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> None: + """Init this number configuration entity.""" + self._cluster_handler: ClusterHandler = cluster_handlers[0] + if QUIRK_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + super()._init_from_quirks_metadata(entity_metadata) + switch_metadata: SwitchMetadata = entity_metadata.entity_metadata + self._attribute_name = switch_metadata.attribute_name + if switch_metadata.invert_attribute_name: + self._inverter_attribute_name = switch_metadata.invert_attribute_name + if switch_metadata.force_inverted: + self._force_inverted = switch_metadata.force_inverted + self._off_value = switch_metadata.off_value + self._on_value = switch_metadata.on_value + + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + await super().async_added_to_hass() + self.async_accept_signal( + self._cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state + ) + + @callback + def async_set_state(self, attr_id: int, attr_name: str, value: Any): + """Handle state update from cluster handler.""" + self.async_write_ha_state() + + @property + def inverted(self) -> bool: + """Return True if the switch is inverted.""" + if self._inverter_attribute_name: + return bool( + self._cluster_handler.cluster.get(self._inverter_attribute_name) + ) + return self._force_inverted + + @property + def is_on(self) -> bool: + """Return if the switch is on based on the statemachine.""" + if self._on_value != 1: + val = self._cluster_handler.cluster.get(self._attribute_name) + val = val == self._on_value + else: + val = bool(self._cluster_handler.cluster.get(self._attribute_name)) + return (not val) if self.inverted else val + + async def async_turn_on_off(self, state: bool) -> None: + """Turn the entity on or off.""" + if self.inverted: + state = not state + if state: + await self._cluster_handler.write_attributes_safe( + {self._attribute_name: self._on_value} + ) + else: + await self._cluster_handler.write_attributes_safe( + {self._attribute_name: self._off_value} + ) + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.async_turn_on_off(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self.async_turn_on_off(False) + + async def async_update(self) -> None: + """Attempt to retrieve the state of the entity.""" + self.debug("Polling current state") + value = await self._cluster_handler.get_attribute_value( + self._attribute_name, from_cache=False + ) + await self._cluster_handler.get_attribute_value( + self._inverter_attribute_name, from_cache=False + ) + self.debug("read value=%s, inverted=%s", value, self.inverted) + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="tuya_manufacturer", + manufacturers={ + "_TZE200_b6wax7g0", + }, +) +class OnOffWindowDetectionFunctionConfigurationEntity(ZHASwitchConfigurationEntity): + """Representation of a ZHA window detection configuration entity.""" + + _unique_id_suffix = "on_off_window_opened_detection" + _attribute_name = "window_detection_function" + _inverter_attribute_name = "window_detection_function_inverter" + _attr_translation_key = "window_detection_function" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.motion.ac02"} +) +class P1MotionTriggerIndicatorSwitch(ZHASwitchConfigurationEntity): + """Representation of a ZHA motion triggering configuration entity.""" + + _unique_id_suffix = "trigger_indicator" + _attribute_name = "trigger_indicator" + _attr_translation_key = "trigger_indicator" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", + models={"lumi.plug.mmeu01", "lumi.plug.maeu01"}, +) +class XiaomiPlugPowerOutageMemorySwitch(ZHASwitchConfigurationEntity): + """Representation of a ZHA power outage memory configuration entity.""" + + _unique_id_suffix = "power_outage_memory" + _attribute_name = "power_outage_memory" + _attr_translation_key = "power_outage_memory" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_BASIC, + manufacturers={"Philips", "Signify Netherlands B.V."}, + models={"SML001", "SML002", "SML003", "SML004"}, +) +class HueMotionTriggerIndicatorSwitch(ZHASwitchConfigurationEntity): + """Representation of a ZHA motion triggering configuration entity.""" + + _unique_id_suffix = "trigger_indicator" + _attribute_name = "trigger_indicator" + _attr_translation_key = "trigger_indicator" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="ikea_airpurifier", + models={"STARKVIND Air purifier", "STARKVIND Air purifier table"}, +) +class ChildLock(ZHASwitchConfigurationEntity): + """ZHA BinarySensor.""" + + _unique_id_suffix = "child_lock" + _attribute_name = "child_lock" + _attr_translation_key = "child_lock" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="ikea_airpurifier", + models={"STARKVIND Air purifier", "STARKVIND Air purifier table"}, +) +class DisableLed(ZHASwitchConfigurationEntity): + """ZHA BinarySensor.""" + + _unique_id_suffix = "disable_led" + _attribute_name = "disable_led" + _attr_translation_key = "disable_led" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, +) +class InovelliInvertSwitch(ZHASwitchConfigurationEntity): + """Inovelli invert switch control.""" + + _unique_id_suffix = "invert_switch" + _attribute_name = "invert_switch" + _attr_translation_key = "invert_switch" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, +) +class InovelliSmartBulbMode(ZHASwitchConfigurationEntity): + """Inovelli smart bulb mode control.""" + + _unique_id_suffix = "smart_bulb_mode" + _attribute_name = "smart_bulb_mode" + _attr_translation_key = "smart_bulb_mode" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, models={"VZM35-SN"} +) +class InovelliSmartFanMode(ZHASwitchConfigurationEntity): + """Inovelli smart fan mode control.""" + + _unique_id_suffix = "smart_fan_mode" + _attribute_name = "smart_fan_mode" + _attr_translation_key = "smart_fan_mode" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, +) +class InovelliDoubleTapUpEnabled(ZHASwitchConfigurationEntity): + """Inovelli double tap up enabled.""" + + _unique_id_suffix = "double_tap_up_enabled" + _attribute_name = "double_tap_up_enabled" + _attr_translation_key = "double_tap_up_enabled" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, +) +class InovelliDoubleTapDownEnabled(ZHASwitchConfigurationEntity): + """Inovelli double tap down enabled.""" + + _unique_id_suffix = "double_tap_down_enabled" + _attribute_name = "double_tap_down_enabled" + _attr_translation_key = "double_tap_down_enabled" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, +) +class InovelliAuxSwitchScenes(ZHASwitchConfigurationEntity): + """Inovelli unique aux switch scenes.""" + + _unique_id_suffix = "aux_switch_scenes" + _attribute_name = "aux_switch_scenes" + _attr_translation_key = "aux_switch_scenes" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, +) +class InovelliBindingOffToOnSyncLevel(ZHASwitchConfigurationEntity): + """Inovelli send move to level with on/off to bound devices.""" + + _unique_id_suffix = "binding_off_to_on_sync_level" + _attribute_name = "binding_off_to_on_sync_level" + _attr_translation_key = "binding_off_to_on_sync_level" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, +) +class InovelliLocalProtection(ZHASwitchConfigurationEntity): + """Inovelli local protection control.""" + + _unique_id_suffix = "local_protection" + _attribute_name = "local_protection" + _attr_translation_key = "local_protection" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, +) +class InovelliOnOffLEDMode(ZHASwitchConfigurationEntity): + """Inovelli only 1 LED mode control.""" + + _unique_id_suffix = "on_off_led_mode" + _attribute_name = "on_off_led_mode" + _attr_translation_key = "one_led_mode" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, +) +class InovelliFirmwareProgressLED(ZHASwitchConfigurationEntity): + """Inovelli firmware progress LED control.""" + + _unique_id_suffix = "firmware_progress_led" + _attribute_name = "firmware_progress_led" + _attr_translation_key = "firmware_progress_led" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, +) +class InovelliRelayClickInOnOffMode(ZHASwitchConfigurationEntity): + """Inovelli relay click in on off mode control.""" + + _unique_id_suffix = "relay_click_in_on_off_mode" + _attribute_name = "relay_click_in_on_off_mode" + _attr_translation_key = "relay_click_in_on_off_mode" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_INOVELLI, +) +class InovelliDisableDoubleTapClearNotificationsMode(ZHASwitchConfigurationEntity): + """Inovelli disable clear notifications double tap control.""" + + _unique_id_suffix = "disable_clear_notifications_double_tap" + _attribute_name = "disable_clear_notifications_double_tap" + _attr_translation_key = "disable_clear_notifications_double_tap" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} +) +class AqaraPetFeederLEDIndicator(ZHASwitchConfigurationEntity): + """Representation of a LED indicator configuration entity.""" + + _unique_id_suffix = "disable_led_indicator" + _attribute_name = "disable_led_indicator" + _attr_translation_key = "led_indicator" + _force_inverted = True + _attr_icon: str = "mdi:led-on" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} +) +class AqaraPetFeederChildLock(ZHASwitchConfigurationEntity): + """Representation of a child lock configuration entity.""" + + _unique_id_suffix = "child_lock" + _attribute_name = "child_lock" + _attr_translation_key = "child_lock" + _attr_icon: str = "mdi:account-lock" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_ON_OFF, quirk_ids=TUYA_PLUG_ONOFF +) +class TuyaChildLockSwitch(ZHASwitchConfigurationEntity): + """Representation of a child lock configuration entity.""" + + _unique_id_suffix = "child_lock" + _attribute_name = "child_lock" + _attr_translation_key = "child_lock" + _attr_icon: str = "mdi:account-lock" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} +) +class AqaraThermostatWindowDetection(ZHASwitchConfigurationEntity): + """Representation of an Aqara thermostat window detection configuration entity.""" + + _unique_id_suffix = "window_detection" + _attribute_name = "window_detection" + _attr_translation_key = "window_detection" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} +) +class AqaraThermostatValveDetection(ZHASwitchConfigurationEntity): + """Representation of an Aqara thermostat valve detection configuration entity.""" + + _unique_id_suffix = "valve_detection" + _attribute_name = "valve_detection" + _attr_translation_key = "valve_detection" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} +) +class AqaraThermostatChildLock(ZHASwitchConfigurationEntity): + """Representation of an Aqara thermostat child lock configuration entity.""" + + _unique_id_suffix = "child_lock" + _attribute_name = "child_lock" + _attr_translation_key = "child_lock" + _attr_icon: str = "mdi:account-lock" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} +) +class AqaraHeartbeatIndicator(ZHASwitchConfigurationEntity): + """Representation of a heartbeat indicator configuration entity for Aqara smoke sensors.""" + + _unique_id_suffix = "heartbeat_indicator" + _attribute_name = "heartbeat_indicator" + _attr_translation_key = "heartbeat_indicator" + _attr_icon: str = "mdi:heart-flash" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} +) +class AqaraLinkageAlarm(ZHASwitchConfigurationEntity): + """Representation of a linkage alarm configuration entity for Aqara smoke sensors.""" + + _unique_id_suffix = "linkage_alarm" + _attribute_name = "linkage_alarm" + _attr_translation_key = "linkage_alarm" + _attr_icon: str = "mdi:shield-link-variant" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} +) +class AqaraBuzzerManualMute(ZHASwitchConfigurationEntity): + """Representation of a buzzer manual mute configuration entity for Aqara smoke sensors.""" + + _unique_id_suffix = "buzzer_manual_mute" + _attribute_name = "buzzer_manual_mute" + _attr_translation_key = "buzzer_manual_mute" + _attr_icon: str = "mdi:volume-off" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} +) +class AqaraBuzzerManualAlarm(ZHASwitchConfigurationEntity): + """Representation of a buzzer manual mute configuration entity for Aqara smoke sensors.""" + + _unique_id_suffix = "buzzer_manual_alarm" + _attribute_name = "buzzer_manual_alarm" + _attr_translation_key = "buzzer_manual_alarm" + _attr_icon: str = "mdi:bullhorn" + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_COVER) +class WindowCoveringInversionSwitch(ZHASwitchConfigurationEntity): + """Representation of a switch that controls inversion for window covering devices. + + This is necessary because this cluster uses 2 attributes to control inversion. + """ + + _unique_id_suffix = "inverted" + _attribute_name = WindowCovering.AttributeDefs.config_status.name + _attr_translation_key = "inverted" + _attr_icon: str = "mdi:arrow-up-down" + + @classmethod + def create_entity( + cls, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> Self | None: + """Entity Factory. + + Return entity if it is a supported configuration, otherwise return None + """ + cluster_handler = cluster_handlers[0] + window_covering_mode_attr = ( + WindowCovering.AttributeDefs.window_covering_mode.name + ) + # this entity needs 2 attributes to function + if ( + cls._attribute_name in cluster_handler.cluster.unsupported_attributes + or cls._attribute_name not in cluster_handler.cluster.attributes_by_name + or cluster_handler.cluster.get(cls._attribute_name) is None + or window_covering_mode_attr + in cluster_handler.cluster.unsupported_attributes + or window_covering_mode_attr + not in cluster_handler.cluster.attributes_by_name + or cluster_handler.cluster.get(window_covering_mode_attr) is None + ): + _LOGGER.debug( + "%s is not supported - skipping %s entity creation", + cls._attribute_name, + cls.__name__, + ) + return None + + return cls(unique_id, zha_device, cluster_handlers, **kwargs) + + @property + def is_on(self) -> bool: + """Return if the switch is on based on the statemachine.""" + config_status = ConfigStatus( + self._cluster_handler.cluster.get(self._attribute_name) + ) + return ConfigStatus.Open_up_commands_reversed in config_status + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self._async_on_off(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self._async_on_off(False) + + async def async_update(self) -> None: + """Attempt to retrieve the state of the entity.""" + self.debug("Polling current state") + await self._cluster_handler.get_attributes( + [ + self._attribute_name, + WindowCovering.AttributeDefs.window_covering_mode.name, + ], + from_cache=False, + only_cache=False, + ) + self.async_write_ha_state() + + async def _async_on_off(self, invert: bool) -> None: + """Turn the entity on or off.""" + name: str = WindowCovering.AttributeDefs.window_covering_mode.name + current_mode: WindowCoveringMode = WindowCoveringMode( + self._cluster_handler.cluster.get(name) + ) + send_command: bool = False + if invert and WindowCoveringMode.Motor_direction_reversed not in current_mode: + current_mode |= WindowCoveringMode.Motor_direction_reversed + send_command = True + elif not invert and WindowCoveringMode.Motor_direction_reversed in current_mode: + current_mode &= ~WindowCoveringMode.Motor_direction_reversed + send_command = True + if send_command: + await self._cluster_handler.write_attributes_safe({name: current_mode}) + await self.async_update() + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.curtain.agl001"} +) +class AqaraE1CurtainMotorHooksLockedSwitch(ZHASwitchConfigurationEntity): + """Representation of a switch that controls whether the curtain motor hooks are locked.""" + + _unique_id_suffix = "hooks_lock" + _attribute_name = "hooks_lock" + _attr_translation_key = "hooks_locked" + _attr_icon: str = "mdi:lock" diff --git a/zha/update.py b/zha/update.py new file mode 100644 index 00000000..fba93ff4 --- /dev/null +++ b/zha/update.py @@ -0,0 +1,225 @@ +"""Representation of ZHA updates.""" + +from __future__ import annotations + +import functools +import logging +import math +from typing import TYPE_CHECKING, Any + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) +from zigpy.ota import OtaImageWithMetadata +from zigpy.zcl.clusters.general import Ota +from zigpy.zcl.foundation import Status + +from .core import discovery +from .core.const import CLUSTER_HANDLER_OTA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED +from .core.helpers import get_zha_data, get_zha_gateway +from .core.registries import ZHA_ENTITIES +from .entity import ZhaEntity + +if TYPE_CHECKING: + from zigpy.application import ControllerApplication + + from .core.cluster_handlers import ClusterHandler + from .core.device import ZHADevice + +_LOGGER = logging.getLogger(__name__) + +CONFIG_DIAGNOSTIC_MATCH = functools.partial( + ZHA_ENTITIES.config_diagnostic_match, Platform.UPDATE +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Zigbee Home Automation update from config entry.""" + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.UPDATE] + + coordinator = ZHAFirmwareUpdateCoordinator( + hass, get_zha_gateway(hass).application_controller + ) + + unsub = async_dispatcher_connect( + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, + async_add_entities, + entities_to_create, + coordinator=coordinator, + ), + ) + config_entry.async_on_unload(unsub) + + +class ZHAFirmwareUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module + """Firmware update coordinator that broadcasts updates network-wide.""" + + def __init__( + self, hass: HomeAssistant, controller_application: ControllerApplication + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name="ZHA firmware update coordinator", + update_method=self.async_update_data, + ) + self.controller_application = controller_application + + async def async_update_data(self) -> None: + """Fetch the latest firmware update data.""" + # Broadcast to all devices + await self.controller_application.ota.broadcast_notify(jitter=100) + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_OTA) +class ZHAFirmwareUpdateEntity( + ZhaEntity, CoordinatorEntity[ZHAFirmwareUpdateCoordinator], UpdateEntity +): + """Representation of a ZHA firmware update entity.""" + + _unique_id_suffix = "firmware_update" + _attr_entity_category = EntityCategory.CONFIG + _attr_device_class = UpdateDeviceClass.FIRMWARE + _attr_supported_features = ( + UpdateEntityFeature.INSTALL + | UpdateEntityFeature.PROGRESS + | UpdateEntityFeature.SPECIFIC_VERSION + ) + + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + channels: list[ClusterHandler], + coordinator: ZHAFirmwareUpdateCoordinator, + **kwargs: Any, + ) -> None: + """Initialize the ZHA update entity.""" + super().__init__(unique_id, zha_device, channels, **kwargs) + CoordinatorEntity.__init__(self, coordinator) + + self._ota_cluster_handler: ClusterHandler = self.cluster_handlers[ + CLUSTER_HANDLER_OTA + ] + self._attr_installed_version: str | None = self._get_cluster_version() + self._attr_latest_version = self._attr_installed_version + self._latest_firmware: OtaImageWithMetadata | None = None + + def _get_cluster_version(self) -> str | None: + """Synchronize current file version with the cluster.""" + + device = self._ota_cluster_handler._endpoint.device # pylint: disable=protected-access + + if self._ota_cluster_handler.current_file_version is not None: + return f"0x{self._ota_cluster_handler.current_file_version:08x}" + + if device.sw_version is not None: + return device.sw_version + + return None + + @callback + def attribute_updated(self, attrid: int, name: str, value: Any) -> None: + """Handle attribute updates on the OTA cluster.""" + if attrid == Ota.AttributeDefs.current_file_version.id: + self._attr_installed_version = f"0x{value:08x}" + self.async_write_ha_state() + + @callback + def device_ota_update_available( + self, image: OtaImageWithMetadata, current_file_version: int + ) -> None: + """Handle ota update available signal from Zigpy.""" + self._latest_firmware = image + self._attr_latest_version = f"0x{image.version:08x}" + self._attr_installed_version = f"0x{current_file_version:08x}" + + if image.metadata.changelog: + self._attr_release_summary = image.metadata.changelog + + self.async_write_ha_state() + + @callback + def _update_progress(self, current: int, total: int, progress: float) -> None: + """Update install progress on event.""" + # If we are not supposed to be updating, do nothing + if self._attr_in_progress is False: + return + + # Remap progress to 2-100 to avoid 0 and 1 + self._attr_in_progress = int(math.ceil(2 + 98 * progress / 100)) + self.async_write_ha_state() + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install an update.""" + assert self._latest_firmware is not None + + # Set the progress to an indeterminate state + self._attr_in_progress = True + self.async_write_ha_state() + + try: + result = await self.zha_device.device.update_firmware( + image=self._latest_firmware, + progress_callback=self._update_progress, + ) + except Exception as ex: + raise HomeAssistantError(f"Update was not successful: {ex}") from ex + + # If we tried to install firmware that is no longer compatible with the device, + # bail out + if result == Status.NO_IMAGE_AVAILABLE: + self._attr_latest_version = self._attr_installed_version + self.async_write_ha_state() + + # If the update finished but was not successful, we should also throw an error + if result != Status.SUCCESS: + raise HomeAssistantError(f"Update was not successful: {result}") + + # Clear the state + self._latest_firmware = None + self._attr_in_progress = False + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Call when entity is added.""" + await super().async_added_to_hass() + + # OTA events are sent by the device + self.zha_device.device.add_listener(self) + self.async_accept_signal( + self._ota_cluster_handler, SIGNAL_ATTR_UPDATED, self.attribute_updated + ) + + async def async_will_remove_from_hass(self) -> None: + """Call when entity will be removed.""" + await super().async_will_remove_from_hass() + self._attr_in_progress = False + + async def async_update(self) -> None: + """Update the entity.""" + await CoordinatorEntity.async_update(self) + await super().async_update() diff --git a/zha/websocket_api.py b/zha/websocket_api.py new file mode 100644 index 00000000..78b21c1f --- /dev/null +++ b/zha/websocket_api.py @@ -0,0 +1,1577 @@ +"""Web socket API for Zigbee Home Automation devices.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, Any, Literal, NamedTuple, TypeVar, cast + +from homeassistant.components import websocket_api +from homeassistant.const import ATTR_COMMAND, ATTR_ID, ATTR_NAME +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import entity_registry as er +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.service import async_register_admin_service +import voluptuous as vol +import zigpy.backups +from zigpy.config import CONF_DEVICE +from zigpy.config.validators import cv_boolean +from zigpy.types.named import EUI64, KeyData +from zigpy.zcl.clusters.security import IasAce +import zigpy.zdo.types as zdo_types + +from .api import ( + async_change_channel, + async_get_active_network_settings, + async_get_radio_type, +) +from .core.const import ( + ATTR_ARGS, + ATTR_ATTRIBUTE, + ATTR_CLUSTER_ID, + ATTR_CLUSTER_TYPE, + ATTR_COMMAND_TYPE, + ATTR_ENDPOINT_ID, + ATTR_IEEE, + ATTR_LEVEL, + ATTR_MANUFACTURER, + ATTR_MEMBERS, + ATTR_PARAMS, + ATTR_TYPE, + ATTR_VALUE, + ATTR_WARNING_DEVICE_DURATION, + ATTR_WARNING_DEVICE_MODE, + ATTR_WARNING_DEVICE_STROBE, + ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, + ATTR_WARNING_DEVICE_STROBE_INTENSITY, + BINDINGS, + CLUSTER_COMMAND_SERVER, + CLUSTER_COMMANDS_CLIENT, + CLUSTER_COMMANDS_SERVER, + CLUSTER_HANDLER_IAS_WD, + CLUSTER_TYPE_IN, + CLUSTER_TYPE_OUT, + CUSTOM_CONFIGURATION, + DOMAIN, + EZSP_OVERWRITE_EUI64, + GROUP_ID, + GROUP_IDS, + GROUP_NAME, + MFG_CLUSTER_ID_START, + WARNING_DEVICE_MODE_EMERGENCY, + WARNING_DEVICE_SOUND_HIGH, + WARNING_DEVICE_SQUAWK_MODE_ARMED, + WARNING_DEVICE_STROBE_HIGH, + WARNING_DEVICE_STROBE_YES, + ZHA_ALARM_OPTIONS, + ZHA_CLUSTER_HANDLER_MSG, + ZHA_CONFIG_SCHEMAS, +) +from .core.gateway import EntityReference +from .core.group import GroupMember +from .core.helpers import ( + async_cluster_exists, + async_is_bindable_target, + cluster_command_schema_to_vol_schema, + convert_install_code, + get_matched_clusters, + get_zha_gateway, + qr_to_install_code, +) + +if TYPE_CHECKING: + from homeassistant.components.websocket_api.connection import ActiveConnection + + from .core.device import ZHADevice + from .core.gateway import ZHAGateway + +_LOGGER = logging.getLogger(__name__) + +TYPE = "type" +CLIENT = "client" +ID = "id" +RESPONSE = "response" +DEVICE_INFO = "device_info" + +ATTR_DURATION = "duration" +ATTR_GROUP = "group" +ATTR_IEEE_ADDRESS = "ieee_address" +ATTR_INSTALL_CODE = "install_code" +ATTR_NEW_CHANNEL = "new_channel" +ATTR_SOURCE_IEEE = "source_ieee" +ATTR_TARGET_IEEE = "target_ieee" +ATTR_QR_CODE = "qr_code" + +SERVICE_PERMIT = "permit" +SERVICE_REMOVE = "remove" +SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE = "set_zigbee_cluster_attribute" +SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND = "issue_zigbee_cluster_command" +SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND = "issue_zigbee_group_command" +SERVICE_DIRECT_ZIGBEE_BIND = "issue_direct_zigbee_bind" +SERVICE_DIRECT_ZIGBEE_UNBIND = "issue_direct_zigbee_unbind" +SERVICE_WARNING_DEVICE_SQUAWK = "warning_device_squawk" +SERVICE_WARNING_DEVICE_WARN = "warning_device_warn" +SERVICE_ZIGBEE_BIND = "service_zigbee_bind" +IEEE_SERVICE = "ieee_based_service" + +IEEE_SCHEMA = vol.All(cv.string, EUI64.convert) + +# typing typevar +_T = TypeVar("_T") + + +def _ensure_list_if_present(value: _T | None) -> list[_T] | list[Any] | None: + """Wrap value in list if it is provided and not one.""" + if value is None: + return None + return cast("list[_T]", value) if isinstance(value, list) else [value] + + +SERVICE_PERMIT_PARAMS = { + vol.Optional(ATTR_IEEE): IEEE_SCHEMA, + vol.Optional(ATTR_DURATION, default=60): vol.All( + vol.Coerce(int), vol.Range(0, 254) + ), + vol.Inclusive(ATTR_SOURCE_IEEE, "install_code"): IEEE_SCHEMA, + vol.Inclusive(ATTR_INSTALL_CODE, "install_code"): vol.All( + cv.string, convert_install_code + ), + vol.Exclusive(ATTR_QR_CODE, "install_code"): vol.All(cv.string, qr_to_install_code), +} + +SERVICE_SCHEMAS = { + SERVICE_PERMIT: vol.Schema( + vol.All( + cv.deprecated(ATTR_IEEE_ADDRESS, replacement_key=ATTR_IEEE), + SERVICE_PERMIT_PARAMS, + ) + ), + IEEE_SERVICE: vol.Schema( + vol.All( + cv.deprecated(ATTR_IEEE_ADDRESS, replacement_key=ATTR_IEEE), + {vol.Required(ATTR_IEEE): IEEE_SCHEMA}, + ) + ), + SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE: vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): cv.positive_int, + vol.Required(ATTR_CLUSTER_ID): cv.positive_int, + vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, + vol.Required(ATTR_ATTRIBUTE): vol.Any(cv.positive_int, str), + vol.Required(ATTR_VALUE): vol.Any(int, cv.boolean, cv.string), + vol.Optional(ATTR_MANUFACTURER): vol.All( + vol.Coerce(int), vol.Range(min=-1) + ), + } + ), + SERVICE_WARNING_DEVICE_SQUAWK: vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Optional( + ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_SQUAWK_MODE_ARMED + ): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES + ): cv.positive_int, + vol.Optional( + ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH + ): cv.positive_int, + } + ), + SERVICE_WARNING_DEVICE_WARN: vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Optional( + ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_MODE_EMERGENCY + ): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES + ): cv.positive_int, + vol.Optional( + ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH + ): cv.positive_int, + vol.Optional(ATTR_WARNING_DEVICE_DURATION, default=5): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, default=0x00 + ): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE_INTENSITY, default=WARNING_DEVICE_STROBE_HIGH + ): cv.positive_int, + } + ), + SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND: vol.All( + vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): cv.positive_int, + vol.Required(ATTR_CLUSTER_ID): cv.positive_int, + vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, + vol.Required(ATTR_COMMAND): cv.positive_int, + vol.Required(ATTR_COMMAND_TYPE): cv.string, + vol.Exclusive(ATTR_ARGS, "attrs_params"): _ensure_list_if_present, + vol.Exclusive(ATTR_PARAMS, "attrs_params"): dict, + vol.Optional(ATTR_MANUFACTURER): vol.All( + vol.Coerce(int), vol.Range(min=-1) + ), + } + ), + cv.deprecated(ATTR_ARGS), + cv.has_at_least_one_key(ATTR_ARGS, ATTR_PARAMS), + ), + SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND: vol.Schema( + { + vol.Required(ATTR_GROUP): cv.positive_int, + vol.Required(ATTR_CLUSTER_ID): cv.positive_int, + vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, + vol.Required(ATTR_COMMAND): cv.positive_int, + vol.Optional(ATTR_ARGS, default=[]): cv.ensure_list, + vol.Optional(ATTR_MANUFACTURER): vol.All( + vol.Coerce(int), vol.Range(min=-1) + ), + } + ), +} + + +class ClusterBinding(NamedTuple): + """Describes a cluster binding.""" + + name: str + type: str + id: int + endpoint_id: int + + +def _cv_group_member(value: dict[str, Any]) -> GroupMember: + """Transform a group member.""" + return GroupMember( + ieee=value[ATTR_IEEE], + endpoint_id=value[ATTR_ENDPOINT_ID], + ) + + +def _cv_cluster_binding(value: dict[str, Any]) -> ClusterBinding: + """Transform a cluster binding.""" + return ClusterBinding( + name=value[ATTR_NAME], + type=value[ATTR_TYPE], + id=value[ATTR_ID], + endpoint_id=value[ATTR_ENDPOINT_ID], + ) + + +def _cv_zigpy_network_backup(value: dict[str, Any]) -> zigpy.backups.NetworkBackup: + """Transform a zigpy network backup.""" + + try: + return zigpy.backups.NetworkBackup.from_dict(value) + except ValueError as err: + raise vol.Invalid(str(err)) from err + + +GROUP_MEMBER_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): vol.Coerce(int), + } + ), + _cv_group_member, +) + + +CLUSTER_BINDING_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(ATTR_NAME): cv.string, + vol.Required(ATTR_TYPE): cv.string, + vol.Required(ATTR_ID): vol.Coerce(int), + vol.Required(ATTR_ENDPOINT_ID): vol.Coerce(int), + } + ), + _cv_cluster_binding, +) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "zha/devices/permit", + **SERVICE_PERMIT_PARAMS, + } +) +@websocket_api.async_response +async def websocket_permit_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Permit ZHA zigbee devices.""" + zha_gateway = get_zha_gateway(hass) + duration: int = msg[ATTR_DURATION] + ieee: EUI64 | None = msg.get(ATTR_IEEE) + + async def forward_messages(data): + """Forward events to websocket.""" + connection.send_message(websocket_api.event_message(msg["id"], data)) + + remove_dispatcher_function = async_dispatcher_connect( + hass, "zha_gateway_message", forward_messages + ) + + @callback + def async_cleanup() -> None: + """Remove signal listener and turn off debug mode.""" + zha_gateway.async_disable_debug_mode() + remove_dispatcher_function() + + connection.subscriptions[msg["id"]] = async_cleanup + zha_gateway.async_enable_debug_mode() + src_ieee: EUI64 + link_key: KeyData + if ATTR_SOURCE_IEEE in msg: + src_ieee = msg[ATTR_SOURCE_IEEE] + link_key = msg[ATTR_INSTALL_CODE] + _LOGGER.debug("Allowing join for %s device with link key", src_ieee) + await zha_gateway.application_controller.permit_with_link_key( + time_s=duration, node=src_ieee, link_key=link_key + ) + elif ATTR_QR_CODE in msg: + src_ieee, link_key = msg[ATTR_QR_CODE] + _LOGGER.debug("Allowing join for %s device with link key", src_ieee) + await zha_gateway.application_controller.permit_with_link_key( + time_s=duration, node=src_ieee, link_key=link_key + ) + else: + await zha_gateway.application_controller.permit(time_s=duration, node=ieee) + connection.send_result(msg[ID]) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/devices"}) +@websocket_api.async_response +async def websocket_get_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA devices.""" + zha_gateway = get_zha_gateway(hass) + devices = [device.zha_device_info for device in zha_gateway.devices.values()] + connection.send_result(msg[ID], devices) + + +@callback +def _get_entity_name( + zha_gateway: ZHAGateway, entity_ref: EntityReference +) -> str | None: + entity_registry = er.async_get(zha_gateway.hass) + entry = entity_registry.async_get(entity_ref.reference_id) + return entry.name if entry else None + + +@callback +def _get_entity_original_name( + zha_gateway: ZHAGateway, entity_ref: EntityReference +) -> str | None: + entity_registry = er.async_get(zha_gateway.hass) + entry = entity_registry.async_get(entity_ref.reference_id) + return entry.original_name if entry else None + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/devices/groupable"}) +@websocket_api.async_response +async def websocket_get_groupable_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA devices that can be grouped.""" + zha_gateway = get_zha_gateway(hass) + + devices = [device for device in zha_gateway.devices.values() if device.is_groupable] + groupable_devices = [] + + for device in devices: + entity_refs = zha_gateway.device_registry[device.ieee] + for ep_id in device.async_get_groupable_endpoints(): + groupable_devices.append( + { + "endpoint_id": ep_id, + "entities": [ + { + "name": _get_entity_name(zha_gateway, entity_ref), + "original_name": _get_entity_original_name( + zha_gateway, entity_ref + ), + } + for entity_ref in entity_refs + if list(entity_ref.cluster_handlers.values())[ + 0 + ].cluster.endpoint.endpoint_id + == ep_id + ], + "device": device.zha_device_info, + } + ) + + connection.send_result(msg[ID], groupable_devices) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/groups"}) +@websocket_api.async_response +async def websocket_get_groups( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA groups.""" + zha_gateway = get_zha_gateway(hass) + groups = [group.group_info for group in zha_gateway.groups.values()] + connection.send_result(msg[ID], groups) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/device", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_get_device( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA devices.""" + zha_gateway = get_zha_gateway(hass) + ieee: EUI64 = msg[ATTR_IEEE] + + if not (zha_device := zha_gateway.devices.get(ieee)): + connection.send_message( + websocket_api.error_message( + msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Device not found" + ) + ) + return + + device_info = zha_device.zha_device_info + connection.send_result(msg[ID], device_info) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/group", + vol.Required(GROUP_ID): cv.positive_int, + } +) +@websocket_api.async_response +async def websocket_get_group( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA group.""" + zha_gateway = get_zha_gateway(hass) + group_id: int = msg[GROUP_ID] + + if not (zha_group := zha_gateway.groups.get(group_id)): + connection.send_message( + websocket_api.error_message( + msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" + ) + ) + return + + group_info = zha_group.group_info + connection.send_result(msg[ID], group_info) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/group/add", + vol.Required(GROUP_NAME): cv.string, + vol.Optional(GROUP_ID): cv.positive_int, + vol.Optional(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), + } +) +@websocket_api.async_response +async def websocket_add_group( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Add a new ZHA group.""" + zha_gateway = get_zha_gateway(hass) + group_name: str = msg[GROUP_NAME] + group_id: int | None = msg.get(GROUP_ID) + members: list[GroupMember] | None = msg.get(ATTR_MEMBERS) + group = await zha_gateway.async_create_zigpy_group(group_name, members, group_id) + assert group + connection.send_result(msg[ID], group.group_info) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/group/remove", + vol.Required(GROUP_IDS): vol.All(cv.ensure_list, [cv.positive_int]), + } +) +@websocket_api.async_response +async def websocket_remove_groups( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Remove the specified ZHA groups.""" + zha_gateway = get_zha_gateway(hass) + group_ids: list[int] = msg[GROUP_IDS] + + if len(group_ids) > 1: + tasks = [] + for group_id in group_ids: + tasks.append(zha_gateway.async_remove_zigpy_group(group_id)) + await asyncio.gather(*tasks) + else: + await zha_gateway.async_remove_zigpy_group(group_ids[0]) + ret_groups = [group.group_info for group in zha_gateway.groups.values()] + connection.send_result(msg[ID], ret_groups) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/group/members/add", + vol.Required(GROUP_ID): cv.positive_int, + vol.Required(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), + } +) +@websocket_api.async_response +async def websocket_add_group_members( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Add members to a ZHA group.""" + zha_gateway = get_zha_gateway(hass) + group_id: int = msg[GROUP_ID] + members: list[GroupMember] = msg[ATTR_MEMBERS] + + if not (zha_group := zha_gateway.groups.get(group_id)): + connection.send_message( + websocket_api.error_message( + msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" + ) + ) + return + + await zha_group.async_add_members(members) + ret_group = zha_group.group_info + connection.send_result(msg[ID], ret_group) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/group/members/remove", + vol.Required(GROUP_ID): cv.positive_int, + vol.Required(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), + } +) +@websocket_api.async_response +async def websocket_remove_group_members( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Remove members from a ZHA group.""" + zha_gateway = get_zha_gateway(hass) + group_id: int = msg[GROUP_ID] + members: list[GroupMember] = msg[ATTR_MEMBERS] + + if not (zha_group := zha_gateway.groups.get(group_id)): + connection.send_message( + websocket_api.error_message( + msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" + ) + ) + return + + await zha_group.async_remove_members(members) + ret_group = zha_group.group_info + connection.send_result(msg[ID], ret_group) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/reconfigure", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_reconfigure_node( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Reconfigure a ZHA nodes entities by its ieee address.""" + zha_gateway = get_zha_gateway(hass) + ieee: EUI64 = msg[ATTR_IEEE] + device: ZHADevice | None = zha_gateway.get_device(ieee) + + async def forward_messages(data): + """Forward events to websocket.""" + connection.send_message(websocket_api.event_message(msg["id"], data)) + + remove_dispatcher_function = async_dispatcher_connect( + hass, ZHA_CLUSTER_HANDLER_MSG, forward_messages + ) + + @callback + def async_cleanup() -> None: + """Remove signal listener.""" + remove_dispatcher_function() + + connection.subscriptions[msg["id"]] = async_cleanup + + _LOGGER.debug("Reconfiguring node with ieee_address: %s", ieee) + assert device + hass.async_create_task(device.async_configure()) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/topology/update", + } +) +@websocket_api.async_response +async def websocket_update_topology( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Update the ZHA network topology.""" + zha_gateway = get_zha_gateway(hass) + hass.async_create_task(zha_gateway.application_controller.topology.scan()) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/clusters", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_device_clusters( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Return a list of device clusters.""" + zha_gateway = get_zha_gateway(hass) + ieee: EUI64 = msg[ATTR_IEEE] + zha_device = zha_gateway.get_device(ieee) + response_clusters = [] + if zha_device is not None: + clusters_by_endpoint = zha_device.async_get_clusters() + for ep_id, clusters in clusters_by_endpoint.items(): + for c_id, cluster in clusters[CLUSTER_TYPE_IN].items(): + response_clusters.append( + { + TYPE: CLUSTER_TYPE_IN, + ID: c_id, + ATTR_NAME: cluster.__class__.__name__, + "endpoint_id": ep_id, + } + ) + for c_id, cluster in clusters[CLUSTER_TYPE_OUT].items(): + response_clusters.append( + { + TYPE: CLUSTER_TYPE_OUT, + ID: c_id, + ATTR_NAME: cluster.__class__.__name__, + "endpoint_id": ep_id, + } + ) + + connection.send_result(msg[ID], response_clusters) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/clusters/attributes", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): int, + vol.Required(ATTR_CLUSTER_ID): int, + vol.Required(ATTR_CLUSTER_TYPE): str, + } +) +@websocket_api.async_response +async def websocket_device_cluster_attributes( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Return a list of cluster attributes.""" + zha_gateway = get_zha_gateway(hass) + ieee: EUI64 = msg[ATTR_IEEE] + endpoint_id: int = msg[ATTR_ENDPOINT_ID] + cluster_id: int = msg[ATTR_CLUSTER_ID] + cluster_type: str = msg[ATTR_CLUSTER_TYPE] + cluster_attributes: list[dict[str, Any]] = [] + zha_device = zha_gateway.get_device(ieee) + attributes = None + if zha_device is not None: + attributes = zha_device.async_get_cluster_attributes( + endpoint_id, cluster_id, cluster_type + ) + if attributes is not None: + for attr_id, attr in attributes.items(): + cluster_attributes.append({ID: attr_id, ATTR_NAME: attr.name}) + _LOGGER.debug( + "Requested attributes for: %s: %s, %s: '%s', %s: %s, %s: %s", + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + RESPONSE, + cluster_attributes, + ) + + connection.send_result(msg[ID], cluster_attributes) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/clusters/commands", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): int, + vol.Required(ATTR_CLUSTER_ID): int, + vol.Required(ATTR_CLUSTER_TYPE): str, + } +) +@websocket_api.async_response +async def websocket_device_cluster_commands( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Return a list of cluster commands.""" + import voluptuous_serialize # pylint: disable=import-outside-toplevel + + zha_gateway = get_zha_gateway(hass) + ieee: EUI64 = msg[ATTR_IEEE] + endpoint_id: int = msg[ATTR_ENDPOINT_ID] + cluster_id: int = msg[ATTR_CLUSTER_ID] + cluster_type: str = msg[ATTR_CLUSTER_TYPE] + zha_device = zha_gateway.get_device(ieee) + cluster_commands: list[dict[str, Any]] = [] + commands = None + if zha_device is not None: + commands = zha_device.async_get_cluster_commands( + endpoint_id, cluster_id, cluster_type + ) + + if commands is not None: + for cmd_id, cmd in commands[CLUSTER_COMMANDS_CLIENT].items(): + cluster_commands.append( + { + TYPE: CLIENT, + ID: cmd_id, + ATTR_NAME: cmd.name, + "schema": voluptuous_serialize.convert( + cluster_command_schema_to_vol_schema(cmd.schema), + custom_serializer=cv.custom_serializer, + ), + } + ) + for cmd_id, cmd in commands[CLUSTER_COMMANDS_SERVER].items(): + cluster_commands.append( + { + TYPE: CLUSTER_COMMAND_SERVER, + ID: cmd_id, + ATTR_NAME: cmd.name, + "schema": voluptuous_serialize.convert( + cluster_command_schema_to_vol_schema(cmd.schema), + custom_serializer=cv.custom_serializer, + ), + } + ) + _LOGGER.debug( + "Requested commands for: %s: %s, %s: '%s', %s: %s, %s: %s", + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + RESPONSE, + cluster_commands, + ) + + connection.send_result(msg[ID], cluster_commands) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/clusters/attributes/value", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): int, + vol.Required(ATTR_CLUSTER_ID): int, + vol.Required(ATTR_CLUSTER_TYPE): str, + vol.Required(ATTR_ATTRIBUTE): int, + vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + } +) +@websocket_api.async_response +async def websocket_read_zigbee_cluster_attributes( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Read zigbee attribute for cluster on ZHA entity.""" + zha_gateway = get_zha_gateway(hass) + ieee: EUI64 = msg[ATTR_IEEE] + endpoint_id: int = msg[ATTR_ENDPOINT_ID] + cluster_id: int = msg[ATTR_CLUSTER_ID] + cluster_type: str = msg[ATTR_CLUSTER_TYPE] + attribute: int = msg[ATTR_ATTRIBUTE] + manufacturer: int | None = msg.get(ATTR_MANUFACTURER) + zha_device = zha_gateway.get_device(ieee) + success = {} + failure = {} + if zha_device is not None: + cluster = zha_device.async_get_cluster( + endpoint_id, cluster_id, cluster_type=cluster_type + ) + success, failure = await cluster.read_attributes( + [attribute], allow_cache=False, only_cache=False, manufacturer=manufacturer + ) + _LOGGER.debug( + ( + "Read attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s]" + " %s: [%s]," + ), + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + ATTR_ATTRIBUTE, + attribute, + ATTR_MANUFACTURER, + manufacturer, + RESPONSE, + str(success.get(attribute)), + "failure", + failure, + ) + connection.send_result(msg[ID], str(success.get(attribute))) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/bindable", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_get_bindable_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Directly bind devices.""" + zha_gateway = get_zha_gateway(hass) + source_ieee: EUI64 = msg[ATTR_IEEE] + source_device = zha_gateway.get_device(source_ieee) + + devices = [ + device.zha_device_info + for device in zha_gateway.devices.values() + if async_is_bindable_target(source_device, device) + ] + + _LOGGER.debug( + "Get bindable devices: %s: [%s], %s: [%s]", + ATTR_SOURCE_IEEE, + source_ieee, + "bindable devices", + devices, + ) + + connection.send_message(websocket_api.result_message(msg[ID], devices)) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/bind", + vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_TARGET_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_bind_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Directly bind devices.""" + zha_gateway = get_zha_gateway(hass) + source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] + target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] + await async_binding_operation( + zha_gateway, source_ieee, target_ieee, zdo_types.ZDOCmd.Bind_req + ) + _LOGGER.info( + "Devices bound: %s: [%s] %s: [%s]", + ATTR_SOURCE_IEEE, + source_ieee, + ATTR_TARGET_IEEE, + target_ieee, + ) + connection.send_result(msg[ID]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/unbind", + vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_TARGET_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_unbind_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Remove a direct binding between devices.""" + zha_gateway = get_zha_gateway(hass) + source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] + target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] + await async_binding_operation( + zha_gateway, source_ieee, target_ieee, zdo_types.ZDOCmd.Unbind_req + ) + _LOGGER.info( + "Devices un-bound: %s: [%s] %s: [%s]", + ATTR_SOURCE_IEEE, + source_ieee, + ATTR_TARGET_IEEE, + target_ieee, + ) + connection.send_result(msg[ID]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/groups/bind", + vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, + vol.Required(GROUP_ID): cv.positive_int, + vol.Required(BINDINGS): vol.All(cv.ensure_list, [CLUSTER_BINDING_SCHEMA]), + } +) +@websocket_api.async_response +async def websocket_bind_group( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Directly bind a device to a group.""" + zha_gateway = get_zha_gateway(hass) + source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] + group_id: int = msg[GROUP_ID] + bindings: list[ClusterBinding] = msg[BINDINGS] + source_device = zha_gateway.get_device(source_ieee) + assert source_device + await source_device.async_bind_to_group(group_id, bindings) + connection.send_result(msg[ID]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/groups/unbind", + vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, + vol.Required(GROUP_ID): cv.positive_int, + vol.Required(BINDINGS): vol.All(cv.ensure_list, [CLUSTER_BINDING_SCHEMA]), + } +) +@websocket_api.async_response +async def websocket_unbind_group( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Unbind a device from a group.""" + zha_gateway = get_zha_gateway(hass) + source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] + group_id: int = msg[GROUP_ID] + bindings: list[ClusterBinding] = msg[BINDINGS] + source_device = zha_gateway.get_device(source_ieee) + assert source_device + await source_device.async_unbind_from_group(group_id, bindings) + connection.send_result(msg[ID]) + + +async def async_binding_operation( + zha_gateway: ZHAGateway, + source_ieee: EUI64, + target_ieee: EUI64, + operation: zdo_types.ZDOCmd, +) -> None: + """Create or remove a direct zigbee binding between 2 devices.""" + + source_device = zha_gateway.get_device(source_ieee) + target_device = zha_gateway.get_device(target_ieee) + + assert source_device + assert target_device + clusters_to_bind = await get_matched_clusters(source_device, target_device) + + zdo = source_device.device.zdo + bind_tasks = [] + for binding_pair in clusters_to_bind: + op_msg = "cluster: %s %s --> [%s]" + op_params = ( + binding_pair.source_cluster.cluster_id, + operation.name, + target_ieee, + ) + zdo.debug(f"processing {op_msg}", *op_params) + + bind_tasks.append( + ( + zdo.request( + operation, + source_device.ieee, + binding_pair.source_cluster.endpoint.endpoint_id, + binding_pair.source_cluster.cluster_id, + binding_pair.destination_address, + ), + op_msg, + op_params, + ) + ) + res = await asyncio.gather(*(t[0] for t in bind_tasks), return_exceptions=True) + for outcome, log_msg in zip(res, bind_tasks): + if isinstance(outcome, Exception): + fmt = f"{log_msg[1]} failed: %s" + else: + fmt = f"{log_msg[1]} completed: %s" + zdo.debug(fmt, *(log_msg[2] + (outcome,))) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/configuration"}) +@websocket_api.async_response +async def websocket_get_configuration( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA configuration.""" + zha_gateway = get_zha_gateway(hass) + 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: dict[str, dict[str, Any]] = {"schemas": {}, "data": {}} + for section, schema in ZHA_CONFIG_SCHEMAS.items(): + if section == ZHA_ALARM_OPTIONS and not async_cluster_exists( + hass, IasAce.cluster_id + ): + continue + data["schemas"][section] = voluptuous_serialize.convert( + schema, custom_serializer=custom_serializer + ) + data["data"][section] = zha_gateway.config_entry.options.get( + CUSTOM_CONFIGURATION, {} + ).get(section, {}) + + # send default values for unconfigured options + for entry in data["schemas"][section]: + if data["data"][section].get(entry["name"]) is None: + data["data"][section][entry["name"]] = entry["default"] + + connection.send_result(msg[ID], data) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/configuration/update", + vol.Required("data"): ZHA_CONFIG_SCHEMAS, + } +) +@websocket_api.async_response +async def websocket_update_zha_configuration( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Update the ZHA configuration.""" + zha_gateway = get_zha_gateway(hass) + options = zha_gateway.config_entry.options + data_to_save = {**options, **{CUSTOM_CONFIGURATION: msg["data"]}} + + for section, schema in ZHA_CONFIG_SCHEMAS.items(): + for entry in schema.schema: + # remove options that match defaults + if ( + data_to_save[CUSTOM_CONFIGURATION].get(section, {}).get(entry) + == entry.default() + ): + data_to_save[CUSTOM_CONFIGURATION][section].pop(entry) + # remove entire section block if empty + if ( + not data_to_save[CUSTOM_CONFIGURATION].get(section) + and section in data_to_save[CUSTOM_CONFIGURATION] + ): + data_to_save[CUSTOM_CONFIGURATION].pop(section) + + # remove entire custom_configuration block if empty + if ( + not data_to_save.get(CUSTOM_CONFIGURATION) + and CUSTOM_CONFIGURATION in data_to_save + ): + data_to_save.pop(CUSTOM_CONFIGURATION) + + _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) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/settings"}) +@websocket_api.async_response +async def websocket_get_network_settings( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA network settings.""" + backup = async_get_active_network_settings(hass) + zha_gateway = get_zha_gateway(hass) + connection.send_result( + msg[ID], + { + "radio_type": async_get_radio_type(hass, zha_gateway.config_entry).name, + "device": zha_gateway.application_controller.config[CONF_DEVICE], + "settings": backup.as_dict(), + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/backups/list"}) +@websocket_api.async_response +async def websocket_list_network_backups( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA network settings.""" + zha_gateway = get_zha_gateway(hass) + application_controller = zha_gateway.application_controller + + # Serialize known backups + connection.send_result( + msg[ID], [backup.as_dict() for backup in application_controller.backups] + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/backups/create"}) +@websocket_api.async_response +async def websocket_create_network_backup( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Create a ZHA network backup.""" + zha_gateway = get_zha_gateway(hass) + application_controller = zha_gateway.application_controller + + # This can take 5-30s + backup = await application_controller.backups.create_backup(load_devices=True) + connection.send_result( + msg[ID], + { + "backup": backup.as_dict(), + "is_complete": backup.is_complete(), + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/network/backups/restore", + vol.Required("backup"): _cv_zigpy_network_backup, + vol.Optional("ezsp_force_write_eui64", default=False): cv.boolean, + } +) +@websocket_api.async_response +async def websocket_restore_network_backup( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Restore a ZHA network backup.""" + zha_gateway = get_zha_gateway(hass) + application_controller = zha_gateway.application_controller + backup = msg["backup"] + + if msg["ezsp_force_write_eui64"]: + backup.network_info.stack_specific.setdefault("ezsp", {})[ + EZSP_OVERWRITE_EUI64 + ] = True + + # This can take 30-40s + try: + await application_controller.backups.restore_backup(backup) + except ValueError as err: + connection.send_error(msg[ID], websocket_api.const.ERR_INVALID_FORMAT, str(err)) + else: + connection.send_result(msg[ID]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/network/change_channel", + vol.Required(ATTR_NEW_CHANNEL): vol.Any("auto", vol.Range(11, 26)), + } +) +@websocket_api.async_response +async def websocket_change_channel( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Migrate the Zigbee network to a new channel.""" + new_channel = cast(Literal["auto"] | int, msg[ATTR_NEW_CHANNEL]) + await async_change_channel(hass, new_channel=new_channel) + connection.send_result(msg[ID]) + + +@callback +def async_load_api(hass: HomeAssistant) -> None: + """Set up the web socket API.""" + zha_gateway = get_zha_gateway(hass) + application_controller = zha_gateway.application_controller + + async def permit(service: ServiceCall) -> None: + """Allow devices to join this network.""" + duration: int = service.data[ATTR_DURATION] + ieee: EUI64 | None = service.data.get(ATTR_IEEE) + src_ieee: EUI64 + link_key: KeyData + if ATTR_SOURCE_IEEE in service.data: + src_ieee = service.data[ATTR_SOURCE_IEEE] + link_key = service.data[ATTR_INSTALL_CODE] + _LOGGER.info("Allowing join for %s device with link key", src_ieee) + await application_controller.permit_with_link_key( + time_s=duration, node=src_ieee, link_key=link_key + ) + return + + if ATTR_QR_CODE in service.data: + src_ieee, link_key = service.data[ATTR_QR_CODE] + _LOGGER.info("Allowing join for %s device with link key", src_ieee) + await application_controller.permit_with_link_key( + time_s=duration, node=src_ieee, link_key=link_key + ) + return + + if ieee: + _LOGGER.info("Permitting joins for %ss on %s device", duration, ieee) + else: + _LOGGER.info("Permitting joins for %ss", duration) + await application_controller.permit(time_s=duration, node=ieee) + + async_register_admin_service( + hass, DOMAIN, SERVICE_PERMIT, permit, schema=SERVICE_SCHEMAS[SERVICE_PERMIT] + ) + + async def remove(service: ServiceCall) -> None: + """Remove a node from the network.""" + zha_gateway = get_zha_gateway(hass) + ieee: EUI64 = service.data[ATTR_IEEE] + zha_device: ZHADevice | None = zha_gateway.get_device(ieee) + if zha_device is not None and zha_device.is_active_coordinator: + _LOGGER.info("Removing the coordinator (%s) is not allowed", ieee) + return + _LOGGER.info("Removing node %s", ieee) + await application_controller.remove(ieee) + + async_register_admin_service( + hass, DOMAIN, SERVICE_REMOVE, remove, schema=SERVICE_SCHEMAS[IEEE_SERVICE] + ) + + async def set_zigbee_cluster_attributes(service: ServiceCall) -> None: + """Set zigbee attribute for cluster on zha entity.""" + ieee: EUI64 = service.data[ATTR_IEEE] + endpoint_id: int = service.data[ATTR_ENDPOINT_ID] + cluster_id: int = service.data[ATTR_CLUSTER_ID] + cluster_type: str = service.data[ATTR_CLUSTER_TYPE] + attribute: int | str = service.data[ATTR_ATTRIBUTE] + value: int | bool | str = service.data[ATTR_VALUE] + manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) + zha_device = zha_gateway.get_device(ieee) + response = None + if zha_device is not None: + response = await zha_device.write_zigbee_attribute( + endpoint_id, + cluster_id, + attribute, + value, + cluster_type=cluster_type, + manufacturer=manufacturer, + ) + else: + raise ValueError(f"Device with IEEE {str(ieee)} not found") + + _LOGGER.debug( + ( + "Set attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s:" + " [%s] %s: [%s]" + ), + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + ATTR_ATTRIBUTE, + attribute, + ATTR_VALUE, + value, + ATTR_MANUFACTURER, + manufacturer, + RESPONSE, + response, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE, + set_zigbee_cluster_attributes, + schema=SERVICE_SCHEMAS[SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE], + ) + + async def issue_zigbee_cluster_command(service: ServiceCall) -> None: + """Issue command on zigbee cluster on ZHA entity.""" + ieee: EUI64 = service.data[ATTR_IEEE] + endpoint_id: int = service.data[ATTR_ENDPOINT_ID] + cluster_id: int = service.data[ATTR_CLUSTER_ID] + cluster_type: str = service.data[ATTR_CLUSTER_TYPE] + command: int = service.data[ATTR_COMMAND] + command_type: str = service.data[ATTR_COMMAND_TYPE] + args: list | None = service.data.get(ATTR_ARGS) + params: dict | None = service.data.get(ATTR_PARAMS) + manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) + zha_device = zha_gateway.get_device(ieee) + if zha_device is not None: + if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: + manufacturer = zha_device.manufacturer_code + + await zha_device.issue_cluster_command( + endpoint_id, + cluster_id, + command, + command_type, + args, + params, + cluster_type=cluster_type, + manufacturer=manufacturer, + ) + _LOGGER.debug( + ( + "Issued command for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s]" + " %s: [%s] %s: [%s] %s: [%s]" + ), + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + ATTR_COMMAND, + command, + ATTR_COMMAND_TYPE, + command_type, + ATTR_ARGS, + args, + ATTR_PARAMS, + params, + ATTR_MANUFACTURER, + manufacturer, + ) + else: + raise ValueError(f"Device with IEEE {str(ieee)} not found") + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND, + issue_zigbee_cluster_command, + schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND], + ) + + async def issue_zigbee_group_command(service: ServiceCall) -> None: + """Issue command on zigbee cluster on a zigbee group.""" + group_id: int = service.data[ATTR_GROUP] + cluster_id: int = service.data[ATTR_CLUSTER_ID] + command: int = service.data[ATTR_COMMAND] + args: list = service.data[ATTR_ARGS] + manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) + group = zha_gateway.get_group(group_id) + if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: + _LOGGER.error("Missing manufacturer attribute for cluster: %d", cluster_id) + response = None + if group is not None: + cluster = group.endpoint[cluster_id] + response = await cluster.command( + command, *args, manufacturer=manufacturer, expect_reply=True + ) + _LOGGER.debug( + "Issued group command for: %s: [%s] %s: [%s] %s: %s %s: [%s] %s: %s", + ATTR_CLUSTER_ID, + cluster_id, + ATTR_COMMAND, + command, + ATTR_ARGS, + args, + ATTR_MANUFACTURER, + manufacturer, + RESPONSE, + response, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND, + issue_zigbee_group_command, + schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND], + ) + + def _get_ias_wd_cluster_handler(zha_device): + """Get the IASWD cluster handler for a device.""" + cluster_handlers = { + ch.name: ch + for endpoint in zha_device.endpoints.values() + for ch in endpoint.claimed_cluster_handlers.values() + } + return cluster_handlers.get(CLUSTER_HANDLER_IAS_WD) + + async def warning_device_squawk(service: ServiceCall) -> None: + """Issue the squawk command for an IAS warning device.""" + ieee: EUI64 = service.data[ATTR_IEEE] + mode: int = service.data[ATTR_WARNING_DEVICE_MODE] + strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] + level: int = service.data[ATTR_LEVEL] + + if (zha_device := zha_gateway.get_device(ieee)) is not None: + if cluster_handler := _get_ias_wd_cluster_handler(zha_device): + await cluster_handler.issue_squawk(mode, strobe, level) + else: + _LOGGER.error( + "Squawking IASWD: %s: [%s] is missing the required IASWD cluster handler!", + ATTR_IEEE, + str(ieee), + ) + else: + _LOGGER.error( + "Squawking IASWD: %s: [%s] could not be found!", ATTR_IEEE, str(ieee) + ) + _LOGGER.debug( + "Squawking IASWD: %s: [%s] %s: [%s] %s: [%s] %s: [%s]", + ATTR_IEEE, + str(ieee), + ATTR_WARNING_DEVICE_MODE, + mode, + ATTR_WARNING_DEVICE_STROBE, + strobe, + ATTR_LEVEL, + level, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_WARNING_DEVICE_SQUAWK, + warning_device_squawk, + schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_SQUAWK], + ) + + async def warning_device_warn(service: ServiceCall) -> None: + """Issue the warning command for an IAS warning device.""" + ieee: EUI64 = service.data[ATTR_IEEE] + mode: int = service.data[ATTR_WARNING_DEVICE_MODE] + strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] + level: int = service.data[ATTR_LEVEL] + duration: int = service.data[ATTR_WARNING_DEVICE_DURATION] + duty_mode: int = service.data[ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE] + intensity: int = service.data[ATTR_WARNING_DEVICE_STROBE_INTENSITY] + + if (zha_device := zha_gateway.get_device(ieee)) is not None: + if cluster_handler := _get_ias_wd_cluster_handler(zha_device): + await cluster_handler.issue_start_warning( + mode, strobe, level, duration, duty_mode, intensity + ) + else: + _LOGGER.error( + "Warning IASWD: %s: [%s] is missing the required IASWD cluster handler!", + ATTR_IEEE, + str(ieee), + ) + else: + _LOGGER.error( + "Warning IASWD: %s: [%s] could not be found!", ATTR_IEEE, str(ieee) + ) + _LOGGER.debug( + "Warning IASWD: %s: [%s] %s: [%s] %s: [%s] %s: [%s]", + ATTR_IEEE, + str(ieee), + ATTR_WARNING_DEVICE_MODE, + mode, + ATTR_WARNING_DEVICE_STROBE, + strobe, + ATTR_LEVEL, + level, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_WARNING_DEVICE_WARN, + warning_device_warn, + schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_WARN], + ) + + websocket_api.async_register_command(hass, websocket_permit_devices) + websocket_api.async_register_command(hass, websocket_get_devices) + websocket_api.async_register_command(hass, websocket_get_groupable_devices) + websocket_api.async_register_command(hass, websocket_get_groups) + websocket_api.async_register_command(hass, websocket_get_device) + websocket_api.async_register_command(hass, websocket_get_group) + websocket_api.async_register_command(hass, websocket_add_group) + websocket_api.async_register_command(hass, websocket_remove_groups) + websocket_api.async_register_command(hass, websocket_add_group_members) + websocket_api.async_register_command(hass, websocket_remove_group_members) + websocket_api.async_register_command(hass, websocket_bind_group) + websocket_api.async_register_command(hass, websocket_unbind_group) + websocket_api.async_register_command(hass, websocket_reconfigure_node) + websocket_api.async_register_command(hass, websocket_device_clusters) + websocket_api.async_register_command(hass, websocket_device_cluster_attributes) + websocket_api.async_register_command(hass, websocket_device_cluster_commands) + websocket_api.async_register_command(hass, websocket_read_zigbee_cluster_attributes) + websocket_api.async_register_command(hass, websocket_get_bindable_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_update_topology) + websocket_api.async_register_command(hass, websocket_get_configuration) + websocket_api.async_register_command(hass, websocket_update_zha_configuration) + websocket_api.async_register_command(hass, websocket_get_network_settings) + websocket_api.async_register_command(hass, websocket_list_network_backups) + websocket_api.async_register_command(hass, websocket_create_network_backup) + websocket_api.async_register_command(hass, websocket_restore_network_backup) + websocket_api.async_register_command(hass, websocket_change_channel) + + +@callback +def async_unload_api(hass: HomeAssistant) -> None: + """Unload the ZHA API.""" + hass.services.async_remove(DOMAIN, SERVICE_PERMIT) + hass.services.async_remove(DOMAIN, SERVICE_REMOVE) + hass.services.async_remove(DOMAIN, SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE) + hass.services.async_remove(DOMAIN, SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND) + hass.services.async_remove(DOMAIN, SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND) + hass.services.async_remove(DOMAIN, SERVICE_WARNING_DEVICE_SQUAWK) + hass.services.async_remove(DOMAIN, SERVICE_WARNING_DEVICE_WARN)