ZHA as an external library (#120190)

Co-authored-by: David Mulcahey <david.mulcahey@icloud.com>
Co-authored-by: David Mulcahey <david.mulcahey@me.com>
This commit is contained in:
puddly 2024-07-08 14:18:30 -04:00 committed by GitHub
parent e11d24f06f
commit b754f03c11
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
93 changed files with 3910 additions and 34014 deletions

View File

@ -1,17 +1,18 @@
"""Support for Zigbee Home Automation devices."""
import contextlib
import copy
import logging
import re
import voluptuous as vol
from zhaquirks import setup as setup_quirks
from zha.application.const import BAUD_RATES, RadioType
from zha.application.gateway import Gateway
from zha.application.helpers import ZHAData
from zha.zigbee.device import get_device_automation_triggers
from zigpy.config import CONF_DATABASE, CONF_DEVICE, CONF_DEVICE_PATH
from zigpy.exceptions import NetworkSettingsInconsistent, TransientConnectionError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TYPE, EVENT_HOMEASSISTANT_STOP
from homeassistant.const import CONF_TYPE, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
@ -20,9 +21,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import ConfigType
from . import repairs, websocket_api
from .core import ZHAGateway
from .core.const import (
BAUD_RATES,
from .const import (
CONF_BAUDRATE,
CONF_CUSTOM_QUIRKS_PATH,
CONF_DEVICE_CONFIG,
@ -33,13 +32,14 @@ from .core.const import (
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 .helpers import (
SIGNAL_ADD_ENTITIES,
HAZHAData,
ZHAGatewayProxy,
create_zha_config,
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 (
@ -74,6 +74,25 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
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,
)
# Zigbee definitions
CENTICELSIUS = "C-100"
@ -83,49 +102,20 @@ _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
ha_zha_data = HAZHAData(yaml_config=config.get(DOMAIN, {}))
hass.data[DATA_ZHA] = ha_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):
await hass.async_add_import_executor_job(
setup_quirks, zha_data.yaml_config.get(CONF_CUSTOM_QUIRKS_PATH)
)
ha_zha_data: HAZHAData = get_zha_data(hass)
ha_zha_data.config_entry = config_entry
zha_lib_data: ZHAData = create_zha_config(hass, ha_zha_data)
# Load and cache device trigger information early
device_registry = dr.async_get(hass)
@ -141,19 +131,16 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
if dev_entry is None:
continue
zha_data.device_trigger_cache[dev_entry.id] = (
zha_lib_data.device_trigger_cache[dev_entry.id] = (
str(dev.ieee),
get_device_automation_triggers(dev),
)
ha_zha_data.device_trigger_cache = zha_lib_data.device_trigger_cache
_LOGGER.debug("Trigger cache: %s", zha_data.device_trigger_cache)
_LOGGER.debug("Trigger cache: %s", zha_lib_data.device_trigger_cache)
try:
zha_gateway = await ZHAGateway.async_from_config(
hass=hass,
config=zha_data.yaml_config,
config_entry=config_entry,
)
zha_gateway = await Gateway.async_from_config(zha_lib_data)
except NetworkSettingsInconsistent as exc:
await warn_on_inconsistent_network_settings(
hass,
@ -185,6 +172,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
repairs.async_delete_blocking_issues(hass)
ha_zha_data.gateway_proxy = ZHAGatewayProxy(hass, config_entry, zha_gateway)
manufacturer = zha_gateway.state.node_info.manufacturer
model = zha_gateway.state.node_info.model
@ -205,13 +194,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
websocket_api.async_load_api(hass)
async def async_shutdown(_: Event) -> None:
await zha_gateway.shutdown()
"""Handle shutdown tasks."""
assert ha_zha_data.gateway_proxy is not None
await ha_zha_data.gateway_proxy.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 ha_zha_data.gateway_proxy.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
@ -219,11 +210,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Unload ZHA config entry."""
zha_data = get_zha_data(hass)
ha_zha_data = get_zha_data(hass)
ha_zha_data.config_entry = None
if zha_data.gateway is not None:
await zha_data.gateway.shutdown()
zha_data.gateway = None
if ha_zha_data.gateway_proxy is not None:
await ha_zha_data.gateway_proxy.shutdown()
ha_zha_data.gateway_proxy = None
# clean up any remaining entity metadata
# (entities that have been discovered but not yet added to HA)
@ -231,15 +223,11 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
# be in when we get here in failure cases
with contextlib.suppress(KeyError):
for platform in PLATFORMS:
del zha_data.platforms[platform]
del ha_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 hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
return True
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:

View File

@ -3,9 +3,6 @@
from __future__ import annotations
import functools
from typing import TYPE_CHECKING
from zigpy.zcl.clusters.security import IasAce
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntity,
@ -13,50 +10,18 @@ from homeassistant.components.alarm_control_panel import (
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.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
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,
from .entity import ZHAEntity
from .helpers import (
SIGNAL_ADD_ENTITIES,
ZHA_ALARM_OPTIONS,
async_add_entities as zha_async_add_entities,
convert_zha_error_to_ha_error,
get_zha_data,
)
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(
@ -72,14 +37,16 @@ async def async_setup_entry(
hass,
SIGNAL_ADD_ENTITIES,
functools.partial(
discovery.async_add_entities, async_add_entities, entities_to_create
zha_async_add_entities,
async_add_entities,
ZHAAlarmControlPanel,
entities_to_create,
),
)
config_entry.async_on_unload(unsub)
@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_ACE)
class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity):
class ZHAAlarmControlPanel(ZHAEntity, AlarmControlPanelEntity):
"""Entity for ZHA alarm control devices."""
_attr_translation_key: str = "alarm_control_panel"
@ -91,68 +58,42 @@ class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity):
| 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
return self.entity_data.entity.code_arm_required
@convert_zha_error_to_ha_error
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command."""
self._cluster_handler.arm(IasAce.ArmMode.Disarm, code, 0)
await self.entity_data.entity.async_alarm_disarm(code)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
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)
await self.entity_data.entity.async_alarm_arm_home(code)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
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)
await self.entity_data.entity.async_alarm_arm_away(code)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
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)
await self.entity_data.entity.async_alarm_arm_night(code)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
async def async_alarm_trigger(self, code: str | None = None) -> None:
"""Send alarm trigger command."""
await self.entity_data.entity.async_alarm_trigger(code)
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)
return self.entity_data.entity.state["state"]

View File

@ -4,13 +4,14 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Literal
from zha.application.const import RadioType
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 .const import CONF_RADIO_TYPE, DOMAIN
from .helpers import get_zha_data, get_zha_gateway
from .radio_manager import ZhaRadioManager
if TYPE_CHECKING:
@ -22,14 +23,12 @@ 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
zha_data = get_zha_data(hass)
# Otherwise, find one
if zha_data.config_entry is not None:
return zha_data.config_entry
# Otherwise, find an inactive one
entries = hass.config_entries.async_entries(DOMAIN)
if len(entries) != 1:

View File

@ -4,7 +4,7 @@ import logging
from homeassistant.core import HomeAssistant
from .core.helpers import get_zha_gateway
from .helpers import get_zha_gateway
_LOGGER = logging.getLogger(__name__)

View File

@ -3,58 +3,24 @@
from __future__ import annotations
import functools
import logging
from zhaquirks.quirk_ids import DANFOSS_ALLY_THERMOSTAT
from zigpy.quirks.v2 import BinarySensorMetadata
import zigpy.types as t
from zigpy.zcl.clusters.general import OnOff
from zigpy.zcl.clusters.security import IasZone
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.const import Platform
from homeassistant.core import HomeAssistant
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_ACCELEROMETER,
CLUSTER_HANDLER_BINARY_INPUT,
CLUSTER_HANDLER_HUE_OCCUPANCY,
CLUSTER_HANDLER_OCCUPANCY,
CLUSTER_HANDLER_ON_OFF,
CLUSTER_HANDLER_THERMOSTAT,
CLUSTER_HANDLER_ZONE,
ENTITY_METADATA,
from .entity import ZHAEntity
from .helpers import (
SIGNAL_ADD_ENTITIES,
SIGNAL_ATTR_UPDATED,
EntityData,
async_add_entities as zha_async_add_entities,
get_zha_data,
)
from .core.helpers import get_zha_data, validate_device_class
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
)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
@ -70,312 +36,24 @@ async def async_setup_entry(
hass,
SIGNAL_ADD_ENTITIES,
functools.partial(
discovery.async_add_entities, async_add_entities, entities_to_create
zha_async_add_entities, async_add_entities, BinarySensor, entities_to_create
),
)
config_entry.async_on_unload(unsub)
class BinarySensor(ZhaEntity, BinarySensorEntity):
class BinarySensor(ZHAEntity, BinarySensorEntity):
"""ZHA BinarySensor."""
_attribute_name: str
def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs) -> None:
def __init__(self, entity_data: EntityData) -> None:
"""Initialize the ZHA binary sensor."""
self._cluster_handler = cluster_handlers[0]
if ENTITY_METADATA in kwargs:
self._init_from_quirks_metadata(kwargs[ENTITY_METADATA])
super().__init__(unique_id, zha_device, cluster_handlers, **kwargs)
def _init_from_quirks_metadata(self, entity_metadata: BinarySensorMetadata) -> None:
"""Init this entity from the quirks metadata."""
super()._init_from_quirks_metadata(entity_metadata)
self._attribute_name = entity_metadata.attribute_name
if entity_metadata.device_class is not None:
self._attr_device_class = validate_device_class(
BinarySensorDeviceClass,
entity_metadata.device_class,
Platform.BINARY_SENSOR.value,
_LOGGER,
super().__init__(entity_data)
if self.entity_data.entity.info_object.device_class is not None:
self._attr_device_class = BinarySensorDeviceClass(
self.entity_data.entity.info_object.device_class
)
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
@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_entity_category = EntityCategory.DIAGNOSTIC
@CONFIG_DIAGNOSTIC_MATCH(
cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT,
quirk_ids={DANFOSS_ALLY_THERMOSTAT},
)
class DanfossMountingModeActive(BinarySensor):
"""Danfoss TRV proprietary attribute exposing whether in mounting mode."""
_unique_id_suffix = "mounting_mode_active"
_attribute_name = "mounting_mode_active"
_attr_translation_key: str = "mounting_mode_active"
_attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OPENING
_attr_entity_category = EntityCategory.DIAGNOSTIC
@MULTI_MATCH(
cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT,
quirk_ids={DANFOSS_ALLY_THERMOSTAT},
)
class DanfossHeatRequired(BinarySensor):
"""Danfoss TRV proprietary attribute exposing whether heat is required."""
_unique_id_suffix = "heat_required"
_attribute_name = "heat_required"
_attr_translation_key: str = "heat_required"
@CONFIG_DIAGNOSTIC_MATCH(
cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT,
quirk_ids={DANFOSS_ALLY_THERMOSTAT},
)
class DanfossPreheatStatus(BinarySensor):
"""Danfoss TRV proprietary attribute exposing whether in pre-heating mode."""
_unique_id_suffix = "preheat_status"
_attribute_name = "preheat_status"
_attr_translation_key: str = "preheat_status"
_attr_entity_registry_enabled_default = False
_attr_entity_category = EntityCategory.DIAGNOSTIC
return self.entity_data.entity.is_on

View File

@ -4,33 +4,22 @@ from __future__ import annotations
import functools
import logging
from typing import TYPE_CHECKING, Any, Self
from zigpy.quirks.v2 import WriteAttributeButtonMetadata, ZCLCommandButtonMetadata
from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, Platform
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
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_IDENTIFY, ENTITY_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
from .entity import ZHAEntity
from .helpers import (
SIGNAL_ADD_ENTITIES,
EntityData,
async_add_entities as zha_async_add_entities,
convert_zha_error_to_ha_error,
get_zha_data,
)
DEFAULT_DURATION = 5 # seconds
_LOGGER = logging.getLogger(__name__)
@ -48,172 +37,24 @@ async def async_setup_entry(
hass,
SIGNAL_ADD_ENTITIES,
functools.partial(
discovery.async_add_entities,
async_add_entities,
entities_to_create,
zha_async_add_entities, async_add_entities, ZHAButton, entities_to_create
),
)
config_entry.async_on_unload(unsub)
class ZHAButton(ZhaEntity, ButtonEntity):
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 ENTITY_METADATA in kwargs:
self._init_from_quirks_metadata(kwargs[ENTITY_METADATA])
super().__init__(unique_id, zha_device, cluster_handlers, **kwargs)
def _init_from_quirks_metadata(
self, entity_metadata: ZCLCommandButtonMetadata
) -> None:
"""Init this entity from the quirks metadata."""
super()._init_from_quirks_metadata(entity_metadata)
self._command_name = entity_metadata.command_name
self._args = entity_metadata.args
self._kwargs = entity_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
def __init__(self, entity_data: EntityData) -> None:
"""Initialize the ZHA binary sensor."""
super().__init__(entity_data)
if self.entity_data.entity.info_object.device_class is not None:
self._attr_device_class = ButtonDeviceClass(
self.entity_data.entity.info_object.device_class
)
@convert_zha_error_to_ha_error
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 ENTITY_METADATA in kwargs:
self._init_from_quirks_metadata(kwargs[ENTITY_METADATA])
super().__init__(unique_id, zha_device, cluster_handlers, **kwargs)
def _init_from_quirks_metadata(
self, entity_metadata: WriteAttributeButtonMetadata
) -> None:
"""Init this entity from the quirks metadata."""
super()._init_from_quirks_metadata(entity_metadata)
self._attribute_name = entity_metadata.attribute_name
self._attribute_value = entity_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"
await self.entity_data.entity.async_press()

View File

@ -6,109 +6,62 @@ at https://home-assistant.io/components/zha.climate/
from __future__ import annotations
from datetime import datetime, timedelta
from collections.abc import Mapping
import functools
from random import randint
from typing import Any
from zigpy.zcl.clusters.hvac import Fan as F, Thermostat as T
from zha.application.platforms.climate.const import (
ClimateEntityFeature as ZHAClimateEntityFeature,
HVACAction as ZHAHVACAction,
HVACMode as ZHAHVACMode,
)
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,
ATTR_TEMPERATURE,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_TEMPERATURE,
PRECISION_TENTHS,
Platform,
UnitOfTemperature,
)
from homeassistant.const import 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 .core import discovery
from .core.const import (
CLUSTER_HANDLER_FAN,
CLUSTER_HANDLER_THERMOSTAT,
PRESET_COMPLEX,
PRESET_SCHEDULE,
PRESET_TEMP_MANUAL,
from .entity import ZHAEntity
from .helpers import (
SIGNAL_ADD_ENTITIES,
SIGNAL_ATTR_UPDATED,
EntityData,
async_add_entities as zha_async_add_entities,
convert_zha_error_to_ha_error,
exclude_none_values,
get_zha_data,
)
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
ZHA_TO_HA_HVAC_MODE = {
ZHAHVACMode.OFF: HVACMode.OFF,
ZHAHVACMode.AUTO: HVACMode.AUTO,
ZHAHVACMode.HEAT: HVACMode.HEAT,
ZHAHVACMode.COOL: HVACMode.COOL,
ZHAHVACMode.HEAT_COOL: HVACMode.HEAT_COOL,
ZHAHVACMode.DRY: HVACMode.DRY,
ZHAHVACMode.FAN_ONLY: HVACMode.FAN_ONLY,
}
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,
ZHA_TO_HA_HVAC_ACTION = {
ZHAHVACAction.OFF: HVACAction.OFF,
ZHAHVACAction.HEATING: HVACAction.HEATING,
ZHAHVACAction.COOLING: HVACAction.COOLING,
ZHAHVACAction.DRYING: HVACAction.DRYING,
ZHAHVACAction.IDLE: HVACAction.IDLE,
ZHAHVACAction.FAN: HVACAction.FAN,
ZHAHVACAction.PREHEATING: HVACAction.PREHEATING,
}
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,
@ -118,708 +71,168 @@ async def async_setup_entry(
"""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
zha_async_add_entities, async_add_entities, Thermostat, 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):
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
def __init__(self, entity_data: EntityData, **kwargs: Any) -> None:
"""Initialize the ZHA thermostat entity."""
super().__init__(entity_data, **kwargs)
self._attr_hvac_modes = [
ZHA_TO_HA_HVAC_MODE[mode] for mode in self.entity_data.entity.hvac_modes
]
self._attr_hvac_mode = ZHA_TO_HA_HVAC_MODE.get(
self.entity_data.entity.hvac_mode
)
self._fan = self.cluster_handlers.get(CLUSTER_HANDLER_FAN)
self._attr_hvac_action = ZHA_TO_HA_HVAC_ACTION.get(
self.entity_data.entity.hvac_action
)
features: ClimateEntityFeature = ClimateEntityFeature(0)
zha_features: ZHAClimateEntityFeature = (
self.entity_data.entity.supported_features
)
if ZHAClimateEntityFeature.TARGET_TEMPERATURE in zha_features:
features |= ClimateEntityFeature.TARGET_TEMPERATURE
if ZHAClimateEntityFeature.TARGET_TEMPERATURE_RANGE in zha_features:
features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
if ZHAClimateEntityFeature.TARGET_HUMIDITY in zha_features:
features |= ClimateEntityFeature.TARGET_HUMIDITY
if ZHAClimateEntityFeature.PRESET_MODE in zha_features:
features |= ClimateEntityFeature.PRESET_MODE
if ZHAClimateEntityFeature.FAN_MODE in zha_features:
features |= ClimateEntityFeature.FAN_MODE
if ZHAClimateEntityFeature.SWING_MODE in zha_features:
features |= ClimateEntityFeature.SWING_MODE
if ZHAClimateEntityFeature.AUX_HEAT in zha_features:
features |= ClimateEntityFeature.AUX_HEAT
if ZHAClimateEntityFeature.TURN_OFF in zha_features:
features |= ClimateEntityFeature.TURN_OFF
if ZHAClimateEntityFeature.TURN_ON in zha_features:
features |= ClimateEntityFeature.TURN_ON
self._attr_supported_features = features
@property
def current_temperature(self):
def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Return entity specific state attributes."""
state = self.entity_data.entity.state
return exclude_none_values(
{
"occupancy": state.get("occupancy"),
"occupied_cooling_setpoint": state.get("occupied_cooling_setpoint"),
"occupied_heating_setpoint": state.get("occupied_heating_setpoint"),
"pi_cooling_demand": state.get("pi_cooling_demand"),
"pi_heating_demand": state.get("pi_heating_demand"),
"system_mode": state.get("system_mode"),
"unoccupied_cooling_setpoint": state.get("unoccupied_cooling_setpoint"),
"unoccupied_heating_setpoint": state.get("unoccupied_heating_setpoint"),
}
)
@property
def current_temperature(self) -> float | None:
"""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
return self.entity_data.entity.current_temperature
@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
return self.entity_data.entity.fan_mode
@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])
return self.entity_data.entity.fan_modes
@property
def preset_mode(self) -> str:
"""Return current preset mode."""
return self._preset
return self.entity_data.entity.preset_mode
@property
def preset_modes(self) -> list[str] | None:
"""Return supported preset modes."""
return self._presets
return self.entity_data.entity.preset_modes
@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):
def target_temperature(self) -> float | None:
"""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)
return self.entity_data.entity.target_temperature
@property
def target_temperature_high(self):
def target_temperature_high(self) -> float | None:
"""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)
return self.entity_data.entity.target_temperature_high
@property
def target_temperature_low(self):
def target_temperature_low(self) -> float | None:
"""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)
return self.entity_data.entity.target_temperature_low
@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)
return self.entity_data.entity.max_temp
@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
return self.entity_data.entity.min_temp
@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
)
def _handle_entity_events(self, event: Any) -> None:
"""Entity state changed."""
self._attr_hvac_mode = self._attr_hvac_mode = ZHA_TO_HA_HVAC_MODE.get(
self.entity_data.entity.hvac_mode
)
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._attr_hvac_action = ZHA_TO_HA_HVAC_ACTION.get(
self.entity_data.entity.hvac_action
)
self._async_update_time()
super()._handle_entity_events(event)
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
@convert_zha_error_to_ha_error
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set fan mode."""
await self.entity_data.entity.async_set_fan_mode(fan_mode=fan_mode)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target operation mode."""
await self.entity_data.entity.async_set_hvac_mode(hvac_mode=hvac_mode)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
await self.entity_data.entity.async_set_preset_mode(preset_mode=preset_mode)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
await self.entity_data.entity.async_set_temperature(
target_temp_low=kwargs.get(ATTR_TARGET_TEMP_LOW),
target_temp_high=kwargs.get(ATTR_TARGET_TEMP_HIGH),
temperature=kwargs.get(ATTR_TEMPERATURE),
hvac_mode=kwargs.get(ATTR_HVAC_MODE),
)
@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
)
self.async_write_ha_state()

View File

@ -10,6 +10,7 @@ from typing import Any
import serial.tools.list_ports
from serial.tools.list_ports_common import ListPortInfo
import voluptuous as vol
from zha.application.const import RadioType
import zigpy.backups
from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH
@ -35,13 +36,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.selector import FileSelector, FileSelectorConfig
from homeassistant.util import dt as dt_util
from .core.const import (
CONF_BAUDRATE,
CONF_FLOW_CONTROL,
CONF_RADIO_TYPE,
DOMAIN,
RadioType,
)
from .const import CONF_BAUDRATE, CONF_FLOW_CONTROL, CONF_RADIO_TYPE, DOMAIN
from .radio_manager import (
DEVICE_SCHEMA,
HARDWARE_DISCOVERY_SCHEMA,
@ -146,12 +141,12 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
self._title: str | None = None
@property
def hass(self):
def hass(self) -> HomeAssistant:
"""Return hass."""
return self._hass
@hass.setter
def hass(self, hass):
def hass(self, hass: HomeAssistant) -> None:
"""Set hass."""
self._hass = hass
self._radio_mgr.hass = hass

View File

@ -0,0 +1,76 @@
"""Constants for the ZHA integration."""
EZSP_OVERWRITE_EUI64 = (
"i_understand_i_can_update_eui64_only_once_and_i_still_want_to_do_it"
)
ATTR_ACTIVE_COORDINATOR = "active_coordinator"
ATTR_ATTRIBUTES = "attributes"
ATTR_AVAILABLE = "available"
ATTR_DEVICE_TYPE = "device_type"
ATTR_CLUSTER_NAME = "cluster_name"
ATTR_ENDPOINT_NAMES = "endpoint_names"
ATTR_IEEE = "ieee"
ATTR_LAST_SEEN = "last_seen"
ATTR_LQI = "lqi"
ATTR_MANUFACTURER = "manufacturer"
ATTR_MANUFACTURER_CODE = "manufacturer_code"
ATTR_NEIGHBORS = "neighbors"
ATTR_NWK = "nwk"
ATTR_POWER_SOURCE = "power_source"
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_SUCCESS = "success"
CONF_ALARM_MASTER_CODE = "alarm_master_code"
CONF_ALARM_FAILED_TRIES = "alarm_failed_tries"
CONF_ALARM_ARM_REQUIRES_CODE = "alarm_arm_requires_code"
CONF_RADIO_TYPE = "radio_type"
CONF_USB_PATH = "usb_path"
CONF_USE_THREAD = "use_thread"
CONF_BAUDRATE = "baudrate"
CONF_FLOW_CONTROL = "flow_control"
CONF_ENABLE_QUIRKS = "enable_quirks"
CONF_CUSTOM_QUIRKS_PATH = "custom_quirks_path"
CONF_DEFAULT_LIGHT_TRANSITION = "default_light_transition"
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_CONSIDER_UNAVAILABLE_MAINS = "consider_unavailable_mains"
CONF_CONSIDER_UNAVAILABLE_BATTERY = "consider_unavailable_battery"
CONF_ZIGPY = "zigpy_config"
CONF_DEVICE_CONFIG = "device_config"
CUSTOM_CONFIGURATION = "custom_configuration"
DATA_ZHA = "zha"
DATA_ZHA_DEVICE_TRIGGER_CACHE = "zha_device_trigger_cache"
DEFAULT_DATABASE_NAME = "zigbee.db"
DEVICE_PAIRING_STATUS = "pairing_status"
DOMAIN = "zha"
GROUP_ID = "group_id"
GROUP_IDS = "group_ids"
GROUP_NAME = "group_name"
MFG_CLUSTER_ID_START = 0xFC00
ZHA_ALARM_OPTIONS = "zha_alarm_options"
ZHA_OPTIONS = "zha_options"

View File

@ -1,6 +0,0 @@
"""Core module for Zigbee Home Automation."""
from .device import ZHADevice
from .gateway import ZHAGateway
__all__ = ["ZHADevice", "ZHAGateway"]

View File

@ -1,654 +0,0 @@
"""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, TypedDict
import zigpy.exceptions
import zigpy.util
import zigpy.zcl
from zigpy.zcl.foundation import (
CommandSchema,
ConfigureReportingResponseRecord,
Status,
ZCLAttributeDef,
)
from homeassistant.const import ATTR_COMMAND
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_send
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"}
type _FuncType[**_P] = Callable[_P, Awaitable[Any]]
type _ReturnFuncType[**_P] = 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[**_P](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"{device.ieee!s}:{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)

View File

@ -1,271 +0,0 @@
"""Closures cluster handlers module for Zigbee Home Automation."""
from __future__ import annotations
from typing import Any
import zigpy.types as t
from zigpy.zcl.clusters.closures import ConfigStatus, DoorLock, Shade, WindowCovering
from homeassistant.core import callback
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."""
return await self.get_pin_code(code_slot - 1)
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."""
return await self.get_user_type(code_slot - 1)
@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)

View File

@ -1,690 +0,0 @@
"""General cluster handlers module for Zigbee Home Automation."""
from __future__ import annotations
from collections.abc import Coroutine
from typing import TYPE_CHECKING, Any
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 homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.event import async_call_later
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,
}
@callback
def attribute_updated(self, attrid: int, value: Any, timestamp: Any) -> None:
"""Handle an attribute updated on this cluster."""
# We intentionally avoid the `ClientClusterHandler` attribute update handler:
# it emits a logbook event on every update, which pollutes the logbook
ClusterHandler.attribute_updated(self, attrid, value, timestamp)
@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."""

View File

@ -1,23 +0,0 @@
"""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",)

View File

@ -1,236 +0,0 @@
"""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."""

View File

@ -1,347 +0,0 @@
"""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 zigpy.zcl.clusters.hvac import (
Dehumidification,
Fan,
Pump,
Thermostat,
UserInterface,
)
from homeassistant.core import callback
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: tuple[AttrReportConfig, ...] = (
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}

View File

@ -1,196 +0,0 @@
"""Lighting cluster handlers module for Zigbee Home Automation."""
from __future__ import annotations
from 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

View File

@ -1,48 +0,0 @@
"""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")

View File

@ -1,515 +0,0 @@
"""Manufacturer specific cluster handlers module for Zigbee Home Automation."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from zhaquirks.inovelli.types import AllLEDEffectType, SingleLEDEffectType
from zhaquirks.quirk_ids import (
DANFOSS_ALLY_THERMOSTAT,
TUYA_PLUG_MANUFACTURER,
XIAOMI_AQARA_VIBRATION_AQ1,
)
import zigpy.zcl
from zigpy.zcl import clusters
from zigpy.zcl.clusters.closures import DoorLock
from homeassistant.core import callback
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
from .homeautomation import DiagnosticClusterHandler
from .hvac import ThermostatClusterHandler, UserInterfaceClusterHandler
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}
@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(
clusters.hvac.Thermostat.cluster_id, DANFOSS_ALLY_THERMOSTAT
)
class DanfossThermostatClusterHandler(ThermostatClusterHandler):
"""Thermostat cluster handler for the Danfoss TRV and derivatives."""
REPORT_CONFIG = (
*ThermostatClusterHandler.REPORT_CONFIG,
AttrReportConfig(attr="open_window_detection", config=REPORT_CONFIG_DEFAULT),
AttrReportConfig(attr="heat_required", config=REPORT_CONFIG_ASAP),
AttrReportConfig(attr="mounting_mode_active", config=REPORT_CONFIG_DEFAULT),
AttrReportConfig(attr="load_estimate", config=REPORT_CONFIG_DEFAULT),
AttrReportConfig(attr="adaptation_run_status", config=REPORT_CONFIG_DEFAULT),
AttrReportConfig(attr="preheat_status", config=REPORT_CONFIG_DEFAULT),
AttrReportConfig(attr="preheat_time", config=REPORT_CONFIG_DEFAULT),
)
ZCL_INIT_ATTRS = {
**ThermostatClusterHandler.ZCL_INIT_ATTRS,
"external_open_window_detected": True,
"window_open_feature": True,
"exercise_day_of_week": True,
"exercise_trigger_time": True,
"mounting_mode_control": False, # Can change
"orientation": True,
"external_measured_room_sensor": False, # Can change
"radiator_covered": True,
"heat_available": True,
"load_balancing_enable": True,
"load_room_mean": False, # Can change
"control_algorithm_scale_factor": True,
"regulation_setpoint_offset": True,
"adaptation_run_control": True,
"adaptation_run_settings": True,
}
@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(
clusters.hvac.UserInterface.cluster_id, DANFOSS_ALLY_THERMOSTAT
)
class DanfossUserInterfaceClusterHandler(UserInterfaceClusterHandler):
"""Interface cluster handler for the Danfoss TRV and derivatives."""
ZCL_INIT_ATTRS = {
**UserInterfaceClusterHandler.ZCL_INIT_ATTRS,
"viewing_direction": True,
}
@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(
clusters.homeautomation.Diagnostic.cluster_id, DANFOSS_ALLY_THERMOSTAT
)
class DanfossDiagnosticClusterHandler(DiagnosticClusterHandler):
"""Diagnostic cluster handler for the Danfoss TRV and derivatives."""
REPORT_CONFIG = (
*DiagnosticClusterHandler.REPORT_CONFIG,
AttrReportConfig(attr="sw_error_code", config=REPORT_CONFIG_DEFAULT),
AttrReportConfig(attr="motor_step_counter", config=REPORT_CONFIG_DEFAULT),
)

View File

@ -1,208 +0,0 @@
"""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),
),
)

View File

@ -1,129 +0,0 @@
"""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."""

View File

@ -1,400 +0,0 @@
"""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
import zigpy.zcl
from zigpy.zcl.clusters.security import IasAce as AceCluster, IasWd, IasZone
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
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,
)

View File

@ -1,388 +0,0 @@
"""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."""

View File

@ -1,423 +0,0 @@
"""All constants related to the ZHA component."""
from __future__ import annotations
import enum
import logging
import bellows.zigbee.application
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
from homeassistant.const import Platform
import homeassistant.helpers.config_validation as cv
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"
ENTITY_METADATA = "entity_metadata"
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"
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,
}
type _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"
)

View File

@ -1,56 +0,0 @@
"""Decorators for ZHA core registries."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
class DictRegistry[_TypeT: type[Any]](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[_TypeT: type[Any]](
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[_TypeT: type[Any]](
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

File diff suppressed because it is too large Load Diff

View File

@ -1,661 +0,0 @@
"""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 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 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 .. 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,
WriteAttributeButtonMetadata,
EntityType.STANDARD,
): button.ZHAAttributeButton,
(Platform.BUTTON, ZCLCommandButtonMetadata, EntityType.CONFIG): button.ZHAButton,
(
Platform.BUTTON,
ZCLCommandButtonMetadata,
EntityType.DIAGNOSTIC,
): button.ZHAButton,
(Platform.BUTTON, ZCLCommandButtonMetadata, EntityType.STANDARD): 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.STANDARD): 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,
entity_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 entity_metadata in entity_metadata_list:
platform = Platform(entity_metadata.entity_platform.value)
metadata_type = type(entity_metadata)
entity_class = QUIRKS_ENTITY_META_TO_ENTITY_CLASS.get(
(platform, metadata_type, entity_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.ENTITY_METADATA: entity_metadata,
},
)
continue
# automatically add the attribute to ZCL_INIT_ATTRS for the cluster
# handler if it is not already in the list
if (
hasattr(entity_metadata, "attribute_name")
and entity_metadata.attribute_name
not in cluster_handler.ZCL_INIT_ATTRS
):
init_attrs = cluster_handler.ZCL_INIT_ATTRS.copy()
init_attrs[entity_metadata.attribute_name] = (
entity_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],
entity_metadata=entity_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()

View File

@ -1,253 +0,0 @@
"""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
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__)
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"{device.ieee!s}-{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, strict=False):
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: type,
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)
]

View File

@ -1,882 +0,0 @@
"""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 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 homeassistant import __path__ as HOMEASSISTANT_PATH
from homeassistant.components.system_log import LogEntry
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 . 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
type _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"{timedelta(seconds=delta)!s} 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 # noqa: SLF001
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]] = [
entity_ref.remove_future for entity_ref in entity_refs
]
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}_{zha_device.ieee!s}")
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 = [member.async_remove_from_group() for member in group.members]
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."""
entry = LogEntry(
record,
self.paths_re,
formatter=self.formatter,
figure_out_source=record.levelno >= logging.WARNING,
)
async_dispatcher_send(
self.hass,
ZHA_GW_MSG,
{ATTR_TYPE: ZHA_GW_MSG_LOG_OUTPUT, ZHA_GW_MSG_LOG_ENTRY: entry.to_dict()},
)

View File

@ -1,246 +0,0 @@
"""Group for Zigbee Home Automation."""
from __future__ import annotations
import asyncio
import logging
from typing import TYPE_CHECKING, Any, NamedTuple
import zigpy.endpoint
import zigpy.exceptions
import zigpy.group
from zigpy.types.named import EUI64
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_registry import async_entries_for_device
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 = [
self._zha_gateway.devices[member.ieee].async_add_endpoint_to_group(
member.endpoint_id, self.group_id
)
for member in members
]
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 = [
self._zha_gateway.devices[member.ieee].async_remove_endpoint_from_group(
member.endpoint_id, self.group_id
)
for member in members
]
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."""
return [
entity_reference["entity_id"]
for member in self.members
for entity_reference in member.associated_entities
]
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)

View File

@ -1,523 +0,0 @@
"""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, overload
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 homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.components.number import NumberDeviceClass
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
Platform,
UnitOfApparentPower,
UnitOfDataRate,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfFrequency,
UnitOfInformation,
UnitOfIrradiance,
UnitOfLength,
UnitOfMass,
UnitOfPower,
UnitOfPrecipitationDepth,
UnitOfPressure,
UnitOfSoundPressure,
UnitOfSpeed,
UnitOfTemperature,
UnitOfTime,
UnitOfVolume,
UnitOfVolumeFlowRate,
UnitOfVolumetricFlux,
)
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
from .const import CLUSTER_TYPE_IN, CLUSTER_TYPE_OUT, CUSTOM_CONFIGURATION, DATA_ZHA
from .registries import BINDABLE_CLUSTERS
if TYPE_CHECKING:
from .device import ZHADevice
from .gateway import ZHAGateway
_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,
)
except Exception: # noqa: BLE001
return {}
return result
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[_T](
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: HomeAssistant, 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, strict=False))
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
UNITS_OF_MEASURE = {
UnitOfApparentPower.__name__: UnitOfApparentPower,
UnitOfPower.__name__: UnitOfPower,
UnitOfEnergy.__name__: UnitOfEnergy,
UnitOfElectricCurrent.__name__: UnitOfElectricCurrent,
UnitOfElectricPotential.__name__: UnitOfElectricPotential,
UnitOfTemperature.__name__: UnitOfTemperature,
UnitOfTime.__name__: UnitOfTime,
UnitOfLength.__name__: UnitOfLength,
UnitOfFrequency.__name__: UnitOfFrequency,
UnitOfPressure.__name__: UnitOfPressure,
UnitOfSoundPressure.__name__: UnitOfSoundPressure,
UnitOfVolume.__name__: UnitOfVolume,
UnitOfVolumeFlowRate.__name__: UnitOfVolumeFlowRate,
UnitOfMass.__name__: UnitOfMass,
UnitOfIrradiance.__name__: UnitOfIrradiance,
UnitOfVolumetricFlux.__name__: UnitOfVolumetricFlux,
UnitOfPrecipitationDepth.__name__: UnitOfPrecipitationDepth,
UnitOfSpeed.__name__: UnitOfSpeed,
UnitOfInformation.__name__: UnitOfInformation,
UnitOfDataRate.__name__: UnitOfDataRate,
}
def validate_unit(quirks_unit: enum.Enum) -> enum.Enum:
"""Validate and return a unit of measure."""
return UNITS_OF_MEASURE[type(quirks_unit).__name__](quirks_unit.value)
@overload
def validate_device_class(
device_class_enum: type[BinarySensorDeviceClass],
metadata_value,
platform: str,
logger: logging.Logger,
) -> BinarySensorDeviceClass | None: ...
@overload
def validate_device_class(
device_class_enum: type[SensorDeviceClass],
metadata_value,
platform: str,
logger: logging.Logger,
) -> SensorDeviceClass | None: ...
@overload
def validate_device_class(
device_class_enum: type[NumberDeviceClass],
metadata_value,
platform: str,
logger: logging.Logger,
) -> NumberDeviceClass | None: ...
def validate_device_class(
device_class_enum: type[
BinarySensorDeviceClass | SensorDeviceClass | NumberDeviceClass
],
metadata_value: enum.Enum,
platform: str,
logger: logging.Logger,
) -> BinarySensorDeviceClass | SensorDeviceClass | NumberDeviceClass | None:
"""Validate and return a device class."""
try:
return device_class_enum(metadata_value.value)
except ValueError as ex:
logger.warning(
"Quirks provided an invalid device class: %s for platform %s: %s",
metadata_value,
platform,
ex,
)
return None

View File

@ -1,516 +0,0 @@
"""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
import attr
from zigpy import zcl
import zigpy.profiles.zha
import zigpy.profiles.zll
from zigpy.types.named import EUI64
from homeassistant.const import Platform
from .decorators import DictRegistry, NestedDictRegistry, SetRegistry
if TYPE_CHECKING:
from ..entity import ZhaEntity, ZhaGroupEntity
from .cluster_handlers import ClientClusterHandler, ClusterHandler
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[_ZhaEntityT: type[ZhaEntity]](
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[_ZhaEntityT: type[ZhaEntity]](
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[_ZhaEntityT: type[ZhaEntity]](
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[_ZhaGroupEntityT: type[ZhaGroupEntity]](
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()

View File

@ -2,16 +2,17 @@
from __future__ import annotations
import asyncio
from collections.abc import Mapping
import functools
import logging
from typing import TYPE_CHECKING, Any, cast
from typing import Any
from zigpy.zcl.clusters.closures import WindowCovering as WindowCoveringCluster
from zigpy.zcl.foundation import Status
from zha.application.platforms.cover import Shade as ZhaShade
from zha.application.platforms.cover.const import (
CoverEntityFeature as ZHACoverEntityFeature,
)
from homeassistant.components.cover import (
ATTR_CURRENT_POSITION,
ATTR_POSITION,
ATTR_TILT_POSITION,
CoverDeviceClass,
@ -19,41 +20,22 @@ from homeassistant.components.cover import (
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.const import Platform
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
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,
from .entity import ZHAEntity
from .helpers import (
SIGNAL_ADD_ENTITIES,
SIGNAL_ATTR_UPDATED,
SIGNAL_SET_LEVEL,
EntityData,
async_add_entities as zha_async_add_entities,
convert_zha_error_to_ha_error,
get_zha_data,
)
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,
@ -68,421 +50,143 @@ async def async_setup_entry(
hass,
SIGNAL_ADD_ENTITIES,
functools.partial(
discovery.async_add_entities, async_add_entities, entities_to_create
zha_async_add_entities, async_add_entities, ZhaCover, 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):
class ZhaCover(ZHAEntity, CoverEntity):
"""Representation of a ZHA cover."""
_attr_translation_key: str = "cover"
def __init__(self, entity_data: EntityData) -> None:
"""Initialize the ZHA cover."""
super().__init__(entity_data)
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
)
if self.entity_data.entity.info_object.device_class is not None:
self._attr_device_class = CoverDeviceClass(
self.entity_data.entity.info_object.device_class
)
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
features = CoverEntityFeature(0)
zha_features: ZHACoverEntityFeature = self.entity_data.entity.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)
if ZHACoverEntityFeature.OPEN in zha_features:
features |= CoverEntityFeature.OPEN
if ZHACoverEntityFeature.CLOSE in zha_features:
features |= CoverEntityFeature.CLOSE
if ZHACoverEntityFeature.SET_POSITION in zha_features:
features |= CoverEntityFeature.SET_POSITION
if ZHACoverEntityFeature.STOP in zha_features:
features |= CoverEntityFeature.STOP
if ZHACoverEntityFeature.OPEN_TILT in zha_features:
features |= CoverEntityFeature.OPEN_TILT
if ZHACoverEntityFeature.CLOSE_TILT in zha_features:
features |= CoverEntityFeature.CLOSE_TILT
if ZHACoverEntityFeature.STOP_TILT in zha_features:
features |= CoverEntityFeature.STOP_TILT
if ZHACoverEntityFeature.SET_TILT_POSITION in zha_features:
features |= CoverEntityFeature.SET_TILT_POSITION
def _determine_state(self, position_or_tilt, is_lift_update=True) -> None:
"""Determine the state of the cover.
self._attr_supported_features = features
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 extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Return entity specific state attributes."""
state = self.entity_data.entity.state
return {
"target_lift_position": state.get("target_lift_position"),
"target_tilt_position": state.get("target_tilt_position"),
}
@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
"""Return True if the cover is closed."""
return self.entity_data.entity.is_closed
@property
def is_opening(self) -> bool:
"""Return if the cover is opening or not."""
return self._state == STATE_OPENING
return self.entity_data.entity.is_opening
@property
def is_closing(self) -> bool:
"""Return if the cover is closing or not."""
return self._state == STATE_CLOSING
return self.entity_data.entity.is_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
"""Return the current position of ZHA cover."""
return self.entity_data.entity.current_cover_position
@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()
return self.entity_data.entity.current_cover_tilt_position
@convert_zha_error_to_ha_error
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)
await self.entity_data.entity.async_open_cover()
self.async_write_ha_state()
@convert_zha_error_to_ha_error
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)
await self.entity_data.entity.async_open_cover_tilt()
self.async_write_ha_state()
@convert_zha_error_to_ha_error
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)
await self.entity_data.entity.async_close_cover()
self.async_write_ha_state()
@convert_zha_error_to_ha_error
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)
await self.entity_data.entity.async_close_cover_tilt()
self.async_write_ha_state()
@convert_zha_error_to_ha_error
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
await self.entity_data.entity.async_set_cover_position(
position=kwargs.get(ATTR_POSITION)
)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
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
await self.entity_data.entity.async_set_cover_tilt_position(
tilt_position=kwargs.get(ATTR_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()
@convert_zha_error_to_ha_error
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
await self.entity_data.entity.async_stop_cover()
self.async_write_ha_state()
@convert_zha_error_to_ha_error
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)
await self.entity_data.entity.async_stop_cover_tilt()
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()
def restore_external_state_attributes(self, state: State) -> None:
"""Restore entity 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]}")
# Shades are a subtype of cover that do not need external state restored
if isinstance(self.entity_data.entity, ZhaShade):
return
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
# Same as `light`, some entity state is not derived from ZCL attributes
self.entity_data.entity.restore_external_state_attributes(
state=state.state,
target_lift_position=state.attributes.get("target_lift_position"),
target_tilt_position=state.attributes.get("target_tilt_position"),
)
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()

View File

@ -5,20 +5,25 @@ from __future__ import annotations
from typing import Any
import voluptuous as vol
from zha.exceptions import ZHAException
from zha.zigbee.cluster_handlers.const import (
CLUSTER_HANDLER_IAS_WD,
CLUSTER_HANDLER_INOVELLI,
)
from zha.zigbee.cluster_handlers.manufacturerspecific import (
AllLEDEffectType,
SingleLEDEffectType,
)
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.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
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 .const import DOMAIN
from .helpers import async_get_zha_device_proxy
from .websocket_api import SERVICE_WARNING_DEVICE_SQUAWK, SERVICE_WARNING_DEVICE_WARN
# mypy: disallow-any-generics
@ -144,7 +149,7 @@ async def async_get_actions(
) -> list[dict[str, str]]:
"""List device actions."""
try:
zha_device = async_get_zha_device(hass, device_id)
zha_device = async_get_zha_device_proxy(hass, device_id).device
except (KeyError, AttributeError):
return []
cluster_handlers = [
@ -181,7 +186,7 @@ async def _execute_service_based_action(
action_type = config[CONF_TYPE]
service_name = SERVICE_NAMES[action_type]
try:
zha_device = async_get_zha_device(hass, config[CONF_DEVICE_ID])
zha_device = async_get_zha_device_proxy(hass, config[CONF_DEVICE_ID]).device
except (KeyError, AttributeError):
return
@ -201,7 +206,7 @@ async def _execute_cluster_handler_command_based_action(
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])
zha_device = async_get_zha_device_proxy(hass, config[CONF_DEVICE_ID]).device
except (KeyError, AttributeError):
return
@ -224,7 +229,10 @@ async def _execute_cluster_handler_command_based_action(
f" {action_type}"
)
await getattr(action_cluster_handler, action_type)(**config)
try:
await getattr(action_cluster_handler, action_type)(**config)
except ZHAException as err:
raise HomeAssistantError(err) from err
ZHA_ACTION_TYPES = {

View File

@ -3,28 +3,21 @@
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.core import HomeAssistant
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,
from .entity import ZHAEntity
from .helpers import (
SIGNAL_ADD_ENTITIES,
SIGNAL_ATTR_UPDATED,
async_add_entities as zha_async_add_entities,
get_zha_data,
)
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(
@ -40,92 +33,48 @@ async def async_setup_entry(
hass,
SIGNAL_ADD_ENTITIES,
functools.partial(
discovery.async_add_entities, async_add_entities, entities_to_create
zha_async_add_entities,
async_add_entities,
ZHADeviceScannerEntity,
entities_to_create,
),
)
config_entry.async_on_unload(unsub)
@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_POWER_CONFIGURATION)
class ZHADeviceScannerEntity(ScannerEntity, ZhaEntity):
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):
def is_connected(self) -> bool:
"""Return true if the device is connected to the network."""
return self._connected
return self.entity_data.entity.is_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):
def battery_level(self) -> int | None:
"""Return the battery level of the device.
Percentage from 0-100.
"""
return self._battery_level
return self.entity_data.entity.battery_level
@property # type: ignore[misc]
def device_info(
self,
) -> DeviceInfo:
@property # type: ignore[explicit-override, 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]
return ZHAEntity.device_info.__get__(self)
@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]
return ZHAEntity.unique_id.__get__(self)

View File

@ -1,6 +1,7 @@
"""Provides device automations for ZHA devices that emit events."""
import voluptuous as vol
from zha.application.const import ZHA_EVENT
from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA
from homeassistant.components.device_automation.exceptions import (
@ -13,9 +14,8 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
from . import DOMAIN as ZHA_DOMAIN
from .core.const import ZHA_EVENT
from .core.helpers import async_get_zha_device, get_zha_data
from .const import DOMAIN as ZHA_DOMAIN
from .helpers import async_get_zha_device_proxy, get_zha_data
CONF_SUBTYPE = "subtype"
DEVICE = "device"
@ -31,7 +31,7 @@ def _get_device_trigger_data(hass: HomeAssistant, device_id: str) -> tuple[str,
# First, try checking to see if the device itself is accessible
try:
zha_device = async_get_zha_device(hass, device_id)
zha_device = async_get_zha_device_proxy(hass, device_id).device
except ValueError:
pass
else:

View File

@ -6,6 +6,18 @@ import dataclasses
from importlib.metadata import version
from typing import Any
from zha.application.const import (
ATTR_ATTRIBUTE_NAME,
ATTR_DEVICE_TYPE,
ATTR_IEEE,
ATTR_IN_CLUSTERS,
ATTR_OUT_CLUSTERS,
ATTR_PROFILE_ID,
ATTR_VALUE,
UNKNOWN,
)
from zha.application.gateway import Gateway
from zha.zigbee.device import Device
from zigpy.config import CONF_NWK_EXTENDED_PAN_ID
from zigpy.profiles import PROFILES
from zigpy.types import Channels
@ -17,20 +29,13 @@ 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 .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 .const import CONF_ALARM_MASTER_CODE
from .helpers import (
ZHADeviceProxy,
async_get_zha_device_proxy,
get_zha_data,
get_zha_gateway,
)
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,
@ -65,7 +70,7 @@ async def async_get_config_entry_diagnostics(
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
zha_data = get_zha_data(hass)
gateway: ZHAGateway = get_zha_gateway(hass)
gateway: Gateway = get_zha_gateway(hass)
app = gateway.application_controller
energy_scan = await app.energy_scan(
@ -88,6 +93,7 @@ async def async_get_config_entry_diagnostics(
"zigpy_znp": version("zigpy_znp"),
"zigpy_zigate": version("zigpy-zigate"),
"zhaquirks": version("zha-quirks"),
"zha": version("zha"),
},
"devices": [
{
@ -106,13 +112,15 @@ 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)
zha_device_proxy: ZHADeviceProxy = async_get_zha_device_proxy(hass, device.id)
device_info: dict[str, Any] = zha_device_proxy.zha_device_info
device_info[CLUSTER_DETAILS] = get_endpoint_cluster_attr_data(
zha_device_proxy.device
)
return async_redact_data(device_info, KEYS_TO_REDACT)
def get_endpoint_cluster_attr_data(zha_device: ZHADevice) -> dict:
def get_endpoint_cluster_attr_data(zha_device: Device) -> dict:
"""Return endpoint cluster attribute data."""
cluster_details = {}
for ep_id, endpoint in zha_device.device.endpoints.items():

View File

@ -6,84 +6,70 @@ import asyncio
from collections.abc import Callable
import functools
import logging
from typing import TYPE_CHECKING, Any, Self
from typing import Any
from zigpy.quirks.v2 import EntityMetadata, EntityType
from zha.mixins import LogMixin
from homeassistant.const import ATTR_NAME, EntityCategory
from homeassistant.core import CALLBACK_TYPE, Event, EventStateChangedData, callback
from homeassistant.helpers import entity
from homeassistant.helpers.debounce import Debouncer
from homeassistant.const import ATTR_MANUFACTURER, ATTR_MODEL, ATTR_NAME, EntityCategory
from homeassistant.core import State, callback
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 async_track_state_change_event
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.restore_state import RestoreEntity
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
from .const import DOMAIN
from .helpers import SIGNAL_REMOVE_ENTITIES, EntityData, convert_zha_error_to_ha_error
_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."""
class ZHAEntity(LogMixin, RestoreEntity, Entity):
"""ZHA eitity."""
_attr_has_entity_name = True
_attr_should_poll = False
remove_future: asyncio.Future[Any]
def __init__(self, unique_id: str, zha_device: ZHADevice, **kwargs: Any) -> None:
def __init__(self, entity_data: EntityData, *args, **kwargs) -> 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
super().__init__(*args, **kwargs)
self.entity_data: EntityData = entity_data
self._unsubs: list[Callable[[], None]] = []
@property
def unique_id(self) -> str:
"""Return a unique ID."""
return self._unique_id
if self.entity_data.entity.icon is not None:
# Only custom quirks will realistically set an icon
self._attr_icon = self.entity_data.entity.icon
meta = self.entity_data.entity.info_object
self._attr_unique_id = meta.unique_id
if meta.translation_key is not None:
self._attr_translation_key = meta.translation_key
elif meta.fallback_name is not None:
# Only custom quirks will create entities with just a fallback name!
#
# This is to allow local development and to register niche devices, since
# their translation_key will probably never be added to `zha/strings.json`.
self._attr_name = meta.fallback_name
if meta.entity_category is not None:
self._attr_entity_category = EntityCategory(meta.entity_category)
self._attr_entity_registry_enabled_default = (
meta.entity_registry_enabled_default
)
@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
def available(self) -> bool:
"""Return entity availability."""
return self.entity_data.device_proxy.device.available
@property
def device_info(self) -> DeviceInfo:
"""Return a device description for device registry."""
zha_device_info = self._zha_device.device_info
zha_device_info = self.entity_data.device_proxy.device_info
ieee = zha_device_info["ieee"]
zha_gateway = get_zha_gateway(self.hass)
zha_gateway = self.entity_data.device_proxy.gateway_proxy.gateway
return DeviceInfo(
connections={(CONNECTION_ZIGBEE, ieee)},
@ -95,265 +81,67 @@ class BaseZhaEntity(LogMixin, entity.Entity):
)
@callback
def async_state_changed(self) -> None:
def _handle_entity_events(self, event: Any) -> None:
"""Entity state changed."""
self.debug("Handling event from entity: %s", event)
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()
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._unsubs.append(
self.entity_data.entity.on_all_events(self._handle_entity_events)
)
remove_signal = (
f"{SIGNAL_REMOVE_ENTITIES}_group_{self.entity_data.group_proxy.group.group_id}"
if self.entity_data.is_group_entity
and self.entity_data.group_proxy is not None
else f"{SIGNAL_REMOVE_ENTITIES}_{self.entity_data.device_proxy.device.ieee}"
)
self._unsubs.append(
async_dispatcher_connect(
self.hass,
remove_signal,
functools.partial(self.async_remove, force_remove=True),
)
)
self.entity_data.device_proxy.gateway_proxy.register_entity_reference(
self.entity_id,
self.entity_data,
self.device_info,
self.remove_future,
)
if (state := await self.async_get_last_state()) is None:
return
self.restore_external_state_attributes(state)
@callback
def async_set_state(self, attr_id: int, attr_name: str, value: Any) -> None:
"""Set the entity state."""
def restore_external_state_attributes(self, state: State) -> None:
"""Restore ephemeral external state from Home Assistant back into ZHA."""
# Some operations rely on extra state that is not maintained in the ZCL
# attribute cache. Until ZHA is able to maintain its own persistent state (or
# provides a more generic hook to utilize HA to do this), we directly restore
# them.
async def async_will_remove_from_hass(self) -> None:
"""Disconnect entity object when removed."""
for unsub in self._unsubs[:]:
unsub()
self._unsubs.remove(unsub)
await super().async_will_remove_from_hass()
self.remove_future.set_result(True)
@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)
@convert_zha_error_to_ha_error
async def async_update(self) -> None:
"""Update the entity."""
await self.entity_data.entity.async_update()
self.async_write_ha_state()
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
has_device_class = hasattr(entity_metadata, "device_class")
has_attribute_name = hasattr(entity_metadata, "attribute_name")
has_command_name = hasattr(entity_metadata, "command_name")
if not has_device_class or (
has_device_class and entity_metadata.device_class is None
):
if entity_metadata.translation_key:
self._attr_translation_key = entity_metadata.translation_key
elif has_attribute_name:
self._attr_translation_key = entity_metadata.attribute_name
elif has_command_name:
self._attr_translation_key = entity_metadata.command_name
if has_attribute_name:
self._unique_id_suffix = entity_metadata.attribute_name
elif has_command_name:
self._unique_id_suffix = 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
else:
self._attr_entity_category = None
@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)
if len(self._group.members) >= 2:
async_dispatcher_send(
self.hass, SIGNAL_GROUP_ENTITY_REMOVED, self._group_id
)
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
)
@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."""

View File

@ -2,54 +2,23 @@
from __future__ import annotations
from abc import abstractmethod
import functools
import math
from typing import Any
from zigpy.zcl.clusters import hvac
from homeassistant.components.fan import (
ATTR_PERCENTAGE,
ATTR_PRESET_MODE,
FanEntity,
FanEntityFeature,
)
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
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 .entity import ZHAEntity
from .helpers import (
SIGNAL_ADD_ENTITIES,
async_add_entities as zha_async_add_entities,
convert_zha_error_to_ha_error,
get_zha_data,
)
from homeassistant.util.scaling import int_states_in_range
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(
@ -65,50 +34,44 @@ async def async_setup_entry(
hass,
SIGNAL_ADD_ENTITIES,
functools.partial(
discovery.async_add_entities,
async_add_entities,
entities_to_create,
zha_async_add_entities, async_add_entities, ZhaFan, entities_to_create
),
)
config_entry.async_on_unload(unsub)
class BaseFan(FanEntity):
"""Base representation of a ZHA fan."""
class ZhaFan(FanEntity, ZHAEntity):
"""Representation of a ZHA fan."""
_attr_supported_features = FanEntityFeature.SET_SPEED
_attr_translation_key: str = "fan"
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
return self.entity_data.entity.preset_mode
@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()}
return self.entity_data.entity.preset_modes
@property
def default_on_percentage(self) -> int:
"""Return the default on percentage."""
return DEFAULT_ON_PERCENTAGE
return self.entity_data.entity.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
return self.entity_data.entity.speed_range
@property
def speed_count(self) -> int:
"""Return the number of speeds the fan supports."""
return int_states_in_range(self.speed_range)
return self.entity_data.entity.speed_count
@convert_zha_error_to_ha_error
async def async_turn_on(
self,
percentage: int | None = None,
@ -116,201 +79,30 @@ class BaseFan(FanEntity):
**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
await self.entity_data.entity.async_turn_on(
percentage=percentage, preset_mode=preset_mode
)
@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)
@convert_zha_error_to_ha_error
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self.entity_data.entity.async_turn_off()
self.async_write_ha_state()
@convert_zha_error_to_ha_error
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed percentage of the fan."""
await self.entity_data.entity.async_set_percentage(percentage=percentage)
self.async_write_ha_state()
@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
@convert_zha_error_to_ha_error
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode for the fan."""
await self.entity_data.entity.async_set_preset_mode(preset_mode=preset_mode)
self.async_write_ha_state()
@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}
return self.entity_data.entity.percentage

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -4,35 +4,25 @@ import functools
from typing import Any
import voluptuous as vol
from zigpy.zcl.foundation import Status
from homeassistant.components.lock import STATE_LOCKED, STATE_UNLOCKED, LockEntity
from homeassistant.components.lock import LockEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant, State, 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
from .core import discovery
from .core.const import (
CLUSTER_HANDLER_DOORLOCK,
from .entity import ZHAEntity
from .helpers import (
SIGNAL_ADD_ENTITIES,
SIGNAL_ATTR_UPDATED,
async_add_entities as zha_async_add_entities,
convert_zha_error_to_ha_error,
get_zha_data,
)
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"
@ -53,7 +43,7 @@ async def async_setup_entry(
hass,
SIGNAL_ADD_ENTITIES,
functools.partial(
discovery.async_add_entities, async_add_entities, entities_to_create
zha_async_add_entities, async_add_entities, ZhaDoorLock, entities_to_create
),
)
config_entry.async_on_unload(unsub)
@ -94,105 +84,57 @@ async def async_setup_entry(
)
@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_DOORLOCK)
class ZhaDoorLock(ZhaEntity, LockEntity):
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
return self.entity_data.entity.is_locked
@convert_zha_error_to_ha_error
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
await self.entity_data.entity.async_lock()
self.async_write_ha_state()
@convert_zha_error_to_ha_error
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
await self.entity_data.entity.async_unlock()
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)
@convert_zha_error_to_ha_error
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)
await self.entity_data.entity.async_set_lock_user_code(
code_slot=code_slot, user_code=user_code
)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
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)
await self.entity_data.entity.async_enable_lock_user_code(code_slot=code_slot)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
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)
await self.entity_data.entity.async_disable_lock_user_code(code_slot=code_slot)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
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)
await self.entity_data.entity.async_clear_lock_user_code(code_slot=code_slot)
self.async_write_ha_state()
@callback
def restore_external_state_attributes(self, state: State) -> None:
"""Restore entity state."""
self.entity_data.entity.restore_external_state_attributes(
state=state.state,
)

View File

@ -5,16 +5,18 @@ from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING
from zha.application.const import ZHA_EVENT
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
from .const import DOMAIN as ZHA_DOMAIN
from .helpers import async_get_zha_device_proxy
if TYPE_CHECKING:
from .core.device import ZHADevice
from zha.zigbee.device import Device
@callback
@ -30,7 +32,7 @@ def async_describe_events(
"""Describe ZHA logbook event."""
device: dr.DeviceEntry | None = None
device_name: str = "Unknown device"
zha_device: ZHADevice | None = None
zha_device: Device | None = None
event_data = event.data
event_type: str | None = None
event_subtype: str | None = None
@ -39,7 +41,9 @@ def async_describe_events(
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])
zha_device = async_get_zha_device_proxy(
hass, event.data[ATTR_DEVICE_ID]
).device
except (KeyError, AttributeError):
pass

View File

@ -18,20 +18,10 @@
"zigpy_xbee",
"zigpy_zigate",
"zigpy_znp",
"zha",
"universal_silabs_flasher"
],
"requirements": [
"bellows==0.39.1",
"pyserial==3.5",
"zha-quirks==0.0.117",
"zigpy-deconz==0.23.2",
"zigpy==0.64.1",
"zigpy-xbee==0.20.1",
"zigpy-zigate==0.12.1",
"zigpy-znp==0.12.2",
"universal-silabs-flasher==0.0.20",
"pyserial-asyncio-fast==0.11"
],
"requirements": ["universal-silabs-flasher==0.0.20", "zha==0.0.18"],
"usb": [
{
"vid": "10C4",

File diff suppressed because it is too large Load Diff

View File

@ -13,6 +13,7 @@ from typing import Any, Self
from bellows.config import CONF_USE_THREAD
import voluptuous as vol
from zha.application.const import RadioType
from zigpy.application import ControllerApplication
import zigpy.backups
from zigpy.config import (
@ -29,14 +30,13 @@ from homeassistant.components import usb
from homeassistant.core import HomeAssistant
from . import repairs
from .core.const import (
from .const import (
CONF_RADIO_TYPE,
CONF_ZIGPY,
DEFAULT_DATABASE_NAME,
EZSP_OVERWRITE_EUI64,
RadioType,
)
from .core.helpers import get_zha_data
from .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

View File

@ -8,7 +8,7 @@ from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from ..core.const import DOMAIN
from ..const import DOMAIN
from .network_settings_inconsistent import (
ISSUE_INCONSISTENT_NETWORK_SETTINGS,
NetworkSettingsInconsistentFlow,

View File

@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import issue_registry as ir
from ..core.const import DOMAIN
from ..const import DOMAIN
from ..radio_manager import ZhaRadioManager
_LOGGER = logging.getLogger(__name__)

View File

@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir
from ..core.const import DOMAIN
from ..const import DOMAIN
_LOGGER = logging.getLogger(__name__)

View File

@ -2,56 +2,26 @@
from __future__ import annotations
from enum import Enum
import functools
import logging
from typing import TYPE_CHECKING, Any, Self
from zhaquirks.danfoss import thermostat as danfoss_thermostat
from zhaquirks.quirk_ids import (
DANFOSS_ALLY_THERMOSTAT,
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 ZCLEnumMetadata
from zigpy.zcl.clusters.general import OnOff
from zigpy.zcl.clusters.security import IasWd
from typing import Any
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.const import STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant, State, callback
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_HUE_OCCUPANCY,
CLUSTER_HANDLER_IAS_WD,
CLUSTER_HANDLER_INOVELLI,
CLUSTER_HANDLER_OCCUPANCY,
CLUSTER_HANDLER_ON_OFF,
CLUSTER_HANDLER_THERMOSTAT,
ENTITY_METADATA,
from .entity import ZHAEntity
from .helpers import (
SIGNAL_ADD_ENTITIES,
SIGNAL_ATTR_UPDATED,
Strobe,
EntityData,
async_add_entities as zha_async_add_entities,
convert_zha_error_to_ha_error,
get_zha_data,
)
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__)
@ -68,731 +38,38 @@ async def async_setup_entry(
hass,
SIGNAL_ADD_ENTITIES,
functools.partial(
discovery.async_add_entities,
zha_async_add_entities,
async_add_entities,
ZHAEnumSelectEntity,
entities_to_create,
),
)
config_entry.async_on_unload(unsub)
class ZHAEnumSelectEntity(ZhaEntity, SelectEntity):
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)
def __init__(self, entity_data: EntityData, **kwargs: Any) -> None:
"""Initialize the ZHA select entity."""
super().__init__(entity_data, **kwargs)
self._attr_options = self.entity_data.entity.info_object.options
@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("_", " ")
return self.entity_data.entity.current_option
@convert_zha_error_to_ha_error
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(" ", "_")
]
await self.entity_data.entity.async_select_option(option=option)
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 ENTITY_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__,
def restore_external_state_attributes(self, state: State) -> None:
"""Restore entity state."""
if state.state and state.state != STATE_UNKNOWN:
self.entity_data.entity.restore_external_state_attributes(
state=state.state,
)
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 ENTITY_METADATA in kwargs:
self._init_from_quirks_metadata(kwargs[ENTITY_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: ZCLEnumMetadata) -> None:
"""Init this entity from the quirks metadata."""
super()._init_from_quirks_metadata(entity_metadata)
self._attribute_name = entity_metadata.attribute_name
self._enum = entity_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"
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"
@CONFIG_DIAGNOSTIC_MATCH(
cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT,
quirk_ids={DANFOSS_ALLY_THERMOSTAT},
)
class DanfossExerciseDayOfTheWeek(ZCLEnumSelectEntity):
"""Danfoss proprietary attribute for setting the day of the week for exercising."""
_unique_id_suffix = "exercise_day_of_week"
_attribute_name = "exercise_day_of_week"
_attr_translation_key: str = "exercise_day_of_week"
_enum = danfoss_thermostat.DanfossExerciseDayOfTheWeekEnum
_attr_icon: str = "mdi:wrench-clock"
class DanfossOrientationEnum(types.enum8):
"""Vertical or Horizontal."""
Horizontal = 0x00
Vertical = 0x01
@CONFIG_DIAGNOSTIC_MATCH(
cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT,
quirk_ids={DANFOSS_ALLY_THERMOSTAT},
)
class DanfossOrientation(ZCLEnumSelectEntity):
"""Danfoss proprietary attribute for setting the orientation of the valve.
Needed for biasing the internal temperature sensor.
This is implemented as an enum here, but is a boolean on the device.
"""
_unique_id_suffix = "orientation"
_attribute_name = "orientation"
_attr_translation_key: str = "valve_orientation"
_enum = DanfossOrientationEnum
@CONFIG_DIAGNOSTIC_MATCH(
cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT,
quirk_ids={DANFOSS_ALLY_THERMOSTAT},
)
class DanfossAdaptationRunControl(ZCLEnumSelectEntity):
"""Danfoss proprietary attribute for controlling the current adaptation run."""
_unique_id_suffix = "adaptation_run_control"
_attribute_name = "adaptation_run_control"
_attr_translation_key: str = "adaptation_run_command"
_enum = danfoss_thermostat.DanfossAdaptationRunControlEnum
class DanfossControlAlgorithmScaleFactorEnum(types.enum8):
"""The time scale factor for changing the opening of the valve.
Not all values are given, therefore there are some extrapolated values with a margin of error of about 5 minutes.
This is implemented as an enum here, but is a number on the device.
"""
quick_5min = 0x01
quick_10min = 0x02 # extrapolated
quick_15min = 0x03 # extrapolated
quick_25min = 0x04 # extrapolated
moderate_30min = 0x05
moderate_40min = 0x06 # extrapolated
moderate_50min = 0x07 # extrapolated
moderate_60min = 0x08 # extrapolated
moderate_70min = 0x09 # extrapolated
slow_80min = 0x0A
quick_open_disabled = 0x11 # not sure what it does; also requires lower 4 bits to be in [1, 10] I assume
@CONFIG_DIAGNOSTIC_MATCH(
cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT,
quirk_ids={DANFOSS_ALLY_THERMOSTAT},
)
class DanfossControlAlgorithmScaleFactor(ZCLEnumSelectEntity):
"""Danfoss proprietary attribute for setting the scale factor of the setpoint filter time constant."""
_unique_id_suffix = "control_algorithm_scale_factor"
_attribute_name = "control_algorithm_scale_factor"
_attr_translation_key: str = "setpoint_response_time"
_enum = DanfossControlAlgorithmScaleFactorEnum
@CONFIG_DIAGNOSTIC_MATCH(
cluster_handler_names="thermostat_ui",
quirk_ids={DANFOSS_ALLY_THERMOSTAT},
)
class DanfossViewingDirection(ZCLEnumSelectEntity):
"""Danfoss proprietary attribute for setting the viewing direction of the screen."""
_unique_id_suffix = "viewing_direction"
_attribute_name = "viewing_direction"
_attr_translation_key: str = "viewing_direction"
_enum = danfoss_thermostat.DanfossViewingDirectionEnum

File diff suppressed because it is too large Load Diff

View File

@ -2,11 +2,18 @@
from __future__ import annotations
from collections.abc import Callable
import functools
from typing import TYPE_CHECKING, Any, cast
from typing import Any
from zigpy.zcl.clusters.security import IasWd as WD
from zha.application.const import (
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,
)
from zha.application.platforms.siren import SirenEntityFeature as ZHASirenEntityFeature
from homeassistant.components.siren import (
ATTR_DURATION,
@ -17,38 +24,18 @@ from homeassistant.components.siren import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later
from .core import discovery
from .core.cluster_handlers.security import IasWdClusterHandler
from .core.const import (
CLUSTER_HANDLER_IAS_WD,
from .entity import ZHAEntity
from .helpers import (
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,
EntityData,
async_add_entities as zha_async_add_entities,
convert_zha_error_to_ha_error,
get_zha_data,
)
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(
@ -64,115 +51,61 @@ async def async_setup_entry(
hass,
SIGNAL_ADD_ENTITIES,
functools.partial(
discovery.async_add_entities,
async_add_entities,
entities_to_create,
zha_async_add_entities, async_add_entities, ZHASiren, entities_to_create
),
)
config_entry.async_on_unload(unsub)
@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD)
class ZHASiren(ZhaEntity, SirenEntity):
class ZHASiren(ZHAEntity, SirenEntity):
"""Representation of a ZHA siren."""
_attr_name: str = "Siren"
_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",
}
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
def __init__(self, entity_data: EntityData, **kwargs: Any) -> None:
"""Initialize the ZHA siren."""
super().__init__(entity_data, **kwargs)
features: SirenEntityFeature = SirenEntityFeature(0)
zha_features: ZHASirenEntityFeature = self.entity_data.entity.supported_features
if ZHASirenEntityFeature.TURN_ON in zha_features:
features |= SirenEntityFeature.TURN_ON
if ZHASirenEntityFeature.TURN_OFF in zha_features:
features |= SirenEntityFeature.TURN_OFF
if ZHASirenEntityFeature.TONES in zha_features:
features |= SirenEntityFeature.TONES
if ZHASirenEntityFeature.VOLUME_SET in zha_features:
features |= SirenEntityFeature.VOLUME_SET
if ZHASirenEntityFeature.DURATION in zha_features:
features |= SirenEntityFeature.DURATION
self._attr_supported_features = features
@property
def is_on(self) -> bool:
"""Return True if entity is on."""
return self.entity_data.entity.is_on
@convert_zha_error_to_ha_error
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
await self.entity_data.entity.async_turn_on(
duration=kwargs.get(ATTR_DURATION),
tone=kwargs.get(ATTR_TONE),
volume_level=kwargs.get(ATTR_VOLUME_LEVEL),
)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
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
await self.entity_data.entity.async_turn_off()
self.async_write_ha_state()

View File

@ -4,44 +4,21 @@ from __future__ import annotations
import functools
import logging
from typing import TYPE_CHECKING, Any, Self
from zhaquirks.quirk_ids import DANFOSS_ALLY_THERMOSTAT, TUYA_PLUG_ONOFF
from zigpy.quirks.v2 import SwitchMetadata
from zigpy.zcl.clusters.closures import ConfigStatus, WindowCovering, WindowCoveringMode
from zigpy.zcl.clusters.general import OnOff
from zigpy.zcl.foundation import Status
from typing import Any
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.const import Platform
from homeassistant.core import HomeAssistant
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_BASIC,
CLUSTER_HANDLER_COVER,
CLUSTER_HANDLER_INOVELLI,
CLUSTER_HANDLER_ON_OFF,
CLUSTER_HANDLER_THERMOSTAT,
ENTITY_METADATA,
from .entity import ZHAEntity
from .helpers import (
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
async_add_entities as zha_async_add_entities,
convert_zha_error_to_ha_error,
get_zha_data,
)
_LOGGER = logging.getLogger(__name__)
@ -60,752 +37,28 @@ async def async_setup_entry(
hass,
SIGNAL_ADD_ENTITIES,
functools.partial(
discovery.async_add_entities, async_add_entities, entities_to_create
zha_async_add_entities, async_add_entities, Switch, entities_to_create
),
)
config_entry.async_on_unload(unsub)
@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_ON_OFF)
class Switch(ZhaEntity, SwitchEntity):
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
return self.entity_data.entity.is_on
@convert_zha_error_to_ha_error
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
await self._on_off_cluster_handler.turn_on()
await self.entity_data.entity.async_turn_on()
self.async_write_ha_state()
@convert_zha_error_to_ha_error
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self._on_off_cluster_handler.turn_off()
await self.entity_data.entity.async_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 ENTITY_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 ENTITY_METADATA in kwargs:
self._init_from_quirks_metadata(kwargs[ENTITY_METADATA])
super().__init__(unique_id, zha_device, cluster_handlers, **kwargs)
def _init_from_quirks_metadata(self, entity_metadata: SwitchMetadata) -> None:
"""Init this entity from the quirks metadata."""
super()._init_from_quirks_metadata(entity_metadata)
self._attribute_name = entity_metadata.attribute_name
if entity_metadata.invert_attribute_name:
self._inverter_attribute_name = entity_metadata.invert_attribute_name
if entity_metadata.force_inverted:
self._force_inverted = entity_metadata.force_inverted
self._off_value = entity_metadata.off_value
self._on_value = entity_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
@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"
@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"
@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"
@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"
@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"
@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"
@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"
@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"
@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"
@CONFIG_DIAGNOSTIC_MATCH(
cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT,
quirk_ids={DANFOSS_ALLY_THERMOSTAT},
)
class DanfossExternalOpenWindowDetected(ZHASwitchConfigurationEntity):
"""Danfoss proprietary attribute for communicating an open window."""
_unique_id_suffix = "external_open_window_detected"
_attribute_name: str = "external_open_window_detected"
_attr_translation_key: str = "external_window_sensor"
_attr_icon: str = "mdi:window-open"
@CONFIG_DIAGNOSTIC_MATCH(
cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT,
quirk_ids={DANFOSS_ALLY_THERMOSTAT},
)
class DanfossWindowOpenFeature(ZHASwitchConfigurationEntity):
"""Danfoss proprietary attribute enabling open window detection."""
_unique_id_suffix = "window_open_feature"
_attribute_name: str = "window_open_feature"
_attr_translation_key: str = "use_internal_window_detection"
_attr_icon: str = "mdi:window-open"
@CONFIG_DIAGNOSTIC_MATCH(
cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT,
quirk_ids={DANFOSS_ALLY_THERMOSTAT},
)
class DanfossMountingModeControl(ZHASwitchConfigurationEntity):
"""Danfoss proprietary attribute for switching to mounting mode."""
_unique_id_suffix = "mounting_mode_control"
_attribute_name: str = "mounting_mode_control"
_attr_translation_key: str = "mounting_mode"
@CONFIG_DIAGNOSTIC_MATCH(
cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT,
quirk_ids={DANFOSS_ALLY_THERMOSTAT},
)
class DanfossRadiatorCovered(ZHASwitchConfigurationEntity):
"""Danfoss proprietary attribute for communicating full usage of the external temperature sensor."""
_unique_id_suffix = "radiator_covered"
_attribute_name: str = "radiator_covered"
_attr_translation_key: str = "prioritize_external_temperature_sensor"
_attr_icon: str = "mdi:thermometer"
@CONFIG_DIAGNOSTIC_MATCH(
cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT,
quirk_ids={DANFOSS_ALLY_THERMOSTAT},
)
class DanfossHeatAvailable(ZHASwitchConfigurationEntity):
"""Danfoss proprietary attribute for communicating available heat."""
_unique_id_suffix = "heat_available"
_attribute_name: str = "heat_available"
_attr_translation_key: str = "heat_available"
_attr_icon: str = "mdi:water-boiler"
@CONFIG_DIAGNOSTIC_MATCH(
cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT,
quirk_ids={DANFOSS_ALLY_THERMOSTAT},
)
class DanfossLoadBalancingEnable(ZHASwitchConfigurationEntity):
"""Danfoss proprietary attribute for enabling load balancing."""
_unique_id_suffix = "load_balancing_enable"
_attribute_name: str = "load_balancing_enable"
_attr_translation_key: str = "use_load_balancing"
_attr_icon: str = "mdi:scale-balance"
@CONFIG_DIAGNOSTIC_MATCH(
cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT,
quirk_ids={DANFOSS_ALLY_THERMOSTAT},
)
class DanfossAdaptationRunSettings(ZHASwitchConfigurationEntity):
"""Danfoss proprietary attribute for enabling daily adaptation run.
Actually a bitmap, but only the first bit is used.
"""
_unique_id_suffix = "adaptation_run_settings"
_attribute_name: str = "adaptation_run_settings"
_attr_translation_key: str = "adaptation_run_enabled"

View File

@ -5,11 +5,10 @@ from __future__ import annotations
import functools
import logging
import math
from typing import TYPE_CHECKING, Any
from typing import Any
from zigpy.ota import OtaImageWithMetadata
from zigpy.zcl.clusters.general import Ota
from zigpy.zcl.foundation import Status
from zha.exceptions import ZHAException
from zigpy.application import ControllerApplication
from homeassistant.components.update import (
UpdateDeviceClass,
@ -17,8 +16,8 @@ from homeassistant.components.update import (
UpdateEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -27,24 +26,17 @@ from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator,
)
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
from .entity import ZHAEntity
from .helpers import (
SIGNAL_ADD_ENTITIES,
EntityData,
async_add_entities as zha_async_add_entities,
get_zha_data,
get_zha_gateway,
)
_LOGGER = logging.getLogger(__name__)
CONFIG_DIAGNOSTIC_MATCH = functools.partial(
ZHA_ENTITIES.config_diagnostic_match, Platform.UPDATE
)
async def async_setup_entry(
hass: HomeAssistant,
@ -53,20 +45,20 @@ async def async_setup_entry(
) -> None:
"""Set up the Zigbee Home Automation update from config entry."""
zha_data = get_zha_data(hass)
if zha_data.update_coordinator is None:
zha_data.update_coordinator = ZHAFirmwareUpdateCoordinator(
hass, get_zha_gateway(hass).application_controller
)
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,
zha_async_add_entities,
async_add_entities,
ZHAFirmwareUpdateEntity,
entities_to_create,
coordinator=coordinator,
),
)
config_entry.async_on_unload(unsub)
@ -93,14 +85,11 @@ class ZHAFirmwareUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disa
await self.controller_application.ota.broadcast_notify(jitter=100)
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_OTA)
class ZHAFirmwareUpdateEntity(
ZhaEntity, CoordinatorEntity[ZHAFirmwareUpdateCoordinator], UpdateEntity
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
@ -108,113 +97,70 @@ class ZHAFirmwareUpdateEntity(
| 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)
def __init__(self, entity_data: EntityData, **kwargs: Any) -> None:
"""Initialize the ZHA siren."""
zha_data = get_zha_data(entity_data.device_proxy.gateway_proxy.hass)
assert zha_data.update_coordinator is not None
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
super().__init__(entity_data, coordinator=zha_data.update_coordinator, **kwargs)
CoordinatorEntity.__init__(self, zha_data.update_coordinator)
def _get_cluster_version(self) -> str | None:
"""Synchronize current file version with the cluster."""
@property
def installed_version(self) -> str | None:
"""Version installed and in use."""
return self.entity_data.entity.installed_version
if self._ota_cluster_handler.current_file_version is not None:
return f"0x{self._ota_cluster_handler.current_file_version:08x}"
@property
def in_progress(self) -> bool | int | None:
"""Update installation progress.
return None
Needs UpdateEntityFeature.PROGRESS flag to be set for it to be used.
@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()
Can either return a boolean (True if in progress, False if not)
or an integer to indicate the progress in from 0 to 100%.
"""
if not self.entity_data.entity.in_progress:
return self.entity_data.entity.in_progress
@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}"
# Stay in an indeterminate state until we actually send something
if self.entity_data.entity.progress == 0:
return True
if image.metadata.changelog:
self._attr_release_summary = image.metadata.changelog
# Rescale 0-100% to 2-100% to avoid 0 and 1 colliding with None, False, and True
return int(math.ceil(2 + 98 * self.entity_data.entity.progress / 100))
self.async_write_ha_state()
@property
def latest_version(self) -> str | None:
"""Latest version available for install."""
return self.entity_data.entity.latest_version
@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
@property
def release_summary(self) -> str | None:
"""Summary of the release notes or changelog.
# 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()
This is not suitable for long changelogs, but merely suitable
for a short excerpt update description of max 255 characters.
"""
return self.entity_data.entity.release_summary
@property
def release_url(self) -> str | None:
"""URL to the full release notes of the latest version available."""
return self.entity_data.entity.release_url
# We explicitly convert ZHA exceptions to HA exceptions here so there is no need to
# use the `@convert_zha_error_to_ha_error` decorator.
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
await self.entity_data.entity.async_install(version=version, backup=backup)
except ZHAException as exc:
raise HomeAssistantError(exc) from exc
finally:
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)

View File

@ -7,28 +7,7 @@ import logging
from typing import TYPE_CHECKING, Any, Literal, NamedTuple, cast
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 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
from homeassistant.helpers.typing import VolDictType, VolSchemaType
from .api import (
async_change_channel,
async_get_active_network_settings,
async_get_radio_type,
)
from .core.const import (
from zha.application.const import (
ATTR_ARGS,
ATTR_ATTRIBUTE,
ATTR_CLUSTER_ID,
@ -47,13 +26,51 @@ from .core.const import (
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,
WARNING_DEVICE_MODE_EMERGENCY,
WARNING_DEVICE_SOUND_HIGH,
WARNING_DEVICE_SQUAWK_MODE_ARMED,
WARNING_DEVICE_STROBE_HIGH,
WARNING_DEVICE_STROBE_YES,
ZHA_CLUSTER_HANDLER_MSG,
)
from zha.application.gateway import Gateway
from zha.application.helpers import (
async_is_bindable_target,
convert_install_code,
get_matched_clusters,
qr_to_install_code,
)
from zha.zigbee.cluster_handlers.const import CLUSTER_HANDLER_IAS_WD
from zha.zigbee.device import Device
from zha.zigbee.group import GroupMember
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 homeassistant.components import websocket_api
from homeassistant.config_entries import ConfigEntry
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
from homeassistant.helpers.typing import VolDictType, VolSchemaType
from .api import (
async_change_channel,
async_get_active_network_settings,
async_get_radio_type,
)
from .const import (
CUSTOM_CONFIGURATION,
DOMAIN,
EZSP_OVERWRITE_EUI64,
@ -61,33 +78,24 @@ from .core.const import (
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,
ZHA_OPTIONS,
)
from .core.gateway import EntityReference
from .core.group import GroupMember
from .core.helpers import (
from .helpers import (
CONF_ZHA_ALARM_SCHEMA,
CONF_ZHA_OPTIONS_SCHEMA,
EntityReference,
ZHAGatewayProxy,
async_cluster_exists,
async_is_bindable_target,
cluster_command_schema_to_vol_schema,
convert_install_code,
get_matched_clusters,
get_config_entry,
get_zha_gateway,
qr_to_install_code,
get_zha_gateway_proxy,
)
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"
@ -105,6 +113,8 @@ ATTR_SOURCE_IEEE = "source_ieee"
ATTR_TARGET_IEEE = "target_ieee"
ATTR_QR_CODE = "qr_code"
BINDINGS = "bindings"
SERVICE_PERMIT = "permit"
SERVICE_REMOVE = "remove"
SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE = "set_zigbee_cluster_attribute"
@ -234,6 +244,12 @@ SERVICE_SCHEMAS: dict[str, VolSchemaType] = {
}
ZHA_CONFIG_SCHEMAS = {
ZHA_OPTIONS: CONF_ZHA_OPTIONS_SCHEMA,
ZHA_ALARM_OPTIONS: CONF_ZHA_ALARM_SCHEMA,
}
class ClusterBinding(NamedTuple):
"""Describes a cluster binding."""
@ -306,7 +322,7 @@ async def websocket_permit_devices(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Permit ZHA zigbee devices."""
zha_gateway = get_zha_gateway(hass)
zha_gateway_proxy = get_zha_gateway_proxy(hass)
duration: int = msg[ATTR_DURATION]
ieee: EUI64 | None = msg.get(ATTR_IEEE)
@ -321,28 +337,30 @@ async def websocket_permit_devices(
@callback
def async_cleanup() -> None:
"""Remove signal listener and turn off debug mode."""
zha_gateway.async_disable_debug_mode()
zha_gateway_proxy.async_disable_debug_mode()
remove_dispatcher_function()
connection.subscriptions[msg["id"]] = async_cleanup
zha_gateway.async_enable_debug_mode()
zha_gateway_proxy.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(
await zha_gateway_proxy.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(
await zha_gateway_proxy.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)
await zha_gateway_proxy.gateway.application_controller.permit(
time_s=duration, node=ieee
)
connection.send_result(msg[ID])
@ -353,26 +371,26 @@ 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()]
zha_gateway_proxy: ZHAGatewayProxy = get_zha_gateway_proxy(hass)
devices = [
device.zha_device_info for device in zha_gateway_proxy.device_proxies.values()
]
connection.send_result(msg[ID], devices)
@callback
def _get_entity_name(
zha_gateway: ZHAGateway, entity_ref: EntityReference
) -> str | None:
def _get_entity_name(zha_gateway: Gateway, entity_ref: EntityReference) -> str | None:
entity_registry = er.async_get(zha_gateway.hass)
entry = entity_registry.async_get(entity_ref.reference_id)
entry = entity_registry.async_get(entity_ref.ha_entity_id)
return entry.name if entry else None
@callback
def _get_entity_original_name(
zha_gateway: ZHAGateway, entity_ref: EntityReference
zha_gateway: Gateway, entity_ref: EntityReference
) -> str | None:
entity_registry = er.async_get(zha_gateway.hass)
entry = entity_registry.async_get(entity_ref.reference_id)
entry = entity_registry.async_get(entity_ref.ha_entity_id)
return entry.original_name if entry else None
@ -383,32 +401,36 @@ 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)
zha_gateway_proxy = get_zha_gateway_proxy(hass)
devices = [device for device in zha_gateway.devices.values() if device.is_groupable]
devices = [
device
for device in zha_gateway_proxy.device_proxies.values()
if device.device.is_groupable
]
groupable_devices: list[dict[str, Any]] = []
for device in devices:
entity_refs = zha_gateway.device_registry[device.ieee]
entity_refs = zha_gateway_proxy.ha_entity_refs[device.device.ieee]
groupable_devices.extend(
{
"endpoint_id": ep_id,
"entities": [
{
"name": _get_entity_name(zha_gateway, entity_ref),
"name": _get_entity_name(zha_gateway_proxy, entity_ref),
"original_name": _get_entity_original_name(
zha_gateway, entity_ref
zha_gateway_proxy, entity_ref
),
}
for entity_ref in entity_refs
if list(entity_ref.cluster_handlers.values())[
if list(entity_ref.entity_data.entity.cluster_handlers.values())[
0
].cluster.endpoint.endpoint_id
== ep_id
],
"device": device.zha_device_info,
}
for ep_id in device.async_get_groupable_endpoints()
for ep_id in device.device.async_get_groupable_endpoints()
)
connection.send_result(msg[ID], groupable_devices)
@ -421,8 +443,8 @@ 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()]
zha_gateway_proxy = get_zha_gateway_proxy(hass)
groups = [group.group_info for group in zha_gateway_proxy.group_proxies.values()]
connection.send_result(msg[ID], groups)
@ -438,10 +460,10 @@ async def websocket_get_device(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Get ZHA devices."""
zha_gateway = get_zha_gateway(hass)
zha_gateway_proxy = get_zha_gateway_proxy(hass)
ieee: EUI64 = msg[ATTR_IEEE]
if not (zha_device := zha_gateway.devices.get(ieee)):
if not (zha_device := zha_gateway_proxy.device_proxies.get(ieee)):
connection.send_message(
websocket_api.error_message(
msg[ID], websocket_api.ERR_NOT_FOUND, "ZHA Device not found"
@ -465,10 +487,10 @@ async def websocket_get_group(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Get ZHA group."""
zha_gateway = get_zha_gateway(hass)
zha_gateway_proxy = get_zha_gateway_proxy(hass)
group_id: int = msg[GROUP_ID]
if not (zha_group := zha_gateway.groups.get(group_id)):
if not (zha_group := zha_gateway_proxy.group_proxies.get(group_id)):
connection.send_message(
websocket_api.error_message(
msg[ID], websocket_api.ERR_NOT_FOUND, "ZHA Group not found"
@ -494,13 +516,17 @@ 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)
zha_gateway = get_zha_gateway_proxy(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)
group = await zha_gateway.gateway.async_create_zigpy_group(
group_name, members, group_id
)
assert group
connection.send_result(msg[ID], group.group_info)
connection.send_result(
msg[ID], zha_gateway.group_proxies[group.group_id].group_info
)
@websocket_api.require_admin
@ -515,17 +541,18 @@ 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)
zha_gateway = get_zha_gateway_proxy(hass)
group_ids: list[int] = msg[GROUP_IDS]
if len(group_ids) > 1:
tasks = [
zha_gateway.async_remove_zigpy_group(group_id) for group_id in group_ids
zha_gateway.gateway.async_remove_zigpy_group(group_id)
for group_id in group_ids
]
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()]
await zha_gateway.gateway.async_remove_zigpy_group(group_ids[0])
ret_groups = [group.group_info for group in zha_gateway.group_proxies.values()]
connection.send_result(msg[ID], ret_groups)
@ -603,7 +630,7 @@ async def websocket_reconfigure_node(
"""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)
device: Device | None = zha_gateway.get_device(ieee)
async def forward_messages(data):
"""Forward events to websocket."""
@ -865,14 +892,15 @@ async def websocket_get_bindable_devices(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Directly bind devices."""
zha_gateway = get_zha_gateway(hass)
zha_gateway_proxy = get_zha_gateway_proxy(hass)
source_ieee: EUI64 = msg[ATTR_IEEE]
source_device = zha_gateway.get_device(source_ieee)
source_device = zha_gateway_proxy.device_proxies.get(source_ieee)
assert source_device is not None
devices = [
device.zha_device_info
for device in zha_gateway.devices.values()
if async_is_bindable_target(source_device, device)
for device in zha_gateway_proxy.device_proxies.values()
if async_is_bindable_target(source_device.device, device.device)
]
_LOGGER.debug(
@ -993,7 +1021,7 @@ async def websocket_unbind_group(
async def async_binding_operation(
zha_gateway: ZHAGateway,
zha_gateway: Gateway,
source_ieee: EUI64,
target_ieee: EUI64,
operation: zdo_types.ZDOCmd,
@ -1047,7 +1075,7 @@ async def websocket_get_configuration(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Get ZHA configuration."""
zha_gateway = get_zha_gateway(hass)
config_entry: ConfigEntry = get_config_entry(hass)
import voluptuous_serialize # pylint: disable=import-outside-toplevel
def custom_serializer(schema: Any) -> Any:
@ -1070,9 +1098,9 @@ async def websocket_get_configuration(
data["schemas"][section] = voluptuous_serialize.convert(
schema, custom_serializer=custom_serializer
)
data["data"][section] = zha_gateway.config_entry.options.get(
CUSTOM_CONFIGURATION, {}
).get(section, {})
data["data"][section] = config_entry.options.get(CUSTOM_CONFIGURATION, {}).get(
section, {}
)
# send default values for unconfigured options
for entry in data["schemas"][section]:
@ -1094,8 +1122,8 @@ 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
config_entry: ConfigEntry = get_config_entry(hass)
options = config_entry.options
data_to_save = {**options, CUSTOM_CONFIGURATION: msg["data"]}
for section, schema in ZHA_CONFIG_SCHEMAS.items():
@ -1126,10 +1154,8 @@ async def websocket_update_zha_configuration(
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)
hass.config_entries.async_update_entry(config_entry, options=data_to_save)
status = await hass.config_entries.async_reload(config_entry.entry_id)
connection.send_result(msg[ID], status)
@ -1142,10 +1168,11 @@ async def websocket_get_network_settings(
"""Get ZHA network settings."""
backup = async_get_active_network_settings(hass)
zha_gateway = get_zha_gateway(hass)
config_entry: ConfigEntry = get_config_entry(hass)
connection.send_result(
msg[ID],
{
"radio_type": async_get_radio_type(hass, zha_gateway.config_entry).name,
"radio_type": async_get_radio_type(hass, config_entry).name,
"device": zha_gateway.application_controller.config[CONF_DEVICE],
"settings": backup.as_dict(),
},
@ -1280,7 +1307,7 @@ def async_load_api(hass: HomeAssistant) -> 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)
zha_device: Device | 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

View File

@ -555,9 +555,6 @@ beautifulsoup4==4.12.3
# homeassistant.components.beewi_smartclim
# beewi-smartclim==0.0.10
# homeassistant.components.zha
bellows==0.39.1
# homeassistant.components.bmw_connected_drive
bimmer-connected[china]==0.15.3
@ -2151,13 +2148,11 @@ pyschlage==2024.6.0
pysensibo==1.0.36
# homeassistant.components.serial
# homeassistant.components.zha
pyserial-asyncio-fast==0.11
# homeassistant.components.acer_projector
# homeassistant.components.crownstone
# homeassistant.components.usb
# homeassistant.components.zha
# homeassistant.components.zwave_js
pyserial==3.5
@ -2973,7 +2968,7 @@ zeroconf==0.132.2
zeversolar==0.3.1
# homeassistant.components.zha
zha-quirks==0.0.117
zha==0.0.18
# homeassistant.components.zhong_hong
zhong-hong-hvac==1.0.12
@ -2981,21 +2976,6 @@ zhong-hong-hvac==1.0.12
# homeassistant.components.ziggo_mediabox_xl
ziggo-mediabox-xl==1.1.0
# homeassistant.components.zha
zigpy-deconz==0.23.2
# homeassistant.components.zha
zigpy-xbee==0.20.1
# homeassistant.components.zha
zigpy-zigate==0.12.1
# homeassistant.components.zha
zigpy-znp==0.12.2
# homeassistant.components.zha
zigpy==0.64.1
# homeassistant.components.zoneminder
zm-py==0.5.4

View File

@ -480,9 +480,6 @@ base36==0.1.1
# homeassistant.components.scrape
beautifulsoup4==4.12.3
# homeassistant.components.zha
bellows==0.39.1
# homeassistant.components.bmw_connected_drive
bimmer-connected[china]==0.15.3
@ -1692,14 +1689,9 @@ pyschlage==2024.6.0
# homeassistant.components.sensibo
pysensibo==1.0.36
# homeassistant.components.serial
# homeassistant.components.zha
pyserial-asyncio-fast==0.11
# homeassistant.components.acer_projector
# homeassistant.components.crownstone
# homeassistant.components.usb
# homeassistant.components.zha
# homeassistant.components.zwave_js
pyserial==3.5
@ -2326,22 +2318,7 @@ zeroconf==0.132.2
zeversolar==0.3.1
# homeassistant.components.zha
zha-quirks==0.0.117
# homeassistant.components.zha
zigpy-deconz==0.23.2
# homeassistant.components.zha
zigpy-xbee==0.20.1
# homeassistant.components.zha
zigpy-zigate==0.12.1
# homeassistant.components.zha
zigpy-znp==0.12.2
# homeassistant.components.zha
zigpy==0.64.1
zha==0.0.18
# homeassistant.components.zwave_js
zwave-js-server-python==0.57.0

View File

@ -1,19 +1,11 @@
"""Common test objects."""
import asyncio
from datetime import timedelta
import math
from typing import Any
from unittest.mock import AsyncMock, Mock, patch
from unittest.mock import AsyncMock, Mock
import zigpy.zcl
import zigpy.zcl.foundation as zcl_f
import homeassistant.components.zha.core.const as zha_const
from homeassistant.components.zha.core.helpers import (
async_get_zha_config_value,
get_zha_gateway,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
import homeassistant.util.dt as dt_util
@ -98,7 +90,7 @@ def make_attribute(attrid, value, status=0):
return attr
def send_attribute_report(hass, cluster, attrid, value):
def send_attribute_report(hass: HomeAssistant, cluster, attrid, value):
"""Send a single attribute report."""
return send_attributes_report(hass, cluster, {attrid: value})
@ -131,7 +123,7 @@ async def send_attributes_report(
await hass.async_block_till_done()
def find_entity_id(domain, zha_device, hass, qualifier=None):
def find_entity_id(domain, zha_device, hass: HomeAssistant, qualifier=None):
"""Find the entity id under the testing.
This is used to get the entity id in order to get the state from the state
@ -148,7 +140,7 @@ def find_entity_id(domain, zha_device, hass, qualifier=None):
return entities[0]
def find_entity_ids(domain, zha_device, hass):
def find_entity_ids(domain, zha_device, hass: HomeAssistant):
"""Find the entity ids under the testing.
This is used to get the entity id in order to get the state from the state
@ -163,7 +155,7 @@ def find_entity_ids(domain, zha_device, hass):
]
def async_find_group_entity_id(hass, domain, group):
def async_find_group_entity_id(hass: HomeAssistant, domain, group):
"""Find the group entity id under test."""
entity_id = f"{domain}.coordinator_manufacturer_coordinator_model_{group.name.lower().replace(' ', '_')}"
@ -172,13 +164,6 @@ def async_find_group_entity_id(hass, domain, group):
return entity_id
async def async_enable_traffic(hass, zha_devices, enabled=True):
"""Allow traffic to flow through the gateway and the ZHA device."""
for zha_device in zha_devices:
zha_device.update_available(enabled)
await hass.async_block_till_done()
def make_zcl_header(
command_id: int, global_command: bool = True, tsn: int = 1
) -> zcl_f.ZCLHeader:
@ -199,57 +184,8 @@ def reset_clusters(clusters):
cluster.write_attributes.reset_mock()
async def async_test_rejoin(hass, zigpy_device, clusters, report_counts, ep_id=1):
"""Test device rejoins."""
reset_clusters(clusters)
zha_gateway = get_zha_gateway(hass)
await zha_gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done()
for cluster, reports in zip(clusters, report_counts, strict=False):
assert cluster.bind.call_count == 1
assert cluster.bind.await_count == 1
if reports:
assert cluster.configure_reporting.call_count == 0
assert cluster.configure_reporting.await_count == 0
assert cluster.configure_reporting_multiple.call_count == math.ceil(
reports / zha_const.REPORT_CONFIG_ATTR_PER_REQ
)
assert cluster.configure_reporting_multiple.await_count == math.ceil(
reports / zha_const.REPORT_CONFIG_ATTR_PER_REQ
)
else:
# no reports at all
assert cluster.configure_reporting.call_count == reports
assert cluster.configure_reporting.await_count == reports
assert cluster.configure_reporting_multiple.call_count == reports
assert cluster.configure_reporting_multiple.await_count == reports
async def async_wait_for_updates(hass):
"""Wait until all scheduled updates are executed."""
await hass.async_block_till_done()
await asyncio.sleep(0)
await asyncio.sleep(0)
await hass.async_block_till_done()
async def async_shift_time(hass):
async def async_shift_time(hass: HomeAssistant):
"""Shift time to cause call later tasks to run."""
next_update = dt_util.utcnow() + timedelta(seconds=11)
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
def patch_zha_config(component: str, overrides: dict[tuple[str, str], Any]):
"""Patch the ZHA custom configuration defaults."""
def new_get_config(config_entry, section, config_key, default):
if (section, config_key) in overrides:
return overrides[section, config_key]
return async_get_zha_config_value(config_entry, section, config_key, default)
return patch(
f"homeassistant.components.zha.{component}.async_get_zha_config_value",
side_effect=new_get_config,
)

View File

@ -1,6 +1,6 @@
"""Test configuration for the ZHA component."""
from collections.abc import Callable, Generator
from collections.abc import Generator
import itertools
import time
from typing import Any
@ -24,14 +24,9 @@ from zigpy.zcl.clusters.general import Basic, Groups
from zigpy.zcl.foundation import Status
import zigpy.zdo.types as zdo_t
import homeassistant.components.zha.core.const as zha_const
import homeassistant.components.zha.core.device as zha_core_device
from homeassistant.components.zha.core.gateway import ZHAGateway
from homeassistant.components.zha.core.helpers import get_zha_gateway
import homeassistant.components.zha.const as zha_const
from homeassistant.core import HomeAssistant
from homeassistant.helpers import restore_state
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
from .common import patch_cluster as common_patch_cluster
@ -43,17 +38,6 @@ FIXTURE_GRP_NAME = "fixture group"
COUNTER_NAMES = ["counter_1", "counter_2", "counter_3"]
@pytest.fixture(scope="module", autouse=True)
def disable_request_retry_delay():
"""Disable ZHA request retrying delay to speed up failures."""
with patch(
"homeassistant.components.zha.core.cluster_handlers.RETRYABLE_REQUEST_DECORATOR",
zigpy.util.retryable_request(tries=3, delay=0),
):
yield
@pytest.fixture(scope="module", autouse=True)
def globally_load_quirks():
"""Load quirks automatically so that ZHA tests run deterministically in isolation.
@ -127,6 +111,9 @@ class _FakeApp(ControllerApplication):
) -> None:
pass
def _persist_coordinator_model_strings_in_db(self) -> None:
pass
def _wrap_mock_instance(obj: Any) -> MagicMock:
"""Auto-mock every attribute and method in an object."""
@ -201,10 +188,14 @@ async def zigpy_app_controller():
async def config_entry_fixture() -> MockConfigEntry:
"""Fixture representing a config entry."""
return MockConfigEntry(
version=3,
version=4,
domain=zha_const.DOMAIN,
data={
zigpy.config.CONF_DEVICE: {zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB0"},
zigpy.config.CONF_DEVICE: {
zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB0",
zigpy.config.CONF_DEVICE_BAUDRATE: 115200,
zigpy.config.CONF_DEVICE_FLOW_CONTROL: "hardware",
},
zha_const.CONF_RADIO_TYPE: "ezsp",
},
options={
@ -279,170 +270,6 @@ def cluster_handler():
return cluster_handler
@pytest.fixture
def zigpy_device_mock(zigpy_app_controller):
"""Make a fake device using the specified cluster classes."""
def _mock_dev(
endpoints,
ieee="00:0d:6f:00:0a:90:69:e7",
manufacturer="FakeManufacturer",
model="FakeModel",
node_descriptor=b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00",
nwk=0xB79C,
patch_cluster=True,
quirk=None,
attributes=None,
):
"""Make a fake device using the specified cluster classes."""
device = zigpy.device.Device(
zigpy_app_controller, zigpy.types.EUI64.convert(ieee), nwk
)
device.manufacturer = manufacturer
device.model = model
device.node_desc = zdo_t.NodeDescriptor.deserialize(node_descriptor)[0]
device.last_seen = time.time()
for epid, ep in endpoints.items():
endpoint = device.add_endpoint(epid)
endpoint.device_type = ep[SIG_EP_TYPE]
endpoint.profile_id = ep.get(SIG_EP_PROFILE, 0x0104)
endpoint.request = AsyncMock()
for cluster_id in ep.get(SIG_EP_INPUT, []):
endpoint.add_input_cluster(cluster_id)
for cluster_id in ep.get(SIG_EP_OUTPUT, []):
endpoint.add_output_cluster(cluster_id)
device.status = zigpy.device.Status.ENDPOINTS_INIT
if quirk:
device = quirk(zigpy_app_controller, device.ieee, device.nwk, device)
else:
# Allow zigpy to apply quirks if we don't pass one explicitly
device = zigpy.quirks.get_device(device)
if patch_cluster:
for endpoint in (ep for epid, ep in device.endpoints.items() if epid):
endpoint.request = AsyncMock(return_value=[0])
for cluster in itertools.chain(
endpoint.in_clusters.values(), endpoint.out_clusters.values()
):
common_patch_cluster(cluster)
if attributes is not None:
for ep_id, clusters in attributes.items():
for cluster_name, attrs in clusters.items():
cluster = getattr(device.endpoints[ep_id], cluster_name)
for name, value in attrs.items():
attr_id = cluster.find_attribute(name).id
cluster._attr_cache[attr_id] = value
return device
return _mock_dev
@patch("homeassistant.components.zha.setup_quirks", MagicMock(return_value=True))
@pytest.fixture
def zha_device_joined(hass, setup_zha):
"""Return a newly joined ZHA device."""
setup_zha_fixture = setup_zha
async def _zha_device(zigpy_dev, *, setup_zha: bool = True):
zigpy_dev.last_seen = time.time()
if setup_zha:
await setup_zha_fixture()
zha_gateway = get_zha_gateway(hass)
zha_gateway.application_controller.devices[zigpy_dev.ieee] = zigpy_dev
await zha_gateway.async_device_initialized(zigpy_dev)
await hass.async_block_till_done()
return zha_gateway.get_device(zigpy_dev.ieee)
return _zha_device
@patch("homeassistant.components.zha.setup_quirks", MagicMock(return_value=True))
@pytest.fixture
def zha_device_restored(hass, zigpy_app_controller, setup_zha):
"""Return a restored ZHA device."""
setup_zha_fixture = setup_zha
async def _zha_device(zigpy_dev, *, last_seen=None, setup_zha: bool = True):
zigpy_app_controller.devices[zigpy_dev.ieee] = zigpy_dev
if last_seen is not None:
zigpy_dev.last_seen = last_seen
if setup_zha:
await setup_zha_fixture()
zha_gateway = get_zha_gateway(hass)
return zha_gateway.get_device(zigpy_dev.ieee)
return _zha_device
@pytest.fixture(params=["zha_device_joined", "zha_device_restored"])
def zha_device_joined_restored(request: pytest.FixtureRequest):
"""Join or restore ZHA device."""
named_method = request.getfixturevalue(request.param)
named_method.name = request.param
return named_method
@pytest.fixture
def zha_device_mock(
hass: HomeAssistant, config_entry, zigpy_device_mock
) -> Callable[..., zha_core_device.ZHADevice]:
"""Return a ZHA Device factory."""
def _zha_device(
endpoints=None,
ieee="00:11:22:33:44:55:66:77",
manufacturer="mock manufacturer",
model="mock model",
node_desc=b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00",
patch_cluster=True,
) -> zha_core_device.ZHADevice:
if endpoints is None:
endpoints = {
1: {
"in_clusters": [0, 1, 8, 768],
"out_clusters": [0x19],
"device_type": 0x0105,
},
2: {
"in_clusters": [0],
"out_clusters": [6, 8, 0x19, 768],
"device_type": 0x0810,
},
}
zigpy_device = zigpy_device_mock(
endpoints, ieee, manufacturer, model, node_desc, patch_cluster=patch_cluster
)
return zha_core_device.ZHADevice(
hass,
zigpy_device,
ZHAGateway(hass, {}, config_entry),
)
return _zha_device
@pytest.fixture
def hass_disable_services(hass):
"""Mock services."""
with patch.object(
hass, "services", MagicMock(has_service=MagicMock(return_value=True))
):
yield hass
@pytest.fixture(autouse=True)
def speed_up_radio_mgr():
"""Speed up the radio manager connection time by removing delays."""
@ -522,31 +349,66 @@ def network_backup() -> zigpy.backups.NetworkBackup:
@pytest.fixture
def core_rs(hass_storage: dict[str, Any]) -> Callable[[str, Any, dict[str, Any]], None]:
"""Core.restore_state fixture."""
def zigpy_device_mock(zigpy_app_controller):
"""Make a fake device using the specified cluster classes."""
def _storage(entity_id: str, state: str, attributes: dict[str, Any]) -> None:
now = dt_util.utcnow().isoformat()
def _mock_dev(
endpoints,
ieee="00:0d:6f:00:0a:90:69:e7",
manufacturer="FakeManufacturer",
model="FakeModel",
node_descriptor=b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00",
nwk=0xB79C,
patch_cluster=True,
quirk=None,
attributes=None,
):
"""Make a fake device using the specified cluster classes."""
device = zigpy.device.Device(
zigpy_app_controller, zigpy.types.EUI64.convert(ieee), nwk
)
device.manufacturer = manufacturer
device.model = model
device.node_desc = zdo_t.NodeDescriptor.deserialize(node_descriptor)[0]
device.last_seen = time.time()
hass_storage[restore_state.STORAGE_KEY] = {
"version": restore_state.STORAGE_VERSION,
"key": restore_state.STORAGE_KEY,
"data": [
{
"state": {
"entity_id": entity_id,
"state": str(state),
"attributes": attributes,
"last_changed": now,
"last_updated": now,
"context": {
"id": "3c2243ff5f30447eb12e7348cfd5b8ff",
"user_id": None,
},
},
"last_seen": now,
}
],
}
for epid, ep in endpoints.items():
endpoint = device.add_endpoint(epid)
endpoint.device_type = ep[SIG_EP_TYPE]
endpoint.profile_id = ep.get(SIG_EP_PROFILE, 0x0104)
endpoint.request = AsyncMock()
return _storage
for cluster_id in ep.get(SIG_EP_INPUT, []):
endpoint.add_input_cluster(cluster_id)
for cluster_id in ep.get(SIG_EP_OUTPUT, []):
endpoint.add_output_cluster(cluster_id)
device.status = zigpy.device.Status.ENDPOINTS_INIT
if quirk:
device = quirk(zigpy_app_controller, device.ieee, device.nwk, device)
else:
# Allow zigpy to apply quirks if we don't pass one explicitly
device = zigpy.quirks.get_device(device)
if patch_cluster:
for endpoint in (ep for epid, ep in device.endpoints.items() if epid):
endpoint.request = AsyncMock(return_value=[0])
for cluster in itertools.chain(
endpoint.in_clusters.values(), endpoint.out_clusters.values()
):
common_patch_cluster(cluster)
if attributes is not None:
for ep_id, clusters in attributes.items():
for cluster_name, attrs in clusters.items():
cluster = getattr(device.endpoints[ep_id], cluster_name)
for name, value in attrs.items():
attr_id = cluster.find_attribute(name).id
cluster._attr_cache[attr_id] = value
return device
return _mock_dev

View File

@ -4,10 +4,17 @@ from unittest.mock import AsyncMock, call, patch, sentinel
import pytest
from zigpy.profiles import zha
from zigpy.zcl import Cluster
from zigpy.zcl.clusters import security
import zigpy.zcl.foundation as zcl_f
from homeassistant.components.alarm_control_panel import DOMAIN as ALARM_DOMAIN
from homeassistant.components.zha.helpers import (
ZHADeviceProxy,
ZHAGatewayProxy,
get_zha_gateway,
get_zha_gateway_proxy,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
STATE_ALARM_ARMED_AWAY,
@ -15,12 +22,11 @@ from homeassistant.const import (
STATE_ALARM_ARMED_NIGHT,
STATE_ALARM_DISARMED,
STATE_ALARM_TRIGGERED,
STATE_UNAVAILABLE,
Platform,
)
from homeassistant.core import HomeAssistant
from .common import async_enable_traffic, find_entity_id
from .common import find_entity_id
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
@ -39,44 +45,40 @@ def alarm_control_panel_platform_only():
yield
@pytest.fixture
def zigpy_device(zigpy_device_mock):
"""Device tracker zigpy device."""
endpoints = {
1: {
SIG_EP_INPUT: [security.IasAce.cluster_id],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.IAS_ANCILLARY_CONTROL,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
}
return zigpy_device_mock(
endpoints, node_descriptor=b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00"
)
@patch(
"zigpy.zcl.clusters.security.IasAce.client_command",
new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]),
)
async def test_alarm_control_panel(
hass: HomeAssistant, zha_device_joined_restored, zigpy_device
hass: HomeAssistant, setup_zha, zigpy_device_mock
) -> None:
"""Test ZHA alarm control panel platform."""
zha_device = await zha_device_joined_restored(zigpy_device)
cluster = zigpy_device.endpoints.get(1).ias_ace
entity_id = find_entity_id(Platform.ALARM_CONTROL_PANEL, zha_device, hass)
await setup_zha()
gateway = get_zha_gateway(hass)
gateway_proxy: ZHAGatewayProxy = get_zha_gateway_proxy(hass)
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [security.IasAce.cluster_id],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.IAS_ANCILLARY_CONTROL,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
},
node_descriptor=b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00",
)
gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
zha_device_proxy: ZHADeviceProxy = gateway_proxy.get_device_proxy(zigpy_device.ieee)
entity_id = find_entity_id(Platform.ALARM_CONTROL_PANEL, zha_device_proxy, hass)
cluster = zigpy_device.endpoints[1].ias_ace
assert entity_id is not None
assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED
await async_enable_traffic(hass, [zha_device], enabled=False)
# test that the panel was created and that it is unavailable
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [zha_device])
# test that the state has changed from unavailable to STATE_ALARM_DISARMED
assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED
# arm_away from HA
@ -255,8 +257,30 @@ async def test_alarm_control_panel(
# reset the panel
await reset_alarm_panel(hass, cluster, entity_id)
await hass.services.async_call(
ALARM_DOMAIN,
"alarm_trigger",
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_ALARM_TRIGGERED
assert cluster.client_command.call_count == 1
assert cluster.client_command.await_count == 1
assert cluster.client_command.call_args == call(
4,
security.IasAce.PanelStatus.In_Alarm,
0,
security.IasAce.AudibleNotification.Default_Sound,
security.IasAce.AlarmStatus.Emergency_Panic,
)
async def reset_alarm_panel(hass, cluster, entity_id):
# reset the panel
await reset_alarm_panel(hass, cluster, entity_id)
cluster.client_command.reset_mock()
async def reset_alarm_panel(hass: HomeAssistant, cluster: Cluster, entity_id: str):
"""Reset the state of the alarm panel."""
cluster.client_command.reset_mock()
await hass.services.async_call(

View File

@ -6,12 +6,12 @@ from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock, call, patch
import pytest
from zha.application.const import RadioType
import zigpy.backups
import zigpy.state
from homeassistant.components.zha import api
from homeassistant.components.zha.core.const import RadioType
from homeassistant.components.zha.core.helpers import get_zha_gateway
from homeassistant.components.zha.helpers import get_zha_gateway_proxy
from homeassistant.core import HomeAssistant
if TYPE_CHECKING:
@ -41,7 +41,7 @@ async def test_async_get_network_settings_inactive(
"""Test reading settings with an inactive ZHA installation."""
await setup_zha()
gateway = get_zha_gateway(hass)
gateway = get_zha_gateway_proxy(hass)
await hass.config_entries.async_unload(gateway.config_entry.entry_id)
backup = zigpy.backups.NetworkBackup()
@ -53,7 +53,7 @@ async def test_async_get_network_settings_inactive(
controller.new = AsyncMock(return_value=zigpy_app_controller)
with patch.dict(
"homeassistant.components.zha.core.const.RadioType._member_map_",
"homeassistant.components.zha.api.RadioType._member_map_",
ezsp=MagicMock(controller=controller, description="EZSP"),
):
settings = await api.async_get_network_settings(hass)
@ -68,7 +68,7 @@ async def test_async_get_network_settings_missing(
"""Test reading settings with an inactive ZHA installation, no valid channel."""
await setup_zha()
gateway = get_zha_gateway(hass)
gateway = get_zha_gateway_proxy(hass)
await hass.config_entries.async_unload(gateway.config_entry.entry_id)
# Network settings were never loaded for whatever reason

View File

@ -1,19 +0,0 @@
"""Test ZHA base cluster handlers module."""
from homeassistant.components.zha.core.cluster_handlers import parse_and_log_command
from .test_cluster_handlers import ( # noqa: F401
endpoint,
poll_control_ch,
zigpy_coordinator_device,
)
def test_parse_and_log_command(poll_control_ch) -> None: # noqa: F811
"""Test that `parse_and_log_command` correctly parses a known command."""
assert parse_and_log_command(poll_control_ch, 0x00, 0x01, []) == "fast_poll_stop"
def test_parse_and_log_command_unknown(poll_control_ch) -> None: # noqa: F811
"""Test that `parse_and_log_command` correctly parses an unknown command."""
assert parse_and_log_command(poll_control_ch, 0x00, 0xAB, []) == "0xAB"

View File

@ -1,54 +1,25 @@
"""Test ZHA binary sensor."""
from collections.abc import Callable
from typing import Any
from unittest.mock import patch
import pytest
import zigpy.profiles.zha
from zigpy.zcl.clusters import general, measurement, security
from zigpy.profiles import zha
from zigpy.zcl.clusters import general
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform
from homeassistant.components.zha.helpers import (
ZHADeviceProxy,
ZHAGatewayProxy,
get_zha_gateway,
get_zha_gateway_proxy,
)
from homeassistant.const import STATE_OFF, STATE_ON, Platform
from homeassistant.core import HomeAssistant
from .common import (
async_enable_traffic,
async_test_rejoin,
find_entity_id,
send_attributes_report,
)
from .common import find_entity_id, send_attributes_report
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
from tests.common import async_mock_load_restore_state_from_storage
DEVICE_IAS = {
1: {
SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID,
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.IAS_ZONE,
SIG_EP_INPUT: [security.IasZone.cluster_id],
SIG_EP_OUTPUT: [],
}
}
DEVICE_OCCUPANCY = {
1: {
SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID,
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.OCCUPANCY_SENSOR,
SIG_EP_INPUT: [measurement.OccupancySensing.cluster_id],
SIG_EP_OUTPUT: [],
}
}
DEVICE_ONOFF = {
1: {
SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID,
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SENSOR,
SIG_EP_INPUT: [],
SIG_EP_OUTPUT: [general.OnOff.cluster_id],
}
}
ON = 1
OFF = 0
@pytest.fixture(autouse=True)
@ -58,121 +29,51 @@ def binary_sensor_platform_only():
"homeassistant.components.zha.PLATFORMS",
(
Platform.BINARY_SENSOR,
Platform.DEVICE_TRACKER,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
),
):
yield
async def async_test_binary_sensor_on_off(hass, cluster, entity_id):
"""Test getting on and off messages for binary sensors."""
# binary sensor on
await send_attributes_report(hass, cluster, {1: 0, 0: 1, 2: 2})
assert hass.states.get(entity_id).state == STATE_ON
# binary sensor off
await send_attributes_report(hass, cluster, {1: 1, 0: 0, 2: 2})
assert hass.states.get(entity_id).state == STATE_OFF
async def async_test_iaszone_on_off(hass, cluster, entity_id):
"""Test getting on and off messages for iaszone binary sensors."""
# binary sensor on
cluster.listener_event("cluster_command", 1, 0, [1])
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_ON
# binary sensor off
cluster.listener_event("cluster_command", 1, 0, [0])
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_OFF
# check that binary sensor remains off when non-alarm bits change
cluster.listener_event("cluster_command", 1, 0, [0b1111111100])
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_OFF
@pytest.mark.parametrize(
("device", "on_off_test", "cluster_name", "reporting", "name"),
[
(
DEVICE_IAS,
async_test_iaszone_on_off,
"ias_zone",
(0,),
"FakeManufacturer FakeModel IAS zone",
),
(
DEVICE_OCCUPANCY,
async_test_binary_sensor_on_off,
"occupancy",
(1,),
"FakeManufacturer FakeModel Occupancy",
),
],
)
async def test_binary_sensor(
hass: HomeAssistant,
setup_zha,
zigpy_device_mock,
zha_device_joined_restored,
device,
on_off_test,
cluster_name,
reporting,
name,
) -> None:
"""Test ZHA binary_sensor platform."""
zigpy_device = zigpy_device_mock(device)
zha_device = await zha_device_joined_restored(zigpy_device)
entity_id = find_entity_id(Platform.BINARY_SENSOR, zha_device, hass)
await setup_zha()
gateway = get_zha_gateway(hass)
gateway_proxy: ZHAGatewayProxy = get_zha_gateway_proxy(hass)
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_PROFILE: zha.PROFILE_ID,
SIG_EP_TYPE: zha.DeviceType.ON_OFF_SENSOR,
SIG_EP_INPUT: [general.Basic.cluster_id],
SIG_EP_OUTPUT: [general.OnOff.cluster_id],
}
},
ieee="01:2d:6f:00:0a:90:69:e8",
)
cluster = zigpy_device.endpoints[1].out_clusters[general.OnOff.cluster_id]
gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
zha_device_proxy: ZHADeviceProxy = gateway_proxy.get_device_proxy(zigpy_device.ieee)
entity_id = find_entity_id(Platform.BINARY_SENSOR, zha_device_proxy, hass)
assert entity_id is not None
assert hass.states.get(entity_id).name == name
assert hass.states.get(entity_id).state == STATE_OFF
await async_enable_traffic(hass, [zha_device], enabled=False)
# test that the sensors exist and are in the unavailable state
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
await async_enable_traffic(hass, [zha_device])
# test that the sensors exist and are in the off state
assert hass.states.get(entity_id).state == STATE_OFF
# test getting messages that trigger and reset the sensors
cluster = getattr(zigpy_device.endpoints[1], cluster_name)
await on_off_test(hass, cluster, entity_id)
await send_attributes_report(
hass, cluster, {general.OnOff.AttributeDefs.on_off.id: ON}
)
assert hass.states.get(entity_id).state == STATE_ON
# test rejoin
await async_test_rejoin(hass, zigpy_device, [cluster], reporting)
await send_attributes_report(
hass, cluster, {general.OnOff.AttributeDefs.on_off.id: OFF}
)
assert hass.states.get(entity_id).state == STATE_OFF
@pytest.mark.parametrize(
"restored_state",
[
STATE_ON,
STATE_OFF,
],
)
async def test_onoff_binary_sensor_restore_state(
hass: HomeAssistant,
zigpy_device_mock,
core_rs: Callable[[str, Any, dict[str, Any]], None],
zha_device_restored,
restored_state: str,
) -> None:
"""Test ZHA OnOff binary_sensor restores last state from HA."""
entity_id = "binary_sensor.fakemanufacturer_fakemodel_opening"
core_rs(entity_id, state=restored_state, attributes={})
await async_mock_load_restore_state_from_storage(hass)
zigpy_device = zigpy_device_mock(DEVICE_ONOFF)
zha_device = await zha_device_restored(zigpy_device)
entity_id = find_entity_id(Platform.BINARY_SENSOR, zha_device, hass)
assert entity_id is not None
assert hass.states.get(entity_id).state == restored_state

View File

@ -1,29 +1,21 @@
"""Test ZHA button."""
from typing import Final
from unittest.mock import call, patch
from unittest.mock import patch
from freezegun import freeze_time
import pytest
from zhaquirks.const import (
DEVICE_TYPE,
ENDPOINTS,
INPUT_CLUSTERS,
OUTPUT_CLUSTERS,
PROFILE_ID,
)
from zhaquirks.tuya.ts0601_valve import ParksideTuyaValveManufCluster
from zigpy.const import SIG_EP_PROFILE
from zigpy.exceptions import ZigbeeException
from zigpy.const import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
from zigpy.profiles import zha
from zigpy.quirks import CustomCluster, CustomDevice
from zigpy.quirks.v2 import add_to_registry_v2
import zigpy.types as t
from zigpy.zcl.clusters import general, security
from zigpy.zcl.clusters.manufacturer_specific import ManufacturerSpecificCluster
from zigpy.zcl.clusters import general
import zigpy.zcl.foundation as zcl_f
from homeassistant.components.button import DOMAIN, SERVICE_PRESS, ButtonDeviceClass
from homeassistant.components.zha.helpers import (
ZHADeviceProxy,
ZHAGatewayProxy,
get_zha_gateway,
get_zha_gateway_proxy,
)
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
@ -32,11 +24,9 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from .common import find_entity_id, update_attribute_cache
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE
from .common import find_entity_id
@pytest.fixture(autouse=True)
@ -44,106 +34,53 @@ def button_platform_only():
"""Only set up the button and required base platforms to speed up tests."""
with patch(
"homeassistant.components.zha.PLATFORMS",
(
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.DEVICE_TRACKER,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
),
(Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR),
):
yield
@pytest.fixture
async def contact_sensor(
hass: HomeAssistant, zigpy_device_mock, zha_device_joined_restored
):
"""Contact sensor fixture."""
async def setup_zha_integration(hass: HomeAssistant, setup_zha):
"""Set up ZHA component."""
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [
general.Basic.cluster_id,
general.Identify.cluster_id,
security.IasZone.cluster_id,
],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.IAS_ZONE,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
},
)
zha_device = await zha_device_joined_restored(zigpy_device)
return zha_device, zigpy_device.endpoints[1].identify
class FrostLockQuirk(CustomDevice):
"""Quirk with frost lock attribute."""
class TuyaManufCluster(CustomCluster, ManufacturerSpecificCluster):
"""Tuya manufacturer specific cluster."""
cluster_id = 0xEF00
ep_attribute = "tuya_manufacturer"
attributes = {0xEF01: ("frost_lock_reset", t.Bool)}
replacement = {
ENDPOINTS: {
1: {
PROFILE_ID: zha.PROFILE_ID,
DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH,
INPUT_CLUSTERS: [general.Basic.cluster_id, TuyaManufCluster],
OUTPUT_CLUSTERS: [],
},
}
}
@pytest.fixture
async def tuya_water_valve(
hass: HomeAssistant, zigpy_device_mock, zha_device_joined_restored
):
"""Tuya Water Valve fixture."""
zigpy_device = zigpy_device_mock(
{
1: {
PROFILE_ID: zha.PROFILE_ID,
DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH,
INPUT_CLUSTERS: [
general.Basic.cluster_id,
general.Identify.cluster_id,
general.Groups.cluster_id,
general.Scenes.cluster_id,
general.OnOff.cluster_id,
ParksideTuyaValveManufCluster.cluster_id,
],
OUTPUT_CLUSTERS: [general.Time.cluster_id, general.Ota.cluster_id],
},
},
manufacturer="_TZE200_htnnfasr",
model="TS0601",
)
zha_device = await zha_device_joined_restored(zigpy_device)
return zha_device, zigpy_device.endpoints[1].tuya_manufacturer
# if we call this in the test itself the test hangs forever
await setup_zha()
@freeze_time("2021-11-04 17:37:00", tz_offset=-1)
async def test_button(
hass: HomeAssistant, entity_registry: er.EntityRegistry, contact_sensor
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
setup_zha_integration, # pylint: disable=unused-argument
zigpy_device_mock,
) -> None:
"""Test ZHA button platform."""
zha_device, cluster = contact_sensor
assert cluster is not None
entity_id = find_entity_id(DOMAIN, zha_device, hass)
gateway = get_zha_gateway(hass)
gateway_proxy: ZHAGatewayProxy = get_zha_gateway_proxy(hass)
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_PROFILE: zha.PROFILE_ID,
SIG_EP_TYPE: zha.DeviceType.ON_OFF_SENSOR,
SIG_EP_INPUT: [
general.Basic.cluster_id,
general.Identify.cluster_id,
],
SIG_EP_OUTPUT: [general.OnOff.cluster_id],
}
},
ieee="01:2d:6f:00:0a:90:69:e8",
)
cluster = zigpy_device.endpoints[1].identify
gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
zha_device_proxy: ZHADeviceProxy = gateway_proxy.get_device_proxy(zigpy_device.ieee)
entity_id = find_entity_id(Platform.BUTTON, zha_device_proxy, hass)
assert entity_id is not None
state = hass.states.get(entity_id)
@ -175,198 +112,3 @@ async def test_button(
assert state
assert state.state == "2021-11-04T16:37:00+00:00"
assert state.attributes[ATTR_DEVICE_CLASS] == ButtonDeviceClass.IDENTIFY
async def test_frost_unlock(
hass: HomeAssistant, entity_registry: er.EntityRegistry, tuya_water_valve
) -> None:
"""Test custom frost unlock ZHA button."""
zha_device, cluster = tuya_water_valve
assert cluster is not None
entity_id = find_entity_id(DOMAIN, zha_device, hass, qualifier="frost_lock_reset")
assert entity_id is not None
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_UNKNOWN
assert state.attributes[ATTR_DEVICE_CLASS] == ButtonDeviceClass.RESTART
entry = entity_registry.async_get(entity_id)
assert entry
assert entry.entity_category == EntityCategory.CONFIG
with patch(
"zigpy.zcl.Cluster.request",
return_value=[0x00, zcl_f.Status.SUCCESS],
):
await hass.services.async_call(
DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
await hass.async_block_till_done()
assert cluster.write_attributes.mock_calls == [
call({"frost_lock_reset": 0}, manufacturer=None)
]
state = hass.states.get(entity_id)
assert state
assert state.attributes[ATTR_DEVICE_CLASS] == ButtonDeviceClass.RESTART
cluster.write_attributes.reset_mock()
cluster.write_attributes.side_effect = ZigbeeException
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
# There are three retries
assert cluster.write_attributes.mock_calls == [
call({"frost_lock_reset": 0}, manufacturer=None),
call({"frost_lock_reset": 0}, manufacturer=None),
call({"frost_lock_reset": 0}, manufacturer=None),
]
class FakeManufacturerCluster(CustomCluster, ManufacturerSpecificCluster):
"""Fake manufacturer cluster."""
cluster_id: Final = 0xFFF3
ep_attribute: Final = "mfg_identify"
class AttributeDefs(zcl_f.BaseAttributeDefs):
"""Attribute definitions."""
feed: Final = zcl_f.ZCLAttributeDef(
id=0x0000, type=t.uint8_t, access="rw", is_manufacturer_specific=True
)
class ServerCommandDefs(zcl_f.BaseCommandDefs):
"""Server command definitions."""
self_test: Final = zcl_f.ZCLCommandDef(
id=0x00, schema={"identify_time": t.uint16_t}, direction=False
)
(
add_to_registry_v2("Fake_Model", "Fake_Manufacturer")
.replaces(FakeManufacturerCluster)
.command_button(
FakeManufacturerCluster.ServerCommandDefs.self_test.name,
FakeManufacturerCluster.cluster_id,
command_args=(5,),
)
.write_attr_button(
FakeManufacturerCluster.AttributeDefs.feed.name,
2,
FakeManufacturerCluster.cluster_id,
)
)
@pytest.fixture
async def custom_button_device(
hass: HomeAssistant, zigpy_device_mock, zha_device_joined_restored
):
"""Button device fixture for quirks button tests."""
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [
general.Basic.cluster_id,
FakeManufacturerCluster.cluster_id,
],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.REMOTE_CONTROL,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
},
manufacturer="Fake_Model",
model="Fake_Manufacturer",
)
zigpy_device.endpoints[1].mfg_identify.PLUGGED_ATTR_READS = {
FakeManufacturerCluster.AttributeDefs.feed.name: 0,
}
update_attribute_cache(zigpy_device.endpoints[1].mfg_identify)
zha_device = await zha_device_joined_restored(zigpy_device)
return zha_device, zigpy_device.endpoints[1].mfg_identify
@freeze_time("2021-11-04 17:37:00", tz_offset=-1)
async def test_quirks_command_button(hass: HomeAssistant, custom_button_device) -> None:
"""Test ZHA button platform."""
zha_device, cluster = custom_button_device
assert cluster is not None
entity_id = find_entity_id(DOMAIN, zha_device, hass, qualifier="self_test")
assert entity_id is not None
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_UNKNOWN
with patch(
"zigpy.zcl.Cluster.request",
return_value=[0x00, zcl_f.Status.SUCCESS],
):
await hass.services.async_call(
DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
await hass.async_block_till_done()
assert len(cluster.request.mock_calls) == 1
assert cluster.request.call_args[0][0] is False
assert cluster.request.call_args[0][1] == 0
assert cluster.request.call_args[0][3] == 5 # duration in seconds
state = hass.states.get(entity_id)
assert state
assert state.state == "2021-11-04T16:37:00+00:00"
@freeze_time("2021-11-04 17:37:00", tz_offset=-1)
async def test_quirks_write_attr_button(
hass: HomeAssistant, custom_button_device
) -> None:
"""Test ZHA button platform."""
zha_device, cluster = custom_button_device
assert cluster is not None
entity_id = find_entity_id(DOMAIN, zha_device, hass, qualifier="feed")
assert entity_id is not None
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_UNKNOWN
assert cluster.get(cluster.AttributeDefs.feed.name) == 0
with patch(
"zigpy.zcl.Cluster.request",
return_value=[0x00, zcl_f.Status.SUCCESS],
):
await hass.services.async_call(
DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
await hass.async_block_till_done()
assert cluster.write_attributes.mock_calls == [
call({cluster.AttributeDefs.feed.name: 2}, manufacturer=None)
]
state = hass.states.get(entity_id)
assert state
assert state.state == "2021-11-04T16:37:00+00:00"
assert cluster.get(cluster.AttributeDefs.feed.name) == 2

View File

@ -1,17 +1,17 @@
"""Test ZHA climate."""
from typing import Literal
from unittest.mock import call, patch
from unittest.mock import patch
import pytest
from zha.application.platforms.climate.const import HVAC_MODE_2_SYSTEM, SEQ_OF_OPERATION
import zhaquirks.sinope.thermostat
from zhaquirks.sinope.thermostat import SinopeTechnologiesThermostatCluster
import zhaquirks.tuya.ts0601_trv
import zigpy.profiles
from zigpy.profiles import zha
import zigpy.types
import zigpy.zcl.clusters
from zigpy.zcl.clusters.hvac import Thermostat
import zigpy.zcl.foundation as zcl_f
from homeassistant.components.climate import (
ATTR_CURRENT_TEMPERATURE,
@ -28,10 +28,6 @@ from homeassistant.components.climate import (
FAN_LOW,
FAN_ON,
PRESET_AWAY,
PRESET_BOOST,
PRESET_COMFORT,
PRESET_ECO,
PRESET_NONE,
SERVICE_SET_FAN_MODE,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_PRESET_MODE,
@ -39,13 +35,11 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.components.zha.climate import HVAC_MODE_2_SYSTEM, SEQ_OF_OPERATION
from homeassistant.components.zha.core.const import (
PRESET_COMPLEX,
PRESET_SCHEDULE,
PRESET_TEMP_MANUAL,
from homeassistant.components.zha.helpers import (
ZHAGatewayProxy,
get_zha_gateway,
get_zha_gateway_proxy,
)
from homeassistant.components.zha.core.device import ZHADevice
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_TEMPERATURE,
@ -53,15 +47,15 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.exceptions import ServiceValidationError
from .common import async_enable_traffic, find_entity_id, send_attributes_report
from .common import find_entity_id, send_attributes_report
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
CLIMATE = {
1: {
SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID,
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.THERMOSTAT,
SIG_EP_PROFILE: zha.PROFILE_ID,
SIG_EP_TYPE: zha.DeviceType.THERMOSTAT,
SIG_EP_INPUT: [
zigpy.zcl.clusters.general.Basic.cluster_id,
zigpy.zcl.clusters.general.Identify.cluster_id,
@ -74,8 +68,8 @@ CLIMATE = {
CLIMATE_FAN = {
1: {
SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID,
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.THERMOSTAT,
SIG_EP_PROFILE: zha.PROFILE_ID,
SIG_EP_TYPE: zha.DeviceType.THERMOSTAT,
SIG_EP_INPUT: [
zigpy.zcl.clusters.general.Basic.cluster_id,
zigpy.zcl.clusters.general.Identify.cluster_id,
@ -108,72 +102,7 @@ CLIMATE_SINOPE = {
},
}
CLIMATE_ZEN = {
1: {
SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID,
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.THERMOSTAT,
SIG_EP_INPUT: [
zigpy.zcl.clusters.general.Basic.cluster_id,
zigpy.zcl.clusters.general.Identify.cluster_id,
zigpy.zcl.clusters.hvac.Fan.cluster_id,
zigpy.zcl.clusters.hvac.Thermostat.cluster_id,
zigpy.zcl.clusters.hvac.UserInterface.cluster_id,
],
SIG_EP_OUTPUT: [zigpy.zcl.clusters.general.Ota.cluster_id],
}
}
CLIMATE_MOES = {
1: {
SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID,
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.THERMOSTAT,
SIG_EP_INPUT: [
zigpy.zcl.clusters.general.Basic.cluster_id,
zigpy.zcl.clusters.general.Identify.cluster_id,
zigpy.zcl.clusters.hvac.Thermostat.cluster_id,
zigpy.zcl.clusters.hvac.UserInterface.cluster_id,
61148,
],
SIG_EP_OUTPUT: [zigpy.zcl.clusters.general.Ota.cluster_id],
}
}
CLIMATE_BECA = {
1: {
SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID,
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.SMART_PLUG,
SIG_EP_INPUT: [
zigpy.zcl.clusters.general.Basic.cluster_id,
zigpy.zcl.clusters.general.Groups.cluster_id,
zigpy.zcl.clusters.general.Scenes.cluster_id,
61148,
],
SIG_EP_OUTPUT: [
zigpy.zcl.clusters.general.Time.cluster_id,
zigpy.zcl.clusters.general.Ota.cluster_id,
],
}
}
CLIMATE_ZONNSMART = {
1: {
SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID,
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.THERMOSTAT,
SIG_EP_INPUT: [
zigpy.zcl.clusters.general.Basic.cluster_id,
zigpy.zcl.clusters.hvac.Thermostat.cluster_id,
zigpy.zcl.clusters.hvac.UserInterface.cluster_id,
61148,
],
SIG_EP_OUTPUT: [zigpy.zcl.clusters.general.Ota.cluster_id],
}
}
MANUF_SINOPE = "Sinope Technologies"
MANUF_ZEN = "Zen Within"
MANUF_MOES = "_TZE200_ckud7u2l"
MANUF_BECA = "_TZE200_b6wax7g0"
MANUF_ZONNSMART = "_TZE200_hue3yfsn"
ZCL_ATTR_PLUG = {
"abs_min_heat_setpoint_limit": 800,
@ -218,22 +147,22 @@ def climate_platform_only():
@pytest.fixture
def device_climate_mock(hass, zigpy_device_mock, zha_device_joined):
def device_climate_mock(hass: HomeAssistant, setup_zha, zigpy_device_mock):
"""Test regular thermostat device."""
async def _dev(clusters, plug=None, manuf=None, quirk=None):
if plug is None:
plugged_attrs = ZCL_ATTR_PLUG
else:
plugged_attrs = {**ZCL_ATTR_PLUG, **plug}
plugged_attrs = ZCL_ATTR_PLUG if plug is None else {**ZCL_ATTR_PLUG, **plug}
zigpy_device = zigpy_device_mock(clusters, manufacturer=manuf, quirk=quirk)
zigpy_device.node_desc.mac_capability_flags |= 0b_0000_0100
zigpy_device.endpoints[1].thermostat.PLUGGED_ATTR_READS = plugged_attrs
zha_device = await zha_device_joined(zigpy_device)
await async_enable_traffic(hass, [zha_device])
await hass.async_block_till_done()
return zha_device
await setup_zha()
gateway = get_zha_gateway(hass)
gateway_proxy: ZHAGatewayProxy = get_zha_gateway_proxy(hass)
gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
return gateway_proxy.get_device_proxy(zigpy_device.ieee)
return _dev
@ -268,44 +197,6 @@ async def device_climate_sinope(device_climate_mock):
)
@pytest.fixture
async def device_climate_zen(device_climate_mock):
"""Zen Within thermostat."""
return await device_climate_mock(CLIMATE_ZEN, manuf=MANUF_ZEN)
@pytest.fixture
async def device_climate_moes(device_climate_mock):
"""MOES thermostat."""
return await device_climate_mock(
CLIMATE_MOES, manuf=MANUF_MOES, quirk=zhaquirks.tuya.ts0601_trv.MoesHY368_Type1
)
@pytest.fixture
async def device_climate_beca(device_climate_mock) -> ZHADevice:
"""Beca thermostat."""
return await device_climate_mock(
CLIMATE_BECA,
manuf=MANUF_BECA,
quirk=zhaquirks.tuya.ts0601_trv.MoesHY368_Type1new,
)
@pytest.fixture
async def device_climate_zonnsmart(device_climate_mock):
"""ZONNSMART thermostat."""
return await device_climate_mock(
CLIMATE_ZONNSMART,
manuf=MANUF_ZONNSMART,
quirk=zhaquirks.tuya.ts0601_trv.ZonnsmartTV01_ZG,
)
def test_sequence_mappings() -> None:
"""Test correct mapping between control sequence -> HVAC Mode -> Sysmode."""
@ -318,7 +209,7 @@ def test_sequence_mappings() -> None:
async def test_climate_local_temperature(hass: HomeAssistant, device_climate) -> None:
"""Test local temperature."""
thrm_cluster = device_climate.device.endpoints[1].thermostat
thrm_cluster = device_climate.device.device.endpoints[1].thermostat
entity_id = find_entity_id(Platform.CLIMATE, device_climate, hass)
state = hass.states.get(entity_id)
@ -334,7 +225,7 @@ async def test_climate_hvac_action_running_state(
) -> None:
"""Test hvac action via running state."""
thrm_cluster = device_climate_sinope.device.endpoints[1].thermostat
thrm_cluster = device_climate_sinope.device.device.endpoints[1].thermostat
entity_id = find_entity_id(Platform.CLIMATE, device_climate_sinope, hass)
sensor_entity_id = find_entity_id(
Platform.SENSOR, device_climate_sinope, hass, "hvac"
@ -394,101 +285,12 @@ async def test_climate_hvac_action_running_state(
assert hvac_sensor_state.state == HVACAction.FAN
async def test_climate_hvac_action_running_state_zen(
hass: HomeAssistant, device_climate_zen
) -> None:
"""Test Zen hvac action via running state."""
thrm_cluster = device_climate_zen.device.endpoints[1].thermostat
entity_id = find_entity_id(Platform.CLIMATE, device_climate_zen, hass)
sensor_entity_id = find_entity_id(
Platform.SENSOR, device_climate_zen, hass, "hvac_action"
)
state = hass.states.get(entity_id)
assert ATTR_HVAC_ACTION not in state.attributes
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == "unknown"
await send_attributes_report(
hass, thrm_cluster, {0x0029: Thermostat.RunningState.Cool_2nd_Stage_On}
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == HVACAction.COOLING
await send_attributes_report(
hass, thrm_cluster, {0x0029: Thermostat.RunningState.Fan_State_On}
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.FAN
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == HVACAction.FAN
await send_attributes_report(
hass, thrm_cluster, {0x0029: Thermostat.RunningState.Heat_2nd_Stage_On}
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == HVACAction.HEATING
await send_attributes_report(
hass, thrm_cluster, {0x0029: Thermostat.RunningState.Fan_2nd_Stage_On}
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.FAN
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == HVACAction.FAN
await send_attributes_report(
hass, thrm_cluster, {0x0029: Thermostat.RunningState.Cool_State_On}
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == HVACAction.COOLING
await send_attributes_report(
hass, thrm_cluster, {0x0029: Thermostat.RunningState.Fan_3rd_Stage_On}
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.FAN
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == HVACAction.FAN
await send_attributes_report(
hass, thrm_cluster, {0x0029: Thermostat.RunningState.Heat_State_On}
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == HVACAction.HEATING
await send_attributes_report(
hass, thrm_cluster, {0x0029: Thermostat.RunningState.Idle}
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == HVACAction.OFF
await send_attributes_report(
hass, thrm_cluster, {0x001C: Thermostat.SystemMode.Heat}
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == HVACAction.IDLE
async def test_climate_hvac_action_pi_demand(
hass: HomeAssistant, device_climate
) -> None:
"""Test hvac action based on pi_heating/cooling_demand attrs."""
thrm_cluster = device_climate.device.endpoints[1].thermostat
thrm_cluster = device_climate.device.device.endpoints[1].thermostat
entity_id = find_entity_id(Platform.CLIMATE, device_climate, hass)
state = hass.states.get(entity_id)
@ -537,7 +339,7 @@ async def test_hvac_mode(
) -> None:
"""Test HVAC mode."""
thrm_cluster = device_climate.device.endpoints[1].thermostat
thrm_cluster = device_climate.device.device.endpoints[1].thermostat
entity_id = find_entity_id(Platform.CLIMATE, device_climate, hass)
state = hass.states.get(entity_id)
@ -714,7 +516,7 @@ async def test_set_hvac_mode(
) -> None:
"""Test setting hvac mode."""
thrm_cluster = device_climate.device.endpoints[1].thermostat
thrm_cluster = device_climate.device.device.endpoints[1].thermostat
entity_id = find_entity_id(Platform.CLIMATE, device_climate, hass)
state = hass.states.get(entity_id)
@ -753,134 +555,11 @@ async def test_set_hvac_mode(
}
async def test_preset_setting(hass: HomeAssistant, device_climate_sinope) -> None:
"""Test preset setting."""
entity_id = find_entity_id(Platform.CLIMATE, device_climate_sinope, hass)
thrm_cluster = device_climate_sinope.device.endpoints[1].thermostat
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE
# unsuccessful occupancy change
thrm_cluster.write_attributes.return_value = [
zcl_f.WriteAttributesResponse(
[
zcl_f.WriteAttributesStatusRecord(
status=zcl_f.Status.FAILURE,
attrid=SinopeTechnologiesThermostatCluster.AttributeDefs.set_occupancy.id,
)
]
)
]
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY},
blocking=True,
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE
assert thrm_cluster.write_attributes.call_count == 1
assert thrm_cluster.write_attributes.call_args[0][0] == {"set_occupancy": 0}
# successful occupancy change
thrm_cluster.write_attributes.reset_mock()
thrm_cluster.write_attributes.return_value = [
zcl_f.WriteAttributesResponse(
[zcl_f.WriteAttributesStatusRecord(status=zcl_f.Status.SUCCESS)]
)
]
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY},
blocking=True,
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == PRESET_AWAY
assert thrm_cluster.write_attributes.call_count == 1
assert thrm_cluster.write_attributes.call_args[0][0] == {"set_occupancy": 0}
# unsuccessful occupancy change
thrm_cluster.write_attributes.reset_mock()
thrm_cluster.write_attributes.return_value = [
zcl_f.WriteAttributesResponse(
[
zcl_f.WriteAttributesStatusRecord(
status=zcl_f.Status.FAILURE,
attrid=SinopeTechnologiesThermostatCluster.AttributeDefs.set_occupancy.id,
)
]
)
]
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE},
blocking=True,
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == PRESET_AWAY
assert thrm_cluster.write_attributes.call_count == 1
assert thrm_cluster.write_attributes.call_args[0][0] == {"set_occupancy": 1}
# successful occupancy change
thrm_cluster.write_attributes.reset_mock()
thrm_cluster.write_attributes.return_value = [
zcl_f.WriteAttributesResponse(
[zcl_f.WriteAttributesStatusRecord(status=zcl_f.Status.SUCCESS)]
)
]
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE},
blocking=True,
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE
assert thrm_cluster.write_attributes.call_count == 1
assert thrm_cluster.write_attributes.call_args[0][0] == {"set_occupancy": 1}
async def test_preset_setting_invalid(
hass: HomeAssistant, device_climate_sinope
) -> None:
"""Test invalid preset setting."""
entity_id = find_entity_id(Platform.CLIMATE, device_climate_sinope, hass)
thrm_cluster = device_climate_sinope.device.endpoints[1].thermostat
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE
with pytest.raises(ServiceValidationError):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "invalid_preset"},
blocking=True,
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE
assert thrm_cluster.write_attributes.call_count == 0
async def test_set_temperature_hvac_mode(hass: HomeAssistant, device_climate) -> None:
"""Test setting HVAC mode in temperature service call."""
entity_id = find_entity_id(Platform.CLIMATE, device_climate, hass)
thrm_cluster = device_climate.device.endpoints[1].thermostat
thrm_cluster = device_climate.device.device.endpoints[1].thermostat
state = hass.states.get(entity_id)
assert state.state == HVACMode.OFF
@ -922,7 +601,7 @@ async def test_set_temperature_heat_cool(
quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat,
)
entity_id = find_entity_id(Platform.CLIMATE, device_climate, hass)
thrm_cluster = device_climate.device.endpoints[1].thermostat
thrm_cluster = device_climate.device.device.endpoints[1].thermostat
state = hass.states.get(entity_id)
assert state.state == HVACMode.HEAT_COOL
@ -1008,7 +687,7 @@ async def test_set_temperature_heat(hass: HomeAssistant, device_climate_mock) ->
quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat,
)
entity_id = find_entity_id(Platform.CLIMATE, device_climate, hass)
thrm_cluster = device_climate.device.endpoints[1].thermostat
thrm_cluster = device_climate.device.device.endpoints[1].thermostat
state = hass.states.get(entity_id)
assert state.state == HVACMode.HEAT
@ -1087,7 +766,7 @@ async def test_set_temperature_cool(hass: HomeAssistant, device_climate_mock) ->
quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat,
)
entity_id = find_entity_id(Platform.CLIMATE, device_climate, hass)
thrm_cluster = device_climate.device.endpoints[1].thermostat
thrm_cluster = device_climate.device.device.endpoints[1].thermostat
state = hass.states.get(entity_id)
assert state.state == HVACMode.COOL
@ -1172,7 +851,7 @@ async def test_set_temperature_wrong_mode(
manuf=MANUF_SINOPE,
)
entity_id = find_entity_id(Platform.CLIMATE, device_climate, hass)
thrm_cluster = device_climate.device.endpoints[1].thermostat
thrm_cluster = device_climate.device.device.endpoints[1].thermostat
state = hass.states.get(entity_id)
assert state.state == HVACMode.DRY
@ -1191,38 +870,11 @@ async def test_set_temperature_wrong_mode(
assert thrm_cluster.write_attributes.await_count == 0
async def test_occupancy_reset(hass: HomeAssistant, device_climate_sinope) -> None:
"""Test away preset reset."""
entity_id = find_entity_id(Platform.CLIMATE, device_climate_sinope, hass)
thrm_cluster = device_climate_sinope.device.endpoints[1].thermostat
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY},
blocking=True,
)
thrm_cluster.write_attributes.reset_mock()
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == PRESET_AWAY
await send_attributes_report(
hass, thrm_cluster, {"occupied_heating_setpoint": zigpy.types.uint16_t(1950)}
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE
async def test_fan_mode(hass: HomeAssistant, device_climate_fan) -> None:
"""Test fan mode."""
entity_id = find_entity_id(Platform.CLIMATE, device_climate_fan, hass)
thrm_cluster = device_climate_fan.device.endpoints[1].thermostat
thrm_cluster = device_climate_fan.device.device.endpoints[1].thermostat
state = hass.states.get(entity_id)
assert set(state.attributes[ATTR_FAN_MODES]) == {FAN_AUTO, FAN_ON}
@ -1253,7 +905,7 @@ async def test_set_fan_mode_not_supported(
"""Test fan setting unsupported mode."""
entity_id = find_entity_id(Platform.CLIMATE, device_climate_fan, hass)
fan_cluster = device_climate_fan.device.endpoints[1].fan
fan_cluster = device_climate_fan.device.device.endpoints[1].fan
with pytest.raises(ServiceValidationError):
await hass.services.async_call(
@ -1269,7 +921,7 @@ async def test_set_fan_mode(hass: HomeAssistant, device_climate_fan) -> None:
"""Test fan mode setting."""
entity_id = find_entity_id(Platform.CLIMATE, device_climate_fan, hass)
fan_cluster = device_climate_fan.device.endpoints[1].fan
fan_cluster = device_climate_fan.device.device.endpoints[1].fan
state = hass.states.get(entity_id)
assert state.attributes[ATTR_FAN_MODE] == FAN_AUTO
@ -1292,309 +944,3 @@ async def test_set_fan_mode(hass: HomeAssistant, device_climate_fan) -> None:
)
assert fan_cluster.write_attributes.await_count == 1
assert fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 5}
async def test_set_moes_preset(hass: HomeAssistant, device_climate_moes) -> None:
"""Test setting preset for moes trv."""
entity_id = find_entity_id(Platform.CLIMATE, device_climate_moes, hass)
thrm_cluster = device_climate_moes.device.endpoints[1].thermostat
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY},
blocking=True,
)
assert thrm_cluster.write_attributes.await_count == 1
assert thrm_cluster.write_attributes.call_args_list[0][0][0] == {
"operation_preset": 0
}
thrm_cluster.write_attributes.reset_mock()
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_SCHEDULE},
blocking=True,
)
assert thrm_cluster.write_attributes.await_count == 2
assert thrm_cluster.write_attributes.call_args_list[0][0][0] == {
"operation_preset": 2
}
assert thrm_cluster.write_attributes.call_args_list[1][0][0] == {
"operation_preset": 1
}
thrm_cluster.write_attributes.reset_mock()
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_COMFORT},
blocking=True,
)
assert thrm_cluster.write_attributes.await_count == 2
assert thrm_cluster.write_attributes.call_args_list[0][0][0] == {
"operation_preset": 2
}
assert thrm_cluster.write_attributes.call_args_list[1][0][0] == {
"operation_preset": 3
}
thrm_cluster.write_attributes.reset_mock()
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_ECO},
blocking=True,
)
assert thrm_cluster.write_attributes.await_count == 2
assert thrm_cluster.write_attributes.call_args_list[0][0][0] == {
"operation_preset": 2
}
assert thrm_cluster.write_attributes.call_args_list[1][0][0] == {
"operation_preset": 4
}
thrm_cluster.write_attributes.reset_mock()
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_BOOST},
blocking=True,
)
assert thrm_cluster.write_attributes.await_count == 2
assert thrm_cluster.write_attributes.call_args_list[0][0][0] == {
"operation_preset": 2
}
assert thrm_cluster.write_attributes.call_args_list[1][0][0] == {
"operation_preset": 5
}
thrm_cluster.write_attributes.reset_mock()
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_COMPLEX},
blocking=True,
)
assert thrm_cluster.write_attributes.await_count == 2
assert thrm_cluster.write_attributes.call_args_list[0][0][0] == {
"operation_preset": 2
}
assert thrm_cluster.write_attributes.call_args_list[1][0][0] == {
"operation_preset": 6
}
thrm_cluster.write_attributes.reset_mock()
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE},
blocking=True,
)
assert thrm_cluster.write_attributes.await_count == 1
assert thrm_cluster.write_attributes.call_args_list[0][0][0] == {
"operation_preset": 2
}
async def test_set_moes_operation_mode(
hass: HomeAssistant, device_climate_moes
) -> None:
"""Test setting preset for moes trv."""
entity_id = find_entity_id(Platform.CLIMATE, device_climate_moes, hass)
thrm_cluster = device_climate_moes.device.endpoints[1].thermostat
await send_attributes_report(hass, thrm_cluster, {"operation_preset": 0})
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == PRESET_AWAY
await send_attributes_report(hass, thrm_cluster, {"operation_preset": 1})
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == PRESET_SCHEDULE
await send_attributes_report(hass, thrm_cluster, {"operation_preset": 2})
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE
await send_attributes_report(hass, thrm_cluster, {"operation_preset": 3})
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == PRESET_COMFORT
await send_attributes_report(hass, thrm_cluster, {"operation_preset": 4})
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == PRESET_ECO
await send_attributes_report(hass, thrm_cluster, {"operation_preset": 5})
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == PRESET_BOOST
await send_attributes_report(hass, thrm_cluster, {"operation_preset": 6})
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == PRESET_COMPLEX
@pytest.mark.parametrize(
("preset_attr", "preset_mode"),
[
(0, PRESET_AWAY),
(1, PRESET_SCHEDULE),
# pylint: disable-next=fixme
# (2, PRESET_NONE), # TODO: why does this not work?
(4, PRESET_ECO),
(5, PRESET_BOOST),
(7, PRESET_TEMP_MANUAL),
],
)
async def test_beca_operation_mode_update(
hass: HomeAssistant,
device_climate_beca: ZHADevice,
preset_attr: int,
preset_mode: str,
) -> None:
"""Test beca trv operation mode attribute update."""
entity_id = find_entity_id(Platform.CLIMATE, device_climate_beca, hass)
thrm_cluster = device_climate_beca.device.endpoints[1].thermostat
# Test sending an attribute report
await send_attributes_report(hass, thrm_cluster, {"operation_preset": preset_attr})
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == preset_mode
# Test setting the preset
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: preset_mode},
blocking=True,
)
assert thrm_cluster.write_attributes.mock_calls == [
call(
{"operation_preset": preset_attr},
manufacturer=device_climate_beca.manufacturer_code,
)
]
async def test_set_zonnsmart_preset(
hass: HomeAssistant, device_climate_zonnsmart
) -> None:
"""Test setting preset from homeassistant for zonnsmart trv."""
entity_id = find_entity_id(Platform.CLIMATE, device_climate_zonnsmart, hass)
thrm_cluster = device_climate_zonnsmart.device.endpoints[1].thermostat
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_SCHEDULE},
blocking=True,
)
assert thrm_cluster.write_attributes.await_count == 1
assert thrm_cluster.write_attributes.call_args_list[0][0][0] == {
"operation_preset": 0
}
thrm_cluster.write_attributes.reset_mock()
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "holiday"},
blocking=True,
)
assert thrm_cluster.write_attributes.await_count == 2
assert thrm_cluster.write_attributes.call_args_list[0][0][0] == {
"operation_preset": 1
}
assert thrm_cluster.write_attributes.call_args_list[1][0][0] == {
"operation_preset": 3
}
thrm_cluster.write_attributes.reset_mock()
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "frost protect"},
blocking=True,
)
assert thrm_cluster.write_attributes.await_count == 2
assert thrm_cluster.write_attributes.call_args_list[0][0][0] == {
"operation_preset": 1
}
assert thrm_cluster.write_attributes.call_args_list[1][0][0] == {
"operation_preset": 4
}
thrm_cluster.write_attributes.reset_mock()
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE},
blocking=True,
)
assert thrm_cluster.write_attributes.await_count == 1
assert thrm_cluster.write_attributes.call_args_list[0][0][0] == {
"operation_preset": 1
}
async def test_set_zonnsmart_operation_mode(
hass: HomeAssistant, device_climate_zonnsmart
) -> None:
"""Test setting preset from trv for zonnsmart trv."""
entity_id = find_entity_id(Platform.CLIMATE, device_climate_zonnsmart, hass)
thrm_cluster = device_climate_zonnsmart.device.endpoints[1].thermostat
await send_attributes_report(hass, thrm_cluster, {"operation_preset": 0})
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == PRESET_SCHEDULE
await send_attributes_report(hass, thrm_cluster, {"operation_preset": 1})
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE
await send_attributes_report(hass, thrm_cluster, {"operation_preset": 2})
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == "holiday"
await send_attributes_report(hass, thrm_cluster, {"operation_preset": 3})
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == "holiday"
await send_attributes_report(hass, thrm_cluster, {"operation_preset": 4})
state = hass.states.get(entity_id)
assert state.attributes[ATTR_PRESET_MODE] == "frost protect"

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@ import uuid
import pytest
import serial.tools.list_ports
from zha.application.const import RadioType
from zigpy.backups import BackupManager
import zigpy.config
from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH, SCHEMA_DEVICE
@ -21,13 +22,12 @@ from homeassistant.components import ssdp, usb, zeroconf
from homeassistant.components.hassio import AddonState
from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL
from homeassistant.components.zha import config_flow, radio_manager
from homeassistant.components.zha.core.const import (
from homeassistant.components.zha.const import (
CONF_BAUDRATE,
CONF_FLOW_CONTROL,
CONF_RADIO_TYPE,
DOMAIN,
EZSP_OVERWRITE_EUI64,
RadioType,
)
from homeassistant.components.zha.radio_manager import ProbeResult
from homeassistant.config_entries import (

View File

@ -1,12 +1,10 @@
"""Test ZHA cover."""
import asyncio
from unittest.mock import patch
import pytest
import zigpy.profiles.zha
import zigpy.types
from zigpy.zcl.clusters import closures, general
from zigpy.profiles import zha
from zigpy.zcl.clusters import closures
import zigpy.zcl.foundation as zcl_f
from homeassistant.components.cover import (
@ -22,34 +20,27 @@ from homeassistant.components.cover import (
SERVICE_SET_COVER_TILT_POSITION,
SERVICE_STOP_COVER,
SERVICE_STOP_COVER_TILT,
SERVICE_TOGGLE_COVER_TILT,
)
from homeassistant.components.zha.core.const import ZHA_EVENT
from homeassistant.components.zha.helpers import (
ZHADeviceProxy,
ZHAGatewayProxy,
get_zha_gateway,
get_zha_gateway_proxy,
)
from homeassistant.const import (
ATTR_COMMAND,
STATE_CLOSED,
STATE_CLOSING,
STATE_OPEN,
STATE_OPENING,
STATE_UNAVAILABLE,
Platform,
)
from homeassistant.core import CoreState, HomeAssistant, State
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_component import async_update_entity
from .common import (
async_enable_traffic,
async_test_rejoin,
find_entity_id,
make_zcl_header,
send_attributes_report,
update_attribute_cache,
)
from .common import find_entity_id, send_attributes_report, update_attribute_cache
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
from tests.common import async_capture_events, mock_restore_cache
Default_Response = zcl_f.GENERAL_COMMANDS[zcl_f.GeneralCommand.Default_Response].schema
@ -68,135 +59,31 @@ def cover_platform_only():
yield
@pytest.fixture
def zigpy_cover_device(zigpy_device_mock):
"""Zigpy cover device."""
endpoints = {
1: {
SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID,
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.WINDOW_COVERING_DEVICE,
SIG_EP_INPUT: [closures.WindowCovering.cluster_id],
SIG_EP_OUTPUT: [],
}
}
return zigpy_device_mock(endpoints)
@pytest.fixture
def zigpy_cover_remote(zigpy_device_mock):
"""Zigpy cover remote device."""
endpoints = {
1: {
SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID,
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.WINDOW_COVERING_CONTROLLER,
SIG_EP_INPUT: [],
SIG_EP_OUTPUT: [closures.WindowCovering.cluster_id],
}
}
return zigpy_device_mock(endpoints)
@pytest.fixture
def zigpy_shade_device(zigpy_device_mock):
"""Zigpy shade device."""
endpoints = {
1: {
SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID,
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.SHADE,
SIG_EP_INPUT: [
closures.Shade.cluster_id,
general.LevelControl.cluster_id,
general.OnOff.cluster_id,
],
SIG_EP_OUTPUT: [],
}
}
return zigpy_device_mock(endpoints)
@pytest.fixture
def zigpy_keen_vent(zigpy_device_mock):
"""Zigpy Keen Vent device."""
endpoints = {
1: {
SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID,
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.LEVEL_CONTROLLABLE_OUTPUT,
SIG_EP_INPUT: [general.LevelControl.cluster_id, general.OnOff.cluster_id],
SIG_EP_OUTPUT: [],
}
}
return zigpy_device_mock(
endpoints, manufacturer="Keen Home Inc", model="SV02-612-MP-1.3"
)
WCAttrs = closures.WindowCovering.AttributeDefs
WCCmds = closures.WindowCovering.ServerCommandDefs
WCT = closures.WindowCovering.WindowCoveringType
WCCS = closures.WindowCovering.ConfigStatus
async def test_cover_non_tilt_initial_state(
hass: HomeAssistant, zha_device_joined_restored, zigpy_cover_device
) -> None:
async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None:
"""Test ZHA cover platform."""
await setup_zha()
gateway = get_zha_gateway(hass)
gateway_proxy: ZHAGatewayProxy = get_zha_gateway_proxy(hass)
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_PROFILE: zha.PROFILE_ID,
SIG_EP_TYPE: zha.DeviceType.WINDOW_COVERING_DEVICE,
SIG_EP_INPUT: [closures.WindowCovering.cluster_id],
SIG_EP_OUTPUT: [],
}
},
)
# load up cover domain
cluster = zigpy_cover_device.endpoints[1].window_covering
cluster.PLUGGED_ATTR_READS = {
WCAttrs.current_position_lift_percentage.name: 0,
WCAttrs.window_covering_type.name: WCT.Drapery,
WCAttrs.config_status.name: WCCS(~WCCS.Open_up_commands_reversed),
}
update_attribute_cache(cluster)
zha_device = await zha_device_joined_restored(zigpy_cover_device)
assert (
not zha_device.endpoints[1]
.all_cluster_handlers[f"1:0x{cluster.cluster_id:04x}"]
.inverted
)
assert cluster.read_attributes.call_count == 3
assert (
WCAttrs.current_position_lift_percentage.name
in cluster.read_attributes.call_args[0][0]
)
assert (
WCAttrs.current_position_tilt_percentage.name
in cluster.read_attributes.call_args[0][0]
)
entity_id = find_entity_id(Platform.COVER, zha_device, hass)
assert entity_id is not None
await async_enable_traffic(hass, [zha_device], enabled=False)
# test that the cover was created and that it is unavailable
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [zha_device])
await hass.async_block_till_done()
# test update
prev_call_count = cluster.read_attributes.call_count
await async_update_entity(hass, entity_id)
assert cluster.read_attributes.call_count == prev_call_count + 1
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_OPEN
assert state.attributes[ATTR_CURRENT_POSITION] == 100
async def test_cover(
hass: HomeAssistant, zha_device_joined_restored, zigpy_cover_device
) -> None:
"""Test ZHA cover platform."""
# load up cover domain
cluster = zigpy_cover_device.endpoints[1].window_covering
cluster = zigpy_device.endpoints[1].window_covering
cluster.PLUGGED_ATTR_READS = {
WCAttrs.current_position_lift_percentage.name: 0,
WCAttrs.current_position_tilt_percentage.name: 42,
@ -204,9 +91,17 @@ async def test_cover(
WCAttrs.config_status.name: WCCS(~WCCS.Open_up_commands_reversed),
}
update_attribute_cache(cluster)
zha_device = await zha_device_joined_restored(zigpy_cover_device)
gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
zha_device_proxy: ZHADeviceProxy = gateway_proxy.get_device_proxy(zigpy_device.ieee)
entity_id = find_entity_id(Platform.COVER, zha_device_proxy, hass)
assert entity_id is not None
assert (
not zha_device.endpoints[1]
not zha_device_proxy.device.endpoints[1]
.all_cluster_handlers[f"1:0x{cluster.cluster_id:04x}"]
.inverted
)
@ -220,21 +115,7 @@ async def test_cover(
in cluster.read_attributes.call_args[0][0]
)
entity_id = find_entity_id(Platform.COVER, zha_device, hass)
assert entity_id is not None
await async_enable_traffic(hass, [zha_device], enabled=False)
# test that the cover was created and that it is unavailable
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [zha_device])
await hass.async_block_till_done()
# test update
prev_call_count = cluster.read_attributes.call_count
await async_update_entity(hass, entity_id)
assert cluster.read_attributes.call_count == prev_call_count + 1
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_OPEN
@ -440,61 +321,41 @@ async def test_cover(
assert cluster.request.call_args[0][2].command.name == WCCmds.stop.name
assert cluster.request.call_args[1]["expect_reply"] is True
# test rejoin
cluster.PLUGGED_ATTR_READS = {WCAttrs.current_position_lift_percentage.name: 0}
await async_test_rejoin(hass, zigpy_cover_device, [cluster], (1,))
assert hass.states.get(entity_id).state == STATE_OPEN
# test toggle
with patch("zigpy.zcl.Cluster.request", return_value=[0x2, zcl_f.Status.SUCCESS]):
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_TOGGLE_COVER_TILT,
{"entity_id": entity_id},
blocking=True,
)
assert cluster.request.call_count == 1
assert cluster.request.call_args[0][0] is False
assert cluster.request.call_args[0][1] == 0x08
assert (
cluster.request.call_args[0][2].command.name
== WCCmds.go_to_tilt_percentage.name
)
assert cluster.request.call_args[0][3] == 100
assert cluster.request.call_args[1]["expect_reply"] is True
async def test_cover_failures(
hass: HomeAssistant, zha_device_joined_restored, zigpy_cover_device
hass: HomeAssistant, setup_zha, zigpy_device_mock
) -> None:
"""Test ZHA cover platform failure cases."""
await setup_zha()
gateway = get_zha_gateway(hass)
gateway_proxy: ZHAGatewayProxy = get_zha_gateway_proxy(hass)
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_PROFILE: zha.PROFILE_ID,
SIG_EP_TYPE: zha.DeviceType.WINDOW_COVERING_DEVICE,
SIG_EP_INPUT: [closures.WindowCovering.cluster_id],
SIG_EP_OUTPUT: [],
}
},
)
# load up cover domain
cluster = zigpy_cover_device.endpoints[1].window_covering
cluster = zigpy_device.endpoints[1].window_covering
cluster.PLUGGED_ATTR_READS = {
WCAttrs.current_position_tilt_percentage.name: 42,
WCAttrs.window_covering_type.name: WCT.Tilt_blind_tilt_and_lift,
}
update_attribute_cache(cluster)
zha_device = await zha_device_joined_restored(zigpy_cover_device)
entity_id = find_entity_id(Platform.COVER, zha_device, hass)
gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
zha_device_proxy: ZHADeviceProxy = gateway_proxy.get_device_proxy(zigpy_device.ieee)
entity_id = find_entity_id(Platform.COVER, zha_device_proxy, hass)
assert entity_id is not None
await async_enable_traffic(hass, [zha_device], enabled=False)
# test that the cover was created and that it is unavailable
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# test update returned None
prev_call_count = cluster.read_attributes.call_count
await async_update_entity(hass, entity_id)
assert cluster.read_attributes.call_count == prev_call_count + 1
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [zha_device])
await hass.async_block_till_done()
# test that the state has changed from unavailable to closed
await send_attributes_report(hass, cluster, {0: 0, 8: 100, 1: 1})
assert hass.states.get(entity_id).state == STATE_CLOSED
@ -670,319 +531,3 @@ async def test_cover_failures(
cluster.request.call_args[0][1]
== closures.WindowCovering.ServerCommandDefs.stop.id
)
async def test_shade(
hass: HomeAssistant, zha_device_joined_restored, zigpy_shade_device
) -> None:
"""Test ZHA cover platform for shade device type."""
# load up cover domain
zha_device = await zha_device_joined_restored(zigpy_shade_device)
cluster_on_off = zigpy_shade_device.endpoints[1].on_off
cluster_level = zigpy_shade_device.endpoints[1].level
entity_id = find_entity_id(Platform.COVER, zha_device, hass)
assert entity_id is not None
await async_enable_traffic(hass, [zha_device], enabled=False)
# test that the cover was created and that it is unavailable
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [zha_device])
await hass.async_block_till_done()
# test that the state has changed from unavailable to off
await send_attributes_report(hass, cluster_on_off, {8: 0, 0: False, 1: 1})
assert hass.states.get(entity_id).state == STATE_CLOSED
# test to see if it opens
await send_attributes_report(hass, cluster_on_off, {8: 0, 0: True, 1: 1})
assert hass.states.get(entity_id).state == STATE_OPEN
# close from UI command fails
with patch(
"zigpy.zcl.Cluster.request",
return_value=Default_Response(
command_id=closures.WindowCovering.ServerCommandDefs.down_close.id,
status=zcl_f.Status.UNSUP_CLUSTER_COMMAND,
),
):
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_CLOSE_COVER,
{"entity_id": entity_id},
blocking=True,
)
assert cluster_on_off.request.call_count == 1
assert cluster_on_off.request.call_args[0][0] is False
assert cluster_on_off.request.call_args[0][1] == 0x0000
assert hass.states.get(entity_id).state == STATE_OPEN
with patch("zigpy.zcl.Cluster.request", return_value=[0x1, zcl_f.Status.SUCCESS]):
await hass.services.async_call(
COVER_DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": entity_id}, blocking=True
)
assert cluster_on_off.request.call_count == 1
assert cluster_on_off.request.call_args[0][0] is False
assert cluster_on_off.request.call_args[0][1] == 0x0000
assert hass.states.get(entity_id).state == STATE_CLOSED
# open from UI command fails
assert ATTR_CURRENT_POSITION not in hass.states.get(entity_id).attributes
await send_attributes_report(hass, cluster_level, {0: 0})
with patch(
"zigpy.zcl.Cluster.request",
return_value=Default_Response(
command_id=closures.WindowCovering.ServerCommandDefs.up_open.id,
status=zcl_f.Status.UNSUP_CLUSTER_COMMAND,
),
):
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_OPEN_COVER,
{"entity_id": entity_id},
blocking=True,
)
assert cluster_on_off.request.call_count == 1
assert cluster_on_off.request.call_args[0][0] is False
assert cluster_on_off.request.call_args[0][1] == 0x0001
assert hass.states.get(entity_id).state == STATE_CLOSED
# stop from UI command fails
with patch(
"zigpy.zcl.Cluster.request",
return_value=Default_Response(
command_id=general.LevelControl.ServerCommandDefs.stop.id,
status=zcl_f.Status.UNSUP_CLUSTER_COMMAND,
),
):
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_STOP_COVER,
{"entity_id": entity_id},
blocking=True,
)
assert cluster_level.request.call_count == 1
assert cluster_level.request.call_args[0][0] is False
assert (
cluster_level.request.call_args[0][1]
== general.LevelControl.ServerCommandDefs.stop.id
)
assert hass.states.get(entity_id).state == STATE_CLOSED
# open from UI succeeds
with patch("zigpy.zcl.Cluster.request", return_value=[0x0, zcl_f.Status.SUCCESS]):
await hass.services.async_call(
COVER_DOMAIN, SERVICE_OPEN_COVER, {"entity_id": entity_id}, blocking=True
)
assert cluster_on_off.request.call_count == 1
assert cluster_on_off.request.call_args[0][0] is False
assert cluster_on_off.request.call_args[0][1] == 0x0001
assert hass.states.get(entity_id).state == STATE_OPEN
# set position UI command fails
with patch(
"zigpy.zcl.Cluster.request",
return_value=Default_Response(
command_id=closures.WindowCovering.ServerCommandDefs.go_to_lift_percentage.id,
status=zcl_f.Status.UNSUP_CLUSTER_COMMAND,
),
):
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_SET_COVER_POSITION,
{"entity_id": entity_id, "position": 47},
blocking=True,
)
assert cluster_level.request.call_count == 1
assert cluster_level.request.call_args[0][0] is False
assert cluster_level.request.call_args[0][1] == 0x0004
assert int(cluster_level.request.call_args[0][3] * 100 / 255) == 47
assert hass.states.get(entity_id).attributes[ATTR_CURRENT_POSITION] == 0
# set position UI success
with patch("zigpy.zcl.Cluster.request", return_value=[0x5, zcl_f.Status.SUCCESS]):
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_SET_COVER_POSITION,
{"entity_id": entity_id, "position": 47},
blocking=True,
)
assert cluster_level.request.call_count == 1
assert cluster_level.request.call_args[0][0] is False
assert cluster_level.request.call_args[0][1] == 0x0004
assert int(cluster_level.request.call_args[0][3] * 100 / 255) == 47
assert hass.states.get(entity_id).attributes[ATTR_CURRENT_POSITION] == 47
# report position change
await send_attributes_report(hass, cluster_level, {8: 0, 0: 100, 1: 1})
assert hass.states.get(entity_id).attributes[ATTR_CURRENT_POSITION] == int(
100 * 100 / 255
)
# test rejoin
await async_test_rejoin(
hass, zigpy_shade_device, [cluster_level, cluster_on_off], (1,)
)
assert hass.states.get(entity_id).state == STATE_OPEN
# test cover stop
with patch("zigpy.zcl.Cluster.request", side_effect=TimeoutError):
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_STOP_COVER,
{"entity_id": entity_id},
blocking=True,
)
assert cluster_level.request.call_count == 3
assert cluster_level.request.call_args[0][0] is False
assert cluster_level.request.call_args[0][1] in (0x0003, 0x0007)
async def test_shade_restore_state(
hass: HomeAssistant, zha_device_restored, zigpy_shade_device
) -> None:
"""Ensure states are restored on startup."""
mock_restore_cache(
hass,
(
State(
"cover.fakemanufacturer_fakemodel_shade",
STATE_OPEN,
{ATTR_CURRENT_POSITION: 50},
),
),
)
hass.set_state(CoreState.starting)
zha_device = await zha_device_restored(zigpy_shade_device)
entity_id = find_entity_id(Platform.COVER, zha_device, hass)
assert entity_id is not None
# test that the cover was created and that it is available
assert hass.states.get(entity_id).state == STATE_OPEN
assert hass.states.get(entity_id).attributes[ATTR_CURRENT_POSITION] == 50
async def test_cover_restore_state(
hass: HomeAssistant, zha_device_restored, zigpy_cover_device
) -> None:
"""Ensure states are restored on startup."""
cluster = zigpy_cover_device.endpoints[1].window_covering
cluster.PLUGGED_ATTR_READS = {
WCAttrs.current_position_lift_percentage.name: 50,
WCAttrs.current_position_tilt_percentage.name: 42,
WCAttrs.window_covering_type.name: WCT.Tilt_blind_tilt_and_lift,
}
update_attribute_cache(cluster)
hass.set_state(CoreState.starting)
zha_device = await zha_device_restored(zigpy_cover_device)
entity_id = find_entity_id(Platform.COVER, zha_device, hass)
assert entity_id is not None
# test that the cover was created and that it is available
assert hass.states.get(entity_id).state == STATE_OPEN
assert hass.states.get(entity_id).attributes[ATTR_CURRENT_POSITION] == 100 - 50
assert hass.states.get(entity_id).attributes[ATTR_CURRENT_TILT_POSITION] == 100 - 42
async def test_keen_vent(
hass: HomeAssistant, zha_device_joined_restored, zigpy_keen_vent
) -> None:
"""Test keen vent."""
# load up cover domain
zha_device = await zha_device_joined_restored(zigpy_keen_vent)
cluster_on_off = zigpy_keen_vent.endpoints[1].on_off
cluster_level = zigpy_keen_vent.endpoints[1].level
entity_id = find_entity_id(Platform.COVER, zha_device, hass)
assert entity_id is not None
await async_enable_traffic(hass, [zha_device], enabled=False)
# test that the cover was created and that it is unavailable
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [zha_device])
await hass.async_block_till_done()
# test that the state has changed from unavailable to off
await send_attributes_report(hass, cluster_on_off, {8: 0, 0: False, 1: 1})
assert hass.states.get(entity_id).state == STATE_CLOSED
# open from UI command fails
p1 = patch.object(cluster_on_off, "request", side_effect=TimeoutError)
p2 = patch.object(cluster_level, "request", return_value=[4, 0])
with p1, p2:
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_OPEN_COVER,
{"entity_id": entity_id},
blocking=True,
)
assert cluster_on_off.request.call_count == 3
assert cluster_on_off.request.call_args[0][0] is False
assert cluster_on_off.request.call_args[0][1] == 0x0001
assert cluster_level.request.call_count == 1
assert hass.states.get(entity_id).state == STATE_CLOSED
# open from UI command success
p1 = patch.object(cluster_on_off, "request", return_value=[1, 0])
p2 = patch.object(cluster_level, "request", return_value=[4, 0])
with p1, p2:
await hass.services.async_call(
COVER_DOMAIN, SERVICE_OPEN_COVER, {"entity_id": entity_id}, blocking=True
)
await asyncio.sleep(0)
assert cluster_on_off.request.call_count == 1
assert cluster_on_off.request.call_args[0][0] is False
assert cluster_on_off.request.call_args[0][1] == 0x0001
assert cluster_level.request.call_count == 1
assert hass.states.get(entity_id).state == STATE_OPEN
assert hass.states.get(entity_id).attributes[ATTR_CURRENT_POSITION] == 100
async def test_cover_remote(
hass: HomeAssistant, zha_device_joined_restored, zigpy_cover_remote
) -> None:
"""Test ZHA cover remote."""
# load up cover domain
await zha_device_joined_restored(zigpy_cover_remote)
cluster = zigpy_cover_remote.endpoints[1].out_clusters[
closures.WindowCovering.cluster_id
]
zha_events = async_capture_events(hass, ZHA_EVENT)
# up command
hdr = make_zcl_header(0, global_command=False)
cluster.handle_message(hdr, [])
await hass.async_block_till_done()
assert len(zha_events) == 1
assert zha_events[0].data[ATTR_COMMAND] == "up_open"
# down command
hdr = make_zcl_header(1, global_command=False)
cluster.handle_message(hdr, [])
await hass.async_block_till_done()
assert len(zha_events) == 2
assert zha_events[1].data[ATTR_COMMAND] == "down_close"

View File

@ -1,363 +0,0 @@
"""Test ZHA device switch."""
from datetime import timedelta
import logging
import time
from unittest import mock
from unittest.mock import patch
import pytest
import zigpy.profiles.zha
import zigpy.types
from zigpy.zcl.clusters import general
import zigpy.zdo.types as zdo_t
from homeassistant.components.zha.core.const import (
CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY,
CONF_DEFAULT_CONSIDER_UNAVAILABLE_MAINS,
)
from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
import homeassistant.helpers.device_registry as dr
import homeassistant.util.dt as dt_util
from .common import async_enable_traffic, make_zcl_header
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE
from tests.common import async_fire_time_changed
@pytest.fixture(autouse=True)
def required_platforms_only():
"""Only set up the required platform and required base platforms to speed up tests."""
with patch(
"homeassistant.components.zha.PLATFORMS",
(
Platform.DEVICE_TRACKER,
Platform.SENSOR,
Platform.SELECT,
Platform.SWITCH,
Platform.BINARY_SENSOR,
),
):
yield
@pytest.fixture
def zigpy_device(zigpy_device_mock):
"""Device tracker zigpy device."""
def _dev(with_basic_cluster_handler: bool = True, **kwargs):
in_clusters = [general.OnOff.cluster_id]
if with_basic_cluster_handler:
in_clusters.append(general.Basic.cluster_id)
endpoints = {
3: {
SIG_EP_INPUT: in_clusters,
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH,
}
}
return zigpy_device_mock(endpoints, **kwargs)
return _dev
@pytest.fixture
def zigpy_device_mains(zigpy_device_mock):
"""Device tracker zigpy device."""
def _dev(with_basic_cluster_handler: bool = True):
in_clusters = [general.OnOff.cluster_id]
if with_basic_cluster_handler:
in_clusters.append(general.Basic.cluster_id)
endpoints = {
3: {
SIG_EP_INPUT: in_clusters,
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH,
}
}
return zigpy_device_mock(
endpoints, node_descriptor=b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00"
)
return _dev
@pytest.fixture
def device_with_basic_cluster_handler(zigpy_device_mains):
"""Return a ZHA device with a basic cluster handler present."""
return zigpy_device_mains(with_basic_cluster_handler=True)
@pytest.fixture
def device_without_basic_cluster_handler(zigpy_device):
"""Return a ZHA device without a basic cluster handler present."""
return zigpy_device(with_basic_cluster_handler=False)
@pytest.fixture
async def ota_zha_device(zha_device_restored, zigpy_device_mock):
"""ZHA device with OTA cluster fixture."""
zigpy_dev = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [general.Basic.cluster_id],
SIG_EP_OUTPUT: [general.Ota.cluster_id],
SIG_EP_TYPE: 0x1234,
}
},
"00:11:22:33:44:55:66:77",
"test manufacturer",
"test model",
)
return await zha_device_restored(zigpy_dev)
def _send_time_changed(hass, seconds):
"""Send a time changed event."""
now = dt_util.utcnow() + timedelta(seconds=seconds)
async_fire_time_changed(hass, now)
@patch(
"homeassistant.components.zha.core.cluster_handlers.general.BasicClusterHandler.async_initialize",
new=mock.AsyncMock(),
)
async def test_check_available_success(
hass: HomeAssistant, device_with_basic_cluster_handler, zha_device_restored
) -> None:
"""Check device availability success on 1st try."""
zha_device = await zha_device_restored(device_with_basic_cluster_handler)
await async_enable_traffic(hass, [zha_device])
basic_ch = device_with_basic_cluster_handler.endpoints[3].basic
basic_ch.read_attributes.reset_mock()
device_with_basic_cluster_handler.last_seen = None
assert zha_device.available is True
_send_time_changed(hass, zha_device.consider_unavailable_time + 2)
await hass.async_block_till_done()
assert zha_device.available is False
assert basic_ch.read_attributes.await_count == 0
device_with_basic_cluster_handler.last_seen = (
time.time() - zha_device.consider_unavailable_time - 2
)
_seens = [time.time(), device_with_basic_cluster_handler.last_seen]
def _update_last_seen(*args, **kwargs):
device_with_basic_cluster_handler.last_seen = _seens.pop()
basic_ch.read_attributes.side_effect = _update_last_seen
# successfully ping zigpy device, but zha_device is not yet available
_send_time_changed(hass, 91)
await hass.async_block_till_done()
assert basic_ch.read_attributes.await_count == 1
assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"]
assert zha_device.available is False
# There was traffic from the device: pings, but not yet available
_send_time_changed(hass, 91)
await hass.async_block_till_done()
assert basic_ch.read_attributes.await_count == 2
assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"]
assert zha_device.available is False
# There was traffic from the device: don't try to ping, marked as available
_send_time_changed(hass, 91)
await hass.async_block_till_done()
assert basic_ch.read_attributes.await_count == 2
assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"]
assert zha_device.available is True
@patch(
"homeassistant.components.zha.core.cluster_handlers.general.BasicClusterHandler.async_initialize",
new=mock.AsyncMock(),
)
async def test_check_available_unsuccessful(
hass: HomeAssistant, device_with_basic_cluster_handler, zha_device_restored
) -> None:
"""Check device availability all tries fail."""
zha_device = await zha_device_restored(device_with_basic_cluster_handler)
await async_enable_traffic(hass, [zha_device])
basic_ch = device_with_basic_cluster_handler.endpoints[3].basic
assert zha_device.available is True
assert basic_ch.read_attributes.await_count == 0
device_with_basic_cluster_handler.last_seen = (
time.time() - zha_device.consider_unavailable_time - 2
)
# unsuccessfully ping zigpy device, but zha_device is still available
_send_time_changed(hass, 91)
await hass.async_block_till_done()
assert basic_ch.read_attributes.await_count == 1
assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"]
assert zha_device.available is True
# still no traffic, but zha_device is still available
_send_time_changed(hass, 91)
await hass.async_block_till_done()
assert basic_ch.read_attributes.await_count == 2
assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"]
assert zha_device.available is True
# not even trying to update, device is unavailable
_send_time_changed(hass, 91)
await hass.async_block_till_done()
assert basic_ch.read_attributes.await_count == 2
assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"]
assert zha_device.available is False
@patch(
"homeassistant.components.zha.core.cluster_handlers.general.BasicClusterHandler.async_initialize",
new=mock.AsyncMock(),
)
async def test_check_available_no_basic_cluster_handler(
hass: HomeAssistant,
device_without_basic_cluster_handler,
zha_device_restored,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Check device availability for a device without basic cluster."""
caplog.set_level(logging.DEBUG, logger="homeassistant.components.zha")
zha_device = await zha_device_restored(device_without_basic_cluster_handler)
await async_enable_traffic(hass, [zha_device])
assert zha_device.available is True
device_without_basic_cluster_handler.last_seen = (
time.time() - zha_device.consider_unavailable_time - 2
)
assert "does not have a mandatory basic cluster" not in caplog.text
_send_time_changed(hass, 91)
await hass.async_block_till_done()
assert zha_device.available is False
assert "does not have a mandatory basic cluster" in caplog.text
async def test_ota_sw_version(
hass: HomeAssistant, device_registry: dr.DeviceRegistry, ota_zha_device
) -> None:
"""Test device entry gets sw_version updated via OTA cluster handler."""
ota_ch = ota_zha_device._endpoints[1].client_cluster_handlers["1:0x0019"]
entry = device_registry.async_get(ota_zha_device.device_id)
assert entry.sw_version is None
cluster = ota_ch.cluster
hdr = make_zcl_header(1, global_command=False)
sw_version = 0x2345
cluster.handle_message(hdr, [1, 2, 3, sw_version, None])
await hass.async_block_till_done()
entry = device_registry.async_get(ota_zha_device.device_id)
assert int(entry.sw_version, base=16) == sw_version
@pytest.mark.parametrize(
("device", "last_seen_delta", "is_available"),
[
("zigpy_device", 0, True),
(
"zigpy_device",
CONF_DEFAULT_CONSIDER_UNAVAILABLE_MAINS + 2,
True,
),
(
"zigpy_device",
CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY - 2,
True,
),
(
"zigpy_device",
CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY + 2,
False,
),
("zigpy_device_mains", 0, True),
(
"zigpy_device_mains",
CONF_DEFAULT_CONSIDER_UNAVAILABLE_MAINS - 2,
True,
),
(
"zigpy_device_mains",
CONF_DEFAULT_CONSIDER_UNAVAILABLE_MAINS + 2,
False,
),
(
"zigpy_device_mains",
CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY - 2,
False,
),
(
"zigpy_device_mains",
CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY + 2,
False,
),
],
)
async def test_device_restore_availability(
hass: HomeAssistant,
request: pytest.FixtureRequest,
device,
last_seen_delta,
is_available,
zha_device_restored,
) -> None:
"""Test initial availability for restored devices."""
zigpy_device = request.getfixturevalue(device)()
zha_device = await zha_device_restored(
zigpy_device, last_seen=time.time() - last_seen_delta
)
entity_id = "switch.fakemanufacturer_fakemodel_switch"
await hass.async_block_till_done()
# ensure the switch entity was created
assert hass.states.get(entity_id).state is not None
assert zha_device.available is is_available
if is_available:
assert hass.states.get(entity_id).state == STATE_OFF
else:
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
async def test_device_is_active_coordinator(
hass: HomeAssistant, zha_device_joined, zigpy_device
) -> None:
"""Test that the current coordinator is uniquely detected."""
current_coord_dev = zigpy_device(ieee="aa:bb:cc:dd:ee:ff:00:11", nwk=0x0000)
current_coord_dev.node_desc = current_coord_dev.node_desc.replace(
logical_type=zdo_t.LogicalType.Coordinator
)
old_coord_dev = zigpy_device(ieee="aa:bb:cc:dd:ee:ff:00:12", nwk=0x0000)
old_coord_dev.node_desc = old_coord_dev.node_desc.replace(
logical_type=zdo_t.LogicalType.Coordinator
)
# The two coordinators have different IEEE addresses
assert current_coord_dev.ieee != old_coord_dev.ieee
current_coordinator = await zha_device_joined(current_coord_dev)
stale_coordinator = await zha_device_joined(old_coord_dev)
# Ensure the current ApplicationController's IEEE matches our coordinator's
current_coordinator.gateway.application_controller.state.node_info.ieee = (
current_coord_dev.ieee
)
assert current_coordinator.is_active_coordinator
assert not stale_coordinator.is_active_coordinator

View File

@ -1,23 +1,23 @@
"""The test for ZHA device automation actions."""
from unittest.mock import call, patch
from unittest.mock import patch
import pytest
from pytest_unordered import unordered
from zhaquirks.inovelli.VZM31SN import InovelliVZM31SNv11
import zigpy.profiles.zha
from zigpy.profiles import zha
from zigpy.zcl.clusters import general, security
import zigpy.zcl.foundation as zcl_f
from homeassistant.components import automation
from homeassistant.components.device_automation import DeviceAutomationType
from homeassistant.components.zha import DOMAIN
from homeassistant.components.zha.helpers import get_zha_gateway
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
from tests.common import async_get_device_automations, async_mock_service
@ -52,66 +52,37 @@ def required_platforms_only():
yield
@pytest.fixture
async def device_ias(hass, zigpy_device_mock, zha_device_joined_restored):
"""IAS device fixture."""
async def test_get_actions(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
setup_zha,
zigpy_device_mock,
) -> None:
"""Test we get the expected actions from a ZHA device."""
clusters = [general.Basic, security.IasZone, security.IasWd]
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [c.cluster_id for c in clusters],
SIG_EP_OUTPUT: [general.OnOff.cluster_id],
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH,
}
},
)
zha_device = await zha_device_joined_restored(zigpy_device)
zha_device.update_available(True)
await hass.async_block_till_done()
return zigpy_device, zha_device
@pytest.fixture
async def device_inovelli(hass, zigpy_device_mock, zha_device_joined):
"""Inovelli device fixture."""
await setup_zha()
gateway = get_zha_gateway(hass)
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [
general.Basic.cluster_id,
general.Identify.cluster_id,
general.OnOff.cluster_id,
general.LevelControl.cluster_id,
0xFC31,
security.IasZone.cluster_id,
security.IasWd.cluster_id,
],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.DIMMABLE_LIGHT,
SIG_EP_OUTPUT: [general.OnOff.cluster_id],
SIG_EP_TYPE: zha.DeviceType.IAS_WARNING_DEVICE,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
},
ieee="00:1d:8f:08:0c:90:69:6b",
manufacturer="Inovelli",
model="VZM31-SN",
quirk=InovelliVZM31SNv11,
}
)
zha_device = await zha_device_joined(zigpy_device)
zha_device.update_available(True)
await hass.async_block_till_done()
return zigpy_device, zha_device
async def test_get_actions(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
device_ias,
) -> None:
"""Test we get the expected actions from a ZHA device."""
ieee_address = str(device_ias[0].ieee)
gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
ieee_address = str(zigpy_device.ieee)
reg_device = device_registry.async_get_device(identifiers={(DOMAIN, ieee_address)})
siren_level_select = entity_registry.async_get(
@ -168,112 +139,40 @@ async def test_get_actions(
assert actions == unordered(expected_actions)
async def test_get_inovelli_actions(
async def test_action(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
device_inovelli,
) -> None:
"""Test we get the expected actions from a ZHA device."""
inovelli_ieee_address = str(device_inovelli[0].ieee)
inovelli_reg_device = device_registry.async_get_device(
identifiers={(DOMAIN, inovelli_ieee_address)}
)
inovelli_button = entity_registry.async_get("button.inovelli_vzm31_sn_identify")
inovelli_light = entity_registry.async_get("light.inovelli_vzm31_sn_light")
actions = await async_get_device_automations(
hass, DeviceAutomationType.ACTION, inovelli_reg_device.id
)
expected_actions = [
{
"device_id": inovelli_reg_device.id,
"domain": DOMAIN,
"metadata": {},
"type": "issue_all_led_effect",
},
{
"device_id": inovelli_reg_device.id,
"domain": DOMAIN,
"metadata": {},
"type": "issue_individual_led_effect",
},
{
"device_id": inovelli_reg_device.id,
"domain": Platform.BUTTON,
"entity_id": inovelli_button.id,
"metadata": {"secondary": True},
"type": "press",
},
{
"device_id": inovelli_reg_device.id,
"domain": Platform.LIGHT,
"entity_id": inovelli_light.id,
"metadata": {"secondary": False},
"type": "turn_off",
},
{
"device_id": inovelli_reg_device.id,
"domain": Platform.LIGHT,
"entity_id": inovelli_light.id,
"metadata": {"secondary": False},
"type": "turn_on",
},
{
"device_id": inovelli_reg_device.id,
"domain": Platform.LIGHT,
"entity_id": inovelli_light.id,
"metadata": {"secondary": False},
"type": "toggle",
},
{
"device_id": inovelli_reg_device.id,
"domain": Platform.LIGHT,
"entity_id": inovelli_light.id,
"metadata": {"secondary": False},
"type": "brightness_increase",
},
{
"device_id": inovelli_reg_device.id,
"domain": Platform.LIGHT,
"entity_id": inovelli_light.id,
"metadata": {"secondary": False},
"type": "brightness_decrease",
},
{
"device_id": inovelli_reg_device.id,
"domain": Platform.LIGHT,
"entity_id": inovelli_light.id,
"metadata": {"secondary": False},
"type": "flash",
},
]
assert actions == unordered(expected_actions)
async def test_action(
hass: HomeAssistant, device_registry: dr.DeviceRegistry, device_ias, device_inovelli
setup_zha,
zigpy_device_mock,
) -> None:
"""Test for executing a ZHA device action."""
zigpy_device, zha_device = device_ias
inovelli_zigpy_device, inovelli_zha_device = device_inovelli
await setup_zha()
gateway = get_zha_gateway(hass)
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [
general.Basic.cluster_id,
security.IasZone.cluster_id,
security.IasWd.cluster_id,
],
SIG_EP_OUTPUT: [general.OnOff.cluster_id],
SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
}
)
zigpy_device.device_automation_triggers = {
(SHORT_PRESS, SHORT_PRESS): {COMMAND: COMMAND_SINGLE}
}
ieee_address = str(zha_device.ieee)
inovelli_ieee_address = str(inovelli_zha_device.ieee)
gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
ieee_address = str(zigpy_device.ieee)
reg_device = device_registry.async_get_device(identifiers={(DOMAIN, ieee_address)})
inovelli_reg_device = device_registry.async_get_device(
identifiers={(DOMAIN, inovelli_ieee_address)}
)
cluster = inovelli_zigpy_device.endpoints[1].in_clusters[0xFC31]
with patch(
"zigpy.zcl.Cluster.request",
@ -298,25 +197,6 @@ async def test_action(
"device_id": reg_device.id,
"type": "warn",
},
{
"domain": DOMAIN,
"device_id": inovelli_reg_device.id,
"type": "issue_all_led_effect",
"effect_type": "Open_Close",
"duration": 5,
"level": 10,
"color": 41,
},
{
"domain": DOMAIN,
"device_id": inovelli_reg_device.id,
"type": "issue_individual_led_effect",
"effect_type": "Falling",
"led_number": 1,
"duration": 5,
"level": 10,
"color": 41,
},
],
}
]
@ -326,7 +206,11 @@ async def test_action(
await hass.async_block_till_done()
calls = async_mock_service(hass, DOMAIN, "warning_device_warn")
cluster_handler = zha_device.endpoints[1].client_cluster_handlers["1:0x0006"]
cluster_handler = (
gateway.get_device(zigpy_device.ieee)
.endpoints[1]
.client_cluster_handlers["1:0x0006"]
)
cluster_handler.zha_send_event(COMMAND_SINGLE, [])
await hass.async_block_till_done()
@ -335,44 +219,41 @@ async def test_action(
assert calls[0].service == "warning_device_warn"
assert calls[0].data["ieee"] == ieee_address
assert len(cluster.request.mock_calls) == 2
assert (
call(
False,
cluster.commands_by_name["led_effect"].id,
cluster.commands_by_name["led_effect"].schema,
6,
41,
10,
5,
expect_reply=False,
manufacturer=4151,
tsn=None,
)
in cluster.request.call_args_list
)
assert (
call(
False,
cluster.commands_by_name["individual_led_effect"].id,
cluster.commands_by_name["individual_led_effect"].schema,
1,
6,
41,
10,
5,
expect_reply=False,
manufacturer=4151,
tsn=None,
)
in cluster.request.call_args_list
)
async def test_invalid_zha_event_type(hass: HomeAssistant, device_ias) -> None:
async def test_invalid_zha_event_type(
hass: HomeAssistant, setup_zha, zigpy_device_mock
) -> None:
"""Test that unexpected types are not passed to `zha_send_event`."""
zigpy_device, zha_device = device_ias
cluster_handler = zha_device._endpoints[1].client_cluster_handlers["1:0x0006"]
await setup_zha()
gateway = get_zha_gateway(hass)
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [
general.Basic.cluster_id,
security.IasZone.cluster_id,
security.IasWd.cluster_id,
],
SIG_EP_OUTPUT: [general.OnOff.cluster_id],
SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
}
)
zigpy_device.device_automation_triggers = {
(SHORT_PRESS, SHORT_PRESS): {COMMAND: COMMAND_SINGLE}
}
gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
cluster_handler = (
gateway.get_device(zigpy_device.ieee)
.endpoints[1]
.client_cluster_handlers["1:0x0006"]
)
# `zha_send_event` accepts only zigpy responses, lists, and dicts
with pytest.raises(TypeError):

View File

@ -5,23 +5,22 @@ import time
from unittest.mock import patch
import pytest
import zigpy.profiles.zha
from zha.application.registries import SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE
from zigpy.profiles import zha
from zigpy.zcl.clusters import general
from homeassistant.components.device_tracker import SourceType
from homeassistant.components.zha.core.registries import (
SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE,
from homeassistant.components.zha.helpers import (
ZHADeviceProxy,
ZHAGatewayProxy,
get_zha_gateway,
get_zha_gateway_proxy,
)
from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_UNAVAILABLE, Platform
from homeassistant.const import STATE_HOME, STATE_NOT_HOME, Platform
from homeassistant.core import HomeAssistant
import homeassistant.util.dt as dt_util
from .common import (
async_enable_traffic,
async_test_rejoin,
find_entity_id,
send_attributes_report,
)
from .common import find_entity_id, send_attributes_report
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
from tests.common import async_fire_time_changed
@ -44,49 +43,41 @@ def device_tracker_platforms_only():
yield
@pytest.fixture
def zigpy_device_dt(zigpy_device_mock):
"""Device tracker zigpy device."""
endpoints = {
1: {
SIG_EP_INPUT: [
general.Basic.cluster_id,
general.PowerConfiguration.cluster_id,
general.Identify.cluster_id,
general.PollControl.cluster_id,
general.BinaryInput.cluster_id,
],
SIG_EP_OUTPUT: [general.Identify.cluster_id, general.Ota.cluster_id],
SIG_EP_TYPE: SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE,
SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID,
}
}
return zigpy_device_mock(endpoints)
async def test_device_tracker(
hass: HomeAssistant, zha_device_joined_restored, zigpy_device_dt
hass: HomeAssistant, setup_zha, zigpy_device_mock
) -> None:
"""Test ZHA device tracker platform."""
zha_device = await zha_device_joined_restored(zigpy_device_dt)
cluster = zigpy_device_dt.endpoints.get(1).power
entity_id = find_entity_id(Platform.DEVICE_TRACKER, zha_device, hass)
await setup_zha()
gateway = get_zha_gateway(hass)
gateway_proxy: ZHAGatewayProxy = get_zha_gateway_proxy(hass)
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [
general.Basic.cluster_id,
general.PowerConfiguration.cluster_id,
general.Identify.cluster_id,
general.PollControl.cluster_id,
general.BinaryInput.cluster_id,
],
SIG_EP_OUTPUT: [general.Identify.cluster_id, general.Ota.cluster_id],
SIG_EP_TYPE: SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
}
)
gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
zha_device_proxy: ZHADeviceProxy = gateway_proxy.get_device_proxy(zigpy_device.ieee)
entity_id = find_entity_id(Platform.DEVICE_TRACKER, zha_device_proxy, hass)
cluster = zigpy_device.endpoints[1].power
assert entity_id is not None
assert hass.states.get(entity_id).state == STATE_NOT_HOME
await async_enable_traffic(hass, [zha_device], enabled=False)
# test that the device tracker was created and that it is unavailable
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
zigpy_device_dt.last_seen = time.time() - 120
next_update = dt_util.utcnow() + timedelta(seconds=30)
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [zha_device])
# test that the state has changed from unavailable to not home
assert hass.states.get(entity_id).state == STATE_NOT_HOME
@ -95,7 +86,7 @@ async def test_device_tracker(
hass, cluster, {0x0000: 0, 0x0020: 23, 0x0021: 200, 0x0001: 2}
)
zigpy_device_dt.last_seen = time.time() + 10
zigpy_device.last_seen = time.time() + 10
next_update = dt_util.utcnow() + timedelta(seconds=30)
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
@ -107,7 +98,3 @@ async def test_device_tracker(
assert entity.is_connected is True
assert entity.source_type == SourceType.ROUTER
assert entity.battery_level == 100
# test adding device tracker to the network and HA
await async_test_rejoin(hass, zigpy_device_dt, [cluster], (2,))
assert hass.states.get(entity_id).state == STATE_HOME

View File

@ -1,32 +1,27 @@
"""ZHA device automation trigger tests."""
from datetime import timedelta
import time
from unittest.mock import patch
import pytest
from zha.application.const import ATTR_ENDPOINT_ID
from zigpy.application import ControllerApplication
from zigpy.device import Device as ZigpyDevice
import zigpy.profiles.zha
from zigpy.zcl.clusters import general
import zigpy.types
from homeassistant.components import automation
from homeassistant.components.device_automation import DeviceAutomationType
from homeassistant.components.device_automation.exceptions import (
InvalidDeviceAutomationConfig,
)
from homeassistant.components.zha.core.const import ATTR_ENDPOINT_ID
from homeassistant.components.zha.helpers import get_zha_gateway
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import device_registry as dr
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
from .common import async_enable_traffic
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
from tests.common import (
MockConfigEntry,
async_fire_time_changed,
async_get_device_automations,
async_mock_service,
)
@ -51,16 +46,6 @@ LONG_PRESS = "remote_button_long_press"
LONG_RELEASE = "remote_button_long_release"
SWITCH_SIGNATURE = {
1: {
SIG_EP_INPUT: [general.Basic.cluster_id],
SIG_EP_OUTPUT: [general.OnOff.cluster_id],
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH,
SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID,
}
}
@pytest.fixture(autouse=True)
def sensor_platforms_only():
"""Only set up the sensor platform and required base platforms to speed up tests."""
@ -81,25 +66,21 @@ def calls(hass: HomeAssistant) -> list[ServiceCall]:
return async_mock_service(hass, "test", "automation")
@pytest.fixture
async def mock_devices(hass, zigpy_device_mock, zha_device_joined_restored):
"""IAS device fixture."""
zigpy_device = zigpy_device_mock(SWITCH_SIGNATURE)
zha_device = await zha_device_joined_restored(zigpy_device)
zha_device.update_available(True)
await hass.async_block_till_done()
return zigpy_device, zha_device
async def test_triggers(
hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_devices
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
setup_zha,
) -> None:
"""Test ZHA device triggers."""
zigpy_device, zha_device = mock_devices
await setup_zha()
gateway = get_zha_gateway(hass)
zigpy_device = ZigpyDevice(
application=gateway.application_controller,
ieee=zigpy.types.EUI64.convert("aa:bb:cc:dd:11:22:33:44"),
nwk=0x1234,
)
zigpy_device.device_automation_triggers = {
(SHAKEN, SHAKEN): {COMMAND: COMMAND_SHAKE},
(DOUBLE_PRESS, DOUBLE_PRESS): {COMMAND: COMMAND_DOUBLE},
@ -108,9 +89,13 @@ async def test_triggers(
(LONG_RELEASE, LONG_RELEASE): {COMMAND: COMMAND_HOLD},
}
ieee_address = str(zha_device.ieee)
zha_device = gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zha_device.device)
await hass.async_block_till_done(wait_background_tasks=True)
reg_device = device_registry.async_get_device(identifiers={("zha", ieee_address)})
reg_device = device_registry.async_get_device(
identifiers={("zha", str(zha_device.ieee))}
)
triggers = await async_get_device_automations(
hass, DeviceAutomationType.TRIGGER, reg_device.id
@ -170,14 +155,26 @@ async def test_triggers(
async def test_no_triggers(
hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_devices
hass: HomeAssistant, device_registry: dr.DeviceRegistry, setup_zha
) -> None:
"""Test ZHA device with no triggers."""
await setup_zha()
gateway = get_zha_gateway(hass)
_, zha_device = mock_devices
ieee_address = str(zha_device.ieee)
zigpy_device = ZigpyDevice(
application=gateway.application_controller,
ieee=zigpy.types.EUI64.convert("aa:bb:cc:dd:11:22:33:44"),
nwk=0x1234,
)
zigpy_device.device_automation_triggers = {}
reg_device = device_registry.async_get_device(identifiers={("zha", ieee_address)})
zha_device = gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zha_device.device)
await hass.async_block_till_done(wait_background_tasks=True)
reg_device = device_registry.async_get_device(
identifiers={("zha", str(zha_device.ieee))}
)
triggers = await async_get_device_automations(
hass, DeviceAutomationType.TRIGGER, reg_device.id
@ -197,12 +194,21 @@ async def test_no_triggers(
async def test_if_fires_on_event(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_devices,
calls: list[ServiceCall],
setup_zha,
) -> None:
"""Test for remote triggers firing."""
zigpy_device, zha_device = mock_devices
await setup_zha()
gateway = get_zha_gateway(hass)
zigpy_device = ZigpyDevice(
application=gateway.application_controller,
ieee=zigpy.types.EUI64.convert("aa:bb:cc:dd:11:22:33:44"),
nwk=0x1234,
)
ep = zigpy_device.add_endpoint(1)
ep.add_output_cluster(0x0006)
zigpy_device.device_automation_triggers = {
(SHAKEN, SHAKEN): {COMMAND: COMMAND_SHAKE},
@ -212,8 +218,13 @@ async def test_if_fires_on_event(
(LONG_RELEASE, LONG_RELEASE): {COMMAND: COMMAND_HOLD},
}
ieee_address = str(zha_device.ieee)
reg_device = device_registry.async_get_device(identifiers={("zha", ieee_address)})
zha_device = gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zha_device.device)
await hass.async_block_till_done(wait_background_tasks=True)
reg_device = device_registry.async_get_device(
identifiers={("zha", str(zha_device.ieee))}
)
assert await async_setup_component(
hass,
@ -239,8 +250,16 @@ async def test_if_fires_on_event(
await hass.async_block_till_done()
cluster_handler = zha_device.endpoints[1].client_cluster_handlers["1:0x0006"]
cluster_handler.zha_send_event(COMMAND_SINGLE, [])
zha_device.emit_zha_event(
{
"unique_id": f"{zha_device.ieee}:1:0x0006",
"endpoint_id": 1,
"cluster_id": 0x0006,
"command": COMMAND_SINGLE,
"args": [],
"params": {},
},
)
await hass.async_block_till_done()
assert len(calls) == 1
@ -249,25 +268,28 @@ async def test_if_fires_on_event(
async def test_device_offline_fires(
hass: HomeAssistant,
zigpy_device_mock,
zha_device_restored,
device_registry: dr.DeviceRegistry,
calls: list[ServiceCall],
setup_zha,
) -> None:
"""Test for device offline triggers firing."""
zigpy_device = zigpy_device_mock(
{
1: {
"in_clusters": [general.Basic.cluster_id],
"out_clusters": [general.OnOff.cluster_id],
"device_type": 0,
}
}
await setup_zha()
gateway = get_zha_gateway(hass)
zigpy_device = ZigpyDevice(
application=gateway.application_controller,
ieee=zigpy.types.EUI64.convert("aa:bb:cc:dd:11:22:33:44"),
nwk=0x1234,
)
zha_device = await zha_device_restored(zigpy_device, last_seen=time.time())
await async_enable_traffic(hass, [zha_device])
await hass.async_block_till_done()
zha_device = gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zha_device.device)
await hass.async_block_till_done(wait_background_tasks=True)
reg_device = device_registry.async_get_device(
identifiers={("zha", str(zha_device.ieee))}
)
assert await async_setup_component(
hass,
@ -276,7 +298,7 @@ async def test_device_offline_fires(
automation.DOMAIN: [
{
"trigger": {
"device_id": zha_device.device_id,
"device_id": reg_device.id,
"domain": "zha",
"platform": "device",
"type": "device_offline",
@ -291,27 +313,10 @@ async def test_device_offline_fires(
},
)
await hass.async_block_till_done()
assert zha_device.available is True
zha_device.available = False
zha_device.emit_zha_event({"device_event_type": "device_offline"})
zigpy_device.last_seen = time.time() - zha_device.consider_unavailable_time - 2
# there are 3 checkins to perform before marking the device unavailable
future = dt_util.utcnow() + timedelta(seconds=90)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
future = dt_util.utcnow() + timedelta(seconds=90)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
future = dt_util.utcnow() + timedelta(
seconds=zha_device.consider_unavailable_time + 100
)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
assert zha_device.available is False
assert len(calls) == 1
assert calls[0].data["message"] == "service called"
@ -319,16 +324,28 @@ async def test_device_offline_fires(
async def test_exception_no_triggers(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_devices,
calls: list[ServiceCall],
caplog: pytest.LogCaptureFixture,
setup_zha,
) -> None:
"""Test for exception when validating device triggers."""
_, zha_device = mock_devices
await setup_zha()
gateway = get_zha_gateway(hass)
ieee_address = str(zha_device.ieee)
reg_device = device_registry.async_get_device(identifiers={("zha", ieee_address)})
zigpy_device = ZigpyDevice(
application=gateway.application_controller,
ieee=zigpy.types.EUI64.convert("aa:bb:cc:dd:11:22:33:44"),
nwk=0x1234,
)
zha_device = gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zha_device.device)
await hass.async_block_till_done(wait_background_tasks=True)
reg_device = device_registry.async_get_device(
identifiers={("zha", str(zha_device.ieee))}
)
await async_setup_component(
hass,
@ -361,14 +378,20 @@ async def test_exception_no_triggers(
async def test_exception_bad_trigger(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_devices,
calls: list[ServiceCall],
caplog: pytest.LogCaptureFixture,
setup_zha,
) -> None:
"""Test for exception when validating device triggers."""
zigpy_device, zha_device = mock_devices
await setup_zha()
gateway = get_zha_gateway(hass)
zigpy_device = ZigpyDevice(
application=gateway.application_controller,
ieee=zigpy.types.EUI64.convert("aa:bb:cc:dd:11:22:33:44"),
nwk=0x1234,
)
zigpy_device.device_automation_triggers = {
(SHAKEN, SHAKEN): {COMMAND: COMMAND_SHAKE},
(DOUBLE_PRESS, DOUBLE_PRESS): {COMMAND: COMMAND_DOUBLE},
@ -377,8 +400,13 @@ async def test_exception_bad_trigger(
(LONG_RELEASE, LONG_RELEASE): {COMMAND: COMMAND_HOLD},
}
ieee_address = str(zha_device.ieee)
reg_device = device_registry.async_get_device(identifiers={("zha", ieee_address)})
zha_device = gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zha_device.device)
await hass.async_block_till_done(wait_background_tasks=True)
reg_device = device_registry.async_get_device(
identifiers={("zha", str(zha_device.ieee))}
)
await async_setup_component(
hass,
@ -412,23 +440,37 @@ async def test_validate_trigger_config_missing_info(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
config_entry: MockConfigEntry,
zigpy_device_mock,
mock_zigpy_connect: ControllerApplication,
zha_device_joined,
caplog: pytest.LogCaptureFixture,
setup_zha,
) -> None:
"""Test device triggers referring to a missing device."""
# Join a device
switch = zigpy_device_mock(SWITCH_SIGNATURE)
await zha_device_joined(switch)
await setup_zha()
gateway = get_zha_gateway(hass)
zigpy_device = ZigpyDevice(
application=gateway.application_controller,
ieee=zigpy.types.EUI64.convert("aa:bb:cc:dd:11:22:33:44"),
nwk=0x1234,
)
zigpy_device.device_automation_triggers = {
(SHAKEN, SHAKEN): {COMMAND: COMMAND_SHAKE},
(DOUBLE_PRESS, DOUBLE_PRESS): {COMMAND: COMMAND_DOUBLE},
(SHORT_PRESS, SHORT_PRESS): {COMMAND: COMMAND_SINGLE},
(LONG_PRESS, LONG_PRESS): {COMMAND: COMMAND_HOLD},
(LONG_RELEASE, LONG_RELEASE): {COMMAND: COMMAND_HOLD},
}
zha_device = gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zha_device.device)
await hass.async_block_till_done(wait_background_tasks=True)
# After we unload the config entry, trigger info was not cached on startup, nor can
# it be pulled from the current device, making it impossible to validate triggers
await hass.config_entries.async_unload(config_entry.entry_id)
reg_device = device_registry.async_get_device(
identifiers={("zha", str(switch.ieee))}
identifiers={("zha", str(zha_device.ieee))}
)
assert await async_setup_component(
@ -465,16 +507,32 @@ async def test_validate_trigger_config_unloaded_bad_info(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
config_entry: MockConfigEntry,
zigpy_device_mock,
mock_zigpy_connect: ControllerApplication,
zha_device_joined,
caplog: pytest.LogCaptureFixture,
zigpy_app_controller: ControllerApplication,
setup_zha,
) -> None:
"""Test device triggers referring to a missing device."""
# Join a device
switch = zigpy_device_mock(SWITCH_SIGNATURE)
await zha_device_joined(switch)
await setup_zha()
gateway = get_zha_gateway(hass)
zigpy_device = ZigpyDevice(
application=gateway.application_controller,
ieee=zigpy.types.EUI64.convert("aa:bb:cc:dd:11:22:33:44"),
nwk=0x1234,
)
zigpy_device.device_automation_triggers = {
(SHAKEN, SHAKEN): {COMMAND: COMMAND_SHAKE},
(DOUBLE_PRESS, DOUBLE_PRESS): {COMMAND: COMMAND_DOUBLE},
(SHORT_PRESS, SHORT_PRESS): {COMMAND: COMMAND_SINGLE},
(LONG_PRESS, LONG_PRESS): {COMMAND: COMMAND_HOLD},
(LONG_RELEASE, LONG_RELEASE): {COMMAND: COMMAND_HOLD},
}
zigpy_app_controller.devices[zigpy_device.ieee] = zigpy_device
zha_device = gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zha_device.device)
await hass.async_block_till_done(wait_background_tasks=True)
# After we unload the config entry, trigger info was not cached on startup, nor can
# it be pulled from the current device, making it impossible to validate triggers
@ -482,11 +540,12 @@ async def test_validate_trigger_config_unloaded_bad_info(
# Reload ZHA to persist the device info in the cache
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
await hass.async_block_till_done(wait_background_tasks=True)
await hass.config_entries.async_unload(config_entry.entry_id)
reg_device = device_registry.async_get_device(
identifiers={("zha", str(switch.ieee))}
identifiers={("zha", str(zha_device.ieee))}
)
assert await async_setup_component(

View File

@ -7,9 +7,13 @@ from zigpy.profiles import zha
from zigpy.zcl.clusters import security
from homeassistant.components.diagnostics import REDACTED
from homeassistant.components.zha.core.device import ZHADevice
from homeassistant.components.zha.core.helpers import get_zha_gateway
from homeassistant.components.zha.diagnostics import KEYS_TO_REDACT
from homeassistant.components.zha.helpers import (
ZHADeviceProxy,
ZHAGatewayProxy,
get_zha_gateway,
get_zha_gateway_proxy,
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
@ -41,33 +45,35 @@ def required_platforms_only():
yield
@pytest.fixture
def zigpy_device(zigpy_device_mock):
"""Device tracker zigpy device."""
endpoints = {
1: {
SIG_EP_INPUT: [security.IasAce.cluster_id, security.IasZone.cluster_id],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.IAS_ANCILLARY_CONTROL,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
}
return zigpy_device_mock(
endpoints, node_descriptor=b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00"
)
async def test_diagnostics_for_config_entry(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
config_entry: MockConfigEntry,
zha_device_joined,
zigpy_device,
setup_zha,
zigpy_device_mock,
) -> None:
"""Test diagnostics for config entry."""
await zha_device_joined(zigpy_device)
await setup_zha()
gateway = get_zha_gateway(hass)
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [security.IasAce.cluster_id, security.IasZone.cluster_id],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.IAS_ANCILLARY_CONTROL,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
},
ieee="01:2d:6f:00:0a:90:69:e8",
node_descriptor=b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00",
)
gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
scan = {c: c for c in range(11, 26 + 1)}
with patch.object(gateway.application_controller, "energy_scan", return_value=scan):
@ -106,19 +112,40 @@ async def test_diagnostics_for_device(
hass_client: ClientSessionGenerator,
device_registry: dr.DeviceRegistry,
config_entry: MockConfigEntry,
zha_device_joined,
zigpy_device,
setup_zha,
zigpy_device_mock,
) -> None:
"""Test diagnostics for device."""
zha_device: ZHADevice = await zha_device_joined(zigpy_device)
await setup_zha()
gateway = get_zha_gateway(hass)
gateway_proxy: ZHAGatewayProxy = get_zha_gateway_proxy(hass)
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [security.IasAce.cluster_id, security.IasZone.cluster_id],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.IAS_ANCILLARY_CONTROL,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
},
ieee="01:2d:6f:00:0a:90:69:e8",
node_descriptor=b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00",
)
gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
zha_device_proxy: ZHADeviceProxy = gateway_proxy.get_device_proxy(zigpy_device.ieee)
# add unknown unsupported attribute with id and name
zha_device.device.endpoints[1].in_clusters[
zha_device_proxy.device.device.endpoints[1].in_clusters[
security.IasAce.cluster_id
].unsupported_attributes.update({0x1000, "unknown_attribute_name"})
# add known unsupported attributes with id and name
zha_device.device.endpoints[1].in_clusters[
zha_device_proxy.device.device.endpoints[1].in_clusters[
security.IasZone.cluster_id
].unsupported_attributes.update(
{
@ -128,14 +155,14 @@ async def test_diagnostics_for_device(
)
device = device_registry.async_get_device(
identifiers={("zha", str(zha_device.ieee))}
identifiers={("zha", str(zha_device_proxy.device.ieee))}
)
assert device
diagnostics_data = await get_diagnostics_for_device(
hass, hass_client, config_entry, device
)
assert diagnostics_data
device_info: dict = zha_device.zha_device_info
device_info: dict = zha_device_proxy.zha_device_info
for key in device_info:
assert key in diagnostics_data
if key not in KEYS_TO_REDACT:

File diff suppressed because it is too large Load Diff

View File

@ -1,32 +1,25 @@
"""Test ZHA fan."""
from unittest.mock import AsyncMock, call, patch
from unittest.mock import call, patch
import pytest
import zhaquirks.ikea.starkvind
from zigpy.device import Device
from zigpy.exceptions import ZigbeeException
from zha.application.platforms.fan.const import PRESET_MODE_ON
from zigpy.profiles import zha
from zigpy.zcl.clusters import general, hvac
import zigpy.zcl.foundation as zcl_f
from homeassistant.components.fan import (
ATTR_PERCENTAGE,
ATTR_PERCENTAGE_STEP,
ATTR_PRESET_MODE,
DOMAIN as FAN_DOMAIN,
SERVICE_SET_PERCENTAGE,
SERVICE_SET_PRESET_MODE,
NotValidPresetModeError,
)
from homeassistant.components.zha.core.device import ZHADevice
from homeassistant.components.zha.core.discovery import GROUP_PROBE
from homeassistant.components.zha.core.group import GroupMember
from homeassistant.components.zha.core.helpers import get_zha_gateway
from homeassistant.components.zha.fan import (
PRESET_MODE_AUTO,
PRESET_MODE_ON,
PRESET_MODE_SMART,
from homeassistant.components.zha.helpers import (
ZHADeviceProxy,
ZHAGatewayProxy,
get_zha_gateway,
get_zha_gateway_proxy,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
@ -34,25 +27,15 @@ from homeassistant.const import (
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.setup import async_setup_component
from .common import (
async_enable_traffic,
async_find_group_entity_id,
async_test_rejoin,
async_wait_for_updates,
find_entity_id,
send_attributes_report,
)
from .common import find_entity_id, send_attributes_report
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8"
IEEE_GROUPABLE_DEVICE2 = "02:2d:6f:00:0a:90:69:e8"
ON = 1
OFF = 0
@pytest.fixture(autouse=True)
@ -75,122 +58,49 @@ def fan_platform_only():
yield
@pytest.fixture
def zigpy_device(zigpy_device_mock):
"""Fan zigpy device."""
endpoints = {
1: {
SIG_EP_INPUT: [hvac.Fan.cluster_id],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
}
return zigpy_device_mock(
endpoints, node_descriptor=b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00"
)
@pytest.fixture
async def coordinator(hass, zigpy_device_mock, zha_device_joined):
async def test_fan(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None:
"""Test ZHA fan platform."""
await setup_zha()
gateway = get_zha_gateway(hass)
gateway_proxy: ZHAGatewayProxy = get_zha_gateway_proxy(hass)
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [general.Groups.cluster_id],
SIG_EP_INPUT: [general.Basic.cluster_id, hvac.Fan.cluster_id],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT,
SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
},
ieee="00:15:8d:00:02:32:4f:32",
nwk=0x0000,
node_descriptor=b"\xf8\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff",
ieee="01:2d:6f:00:0a:90:69:e8",
node_descriptor=b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00",
)
zha_device = await zha_device_joined(zigpy_device)
zha_device.available = True
return zha_device
gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
@pytest.fixture
async def device_fan_1(hass, zigpy_device_mock, zha_device_joined):
"""Test ZHA fan platform."""
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [
general.Groups.cluster_id,
general.OnOff.cluster_id,
hvac.Fan.cluster_id,
],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.ON_OFF_LIGHT,
SIG_EP_PROFILE: zha.PROFILE_ID,
},
},
ieee=IEEE_GROUPABLE_DEVICE,
)
zha_device = await zha_device_joined(zigpy_device)
zha_device.available = True
await hass.async_block_till_done()
return zha_device
@pytest.fixture
async def device_fan_2(hass, zigpy_device_mock, zha_device_joined):
"""Test ZHA fan platform."""
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [
general.Groups.cluster_id,
general.OnOff.cluster_id,
hvac.Fan.cluster_id,
general.LevelControl.cluster_id,
],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.ON_OFF_LIGHT,
SIG_EP_PROFILE: zha.PROFILE_ID,
},
},
ieee=IEEE_GROUPABLE_DEVICE2,
)
zha_device = await zha_device_joined(zigpy_device)
zha_device.available = True
await hass.async_block_till_done()
return zha_device
async def test_fan(
hass: HomeAssistant, zha_device_joined_restored, zigpy_device
) -> None:
"""Test ZHA fan platform."""
zha_device = await zha_device_joined_restored(zigpy_device)
cluster = zigpy_device.endpoints.get(1).fan
entity_id = find_entity_id(Platform.FAN, zha_device, hass)
zha_device_proxy: ZHADeviceProxy = gateway_proxy.get_device_proxy(zigpy_device.ieee)
entity_id = find_entity_id(Platform.FAN, zha_device_proxy, hass)
cluster = zigpy_device.endpoints[1].fan
assert entity_id is not None
assert hass.states.get(entity_id).state == STATE_OFF
await async_enable_traffic(hass, [zha_device], enabled=False)
# test that the fan was created and that it is unavailable
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [zha_device])
# test that the state has changed from unavailable to off
assert hass.states.get(entity_id).state == STATE_OFF
# turn on at fan
await send_attributes_report(hass, cluster, {1: 2, 0: 1, 2: 3})
await send_attributes_report(
hass,
cluster,
{hvac.Fan.AttributeDefs.fan_mode.id: hvac.FanMode.Low},
)
assert hass.states.get(entity_id).state == STATE_ON
# turn off at fan
await send_attributes_report(hass, cluster, {1: 1, 0: 0, 2: 2})
await send_attributes_report(
hass, cluster, {hvac.Fan.AttributeDefs.fan_mode.id: hvac.FanMode.Off}
)
assert hass.states.get(entity_id).state == STATE_OFF
# turn on from HA
@ -230,11 +140,8 @@ async def test_fan(
assert exc.value.translation_key == "not_valid_preset_mode"
assert len(cluster.write_attributes.mock_calls) == 0
# test adding new fan to the network and HA
await async_test_rejoin(hass, zigpy_device, [cluster], (1,))
async def async_turn_on(hass, entity_id, percentage=None):
async def async_turn_on(hass: HomeAssistant, entity_id, percentage=None):
"""Turn fan on."""
data = {
key: value
@ -245,14 +152,14 @@ async def async_turn_on(hass, entity_id, percentage=None):
await hass.services.async_call(Platform.FAN, SERVICE_TURN_ON, data, blocking=True)
async def async_turn_off(hass, entity_id):
async def async_turn_off(hass: HomeAssistant, entity_id):
"""Turn fan off."""
data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
await hass.services.async_call(Platform.FAN, SERVICE_TURN_OFF, data, blocking=True)
async def async_set_percentage(hass, entity_id, percentage=None):
async def async_set_percentage(hass: HomeAssistant, entity_id, percentage=None):
"""Set percentage for specified fan."""
data = {
key: value
@ -265,7 +172,7 @@ async def async_set_percentage(hass, entity_id, percentage=None):
)
async def async_set_preset_mode(hass, entity_id, preset_mode=None):
async def async_set_preset_mode(hass: HomeAssistant, entity_id, preset_mode=None):
"""Set preset_mode for specified fan."""
data = {
key: value
@ -276,633 +183,3 @@ async def async_set_preset_mode(hass, entity_id, preset_mode=None):
await hass.services.async_call(
FAN_DOMAIN, SERVICE_SET_PRESET_MODE, data, blocking=True
)
@patch(
"zigpy.zcl.clusters.hvac.Fan.write_attributes",
new=AsyncMock(return_value=zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]),
)
@patch(
"homeassistant.components.zha.entity.DEFAULT_UPDATE_GROUP_FROM_CHILD_DELAY",
new=0,
)
async def test_zha_group_fan_entity(
hass: HomeAssistant, device_fan_1, device_fan_2, coordinator
) -> None:
"""Test the fan entity for a ZHA group."""
zha_gateway = get_zha_gateway(hass)
assert zha_gateway is not None
zha_gateway.coordinator_zha_device = coordinator
coordinator._zha_gateway = zha_gateway
device_fan_1._zha_gateway = zha_gateway
device_fan_2._zha_gateway = zha_gateway
member_ieee_addresses = [device_fan_1.ieee, device_fan_2.ieee]
members = [GroupMember(device_fan_1.ieee, 1), GroupMember(device_fan_2.ieee, 1)]
# test creating a group with 2 members
zha_group = await zha_gateway.async_create_zigpy_group("Test Group", members)
await hass.async_block_till_done()
assert zha_group is not None
assert len(zha_group.members) == 2
for member in zha_group.members:
assert member.device.ieee in member_ieee_addresses
assert member.group == zha_group
assert member.endpoint is not None
entity_domains = GROUP_PROBE.determine_entity_domains(hass, zha_group)
assert len(entity_domains) == 2
assert Platform.LIGHT in entity_domains
assert Platform.FAN in entity_domains
entity_id = async_find_group_entity_id(hass, Platform.FAN, zha_group)
assert hass.states.get(entity_id) is not None
group_fan_cluster = zha_group.endpoint[hvac.Fan.cluster_id]
dev1_fan_cluster = device_fan_1.device.endpoints[1].fan
dev2_fan_cluster = device_fan_2.device.endpoints[1].fan
await async_enable_traffic(hass, [device_fan_1, device_fan_2], enabled=False)
await async_wait_for_updates(hass)
# test that the fans were created and that they are unavailable
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [device_fan_1, device_fan_2])
await async_wait_for_updates(hass)
# test that the fan group entity was created and is off
assert hass.states.get(entity_id).state == STATE_OFF
# turn on from HA
group_fan_cluster.write_attributes.reset_mock()
await async_turn_on(hass, entity_id)
await hass.async_block_till_done()
assert len(group_fan_cluster.write_attributes.mock_calls) == 1
assert group_fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 2}
# turn off from HA
group_fan_cluster.write_attributes.reset_mock()
await async_turn_off(hass, entity_id)
assert len(group_fan_cluster.write_attributes.mock_calls) == 1
assert group_fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 0}
# change speed from HA
group_fan_cluster.write_attributes.reset_mock()
await async_set_percentage(hass, entity_id, percentage=100)
assert len(group_fan_cluster.write_attributes.mock_calls) == 1
assert group_fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 3}
# change preset mode from HA
group_fan_cluster.write_attributes.reset_mock()
await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_ON)
assert len(group_fan_cluster.write_attributes.mock_calls) == 1
assert group_fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 4}
# change preset mode from HA
group_fan_cluster.write_attributes.reset_mock()
await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_AUTO)
assert len(group_fan_cluster.write_attributes.mock_calls) == 1
assert group_fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 5}
# change preset mode from HA
group_fan_cluster.write_attributes.reset_mock()
await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_SMART)
assert len(group_fan_cluster.write_attributes.mock_calls) == 1
assert group_fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 6}
# test some of the group logic to make sure we key off states correctly
await send_attributes_report(hass, dev1_fan_cluster, {0: 0})
await send_attributes_report(hass, dev2_fan_cluster, {0: 0})
await hass.async_block_till_done()
# test that group fan is off
assert hass.states.get(entity_id).state == STATE_OFF
await send_attributes_report(hass, dev2_fan_cluster, {0: 2})
await async_wait_for_updates(hass)
# test that group fan is speed medium
assert hass.states.get(entity_id).state == STATE_ON
await send_attributes_report(hass, dev2_fan_cluster, {0: 0})
await async_wait_for_updates(hass)
# test that group fan is now off
assert hass.states.get(entity_id).state == STATE_OFF
@patch(
"zigpy.zcl.clusters.hvac.Fan.write_attributes",
new=AsyncMock(side_effect=ZigbeeException),
)
@patch(
"homeassistant.components.zha.entity.DEFAULT_UPDATE_GROUP_FROM_CHILD_DELAY",
new=0,
)
async def test_zha_group_fan_entity_failure_state(
hass: HomeAssistant,
device_fan_1,
device_fan_2,
coordinator,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test the fan entity for a ZHA group when writing attributes generates an exception."""
zha_gateway = get_zha_gateway(hass)
assert zha_gateway is not None
zha_gateway.coordinator_zha_device = coordinator
coordinator._zha_gateway = zha_gateway
device_fan_1._zha_gateway = zha_gateway
device_fan_2._zha_gateway = zha_gateway
member_ieee_addresses = [device_fan_1.ieee, device_fan_2.ieee]
members = [GroupMember(device_fan_1.ieee, 1), GroupMember(device_fan_2.ieee, 1)]
# test creating a group with 2 members
zha_group = await zha_gateway.async_create_zigpy_group("Test Group", members)
await hass.async_block_till_done()
assert zha_group is not None
assert len(zha_group.members) == 2
for member in zha_group.members:
assert member.device.ieee in member_ieee_addresses
assert member.group == zha_group
assert member.endpoint is not None
entity_domains = GROUP_PROBE.determine_entity_domains(hass, zha_group)
assert len(entity_domains) == 2
assert Platform.LIGHT in entity_domains
assert Platform.FAN in entity_domains
entity_id = async_find_group_entity_id(hass, Platform.FAN, zha_group)
assert hass.states.get(entity_id) is not None
group_fan_cluster = zha_group.endpoint[hvac.Fan.cluster_id]
await async_enable_traffic(hass, [device_fan_1, device_fan_2], enabled=False)
await async_wait_for_updates(hass)
# test that the fans were created and that they are unavailable
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [device_fan_1, device_fan_2])
await async_wait_for_updates(hass)
# test that the fan group entity was created and is off
assert hass.states.get(entity_id).state == STATE_OFF
# turn on from HA
group_fan_cluster.write_attributes.reset_mock()
with pytest.raises(HomeAssistantError):
await async_turn_on(hass, entity_id)
await hass.async_block_till_done()
assert len(group_fan_cluster.write_attributes.mock_calls) == 1
assert group_fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 2}
@pytest.mark.parametrize(
("plug_read", "expected_state", "expected_percentage"),
[
(None, STATE_OFF, None),
({"fan_mode": 0}, STATE_OFF, 0),
({"fan_mode": 1}, STATE_ON, 33),
({"fan_mode": 2}, STATE_ON, 66),
({"fan_mode": 3}, STATE_ON, 100),
],
)
async def test_fan_init(
hass: HomeAssistant,
zha_device_joined_restored,
zigpy_device,
plug_read,
expected_state,
expected_percentage,
) -> None:
"""Test ZHA fan platform."""
cluster = zigpy_device.endpoints.get(1).fan
cluster.PLUGGED_ATTR_READS = plug_read
zha_device = await zha_device_joined_restored(zigpy_device)
entity_id = find_entity_id(Platform.FAN, zha_device, hass)
assert entity_id is not None
assert hass.states.get(entity_id).state == expected_state
assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == expected_percentage
assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None
async def test_fan_update_entity(
hass: HomeAssistant,
zha_device_joined_restored,
zigpy_device,
) -> None:
"""Test ZHA fan platform."""
cluster = zigpy_device.endpoints.get(1).fan
cluster.PLUGGED_ATTR_READS = {"fan_mode": 0}
zha_device = await zha_device_joined_restored(zigpy_device)
entity_id = find_entity_id(Platform.FAN, zha_device, hass)
assert entity_id is not None
assert hass.states.get(entity_id).state == STATE_OFF
assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 0
assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None
assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE_STEP] == 100 / 3
if zha_device_joined_restored.name == "zha_device_joined":
assert cluster.read_attributes.await_count == 2
else:
assert cluster.read_attributes.await_count == 4
await async_setup_component(hass, "homeassistant", {})
await hass.async_block_till_done()
await hass.services.async_call(
"homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True
)
assert hass.states.get(entity_id).state == STATE_OFF
if zha_device_joined_restored.name == "zha_device_joined":
assert cluster.read_attributes.await_count == 3
else:
assert cluster.read_attributes.await_count == 5
cluster.PLUGGED_ATTR_READS = {"fan_mode": 1}
await hass.services.async_call(
"homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True
)
assert hass.states.get(entity_id).state == STATE_ON
assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 33
assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None
assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE_STEP] == 100 / 3
if zha_device_joined_restored.name == "zha_device_joined":
assert cluster.read_attributes.await_count == 4
else:
assert cluster.read_attributes.await_count == 6
@pytest.fixture
def zigpy_device_ikea(zigpy_device_mock):
"""Ikea fan zigpy device."""
endpoints = {
1: {
SIG_EP_INPUT: [
general.Basic.cluster_id,
general.Identify.cluster_id,
general.Groups.cluster_id,
general.Scenes.cluster_id,
64637,
],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.COMBINED_INTERFACE,
SIG_EP_PROFILE: zha.PROFILE_ID,
},
}
return zigpy_device_mock(
endpoints,
manufacturer="IKEA of Sweden",
model="STARKVIND Air purifier",
quirk=zhaquirks.ikea.starkvind.IkeaSTARKVIND,
node_descriptor=b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00",
)
async def test_fan_ikea(
hass: HomeAssistant,
zha_device_joined_restored: ZHADevice,
zigpy_device_ikea: Device,
) -> None:
"""Test ZHA fan Ikea platform."""
zha_device = await zha_device_joined_restored(zigpy_device_ikea)
cluster = zigpy_device_ikea.endpoints.get(1).ikea_airpurifier
entity_id = find_entity_id(Platform.FAN, zha_device, hass)
assert entity_id is not None
assert hass.states.get(entity_id).state == STATE_OFF
await async_enable_traffic(hass, [zha_device], enabled=False)
# test that the fan was created and that it is unavailable
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [zha_device])
# test that the state has changed from unavailable to off
assert hass.states.get(entity_id).state == STATE_OFF
# turn on at fan
await send_attributes_report(hass, cluster, {6: 1})
assert hass.states.get(entity_id).state == STATE_ON
# turn off at fan
await send_attributes_report(hass, cluster, {6: 0})
assert hass.states.get(entity_id).state == STATE_OFF
# turn on from HA
cluster.write_attributes.reset_mock()
await async_turn_on(hass, entity_id)
assert cluster.write_attributes.mock_calls == [
call({"fan_mode": 1}, manufacturer=None)
]
# turn off from HA
cluster.write_attributes.reset_mock()
await async_turn_off(hass, entity_id)
assert cluster.write_attributes.mock_calls == [
call({"fan_mode": 0}, manufacturer=None)
]
# change speed from HA
cluster.write_attributes.reset_mock()
await async_set_percentage(hass, entity_id, percentage=100)
assert cluster.write_attributes.mock_calls == [
call({"fan_mode": 10}, manufacturer=None)
]
# change preset_mode from HA
cluster.write_attributes.reset_mock()
await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_AUTO)
assert cluster.write_attributes.mock_calls == [
call({"fan_mode": 1}, manufacturer=None)
]
# set invalid preset_mode from HA
cluster.write_attributes.reset_mock()
with pytest.raises(NotValidPresetModeError) as exc:
await async_set_preset_mode(
hass, entity_id, preset_mode="invalid does not exist"
)
assert exc.value.translation_key == "not_valid_preset_mode"
assert len(cluster.write_attributes.mock_calls) == 0
# test adding new fan to the network and HA
await async_test_rejoin(hass, zigpy_device_ikea, [cluster], (9,))
@pytest.mark.parametrize(
(
"ikea_plug_read",
"ikea_expected_state",
"ikea_expected_percentage",
"ikea_preset_mode",
),
[
(None, STATE_OFF, None, None),
({"fan_mode": 0}, STATE_OFF, 0, None),
({"fan_mode": 1}, STATE_ON, 10, PRESET_MODE_AUTO),
({"fan_mode": 10}, STATE_ON, 20, "Speed 1"),
({"fan_mode": 15}, STATE_ON, 30, "Speed 1.5"),
({"fan_mode": 20}, STATE_ON, 40, "Speed 2"),
({"fan_mode": 25}, STATE_ON, 50, "Speed 2.5"),
({"fan_mode": 30}, STATE_ON, 60, "Speed 3"),
({"fan_mode": 35}, STATE_ON, 70, "Speed 3.5"),
({"fan_mode": 40}, STATE_ON, 80, "Speed 4"),
({"fan_mode": 45}, STATE_ON, 90, "Speed 4.5"),
({"fan_mode": 50}, STATE_ON, 100, "Speed 5"),
],
)
async def test_fan_ikea_init(
hass: HomeAssistant,
zha_device_joined_restored,
zigpy_device_ikea,
ikea_plug_read,
ikea_expected_state,
ikea_expected_percentage,
ikea_preset_mode,
) -> None:
"""Test ZHA fan platform."""
cluster = zigpy_device_ikea.endpoints.get(1).ikea_airpurifier
cluster.PLUGGED_ATTR_READS = ikea_plug_read
zha_device = await zha_device_joined_restored(zigpy_device_ikea)
entity_id = find_entity_id(Platform.FAN, zha_device, hass)
assert entity_id is not None
assert hass.states.get(entity_id).state == ikea_expected_state
assert (
hass.states.get(entity_id).attributes[ATTR_PERCENTAGE]
== ikea_expected_percentage
)
assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] == ikea_preset_mode
async def test_fan_ikea_update_entity(
hass: HomeAssistant,
zha_device_joined_restored,
zigpy_device_ikea,
) -> None:
"""Test ZHA fan platform."""
cluster = zigpy_device_ikea.endpoints.get(1).ikea_airpurifier
cluster.PLUGGED_ATTR_READS = {"fan_mode": 0}
zha_device = await zha_device_joined_restored(zigpy_device_ikea)
entity_id = find_entity_id(Platform.FAN, zha_device, hass)
assert entity_id is not None
assert hass.states.get(entity_id).state == STATE_OFF
assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 0
assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None
assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE_STEP] == 100 / 10
if zha_device_joined_restored.name == "zha_device_joined":
assert cluster.read_attributes.await_count == 3
else:
assert cluster.read_attributes.await_count == 6
await async_setup_component(hass, "homeassistant", {})
await hass.async_block_till_done()
await hass.services.async_call(
"homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True
)
assert hass.states.get(entity_id).state == STATE_OFF
if zha_device_joined_restored.name == "zha_device_joined":
assert cluster.read_attributes.await_count == 4
else:
assert cluster.read_attributes.await_count == 7
cluster.PLUGGED_ATTR_READS = {"fan_mode": 1}
await hass.services.async_call(
"homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True
)
assert hass.states.get(entity_id).state == STATE_ON
assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 10
assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is PRESET_MODE_AUTO
assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE_STEP] == 100 / 10
if zha_device_joined_restored.name == "zha_device_joined":
assert cluster.read_attributes.await_count == 5
else:
assert cluster.read_attributes.await_count == 8
@pytest.fixture
def zigpy_device_kof(zigpy_device_mock):
"""Fan by King of Fans zigpy device."""
endpoints = {
1: {
SIG_EP_INPUT: [
general.Basic.cluster_id,
general.Identify.cluster_id,
general.Groups.cluster_id,
general.Scenes.cluster_id,
64637,
],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.COMBINED_INTERFACE,
SIG_EP_PROFILE: zha.PROFILE_ID,
},
}
return zigpy_device_mock(
endpoints,
manufacturer="King Of Fans, Inc.",
model="HBUniversalCFRemote",
quirk=zhaquirks.kof.kof_mr101z.CeilingFan,
node_descriptor=b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00",
)
async def test_fan_kof(
hass: HomeAssistant,
zha_device_joined_restored: ZHADevice,
zigpy_device_kof: Device,
) -> None:
"""Test ZHA fan platform for King of Fans."""
zha_device = await zha_device_joined_restored(zigpy_device_kof)
cluster = zigpy_device_kof.endpoints.get(1).fan
entity_id = find_entity_id(Platform.FAN, zha_device, hass)
assert entity_id is not None
assert hass.states.get(entity_id).state == STATE_OFF
await async_enable_traffic(hass, [zha_device], enabled=False)
# test that the fan was created and that it is unavailable
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [zha_device])
# test that the state has changed from unavailable to off
assert hass.states.get(entity_id).state == STATE_OFF
# turn on at fan
await send_attributes_report(hass, cluster, {1: 2, 0: 1, 2: 3})
assert hass.states.get(entity_id).state == STATE_ON
# turn off at fan
await send_attributes_report(hass, cluster, {1: 1, 0: 0, 2: 2})
assert hass.states.get(entity_id).state == STATE_OFF
# turn on from HA
cluster.write_attributes.reset_mock()
await async_turn_on(hass, entity_id)
assert cluster.write_attributes.mock_calls == [
call({"fan_mode": 2}, manufacturer=None)
]
# turn off from HA
cluster.write_attributes.reset_mock()
await async_turn_off(hass, entity_id)
assert cluster.write_attributes.mock_calls == [
call({"fan_mode": 0}, manufacturer=None)
]
# change speed from HA
cluster.write_attributes.reset_mock()
await async_set_percentage(hass, entity_id, percentage=100)
assert cluster.write_attributes.mock_calls == [
call({"fan_mode": 4}, manufacturer=None)
]
# change preset_mode from HA
cluster.write_attributes.reset_mock()
await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_SMART)
assert cluster.write_attributes.mock_calls == [
call({"fan_mode": 6}, manufacturer=None)
]
# set invalid preset_mode from HA
cluster.write_attributes.reset_mock()
with pytest.raises(NotValidPresetModeError) as exc:
await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_AUTO)
assert exc.value.translation_key == "not_valid_preset_mode"
assert len(cluster.write_attributes.mock_calls) == 0
# test adding new fan to the network and HA
await async_test_rejoin(hass, zigpy_device_kof, [cluster], (1,))
@pytest.mark.parametrize(
("plug_read", "expected_state", "expected_percentage", "expected_preset"),
[
(None, STATE_OFF, None, None),
({"fan_mode": 0}, STATE_OFF, 0, None),
({"fan_mode": 1}, STATE_ON, 25, None),
({"fan_mode": 2}, STATE_ON, 50, None),
({"fan_mode": 3}, STATE_ON, 75, None),
({"fan_mode": 4}, STATE_ON, 100, None),
({"fan_mode": 6}, STATE_ON, None, PRESET_MODE_SMART),
],
)
async def test_fan_kof_init(
hass: HomeAssistant,
zha_device_joined_restored,
zigpy_device_kof,
plug_read,
expected_state,
expected_percentage,
expected_preset,
) -> None:
"""Test ZHA fan platform for King of Fans."""
cluster = zigpy_device_kof.endpoints.get(1).fan
cluster.PLUGGED_ATTR_READS = plug_read
zha_device = await zha_device_joined_restored(zigpy_device_kof)
entity_id = find_entity_id(Platform.FAN, zha_device, hass)
assert entity_id is not None
assert hass.states.get(entity_id).state == expected_state
assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == expected_percentage
assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] == expected_preset
async def test_fan_kof_update_entity(
hass: HomeAssistant,
zha_device_joined_restored,
zigpy_device_kof,
) -> None:
"""Test ZHA fan platform for King of Fans."""
cluster = zigpy_device_kof.endpoints.get(1).fan
cluster.PLUGGED_ATTR_READS = {"fan_mode": 0}
zha_device = await zha_device_joined_restored(zigpy_device_kof)
entity_id = find_entity_id(Platform.FAN, zha_device, hass)
assert entity_id is not None
assert hass.states.get(entity_id).state == STATE_OFF
assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 0
assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None
assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE_STEP] == 100 / 4
if zha_device_joined_restored.name == "zha_device_joined":
assert cluster.read_attributes.await_count == 2
else:
assert cluster.read_attributes.await_count == 4
await async_setup_component(hass, "homeassistant", {})
await hass.async_block_till_done()
await hass.services.async_call(
"homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True
)
assert hass.states.get(entity_id).state == STATE_OFF
if zha_device_joined_restored.name == "zha_device_joined":
assert cluster.read_attributes.await_count == 3
else:
assert cluster.read_attributes.await_count == 5
cluster.PLUGGED_ATTR_READS = {"fan_mode": 1}
await hass.services.async_call(
"homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True
)
assert hass.states.get(entity_id).state == STATE_ON
assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 25
assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None
assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE_STEP] == 100 / 4
if zha_device_joined_restored.name == "zha_device_joined":
assert cluster.read_attributes.await_count == 4
else:
assert cluster.read_attributes.await_count == 6

View File

@ -1,404 +0,0 @@
"""Test ZHA Gateway."""
import asyncio
from unittest.mock import MagicMock, PropertyMock, patch
import pytest
from zigpy.application import ControllerApplication
from zigpy.profiles import zha
import zigpy.types
from zigpy.zcl.clusters import general, lighting
import zigpy.zdo.types
from homeassistant.components.zha.core.gateway import ZHAGateway
from homeassistant.components.zha.core.group import GroupMember
from homeassistant.components.zha.core.helpers import get_zha_gateway
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .common import async_find_group_entity_id
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
from tests.common import MockConfigEntry
IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8"
IEEE_GROUPABLE_DEVICE2 = "02:2d:6f:00:0a:90:69:e8"
@pytest.fixture
def zigpy_dev_basic(zigpy_device_mock):
"""Zigpy device with just a basic cluster."""
return zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [general.Basic.cluster_id],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
}
)
@pytest.fixture(autouse=True)
def required_platform_only():
"""Only set up the required and required base platforms to speed up tests."""
with patch(
"homeassistant.components.zha.PLATFORMS",
(
Platform.SENSOR,
Platform.LIGHT,
Platform.DEVICE_TRACKER,
Platform.NUMBER,
Platform.SELECT,
),
):
yield
@pytest.fixture
async def zha_dev_basic(hass, zha_device_restored, zigpy_dev_basic):
"""ZHA device with just a basic cluster."""
return await zha_device_restored(zigpy_dev_basic)
@pytest.fixture
async def coordinator(hass, zigpy_device_mock, zha_device_joined):
"""Test ZHA light platform."""
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
},
ieee="00:15:8d:00:02:32:4f:32",
nwk=0x0000,
node_descriptor=b"\xf8\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff",
)
zha_device = await zha_device_joined(zigpy_device)
zha_device.available = True
return zha_device
@pytest.fixture
async def device_light_1(hass, zigpy_device_mock, zha_device_joined):
"""Test ZHA light platform."""
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [
general.OnOff.cluster_id,
general.LevelControl.cluster_id,
lighting.Color.cluster_id,
general.Groups.cluster_id,
],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
},
ieee=IEEE_GROUPABLE_DEVICE,
)
zha_device = await zha_device_joined(zigpy_device)
zha_device.available = True
return zha_device
@pytest.fixture
async def device_light_2(hass, zigpy_device_mock, zha_device_joined):
"""Test ZHA light platform."""
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [
general.OnOff.cluster_id,
general.LevelControl.cluster_id,
lighting.Color.cluster_id,
general.Groups.cluster_id,
],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
},
ieee=IEEE_GROUPABLE_DEVICE2,
)
zha_device = await zha_device_joined(zigpy_device)
zha_device.available = True
return zha_device
async def test_device_left(hass: HomeAssistant, zigpy_dev_basic, zha_dev_basic) -> None:
"""Device leaving the network should become unavailable."""
assert zha_dev_basic.available is True
get_zha_gateway(hass).device_left(zigpy_dev_basic)
await hass.async_block_till_done()
assert zha_dev_basic.available is False
async def test_gateway_group_methods(
hass: HomeAssistant, device_light_1, device_light_2, coordinator
) -> None:
"""Test creating a group with 2 members."""
zha_gateway = get_zha_gateway(hass)
assert zha_gateway is not None
zha_gateway.coordinator_zha_device = coordinator
coordinator._zha_gateway = zha_gateway
device_light_1._zha_gateway = zha_gateway
device_light_2._zha_gateway = zha_gateway
member_ieee_addresses = [device_light_1.ieee, device_light_2.ieee]
members = [GroupMember(device_light_1.ieee, 1), GroupMember(device_light_2.ieee, 1)]
# test creating a group with 2 members
zha_group = await zha_gateway.async_create_zigpy_group("Test Group", members)
await hass.async_block_till_done()
assert zha_group is not None
assert len(zha_group.members) == 2
for member in zha_group.members:
assert member.device.ieee in member_ieee_addresses
entity_id = async_find_group_entity_id(hass, Platform.LIGHT, zha_group)
assert hass.states.get(entity_id) is not None
# test get group by name
assert zha_group == zha_gateway.async_get_group_by_name(zha_group.name)
# test removing a group
await zha_gateway.async_remove_zigpy_group(zha_group.group_id)
await hass.async_block_till_done()
# we shouldn't have the group anymore
assert zha_gateway.async_get_group_by_name(zha_group.name) is None
# the group entity should be cleaned up
assert entity_id not in hass.states.async_entity_ids(Platform.LIGHT)
# test creating a group with 1 member
zha_group = await zha_gateway.async_create_zigpy_group(
"Test Group", [GroupMember(device_light_1.ieee, 1)]
)
await hass.async_block_till_done()
assert zha_group is not None
assert len(zha_group.members) == 1
for member in zha_group.members:
assert member.device.ieee in [device_light_1.ieee]
# the group entity should not have been cleaned up
assert entity_id not in hass.states.async_entity_ids(Platform.LIGHT)
with patch("zigpy.zcl.Cluster.request", side_effect=TimeoutError):
await zha_group.members[0].async_remove_from_group()
assert len(zha_group.members) == 1
for member in zha_group.members:
assert member.device.ieee in [device_light_1.ieee]
async def test_gateway_create_group_with_id(
hass: HomeAssistant, device_light_1, coordinator
) -> None:
"""Test creating a group with a specific ID."""
zha_gateway = get_zha_gateway(hass)
assert zha_gateway is not None
zha_gateway.coordinator_zha_device = coordinator
coordinator._zha_gateway = zha_gateway
device_light_1._zha_gateway = zha_gateway
zha_group = await zha_gateway.async_create_zigpy_group(
"Test Group", [GroupMember(device_light_1.ieee, 1)], group_id=0x1234
)
await hass.async_block_till_done()
assert len(zha_group.members) == 1
assert zha_group.members[0].device is device_light_1
assert zha_group.group_id == 0x1234
@patch(
"homeassistant.components.zha.core.gateway.ZHAGateway.async_load_devices",
MagicMock(),
)
@patch(
"homeassistant.components.zha.core.gateway.ZHAGateway.async_load_groups",
MagicMock(),
)
@pytest.mark.parametrize(
("device_path", "thread_state", "config_override"),
[
("/dev/ttyUSB0", True, {}),
("socket://192.168.1.123:9999", False, {}),
("socket://192.168.1.123:9999", True, {"use_thread": True}),
],
)
async def test_gateway_initialize_bellows_thread(
device_path: str,
thread_state: bool,
config_override: dict,
hass: HomeAssistant,
zigpy_app_controller: ControllerApplication,
config_entry: MockConfigEntry,
) -> None:
"""Test ZHA disabling the UART thread when connecting to a TCP coordinator."""
data = dict(config_entry.data)
data["device"]["path"] = device_path
config_entry.add_to_hass(hass)
hass.config_entries.async_update_entry(config_entry, data=data)
zha_gateway = ZHAGateway(hass, {"zigpy_config": config_override}, config_entry)
with patch(
"bellows.zigbee.application.ControllerApplication.new",
return_value=zigpy_app_controller,
) as mock_new:
await zha_gateway.async_initialize()
assert mock_new.mock_calls[-1].kwargs["config"]["use_thread"] is thread_state
await zha_gateway.shutdown()
@pytest.mark.parametrize(
("device_path", "config_override", "expected_channel"),
[
("/dev/ttyUSB0", {}, None),
("socket://192.168.1.123:9999", {}, None),
("socket://192.168.1.123:9999", {"network": {"channel": 20}}, 20),
("socket://core-silabs-multiprotocol:9999", {}, 15),
("socket://core-silabs-multiprotocol:9999", {"network": {"channel": 20}}, 20),
],
)
async def test_gateway_force_multi_pan_channel(
device_path: str,
config_override: dict,
expected_channel: int | None,
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test ZHA disabling the UART thread when connecting to a TCP coordinator."""
data = dict(config_entry.data)
data["device"]["path"] = device_path
config_entry.add_to_hass(hass)
hass.config_entries.async_update_entry(config_entry, data=data)
zha_gateway = ZHAGateway(hass, {"zigpy_config": config_override}, config_entry)
_, config = zha_gateway.get_application_controller_data()
assert config["network"]["channel"] == expected_channel
async def test_single_reload_on_multiple_connection_loss(
hass: HomeAssistant,
zigpy_app_controller: ControllerApplication,
config_entry: MockConfigEntry,
) -> None:
"""Test that we only reload once when we lose the connection multiple times."""
config_entry.add_to_hass(hass)
zha_gateway = ZHAGateway(hass, {}, config_entry)
with patch(
"bellows.zigbee.application.ControllerApplication.new",
return_value=zigpy_app_controller,
):
await zha_gateway.async_initialize()
with patch.object(
hass.config_entries, "async_reload", wraps=hass.config_entries.async_reload
) as mock_reload:
zha_gateway.connection_lost(RuntimeError())
zha_gateway.connection_lost(RuntimeError())
zha_gateway.connection_lost(RuntimeError())
zha_gateway.connection_lost(RuntimeError())
zha_gateway.connection_lost(RuntimeError())
assert len(mock_reload.mock_calls) == 1
await hass.async_block_till_done()
@pytest.mark.parametrize("radio_concurrency", [1, 2, 8])
async def test_startup_concurrency_limit(
radio_concurrency: int,
hass: HomeAssistant,
zigpy_app_controller: ControllerApplication,
config_entry: MockConfigEntry,
zigpy_device_mock,
) -> None:
"""Test ZHA gateway limits concurrency on startup."""
config_entry.add_to_hass(hass)
zha_gateway = ZHAGateway(hass, {}, config_entry)
with patch(
"bellows.zigbee.application.ControllerApplication.new",
return_value=zigpy_app_controller,
):
await zha_gateway.async_initialize()
for i in range(50):
zigpy_dev = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [
general.OnOff.cluster_id,
general.LevelControl.cluster_id,
lighting.Color.cluster_id,
general.Groups.cluster_id,
],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
},
ieee=f"11:22:33:44:{i:08x}",
nwk=0x1234 + i,
)
zigpy_dev.node_desc.mac_capability_flags |= (
zigpy.zdo.types.NodeDescriptor.MACCapabilityFlags.MainsPowered
)
zha_gateway._async_get_or_create_device(zigpy_dev)
# Keep track of request concurrency during initialization
current_concurrency = 0
concurrencies = []
async def mock_send_packet(*args, **kwargs):
nonlocal current_concurrency
current_concurrency += 1
concurrencies.append(current_concurrency)
await asyncio.sleep(0.001)
current_concurrency -= 1
concurrencies.append(current_concurrency)
type(zha_gateway).radio_concurrency = PropertyMock(return_value=radio_concurrency)
assert zha_gateway.radio_concurrency == radio_concurrency
with patch(
"homeassistant.components.zha.core.device.ZHADevice.async_initialize",
side_effect=mock_send_packet,
):
await zha_gateway.async_fetch_updated_state_mains()
await zha_gateway.shutdown()
# Make sure concurrency was always limited
assert current_concurrency == 0
assert min(concurrencies) == 0
if radio_concurrency > 1:
assert 1 <= max(concurrencies) < zha_gateway.radio_concurrency
else:
assert 1 == max(concurrencies) == zha_gateway.radio_concurrency

View File

@ -1,81 +1,27 @@
"""Tests for ZHA helpers."""
import enum
import logging
from unittest.mock import patch
from typing import Any
import pytest
import voluptuous_serialize
from zigpy.profiles import zha
from zigpy.quirks.v2.homeassistant import UnitOfPower as QuirksUnitOfPower
from zigpy.types.basic import uint16_t
from zigpy.zcl.clusters import general, lighting
from zigpy.zcl.clusters import lighting
from homeassistant.components.zha.core.helpers import (
from homeassistant.components.zha.helpers import (
cluster_command_schema_to_vol_schema,
convert_to_zcl_values,
validate_unit,
exclude_none_values,
)
from homeassistant.const import Platform, UnitOfPower
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from .common import async_enable_traffic
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
_LOGGER = logging.getLogger(__name__)
@pytest.fixture(autouse=True)
def light_platform_only():
"""Only set up the light and required base platforms to speed up tests."""
with patch(
"homeassistant.components.zha.PLATFORMS",
(
Platform.BUTTON,
Platform.LIGHT,
Platform.NUMBER,
Platform.SELECT,
),
):
yield
@pytest.fixture
async def device_light(hass: HomeAssistant, zigpy_device_mock, zha_device_joined):
"""Test light."""
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [
general.OnOff.cluster_id,
general.LevelControl.cluster_id,
lighting.Color.cluster_id,
general.Groups.cluster_id,
general.Identify.cluster_id,
],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
}
)
color_cluster = zigpy_device.endpoints[1].light_color
color_cluster.PLUGGED_ATTR_READS = {
"color_capabilities": lighting.Color.ColorCapabilities.Color_temperature
| lighting.Color.ColorCapabilities.XY_attributes
}
zha_device = await zha_device_joined(zigpy_device)
zha_device.available = True
return color_cluster, zha_device
async def test_zcl_schema_conversions(hass: HomeAssistant, device_light) -> None:
async def test_zcl_schema_conversions(hass: HomeAssistant) -> None:
"""Test ZHA ZCL schema conversion helpers."""
color_cluster, zha_device = device_light
await async_enable_traffic(hass, [zha_device])
command_schema = color_cluster.commands_by_name["color_loop_set"].schema
command_schema = lighting.Color.ServerCommandDefs.color_loop_set.schema
expected_schema = [
{
"type": "multi_select",
@ -215,23 +161,21 @@ async def test_zcl_schema_conversions(hass: HomeAssistant, device_light) -> None
assert converted_data["update_flags"] == 0
def test_unit_validation() -> None:
"""Test unit validation."""
@pytest.mark.parametrize(
("obj", "expected_output"),
[
({"a": 1, "b": 2, "c": None}, {"a": 1, "b": 2}),
({"a": 1, "b": 2, "c": 0}, {"a": 1, "b": 2, "c": 0}),
({"a": 1, "b": 2, "c": ""}, {"a": 1, "b": 2, "c": ""}),
({"a": 1, "b": 2, "c": False}, {"a": 1, "b": 2, "c": False}),
],
)
def test_exclude_none_values(
obj: dict[str, Any], expected_output: dict[str, Any]
) -> None:
"""Test exclude_none_values helper."""
result = exclude_none_values(obj)
assert result == expected_output
assert validate_unit(QuirksUnitOfPower.WATT) == UnitOfPower.WATT
class FooUnit(enum.Enum):
"""Foo unit."""
BAR = "bar"
class UnitOfMass(enum.Enum):
"""UnitOfMass."""
BAR = "bar"
with pytest.raises(KeyError):
validate_unit(FooUnit.BAR)
with pytest.raises(ValueError):
validate_unit(UnitOfMass.BAR)
for key in expected_output:
assert expected_output[key] == obj[key]

View File

@ -9,14 +9,14 @@ from zigpy.application import ControllerApplication
from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH
from zigpy.exceptions import TransientConnectionError
from homeassistant.components.zha.core.const import (
from homeassistant.components.zha.const import (
CONF_BAUDRATE,
CONF_FLOW_CONTROL,
CONF_RADIO_TYPE,
CONF_USB_PATH,
DOMAIN,
)
from homeassistant.components.zha.core.helpers import get_zha_data
from homeassistant.components.zha.helpers import get_zha_data
from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
MAJOR_VERSION,
@ -43,7 +43,7 @@ def disable_platform_only():
@pytest.fixture
def config_entry_v1(hass):
def config_entry_v1(hass: HomeAssistant):
"""Config entry version 1 fixture."""
return MockConfigEntry(
domain=DOMAIN,
@ -139,7 +139,6 @@ async def test_config_depreciation(hass: HomeAssistant, zha_config) -> None:
("socket://[1.2.3.4]:5678 ", "socket://1.2.3.4:5678"),
],
)
@patch("homeassistant.components.zha.setup_quirks", Mock(return_value=True))
@patch(
"homeassistant.components.zha.websocket_api.async_load_api", Mock(return_value=True)
)
@ -282,7 +281,7 @@ async def test_shutdown_on_ha_stop(
zha_data = get_zha_data(hass)
with patch.object(
zha_data.gateway, "shutdown", wraps=zha_data.gateway.shutdown
zha_data.gateway_proxy, "shutdown", wraps=zha_data.gateway_proxy.shutdown
) as mock_shutdown:
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
hass.set_state(CoreState.stopping)

File diff suppressed because it is too large Load Diff

View File

@ -3,27 +3,23 @@
from unittest.mock import patch
import pytest
import zigpy.profiles.zha
from zigpy.profiles import zha
from zigpy.zcl import Cluster
from zigpy.zcl.clusters import closures, general
import zigpy.zcl.foundation as zcl_f
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
from homeassistant.const import (
STATE_LOCKED,
STATE_UNAVAILABLE,
STATE_UNLOCKED,
Platform,
from homeassistant.components.zha.helpers import (
ZHADeviceProxy,
ZHAGatewayProxy,
get_zha_gateway,
get_zha_gateway_proxy,
)
from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED, Platform
from homeassistant.core import HomeAssistant
from .common import async_enable_traffic, find_entity_id, send_attributes_report
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE
LOCK_DOOR = 0
UNLOCK_DOOR = 1
SET_PIN_CODE = 5
CLEAR_PIN_CODE = 7
SET_USER_STATUS = 9
from .common import find_entity_id, send_attributes_report
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
@pytest.fixture(autouse=True)
@ -40,48 +36,51 @@ def lock_platform_only():
yield
@pytest.fixture
async def lock(hass, zigpy_device_mock, zha_device_joined_restored):
"""Lock cluster fixture."""
async def test_lock(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None:
"""Test ZHA lock platform."""
await setup_zha()
gateway = get_zha_gateway(hass)
gateway_proxy: ZHAGatewayProxy = get_zha_gateway_proxy(hass)
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [closures.DoorLock.cluster_id, general.Basic.cluster_id],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.DOOR_LOCK,
SIG_EP_TYPE: zha.DeviceType.DOOR_LOCK,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
},
ieee="01:2d:6f:00:0a:90:69:e8",
node_descriptor=b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00",
)
zha_device = await zha_device_joined_restored(zigpy_device)
return zha_device, zigpy_device.endpoints[1].door_lock
gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
async def test_lock(hass: HomeAssistant, lock) -> None:
"""Test ZHA lock platform."""
zha_device, cluster = lock
entity_id = find_entity_id(Platform.LOCK, zha_device, hass)
zha_device_proxy: ZHADeviceProxy = gateway_proxy.get_device_proxy(zigpy_device.ieee)
entity_id = find_entity_id(Platform.LOCK, zha_device_proxy, hass)
cluster = zigpy_device.endpoints[1].door_lock
assert entity_id is not None
assert hass.states.get(entity_id).state == STATE_UNLOCKED
await async_enable_traffic(hass, [zha_device], enabled=False)
# test that the lock was created and that it is unavailable
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [zha_device])
# test that the state has changed from unavailable to unlocked
assert hass.states.get(entity_id).state == STATE_UNLOCKED
# set state to locked
await send_attributes_report(hass, cluster, {1: 0, 0: 1, 2: 2})
await send_attributes_report(
hass,
cluster,
{closures.DoorLock.AttributeDefs.lock_state.id: closures.LockState.Locked},
)
assert hass.states.get(entity_id).state == STATE_LOCKED
# set state to unlocked
await send_attributes_report(hass, cluster, {1: 0, 0: 2, 2: 3})
await send_attributes_report(
hass,
cluster,
{closures.DoorLock.AttributeDefs.lock_state.id: closures.LockState.Unlocked},
)
assert hass.states.get(entity_id).state == STATE_UNLOCKED
# lock from HA
@ -103,7 +102,7 @@ async def test_lock(hass: HomeAssistant, lock) -> None:
await async_disable_user_code(hass, cluster, entity_id)
async def async_lock(hass, cluster, entity_id):
async def async_lock(hass: HomeAssistant, cluster: Cluster, entity_id: str):
"""Test lock functionality from hass."""
with patch("zigpy.zcl.Cluster.request", return_value=[zcl_f.Status.SUCCESS]):
# lock via UI
@ -112,10 +111,13 @@ async def async_lock(hass, cluster, entity_id):
)
assert cluster.request.call_count == 1
assert cluster.request.call_args[0][0] is False
assert cluster.request.call_args[0][1] == LOCK_DOOR
assert (
cluster.request.call_args[0][1]
== closures.DoorLock.ServerCommandDefs.lock_door.id
)
async def async_unlock(hass, cluster, entity_id):
async def async_unlock(hass: HomeAssistant, cluster: Cluster, entity_id: str):
"""Test lock functionality from hass."""
with patch("zigpy.zcl.Cluster.request", return_value=[zcl_f.Status.SUCCESS]):
# lock via UI
@ -124,10 +126,13 @@ async def async_unlock(hass, cluster, entity_id):
)
assert cluster.request.call_count == 1
assert cluster.request.call_args[0][0] is False
assert cluster.request.call_args[0][1] == UNLOCK_DOOR
assert (
cluster.request.call_args[0][1]
== closures.DoorLock.ServerCommandDefs.unlock_door.id
)
async def async_set_user_code(hass, cluster, entity_id):
async def async_set_user_code(hass: HomeAssistant, cluster: Cluster, entity_id: str):
"""Test set lock code functionality from hass."""
with patch("zigpy.zcl.Cluster.request", return_value=[zcl_f.Status.SUCCESS]):
# set lock code via service call
@ -139,7 +144,10 @@ async def async_set_user_code(hass, cluster, entity_id):
)
assert cluster.request.call_count == 1
assert cluster.request.call_args[0][0] is False
assert cluster.request.call_args[0][1] == SET_PIN_CODE
assert (
cluster.request.call_args[0][1]
== closures.DoorLock.ServerCommandDefs.set_pin_code.id
)
assert cluster.request.call_args[0][3] == 2 # user slot 3 => internal slot 2
assert cluster.request.call_args[0][4] == closures.DoorLock.UserStatus.Enabled
assert (
@ -148,7 +156,7 @@ async def async_set_user_code(hass, cluster, entity_id):
assert cluster.request.call_args[0][6] == "13246579"
async def async_clear_user_code(hass, cluster, entity_id):
async def async_clear_user_code(hass: HomeAssistant, cluster: Cluster, entity_id: str):
"""Test clear lock code functionality from hass."""
with patch("zigpy.zcl.Cluster.request", return_value=[zcl_f.Status.SUCCESS]):
# set lock code via service call
@ -163,11 +171,14 @@ async def async_clear_user_code(hass, cluster, entity_id):
)
assert cluster.request.call_count == 1
assert cluster.request.call_args[0][0] is False
assert cluster.request.call_args[0][1] == CLEAR_PIN_CODE
assert (
cluster.request.call_args[0][1]
== closures.DoorLock.ServerCommandDefs.clear_pin_code.id
)
assert cluster.request.call_args[0][3] == 2 # user slot 3 => internal slot 2
async def async_enable_user_code(hass, cluster, entity_id):
async def async_enable_user_code(hass: HomeAssistant, cluster: Cluster, entity_id: str):
"""Test enable lock code functionality from hass."""
with patch("zigpy.zcl.Cluster.request", return_value=[zcl_f.Status.SUCCESS]):
# set lock code via service call
@ -182,12 +193,17 @@ async def async_enable_user_code(hass, cluster, entity_id):
)
assert cluster.request.call_count == 1
assert cluster.request.call_args[0][0] is False
assert cluster.request.call_args[0][1] == SET_USER_STATUS
assert (
cluster.request.call_args[0][1]
== closures.DoorLock.ServerCommandDefs.set_user_status.id
)
assert cluster.request.call_args[0][3] == 2 # user slot 3 => internal slot 2
assert cluster.request.call_args[0][4] == closures.DoorLock.UserStatus.Enabled
async def async_disable_user_code(hass, cluster, entity_id):
async def async_disable_user_code(
hass: HomeAssistant, cluster: Cluster, entity_id: str
):
"""Test disable lock code functionality from hass."""
with patch("zigpy.zcl.Cluster.request", return_value=[zcl_f.Status.SUCCESS]):
# set lock code via service call
@ -202,6 +218,9 @@ async def async_disable_user_code(hass, cluster, entity_id):
)
assert cluster.request.call_count == 1
assert cluster.request.call_args[0][0] is False
assert cluster.request.call_args[0][1] == SET_USER_STATUS
assert (
cluster.request.call_args[0][1]
== closures.DoorLock.ServerCommandDefs.set_user_status.id
)
assert cluster.request.call_args[0][3] == 2 # user slot 3 => internal slot 2
assert cluster.request.call_args[0][4] == closures.DoorLock.UserStatus.Disabled

View File

@ -3,10 +3,16 @@
from unittest.mock import patch
import pytest
from zha.application.const import ZHA_EVENT
import zigpy.profiles.zha
from zigpy.zcl.clusters import general
from homeassistant.components.zha.core.const import ZHA_EVENT
from homeassistant.components.zha.helpers import (
ZHADeviceProxy,
ZHAGatewayProxy,
get_zha_gateway,
get_zha_gateway_proxy,
)
from homeassistant.const import CONF_DEVICE_ID, CONF_UNIQUE_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
@ -40,9 +46,13 @@ def sensor_platform_only():
@pytest.fixture
async def mock_devices(hass, zigpy_device_mock, zha_device_joined):
async def mock_devices(hass: HomeAssistant, setup_zha, zigpy_device_mock):
"""IAS device fixture."""
await setup_zha()
gateway = get_zha_gateway(hass)
gateway_proxy: ZHAGatewayProxy = get_zha_gateway_proxy(hass)
zigpy_device = zigpy_device_mock(
{
1: {
@ -54,10 +64,13 @@ async def mock_devices(hass, zigpy_device_mock, zha_device_joined):
}
)
zha_device = await zha_device_joined(zigpy_device)
zha_device.update_available(True)
await hass.async_block_till_done()
return zigpy_device, zha_device
gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
zha_device_proxy: ZHADeviceProxy = gateway_proxy.get_device_proxy(zigpy_device.ieee)
return zigpy_device, zha_device_proxy
async def test_zha_logbook_event_device_with_triggers(
@ -76,7 +89,7 @@ async def test_zha_logbook_event_device_with_triggers(
(LONG_RELEASE, LONG_RELEASE): {COMMAND: COMMAND_HOLD},
}
ieee_address = str(zha_device.ieee)
ieee_address = str(zha_device.device.ieee)
reg_device = device_registry.async_get_device(identifiers={("zha", ieee_address)})
@ -153,7 +166,7 @@ async def test_zha_logbook_event_device_no_triggers(
"""Test ZHA logbook events with device and without triggers."""
zigpy_device, zha_device = mock_devices
ieee_address = str(zha_device.ieee)
ieee_address = str(zha_device.device.ieee)
reg_device = device_registry.async_get_device(identifiers={("zha", ieee_address)})
hass.config.components.add("recorder")

View File

@ -3,26 +3,22 @@
from unittest.mock import call, patch
import pytest
from zigpy.exceptions import ZigbeeException
from zigpy.profiles import zha
from zigpy.zcl.clusters import general, lighting
from zigpy.zcl.clusters import general
import zigpy.zcl.foundation as zcl_f
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN
from homeassistant.components.zha.core.device import ZHADevice
from homeassistant.const import STATE_UNAVAILABLE, EntityCategory, Platform
from homeassistant.components.zha.helpers import (
ZHADeviceProxy,
ZHAGatewayProxy,
get_zha_gateway,
get_zha_gateway_proxy,
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from .common import (
async_enable_traffic,
async_test_rejoin,
find_entity_id,
send_attributes_report,
update_attribute_cache,
)
from .common import find_entity_id, send_attributes_report, update_attribute_cache
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
@ -43,49 +39,28 @@ def number_platform_only():
yield
@pytest.fixture
def zigpy_analog_output_device(zigpy_device_mock):
"""Zigpy analog_output device."""
endpoints = {
1: {
SIG_EP_TYPE: zha.DeviceType.LEVEL_CONTROL_SWITCH,
SIG_EP_INPUT: [general.AnalogOutput.cluster_id, general.Basic.cluster_id],
SIG_EP_OUTPUT: [],
}
}
return zigpy_device_mock(endpoints)
@pytest.fixture
async def light(zigpy_device_mock):
"""Siren fixture."""
return zigpy_device_mock(
{
1: {
SIG_EP_PROFILE: zha.PROFILE_ID,
SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT,
SIG_EP_INPUT: [
general.Basic.cluster_id,
general.Identify.cluster_id,
general.OnOff.cluster_id,
general.LevelControl.cluster_id,
lighting.Color.cluster_id,
],
SIG_EP_OUTPUT: [general.Ota.cluster_id],
}
},
node_descriptor=b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00",
)
async def test_number(
hass: HomeAssistant, zha_device_joined_restored, zigpy_analog_output_device
) -> None:
async def test_number(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None:
"""Test ZHA number platform."""
cluster = zigpy_analog_output_device.endpoints.get(1).analog_output
await setup_zha()
gateway = get_zha_gateway(hass)
gateway_proxy: ZHAGatewayProxy = get_zha_gateway_proxy(hass)
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_TYPE: zha.DeviceType.LEVEL_CONTROL_SWITCH,
SIG_EP_INPUT: [
general.AnalogOutput.cluster_id,
general.Basic.cluster_id,
],
SIG_EP_OUTPUT: [],
SIG_EP_PROFILE: zha.PROFILE_ID,
}
}
)
cluster = zigpy_device.endpoints[1].analog_output
cluster.PLUGGED_ATTR_READS = {
"max_present_value": 100.0,
"min_present_value": 1.0,
@ -98,34 +73,14 @@ async def test_number(
update_attribute_cache(cluster)
cluster.PLUGGED_ATTR_READS["present_value"] = 15.0
zha_device = await zha_device_joined_restored(zigpy_analog_output_device)
# one for present_value and one for the rest configuration attributes
assert cluster.read_attributes.call_count == 3
attr_reads = set()
for call_args in cluster.read_attributes.call_args_list:
attr_reads |= set(call_args[0][0])
assert "max_present_value" in attr_reads
assert "min_present_value" in attr_reads
assert "relinquish_default" in attr_reads
assert "resolution" in attr_reads
assert "description" in attr_reads
assert "engineering_units" in attr_reads
assert "application_type" in attr_reads
gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
entity_id = find_entity_id(Platform.NUMBER, zha_device, hass)
zha_device_proxy: ZHADeviceProxy = gateway_proxy.get_device_proxy(zigpy_device.ieee)
entity_id = find_entity_id(Platform.NUMBER, zha_device_proxy, hass)
assert entity_id is not None
await async_enable_traffic(hass, [zha_device], enabled=False)
# test that the number was created and that it is unavailable
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
assert cluster.read_attributes.call_count == 3
await async_enable_traffic(hass, [zha_device])
await hass.async_block_till_done()
assert cluster.read_attributes.call_count == 6
# test that the state has changed from unavailable to 15.0
assert hass.states.get(entity_id).state == "15.0"
# test attributes
@ -134,13 +89,13 @@ async def test_number(
assert hass.states.get(entity_id).attributes.get("step") == 1.1
assert hass.states.get(entity_id).attributes.get("icon") == "mdi:percent"
assert hass.states.get(entity_id).attributes.get("unit_of_measurement") == "%"
assert (
hass.states.get(entity_id).attributes.get("friendly_name")
== "FakeManufacturer FakeModel Number PWM1"
)
# change value from device
assert cluster.read_attributes.call_count == 6
await send_attributes_report(hass, cluster, {0x0055: 15})
assert hass.states.get(entity_id).state == "15.0"
@ -165,16 +120,8 @@ async def test_number(
]
cluster.PLUGGED_ATTR_READS["present_value"] = 30.0
# test rejoin
assert cluster.read_attributes.call_count == 6
await async_test_rejoin(hass, zigpy_analog_output_device, [cluster], (1,))
assert hass.states.get(entity_id).state == "30.0"
assert cluster.read_attributes.call_count == 9
# update device value with failed attribute report
cluster.PLUGGED_ATTR_READS["present_value"] = 40.0
# validate the entity still contains old value
assert hass.states.get(entity_id).state == "30.0"
await async_setup_component(hass, "homeassistant", {})
await hass.async_block_till_done()
@ -183,251 +130,4 @@ async def test_number(
"homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True
)
assert hass.states.get(entity_id).state == "40.0"
assert cluster.read_attributes.call_count == 10
assert "present_value" in cluster.read_attributes.call_args[0][0]
@pytest.mark.parametrize(
("attr", "initial_value", "new_value"),
[
("on_off_transition_time", 20, 5),
("on_level", 255, 50),
("on_transition_time", 5, 1),
("off_transition_time", 5, 1),
("default_move_rate", 1, 5),
("start_up_current_level", 254, 125),
],
)
async def test_level_control_number(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
light: ZHADevice,
zha_device_joined,
attr: str,
initial_value: int,
new_value: int,
) -> None:
"""Test ZHA level control number entities - new join."""
level_control_cluster = light.endpoints[1].level
level_control_cluster.PLUGGED_ATTR_READS = {
attr: initial_value,
}
zha_device = await zha_device_joined(light)
entity_id = find_entity_id(
Platform.NUMBER,
zha_device,
hass,
qualifier=attr,
)
assert entity_id is not None
assert level_control_cluster.read_attributes.mock_calls == [
call(
[
"on_off_transition_time",
"on_level",
"on_transition_time",
"off_transition_time",
"default_move_rate",
],
allow_cache=True,
only_cache=False,
manufacturer=None,
),
call(
["start_up_current_level"],
allow_cache=True,
only_cache=False,
manufacturer=None,
),
call(
[
"current_level",
],
allow_cache=False,
only_cache=False,
manufacturer=None,
),
]
state = hass.states.get(entity_id)
assert state
assert state.state == str(initial_value)
entity_entry = entity_registry.async_get(entity_id)
assert entity_entry
assert entity_entry.entity_category == EntityCategory.CONFIG
# Test number set_value
await hass.services.async_call(
"number",
"set_value",
{
"entity_id": entity_id,
"value": new_value,
},
blocking=True,
)
assert level_control_cluster.write_attributes.mock_calls == [
call({attr: new_value}, manufacturer=None)
]
state = hass.states.get(entity_id)
assert state
assert state.state == str(new_value)
level_control_cluster.read_attributes.reset_mock()
await async_setup_component(hass, "homeassistant", {})
await hass.async_block_till_done()
await hass.services.async_call(
"homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True
)
# the mocking doesn't update the attr cache so this flips back to initial value
assert hass.states.get(entity_id).state == str(initial_value)
assert level_control_cluster.read_attributes.mock_calls == [
call(
[attr],
allow_cache=False,
only_cache=False,
manufacturer=None,
)
]
level_control_cluster.write_attributes.reset_mock()
level_control_cluster.write_attributes.side_effect = ZigbeeException
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
"number",
"set_value",
{
"entity_id": entity_id,
"value": new_value,
},
blocking=True,
)
assert level_control_cluster.write_attributes.mock_calls == [
call({attr: new_value}, manufacturer=None),
call({attr: new_value}, manufacturer=None),
call({attr: new_value}, manufacturer=None),
]
assert hass.states.get(entity_id).state == str(initial_value)
@pytest.mark.parametrize(
("attr", "initial_value", "new_value"),
[("start_up_color_temperature", 500, 350)],
)
async def test_color_number(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
light: ZHADevice,
zha_device_joined,
attr: str,
initial_value: int,
new_value: int,
) -> None:
"""Test ZHA color number entities - new join."""
color_cluster = light.endpoints[1].light_color
color_cluster.PLUGGED_ATTR_READS = {
attr: initial_value,
}
zha_device = await zha_device_joined(light)
entity_id = find_entity_id(
Platform.NUMBER,
zha_device,
hass,
qualifier=attr,
)
assert entity_id is not None
assert color_cluster.read_attributes.call_count == 3
assert (
call(
[
"color_temp_physical_min",
"color_temp_physical_max",
"color_capabilities",
"start_up_color_temperature",
"options",
],
allow_cache=True,
only_cache=False,
manufacturer=None,
)
in color_cluster.read_attributes.call_args_list
)
state = hass.states.get(entity_id)
assert state
assert state.state == str(initial_value)
entity_entry = entity_registry.async_get(entity_id)
assert entity_entry
assert entity_entry.entity_category == EntityCategory.CONFIG
# Test number set_value
await hass.services.async_call(
"number",
"set_value",
{
"entity_id": entity_id,
"value": new_value,
},
blocking=True,
)
assert color_cluster.write_attributes.call_count == 1
assert color_cluster.write_attributes.call_args[0][0] == {
attr: new_value,
}
state = hass.states.get(entity_id)
assert state
assert state.state == str(new_value)
color_cluster.read_attributes.reset_mock()
await async_setup_component(hass, "homeassistant", {})
await hass.async_block_till_done()
await hass.services.async_call(
"homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True
)
# the mocking doesn't update the attr cache so this flips back to initial value
assert hass.states.get(entity_id).state == str(initial_value)
assert color_cluster.read_attributes.call_count == 1
assert (
call(
[attr],
allow_cache=False,
only_cache=False,
manufacturer=None,
)
in color_cluster.read_attributes.call_args_list
)
color_cluster.write_attributes.reset_mock()
color_cluster.write_attributes.side_effect = ZigbeeException
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
"number",
"set_value",
{
"entity_id": entity_id,
"value": new_value,
},
blocking=True,
)
assert color_cluster.write_attributes.mock_calls == [
call({attr: new_value}, manufacturer=None),
call({attr: new_value}, manufacturer=None),
call({attr: new_value}, manufacturer=None),
]
assert hass.states.get(entity_id).state == str(initial_value)

View File

@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, create_autospec, patch
import pytest
import serial.tools.list_ports
from zha.application.const import RadioType
from zigpy.backups import BackupManager
import zigpy.config
from zigpy.config import CONF_DEVICE_PATH
@ -12,7 +13,7 @@ import zigpy.types
from homeassistant.components.usb import UsbServiceInfo
from homeassistant.components.zha import radio_manager
from homeassistant.components.zha.core.const import DOMAIN, RadioType
from homeassistant.components.zha.const import DOMAIN
from homeassistant.components.zha.radio_manager import ProbeResult, ZhaRadioManager
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant

View File

@ -1,602 +0,0 @@
"""Test ZHA registries."""
from __future__ import annotations
from collections.abc import Generator
from unittest import mock
import pytest
import zigpy.quirks as zigpy_quirks
from homeassistant.components.zha.binary_sensor import IASZone
from homeassistant.components.zha.core import registries
from homeassistant.components.zha.core.const import ATTR_QUIRK_ID
from homeassistant.components.zha.entity import ZhaEntity
from homeassistant.helpers import entity_registry as er
MANUFACTURER = "mock manufacturer"
MODEL = "mock model"
QUIRK_CLASS = "mock.test.quirk.class"
QUIRK_ID = "quirk_id"
@pytest.fixture
def zha_device():
"""Return a mock of ZHA device."""
dev = mock.MagicMock()
dev.manufacturer = MANUFACTURER
dev.model = MODEL
dev.quirk_class = QUIRK_CLASS
dev.quirk_id = QUIRK_ID
return dev
@pytest.fixture
def cluster_handlers(cluster_handler):
"""Return a mock of cluster_handlers."""
return [cluster_handler("level", 8), cluster_handler("on_off", 6)]
@pytest.mark.parametrize(
("rule", "matched"),
[
(registries.MatchRule(), False),
(registries.MatchRule(cluster_handler_names={"level"}), True),
(registries.MatchRule(cluster_handler_names={"level", "no match"}), False),
(registries.MatchRule(cluster_handler_names={"on_off"}), True),
(registries.MatchRule(cluster_handler_names={"on_off", "no match"}), False),
(registries.MatchRule(cluster_handler_names={"on_off", "level"}), True),
(
registries.MatchRule(cluster_handler_names={"on_off", "level", "no match"}),
False,
),
# test generic_id matching
(registries.MatchRule(generic_ids={"cluster_handler_0x0006"}), True),
(registries.MatchRule(generic_ids={"cluster_handler_0x0008"}), True),
(
registries.MatchRule(
generic_ids={"cluster_handler_0x0006", "cluster_handler_0x0008"}
),
True,
),
(
registries.MatchRule(
generic_ids={
"cluster_handler_0x0006",
"cluster_handler_0x0008",
"cluster_handler_0x0009",
}
),
False,
),
(
registries.MatchRule(
generic_ids={"cluster_handler_0x0006", "cluster_handler_0x0008"},
cluster_handler_names={"on_off", "level"},
),
True,
),
# manufacturer matching
(registries.MatchRule(manufacturers="no match"), False),
(registries.MatchRule(manufacturers=MANUFACTURER), True),
(
registries.MatchRule(
manufacturers="no match", aux_cluster_handlers="aux_cluster_handler"
),
False,
),
(
registries.MatchRule(
manufacturers=MANUFACTURER, aux_cluster_handlers="aux_cluster_handler"
),
True,
),
(registries.MatchRule(models=MODEL), True),
(registries.MatchRule(models="no match"), False),
(
registries.MatchRule(
models=MODEL, aux_cluster_handlers="aux_cluster_handler"
),
True,
),
(
registries.MatchRule(
models="no match", aux_cluster_handlers="aux_cluster_handler"
),
False,
),
(registries.MatchRule(quirk_ids=QUIRK_ID), True),
(registries.MatchRule(quirk_ids="no match"), False),
(
registries.MatchRule(
quirk_ids=QUIRK_ID, aux_cluster_handlers="aux_cluster_handler"
),
True,
),
(
registries.MatchRule(
quirk_ids="no match", aux_cluster_handlers="aux_cluster_handler"
),
False,
),
# match everything
(
registries.MatchRule(
generic_ids={"cluster_handler_0x0006", "cluster_handler_0x0008"},
cluster_handler_names={"on_off", "level"},
manufacturers=MANUFACTURER,
models=MODEL,
quirk_ids=QUIRK_ID,
),
True,
),
(
registries.MatchRule(
cluster_handler_names="on_off",
manufacturers={"random manuf", MANUFACTURER},
),
True,
),
(
registries.MatchRule(
cluster_handler_names="on_off",
manufacturers={"random manuf", "Another manuf"},
),
False,
),
(
registries.MatchRule(
cluster_handler_names="on_off",
manufacturers=lambda x: x == MANUFACTURER,
),
True,
),
(
registries.MatchRule(
cluster_handler_names="on_off",
manufacturers=lambda x: x != MANUFACTURER,
),
False,
),
(
registries.MatchRule(
cluster_handler_names="on_off", models={"random model", MODEL}
),
True,
),
(
registries.MatchRule(
cluster_handler_names="on_off", models={"random model", "Another model"}
),
False,
),
(
registries.MatchRule(
cluster_handler_names="on_off", models=lambda x: x == MODEL
),
True,
),
(
registries.MatchRule(
cluster_handler_names="on_off", models=lambda x: x != MODEL
),
False,
),
(
registries.MatchRule(
cluster_handler_names="on_off",
quirk_ids={"random quirk", QUIRK_ID},
),
True,
),
(
registries.MatchRule(
cluster_handler_names="on_off",
quirk_ids={"random quirk", "another quirk"},
),
False,
),
(
registries.MatchRule(
cluster_handler_names="on_off", quirk_ids=lambda x: x == QUIRK_ID
),
True,
),
(
registries.MatchRule(
cluster_handler_names="on_off", quirk_ids=lambda x: x != QUIRK_ID
),
False,
),
(
registries.MatchRule(cluster_handler_names="on_off", quirk_ids=QUIRK_ID),
True,
),
],
)
def test_registry_matching(rule, matched, cluster_handlers) -> None:
"""Test strict rule matching."""
assert (
rule.strict_matched(MANUFACTURER, MODEL, cluster_handlers, QUIRK_ID) is matched
)
@pytest.mark.parametrize(
("rule", "matched"),
[
(registries.MatchRule(), False),
(registries.MatchRule(cluster_handler_names={"level"}), True),
(registries.MatchRule(cluster_handler_names={"level", "no match"}), False),
(registries.MatchRule(cluster_handler_names={"on_off"}), True),
(registries.MatchRule(cluster_handler_names={"on_off", "no match"}), False),
(registries.MatchRule(cluster_handler_names={"on_off", "level"}), True),
(
registries.MatchRule(cluster_handler_names={"on_off", "level", "no match"}),
False,
),
(
registries.MatchRule(
cluster_handler_names={"on_off", "level"}, models="no match"
),
True,
),
(
registries.MatchRule(
cluster_handler_names={"on_off", "level"},
models="no match",
manufacturers="no match",
),
True,
),
(
registries.MatchRule(
cluster_handler_names={"on_off", "level"},
models="no match",
manufacturers=MANUFACTURER,
),
True,
),
# test generic_id matching
(registries.MatchRule(generic_ids={"cluster_handler_0x0006"}), True),
(registries.MatchRule(generic_ids={"cluster_handler_0x0008"}), True),
(
registries.MatchRule(
generic_ids={"cluster_handler_0x0006", "cluster_handler_0x0008"}
),
True,
),
(
registries.MatchRule(
generic_ids={
"cluster_handler_0x0006",
"cluster_handler_0x0008",
"cluster_handler_0x0009",
}
),
False,
),
(
registries.MatchRule(
generic_ids={
"cluster_handler_0x0006",
"cluster_handler_0x0008",
"cluster_handler_0x0009",
},
models="mo match",
),
False,
),
(
registries.MatchRule(
generic_ids={
"cluster_handler_0x0006",
"cluster_handler_0x0008",
"cluster_handler_0x0009",
},
models=MODEL,
),
True,
),
(
registries.MatchRule(
generic_ids={"cluster_handler_0x0006", "cluster_handler_0x0008"},
cluster_handler_names={"on_off", "level"},
),
True,
),
# manufacturer matching
(registries.MatchRule(manufacturers="no match"), False),
(registries.MatchRule(manufacturers=MANUFACTURER), True),
(registries.MatchRule(models=MODEL), True),
(registries.MatchRule(models="no match"), False),
(registries.MatchRule(quirk_ids=QUIRK_ID), True),
(registries.MatchRule(quirk_ids="no match"), False),
# match everything
(
registries.MatchRule(
generic_ids={"cluster_handler_0x0006", "cluster_handler_0x0008"},
cluster_handler_names={"on_off", "level"},
manufacturers=MANUFACTURER,
models=MODEL,
quirk_ids=QUIRK_ID,
),
True,
),
],
)
def test_registry_loose_matching(rule, matched, cluster_handlers) -> None:
"""Test loose rule matching."""
assert (
rule.loose_matched(MANUFACTURER, MODEL, cluster_handlers, QUIRK_ID) is matched
)
def test_match_rule_claim_cluster_handlers_color(cluster_handler) -> None:
"""Test cluster handler claiming."""
ch_color = cluster_handler("color", 0x300)
ch_level = cluster_handler("level", 8)
ch_onoff = cluster_handler("on_off", 6)
rule = registries.MatchRule(
cluster_handler_names="on_off", aux_cluster_handlers={"color", "level"}
)
claimed = rule.claim_cluster_handlers([ch_color, ch_level, ch_onoff])
assert {"color", "level", "on_off"} == {ch.name for ch in claimed}
@pytest.mark.parametrize(
("rule", "match"),
[
(registries.MatchRule(cluster_handler_names={"level"}), {"level"}),
(registries.MatchRule(cluster_handler_names={"level", "no match"}), {"level"}),
(registries.MatchRule(cluster_handler_names={"on_off"}), {"on_off"}),
(registries.MatchRule(generic_ids="cluster_handler_0x0000"), {"basic"}),
(
registries.MatchRule(
cluster_handler_names="level", generic_ids="cluster_handler_0x0000"
),
{"basic", "level"},
),
(
registries.MatchRule(cluster_handler_names={"level", "power"}),
{"level", "power"},
),
(
registries.MatchRule(
cluster_handler_names={"level", "on_off"},
aux_cluster_handlers={"basic", "power"},
),
{"basic", "level", "on_off", "power"},
),
(registries.MatchRule(cluster_handler_names={"color"}), set()),
],
)
def test_match_rule_claim_cluster_handlers(
rule, match, cluster_handler, cluster_handlers
) -> None:
"""Test cluster handler claiming."""
ch_basic = cluster_handler("basic", 0)
cluster_handlers.append(ch_basic)
ch_power = cluster_handler("power", 1)
cluster_handlers.append(ch_power)
claimed = rule.claim_cluster_handlers(cluster_handlers)
assert match == {ch.name for ch in claimed}
@pytest.fixture
def entity_registry():
"""Registry fixture."""
return registries.ZHAEntityRegistry()
@pytest.mark.parametrize(
("manufacturer", "model", "quirk_id", "match_name"),
[
("random manufacturer", "random model", "random.class", "OnOff"),
("random manufacturer", MODEL, "random.class", "OnOffModel"),
(MANUFACTURER, "random model", "random.class", "OnOffManufacturer"),
("random manufacturer", "random model", QUIRK_ID, "OnOffQuirk"),
(MANUFACTURER, MODEL, "random.class", "OnOffModelManufacturer"),
(MANUFACTURER, "some model", "random.class", "OnOffMultimodel"),
],
)
def test_weighted_match(
cluster_handler,
entity_registry: er.EntityRegistry,
manufacturer,
model,
quirk_id,
match_name,
) -> None:
"""Test weightedd match."""
s = mock.sentinel
@entity_registry.strict_match(
s.component,
cluster_handler_names="on_off",
models={MODEL, "another model", "some model"},
)
class OnOffMultimodel:
pass
@entity_registry.strict_match(s.component, cluster_handler_names="on_off")
class OnOff:
pass
@entity_registry.strict_match(
s.component, cluster_handler_names="on_off", manufacturers=MANUFACTURER
)
class OnOffManufacturer:
pass
@entity_registry.strict_match(
s.component, cluster_handler_names="on_off", models=MODEL
)
class OnOffModel:
pass
@entity_registry.strict_match(
s.component,
cluster_handler_names="on_off",
models=MODEL,
manufacturers=MANUFACTURER,
)
class OnOffModelManufacturer:
pass
@entity_registry.strict_match(
s.component, cluster_handler_names="on_off", quirk_ids=QUIRK_ID
)
class OnOffQuirk:
pass
ch_on_off = cluster_handler("on_off", 6)
ch_level = cluster_handler("level", 8)
match, claimed = entity_registry.get_entity(
s.component, manufacturer, model, [ch_on_off, ch_level], quirk_id
)
assert match.__name__ == match_name
assert claimed == [ch_on_off]
def test_multi_sensor_match(
cluster_handler, entity_registry: er.EntityRegistry
) -> None:
"""Test multi-entity match."""
s = mock.sentinel
@entity_registry.multipass_match(
s.binary_sensor,
cluster_handler_names="smartenergy_metering",
)
class SmartEnergySensor2:
pass
ch_se = cluster_handler("smartenergy_metering", 0x0702)
ch_illuminati = cluster_handler("illuminance", 0x0401)
match, claimed = entity_registry.get_multi_entity(
"manufacturer",
"model",
cluster_handlers=[ch_se, ch_illuminati],
quirk_id="quirk_id",
)
assert s.binary_sensor in match
assert s.component not in match
assert set(claimed) == {ch_se}
assert {cls.entity_class.__name__ for cls in match[s.binary_sensor]} == {
SmartEnergySensor2.__name__
}
@entity_registry.multipass_match(
s.component,
cluster_handler_names="smartenergy_metering",
aux_cluster_handlers="illuminance",
)
class SmartEnergySensor1:
pass
@entity_registry.multipass_match(
s.binary_sensor,
cluster_handler_names="smartenergy_metering",
aux_cluster_handlers="illuminance",
)
class SmartEnergySensor3:
pass
match, claimed = entity_registry.get_multi_entity(
"manufacturer",
"model",
cluster_handlers={ch_se, ch_illuminati},
quirk_id="quirk_id",
)
assert s.binary_sensor in match
assert s.component in match
assert set(claimed) == {ch_se, ch_illuminati}
assert {cls.entity_class.__name__ for cls in match[s.binary_sensor]} == {
SmartEnergySensor2.__name__,
SmartEnergySensor3.__name__,
}
assert {cls.entity_class.__name__ for cls in match[s.component]} == {
SmartEnergySensor1.__name__
}
def iter_all_rules() -> Generator[tuple[registries.MatchRule, list[type[ZhaEntity]]]]:
"""Iterate over all match rules and their corresponding entities."""
for rules in registries.ZHA_ENTITIES._strict_registry.values():
for rule, entity in rules.items():
yield rule, [entity]
for rules in registries.ZHA_ENTITIES._multi_entity_registry.values():
for multi in rules.values():
for rule, entities in multi.items():
yield rule, entities
for rules in registries.ZHA_ENTITIES._config_diagnostic_entity_registry.values():
for multi in rules.values():
for rule, entities in multi.items():
yield rule, entities
def test_quirk_classes() -> None:
"""Make sure that all quirk IDs in components matches exist."""
def quirk_class_validator(value):
"""Validate quirk IDs during self test."""
if callable(value):
# Callables cannot be tested
return
if isinstance(value, (frozenset, set, list)):
for v in value:
# Unpack the value if needed
quirk_class_validator(v)
return
if value not in all_quirk_ids:
raise ValueError(f"Quirk ID '{value}' does not exist.")
# get all quirk ID from zigpy quirks registry
all_quirk_ids = []
for manufacturer in zigpy_quirks._DEVICE_REGISTRY._registry.values():
for model_quirk_list in manufacturer.values():
for quirk in model_quirk_list:
quirk_id = getattr(quirk, ATTR_QUIRK_ID, None)
if quirk_id is not None and quirk_id not in all_quirk_ids:
all_quirk_ids.append(quirk_id)
# pylint: disable-next=undefined-loop-variable
del quirk, model_quirk_list, manufacturer
# validate all quirk IDs used in component match rules
for rule, _ in iter_all_rules():
quirk_class_validator(rule.quirk_ids)
def test_entity_names() -> None:
"""Make sure that all handlers expose entities with valid names."""
for _, entity_classes in iter_all_rules():
for entity_class in entity_classes:
if hasattr(entity_class, "__attr_name"):
# The entity has a name
assert (name := entity_class.__attr_name) and isinstance(name, str)
elif hasattr(entity_class, "__attr_translation_key"):
assert (
isinstance(entity_class.__attr_translation_key, str)
and entity_class.__attr_translation_key
)
elif hasattr(entity_class, "__attr_device_class"):
assert entity_class.__attr_device_class
else:
# The only exception (for now) is IASZone
assert entity_class is IASZone

View File

@ -16,7 +16,7 @@ from homeassistant.components.homeassistant_sky_connect.const import ( # pylint
DOMAIN as SKYCONNECT_DOMAIN,
)
from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN
from homeassistant.components.zha.core.const import DOMAIN
from homeassistant.components.zha.const import DOMAIN
from homeassistant.components.zha.repairs.network_settings_inconsistent import (
ISSUE_INCONSISTENT_NETWORK_SETTINGS,
)
@ -148,7 +148,7 @@ async def test_multipan_firmware_repair(
autospec=True,
),
patch(
"homeassistant.components.zha.core.gateway.ZHAGateway.async_initialize",
"homeassistant.components.zha.Gateway.async_from_config",
side_effect=RuntimeError(),
),
patch(
@ -199,7 +199,7 @@ async def test_multipan_firmware_no_repair_on_probe_failure(
autospec=True,
),
patch(
"homeassistant.components.zha.core.gateway.ZHAGateway.async_initialize",
"homeassistant.components.zha.Gateway.async_from_config",
side_effect=RuntimeError(),
),
):
@ -236,7 +236,7 @@ async def test_multipan_firmware_retry_on_probe_ezsp(
autospec=True,
),
patch(
"homeassistant.components.zha.core.gateway.ZHAGateway.async_initialize",
"homeassistant.components.zha.Gateway.async_from_config",
side_effect=RuntimeError(),
),
):
@ -311,7 +311,7 @@ async def test_inconsistent_settings_keep_new(
old_state = network_backup
with patch(
"homeassistant.components.zha.core.gateway.ZHAGateway.async_initialize",
"homeassistant.components.zha.Gateway.async_from_config",
side_effect=NetworkSettingsInconsistent(
message="Network settings are inconsistent",
new_state=new_state,
@ -390,7 +390,7 @@ async def test_inconsistent_settings_restore_old(
old_state = network_backup
with patch(
"homeassistant.components.zha.core.gateway.ZHAGateway.async_initialize",
"homeassistant.components.zha.Gateway.async_from_config",
side_effect=NetworkSettingsInconsistent(
message="Network settings are inconsistent",
new_state=new_state,

View File

@ -1,34 +1,23 @@
"""Test ZHA select entities."""
from typing import Any
from unittest.mock import call, patch
from unittest.mock import patch
import pytest
from zhaquirks import (
DEVICE_TYPE,
ENDPOINTS,
INPUT_CLUSTERS,
OUTPUT_CLUSTERS,
PROFILE_ID,
)
from zigpy.const import SIG_EP_PROFILE
from zigpy.const import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
from zigpy.profiles import zha
from zigpy.quirks import CustomCluster, CustomDevice
from zigpy.quirks.v2 import CustomDeviceV2, add_to_registry_v2
import zigpy.types as t
from zigpy.zcl.clusters import general, security
from zigpy.zcl.clusters.manufacturer_specific import ManufacturerSpecificCluster
from homeassistant.components.zha.select import AqaraMotionSensitivities
from homeassistant.components.zha.helpers import (
ZHADeviceProxy,
ZHAGatewayProxy,
get_zha_gateway,
get_zha_gateway_proxy,
)
from homeassistant.const import STATE_UNKNOWN, EntityCategory, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er, restore_state
from homeassistant.util import dt as dt_util
from homeassistant.helpers import entity_registry as er
from .common import async_enable_traffic, find_entity_id, send_attributes_report
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE
from tests.common import async_mock_load_restore_state_from_storage
from .common import find_entity_id
@pytest.fixture(autouse=True)
@ -50,9 +39,17 @@ def select_select_only():
yield
@pytest.fixture
async def siren(hass, zigpy_device_mock, zha_device_joined_restored):
"""Siren fixture."""
async def test_select(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
setup_zha,
zigpy_device_mock,
) -> None:
"""Test ZHA select platform."""
await setup_zha()
gateway = get_zha_gateway(hass)
gateway_proxy: ZHAGatewayProxy = get_zha_gateway_proxy(hass)
zigpy_device = zigpy_device_mock(
{
@ -62,75 +59,16 @@ async def siren(hass, zigpy_device_mock, zha_device_joined_restored):
SIG_EP_TYPE: zha.DeviceType.IAS_WARNING_DEVICE,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
},
)
zha_device = await zha_device_joined_restored(zigpy_device)
return zha_device, zigpy_device.endpoints[1].ias_wd
@pytest.fixture
async def light(hass, zigpy_device_mock):
"""Siren fixture."""
return zigpy_device_mock(
{
1: {
SIG_EP_PROFILE: zha.PROFILE_ID,
SIG_EP_TYPE: zha.DeviceType.ON_OFF_LIGHT,
SIG_EP_INPUT: [
general.Basic.cluster_id,
general.Identify.cluster_id,
general.OnOff.cluster_id,
],
SIG_EP_OUTPUT: [general.Ota.cluster_id],
}
},
node_descriptor=b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00",
)
@pytest.fixture
def core_rs(hass_storage: dict[str, Any]):
"""Core.restore_state fixture."""
def _storage(entity_id, state):
now = dt_util.utcnow().isoformat()
hass_storage[restore_state.STORAGE_KEY] = {
"version": restore_state.STORAGE_VERSION,
"key": restore_state.STORAGE_KEY,
"data": [
{
"state": {
"entity_id": entity_id,
"state": str(state),
"last_changed": now,
"last_updated": now,
"context": {
"id": "3c2243ff5f30447eb12e7348cfd5b8ff",
"user_id": None,
},
},
"last_seen": now,
}
],
}
)
return _storage
gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
async def test_select(
hass: HomeAssistant, entity_registry: er.EntityRegistry, siren
) -> None:
"""Test ZHA select platform."""
zha_device, cluster = siren
assert cluster is not None
zha_device_proxy: ZHADeviceProxy = gateway_proxy.get_device_proxy(zigpy_device.ieee)
entity_id = find_entity_id(
Platform.SELECT,
zha_device,
hass,
qualifier="tone",
Platform.SELECT, zha_device_proxy, hass, qualifier="tone"
)
assert entity_id is not None
@ -165,329 +103,3 @@ async def test_select(
state = hass.states.get(entity_id)
assert state
assert state.state == security.IasWd.Warning.WarningMode.Burglar.name
async def test_select_restore_state(
hass: HomeAssistant,
zigpy_device_mock,
core_rs,
zha_device_restored,
) -> None:
"""Test ZHA select entity restore state."""
entity_id = "select.fakemanufacturer_fakemodel_default_siren_tone"
core_rs(entity_id, state="Burglar")
await async_mock_load_restore_state_from_storage(hass)
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [general.Basic.cluster_id, security.IasWd.cluster_id],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.IAS_WARNING_DEVICE,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
},
)
zha_device = await zha_device_restored(zigpy_device)
cluster = zigpy_device.endpoints[1].ias_wd
assert cluster is not None
entity_id = find_entity_id(
Platform.SELECT,
zha_device,
hass,
qualifier="tone",
)
assert entity_id is not None
state = hass.states.get(entity_id)
assert state
assert state.state == security.IasWd.Warning.WarningMode.Burglar.name
async def test_on_off_select_new_join(
hass: HomeAssistant, entity_registry: er.EntityRegistry, light, zha_device_joined
) -> None:
"""Test ZHA on off select - new join."""
on_off_cluster = light.endpoints[1].on_off
on_off_cluster.PLUGGED_ATTR_READS = {
"start_up_on_off": general.OnOff.StartUpOnOff.On
}
zha_device = await zha_device_joined(light)
select_name = "start_up_behavior"
entity_id = find_entity_id(
Platform.SELECT,
zha_device,
hass,
qualifier=select_name,
)
assert entity_id is not None
assert on_off_cluster.read_attributes.call_count == 2
assert (
call(["start_up_on_off"], allow_cache=True, only_cache=False, manufacturer=None)
in on_off_cluster.read_attributes.call_args_list
)
assert (
call(["on_off"], allow_cache=False, only_cache=False, manufacturer=None)
in on_off_cluster.read_attributes.call_args_list
)
state = hass.states.get(entity_id)
assert state
assert state.state == general.OnOff.StartUpOnOff.On.name
assert state.attributes["options"] == ["Off", "On", "Toggle", "PreviousValue"]
entity_entry = entity_registry.async_get(entity_id)
assert entity_entry
assert entity_entry.entity_category == EntityCategory.CONFIG
# Test select option with string value
await hass.services.async_call(
"select",
"select_option",
{
"entity_id": entity_id,
"option": general.OnOff.StartUpOnOff.Off.name,
},
blocking=True,
)
assert on_off_cluster.write_attributes.call_count == 1
assert on_off_cluster.write_attributes.call_args[0][0] == {
"start_up_on_off": general.OnOff.StartUpOnOff.Off
}
state = hass.states.get(entity_id)
assert state
assert state.state == general.OnOff.StartUpOnOff.Off.name
async def test_on_off_select_restored(
hass: HomeAssistant, entity_registry: er.EntityRegistry, light, zha_device_restored
) -> None:
"""Test ZHA on off select - restored."""
on_off_cluster = light.endpoints[1].on_off
on_off_cluster.PLUGGED_ATTR_READS = {
"start_up_on_off": general.OnOff.StartUpOnOff.On
}
zha_device = await zha_device_restored(light)
assert zha_device.is_mains_powered
assert on_off_cluster.read_attributes.call_count == 4
# first 2 calls hit cache only
assert (
call(["start_up_on_off"], allow_cache=True, only_cache=True, manufacturer=None)
in on_off_cluster.read_attributes.call_args_list
)
assert (
call(["on_off"], allow_cache=True, only_cache=True, manufacturer=None)
in on_off_cluster.read_attributes.call_args_list
)
# 2nd set of calls can actually read from the device
assert (
call(["start_up_on_off"], allow_cache=True, only_cache=False, manufacturer=None)
in on_off_cluster.read_attributes.call_args_list
)
assert (
call(["on_off"], allow_cache=False, only_cache=False, manufacturer=None)
in on_off_cluster.read_attributes.call_args_list
)
select_name = "start_up_behavior"
entity_id = find_entity_id(
Platform.SELECT,
zha_device,
hass,
qualifier=select_name,
)
assert entity_id is not None
state = hass.states.get(entity_id)
assert state
assert state.state == general.OnOff.StartUpOnOff.On.name
assert state.attributes["options"] == ["Off", "On", "Toggle", "PreviousValue"]
entity_entry = entity_registry.async_get(entity_id)
assert entity_entry
assert entity_entry.entity_category == EntityCategory.CONFIG
async def test_on_off_select_unsupported(
hass: HomeAssistant, light, zha_device_joined_restored
) -> None:
"""Test ZHA on off select unsupported."""
on_off_cluster = light.endpoints[1].on_off
on_off_cluster.add_unsupported_attribute("start_up_on_off")
zha_device = await zha_device_joined_restored(light)
select_name = general.OnOff.StartUpOnOff.__name__
entity_id = find_entity_id(
Platform.SELECT,
zha_device,
hass,
qualifier=select_name.lower(),
)
assert entity_id is None
class MotionSensitivityQuirk(CustomDevice):
"""Quirk with motion sensitivity attribute."""
class OppleCluster(CustomCluster, ManufacturerSpecificCluster):
"""Aqara manufacturer specific cluster."""
cluster_id = 0xFCC0
ep_attribute = "opple_cluster"
attributes = {
0x010C: ("motion_sensitivity", t.uint8_t, True),
0x020C: ("motion_sensitivity_disabled", t.uint8_t, True),
}
def __init__(self, *args, **kwargs):
"""Initialize."""
super().__init__(*args, **kwargs)
# populate cache to create config entity
self._attr_cache.update(
{
0x010C: AqaraMotionSensitivities.Medium,
0x020C: AqaraMotionSensitivities.Medium,
}
)
replacement = {
ENDPOINTS: {
1: {
PROFILE_ID: zha.PROFILE_ID,
DEVICE_TYPE: zha.DeviceType.OCCUPANCY_SENSOR,
INPUT_CLUSTERS: [general.Basic.cluster_id, OppleCluster],
OUTPUT_CLUSTERS: [],
},
}
}
@pytest.fixture
async def zigpy_device_aqara_sensor(hass, zigpy_device_mock, zha_device_joined):
"""Device tracker zigpy Aqara motion sensor device."""
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [general.Basic.cluster_id],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.OCCUPANCY_SENSOR,
}
},
manufacturer="LUMI",
model="lumi.motion.ac02",
quirk=MotionSensitivityQuirk,
)
zha_device = await zha_device_joined(zigpy_device)
zha_device.available = True
await hass.async_block_till_done()
return zigpy_device
async def test_on_off_select_attribute_report(
hass: HomeAssistant, light, zha_device_restored, zigpy_device_aqara_sensor
) -> None:
"""Test ZHA attribute report parsing for select platform."""
zha_device = await zha_device_restored(zigpy_device_aqara_sensor)
cluster = zigpy_device_aqara_sensor.endpoints.get(1).opple_cluster
entity_id = find_entity_id(Platform.SELECT, zha_device, hass)
assert entity_id is not None
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [zha_device])
# test that the state is in default medium state
assert hass.states.get(entity_id).state == AqaraMotionSensitivities.Medium.name
# send attribute report from device
await send_attributes_report(
hass, cluster, {"motion_sensitivity": AqaraMotionSensitivities.Low}
)
assert hass.states.get(entity_id).state == AqaraMotionSensitivities.Low.name
(
add_to_registry_v2("Fake_Manufacturer", "Fake_Model")
.replaces(MotionSensitivityQuirk.OppleCluster)
.enum(
"motion_sensitivity",
AqaraMotionSensitivities,
MotionSensitivityQuirk.OppleCluster.cluster_id,
)
.enum(
"motion_sensitivity_disabled",
AqaraMotionSensitivities,
MotionSensitivityQuirk.OppleCluster.cluster_id,
translation_key="motion_sensitivity",
initially_disabled=True,
)
)
@pytest.fixture
async def zigpy_device_aqara_sensor_v2(
hass: HomeAssistant, zigpy_device_mock, zha_device_joined_restored
):
"""Device tracker zigpy Aqara motion sensor device."""
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [
general.Basic.cluster_id,
MotionSensitivityQuirk.OppleCluster.cluster_id,
],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.OCCUPANCY_SENSOR,
}
},
manufacturer="Fake_Manufacturer",
model="Fake_Model",
)
zha_device = await zha_device_joined_restored(zigpy_device)
return zha_device, zigpy_device.endpoints[1].opple_cluster
async def test_on_off_select_attribute_report_v2(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
zigpy_device_aqara_sensor_v2,
) -> None:
"""Test ZHA attribute report parsing for select platform."""
zha_device, cluster = zigpy_device_aqara_sensor_v2
assert isinstance(zha_device.device, CustomDeviceV2)
entity_id = find_entity_id(
Platform.SELECT, zha_device, hass, qualifier="motion_sensitivity"
)
assert entity_id is not None
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [zha_device])
# test that the state is in default medium state
assert hass.states.get(entity_id).state == AqaraMotionSensitivities.Medium.name
# send attribute report from device
await send_attributes_report(
hass, cluster, {"motion_sensitivity": AqaraMotionSensitivities.Low}
)
assert hass.states.get(entity_id).state == AqaraMotionSensitivities.Low.name
entity_entry = entity_registry.async_get(entity_id)
assert entity_entry
assert entity_entry.entity_category == EntityCategory.CONFIG
assert entity_entry.disabled is False
assert entity_entry.translation_key == "motion_sensitivity"

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,7 @@ import zigpy.state
from homeassistant.components import zha
from homeassistant.components.zha import silabs_multiprotocol
from homeassistant.components.zha.core.helpers import get_zha_gateway
from homeassistant.components.zha.helpers import get_zha_data
from homeassistant.core import HomeAssistant
if TYPE_CHECKING:
@ -38,8 +38,7 @@ async def test_async_get_channel_missing(
"""Test reading channel with an inactive ZHA installation, no valid channel."""
await setup_zha()
gateway = get_zha_gateway(hass)
await zha.async_unload_entry(hass, gateway.config_entry)
await zha.async_unload_entry(hass, get_zha_data(hass).config_entry)
# Network settings were never loaded for whatever reason
zigpy_app_controller.state.network_info = zigpy.state.NetworkInfo()

View File

@ -4,7 +4,11 @@ from datetime import timedelta
from unittest.mock import ANY, call, patch
import pytest
from zigpy.const import SIG_EP_PROFILE
from zha.application.const import (
WARNING_DEVICE_MODE_EMERGENCY_PANIC,
WARNING_DEVICE_SOUND_MEDIUM,
)
from zigpy.const import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
from zigpy.profiles import zha
import zigpy.zcl
from zigpy.zcl.clusters import general, security
@ -16,16 +20,17 @@ from homeassistant.components.siren import (
ATTR_VOLUME_LEVEL,
DOMAIN as SIREN_DOMAIN,
)
from homeassistant.components.zha.core.const import (
WARNING_DEVICE_MODE_EMERGENCY_PANIC,
WARNING_DEVICE_SOUND_MEDIUM,
from homeassistant.components.zha.helpers import (
ZHADeviceProxy,
ZHAGatewayProxy,
get_zha_gateway,
get_zha_gateway_proxy,
)
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform
from homeassistant.const import STATE_OFF, STATE_ON, Platform
from homeassistant.core import HomeAssistant
import homeassistant.util.dt as dt_util
from .common import async_enable_traffic, find_entity_id
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE
from .common import find_entity_id
from tests.common import async_fire_time_changed
@ -46,9 +51,12 @@ def siren_platform_only():
yield
@pytest.fixture
async def siren(hass, zigpy_device_mock, zha_device_joined_restored):
"""Siren fixture."""
async def test_siren(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None:
"""Test zha siren platform."""
await setup_zha()
gateway = get_zha_gateway(hass)
gateway_proxy: ZHAGatewayProxy = get_zha_gateway_proxy(hass)
zigpy_device = zigpy_device_mock(
{
@ -58,30 +66,18 @@ async def siren(hass, zigpy_device_mock, zha_device_joined_restored):
SIG_EP_TYPE: zha.DeviceType.IAS_WARNING_DEVICE,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
},
}
)
zha_device = await zha_device_joined_restored(zigpy_device)
return zha_device, zigpy_device.endpoints[1].ias_wd
gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
async def test_siren(hass: HomeAssistant, siren) -> None:
"""Test zha siren platform."""
zha_device, cluster = siren
assert cluster is not None
entity_id = find_entity_id(Platform.SIREN, zha_device, hass)
zha_device_proxy: ZHADeviceProxy = gateway_proxy.get_device_proxy(zigpy_device.ieee)
entity_id = find_entity_id(Platform.SIREN, zha_device_proxy, hass)
cluster = zigpy_device.endpoints[1].ias_wd
assert entity_id is not None
assert hass.states.get(entity_id).state == STATE_OFF
await async_enable_traffic(hass, [zha_device], enabled=False)
# test that the switch was created and that its state is unavailable
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [zha_device])
# test that the state has changed from unavailable to off
assert hass.states.get(entity_id).state == STATE_OFF
# turn on from HA

View File

@ -1,51 +1,28 @@
"""Test ZHA switch."""
from unittest.mock import AsyncMock, call, patch
from unittest.mock import call, patch
import pytest
from zhaquirks.const import (
DEVICE_TYPE,
ENDPOINTS,
INPUT_CLUSTERS,
OUTPUT_CLUSTERS,
PROFILE_ID,
)
from zigpy.exceptions import ZigbeeException
from zigpy.profiles import zha
from zigpy.quirks import _DEVICE_REGISTRY, CustomCluster, CustomDevice
from zigpy.quirks.v2 import CustomDeviceV2, add_to_registry_v2
import zigpy.types as t
from zigpy.zcl.clusters import closures, general
from zigpy.zcl.clusters.manufacturer_specific import ManufacturerSpecificCluster
from zigpy.zcl.clusters import general
import zigpy.zcl.foundation as zcl_f
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.components.zha.core.group import GroupMember
from homeassistant.components.zha.core.helpers import get_zha_gateway
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform
from homeassistant.components.zha.helpers import (
ZHADeviceProxy,
ZHAGatewayProxy,
get_zha_gateway,
get_zha_gateway_proxy,
)
from homeassistant.const import STATE_OFF, STATE_ON, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_component import async_update_entity
from homeassistant.setup import async_setup_component
from .common import (
async_enable_traffic,
async_find_group_entity_id,
async_test_rejoin,
async_wait_for_updates,
find_entity_id,
send_attributes_report,
update_attribute_cache,
)
from .common import find_entity_id, send_attributes_report
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
from tests.common import MockConfigEntry
ON = 1
OFF = 0
IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8"
IEEE_GROUPABLE_DEVICE2 = "02:2d:6f:00:0a:90:69:e8"
@pytest.fixture(autouse=True)
@ -63,104 +40,51 @@ def switch_platform_only():
yield
@pytest.fixture
def zigpy_device(zigpy_device_mock):
"""Device tracker zigpy device."""
endpoints = {
1: {
SIG_EP_INPUT: [general.Basic.cluster_id, general.OnOff.cluster_id],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH,
}
}
return zigpy_device_mock(endpoints)
@pytest.fixture
def zigpy_cover_device(zigpy_device_mock):
"""Zigpy cover device."""
endpoints = {
1: {
SIG_EP_PROFILE: zha.PROFILE_ID,
SIG_EP_TYPE: zha.DeviceType.WINDOW_COVERING_DEVICE,
SIG_EP_INPUT: [
general.Basic.cluster_id,
closures.WindowCovering.cluster_id,
],
SIG_EP_OUTPUT: [],
}
}
return zigpy_device_mock(endpoints)
@pytest.fixture
async def device_switch_1(hass, zigpy_device_mock, zha_device_joined):
async def test_switch(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None:
"""Test ZHA switch platform."""
await setup_zha()
gateway = get_zha_gateway(hass)
gateway_proxy: ZHAGatewayProxy = get_zha_gateway_proxy(hass)
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [general.OnOff.cluster_id, general.Groups.cluster_id],
SIG_EP_INPUT: [
general.Basic.cluster_id,
general.OnOff.cluster_id,
general.Groups.cluster_id,
],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
},
ieee=IEEE_GROUPABLE_DEVICE,
ieee="01:2d:6f:00:0a:90:69:e8",
node_descriptor=b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00",
)
zha_device = await zha_device_joined(zigpy_device)
zha_device.available = True
await hass.async_block_till_done()
return zha_device
gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
@pytest.fixture
async def device_switch_2(hass, zigpy_device_mock, zha_device_joined):
"""Test ZHA switch platform."""
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [general.OnOff.cluster_id, general.Groups.cluster_id],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH,
}
},
ieee=IEEE_GROUPABLE_DEVICE2,
)
zha_device = await zha_device_joined(zigpy_device)
zha_device.available = True
await hass.async_block_till_done()
return zha_device
async def test_switch(
hass: HomeAssistant, zha_device_joined_restored, zigpy_device
) -> None:
"""Test ZHA switch platform."""
zha_device = await zha_device_joined_restored(zigpy_device)
zha_device_proxy: ZHADeviceProxy = gateway_proxy.get_device_proxy(zigpy_device.ieee)
entity_id = find_entity_id(Platform.SWITCH, zha_device_proxy, hass)
cluster = zigpy_device.endpoints[1].on_off
entity_id = find_entity_id(Platform.SWITCH, zha_device, hass)
assert entity_id is not None
assert hass.states.get(entity_id).state == STATE_OFF
await async_enable_traffic(hass, [zha_device], enabled=False)
# test that the switch was created and that its state is unavailable
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [zha_device])
# test that the state has changed from unavailable to off
assert hass.states.get(entity_id).state == STATE_OFF
# turn on at switch
await send_attributes_report(hass, cluster, {1: 0, 0: 1, 2: 2})
await send_attributes_report(
hass, cluster, {general.OnOff.AttributeDefs.on_off.id: ON}
)
assert hass.states.get(entity_id).state == STATE_ON
# turn off at switch
await send_attributes_report(hass, cluster, {1: 1, 0: 0, 2: 2})
await send_attributes_report(
hass, cluster, {general.OnOff.AttributeDefs.on_off.id: OFF}
)
assert hass.states.get(entity_id).state == STATE_OFF
# turn on from HA
@ -217,765 +141,3 @@ async def test_switch(
assert cluster.read_attributes.call_args == call(
["on_off"], allow_cache=False, only_cache=False, manufacturer=None
)
# test joining a new switch to the network and HA
await async_test_rejoin(hass, zigpy_device, [cluster], (1,))
class WindowDetectionFunctionQuirk(CustomDevice):
"""Quirk with window detection function attribute."""
class TuyaManufCluster(CustomCluster, ManufacturerSpecificCluster):
"""Tuya manufacturer specific cluster."""
cluster_id = 0xEF00
ep_attribute = "tuya_manufacturer"
attributes = {
0xEF01: ("window_detection_function", t.Bool),
0xEF02: ("window_detection_function_inverter", t.Bool),
}
def __init__(self, *args, **kwargs):
"""Initialize with task."""
super().__init__(*args, **kwargs)
self._attr_cache.update(
{0xEF01: False}
) # entity won't be created without this
replacement = {
ENDPOINTS: {
1: {
PROFILE_ID: zha.PROFILE_ID,
DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH,
INPUT_CLUSTERS: [general.Basic.cluster_id, TuyaManufCluster],
OUTPUT_CLUSTERS: [],
},
}
}
@pytest.fixture
async def zigpy_device_tuya(hass, zigpy_device_mock, zha_device_joined):
"""Device tracker zigpy tuya device."""
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [general.Basic.cluster_id],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH,
}
},
manufacturer="_TZE200_b6wax7g0",
quirk=WindowDetectionFunctionQuirk,
)
zha_device = await zha_device_joined(zigpy_device)
zha_device.available = True
await hass.async_block_till_done()
return zigpy_device
@patch(
"homeassistant.components.zha.entity.DEFAULT_UPDATE_GROUP_FROM_CHILD_DELAY",
new=0,
)
async def test_zha_group_switch_entity(
hass: HomeAssistant,
device_switch_1,
device_switch_2,
entity_registry: er.EntityRegistry,
config_entry: MockConfigEntry,
) -> None:
"""Test the switch entity for a ZHA group."""
# make sure we can still get groups when counter entities exist
entity_id = "sensor.coordinator_manufacturer_coordinator_model_counter_1"
state = hass.states.get(entity_id)
assert state is None
# Enable the entity.
entity_registry.async_update_entity(entity_id, disabled_by=None)
await hass.config_entries.async_reload(config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "1"
zha_gateway = get_zha_gateway(hass)
assert zha_gateway is not None
device_switch_1._zha_gateway = zha_gateway
device_switch_2._zha_gateway = zha_gateway
member_ieee_addresses = [
device_switch_1.ieee,
device_switch_2.ieee,
zha_gateway.coordinator_zha_device.ieee,
]
members = [
GroupMember(device_switch_1.ieee, 1),
GroupMember(device_switch_2.ieee, 1),
GroupMember(zha_gateway.coordinator_zha_device.ieee, 1),
]
# test creating a group with 2 members
zha_group = await zha_gateway.async_create_zigpy_group("Test Group", members)
await hass.async_block_till_done()
assert zha_group is not None
assert len(zha_group.members) == 3
for member in zha_group.members:
assert member.device.ieee in member_ieee_addresses
assert member.group == zha_group
assert member.endpoint is not None
entity_id = async_find_group_entity_id(hass, Platform.SWITCH, zha_group)
assert hass.states.get(entity_id) is not None
group_cluster_on_off = zha_group.endpoint[general.OnOff.cluster_id]
dev1_cluster_on_off = device_switch_1.device.endpoints[1].on_off
dev2_cluster_on_off = device_switch_2.device.endpoints[1].on_off
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [device_switch_1, device_switch_2])
await async_wait_for_updates(hass)
# test that the switches were created and are off
assert hass.states.get(entity_id).state == STATE_OFF
# turn on from HA
with patch(
"zigpy.zcl.Cluster.request",
return_value=[0x00, zcl_f.Status.SUCCESS],
):
# turn on via UI
await hass.services.async_call(
SWITCH_DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True
)
assert len(group_cluster_on_off.request.mock_calls) == 1
assert group_cluster_on_off.request.call_args == call(
False,
ON,
group_cluster_on_off.commands_by_name["on"].schema,
expect_reply=True,
manufacturer=None,
tsn=None,
)
assert hass.states.get(entity_id).state == STATE_ON
# test turn off failure case
hold_off = group_cluster_on_off.off
group_cluster_on_off.off = AsyncMock(return_value=[0x01, zcl_f.Status.FAILURE])
# turn off via UI
await hass.services.async_call(
SWITCH_DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True
)
assert len(group_cluster_on_off.off.mock_calls) == 1
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_ON
group_cluster_on_off.off = hold_off
# turn off from HA
with patch(
"zigpy.zcl.Cluster.request",
return_value=[0x01, zcl_f.Status.SUCCESS],
):
# turn off via UI
await hass.services.async_call(
SWITCH_DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True
)
assert len(group_cluster_on_off.request.mock_calls) == 1
assert group_cluster_on_off.request.call_args == call(
False,
OFF,
group_cluster_on_off.commands_by_name["off"].schema,
expect_reply=True,
manufacturer=None,
tsn=None,
)
assert hass.states.get(entity_id).state == STATE_OFF
# test turn on failure case
hold_on = group_cluster_on_off.on
group_cluster_on_off.on = AsyncMock(return_value=[0x01, zcl_f.Status.FAILURE])
# turn on via UI
await hass.services.async_call(
SWITCH_DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True
)
assert len(group_cluster_on_off.on.mock_calls) == 1
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_OFF
group_cluster_on_off.on = hold_on
# test some of the group logic to make sure we key off states correctly
await send_attributes_report(hass, dev1_cluster_on_off, {0: 1})
await send_attributes_report(hass, dev2_cluster_on_off, {0: 1})
await async_wait_for_updates(hass)
# test that group switch is on
assert hass.states.get(entity_id).state == STATE_ON
await send_attributes_report(hass, dev1_cluster_on_off, {0: 0})
await async_wait_for_updates(hass)
# test that group switch is still on
assert hass.states.get(entity_id).state == STATE_ON
await send_attributes_report(hass, dev2_cluster_on_off, {0: 0})
await async_wait_for_updates(hass)
# test that group switch is now off
assert hass.states.get(entity_id).state == STATE_OFF
await send_attributes_report(hass, dev1_cluster_on_off, {0: 1})
await async_wait_for_updates(hass)
# test that group switch is now back on
assert hass.states.get(entity_id).state == STATE_ON
async def test_switch_configurable(
hass: HomeAssistant, zha_device_joined_restored, zigpy_device_tuya
) -> None:
"""Test ZHA configurable switch platform."""
zha_device = await zha_device_joined_restored(zigpy_device_tuya)
cluster = zigpy_device_tuya.endpoints[1].tuya_manufacturer
entity_id = find_entity_id(Platform.SWITCH, zha_device, hass)
assert entity_id is not None
assert hass.states.get(entity_id).state == STATE_OFF
await async_enable_traffic(hass, [zha_device], enabled=False)
# test that the switch was created and that its state is unavailable
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [zha_device])
# test that the state has changed from unavailable to off
assert hass.states.get(entity_id).state == STATE_OFF
# turn on at switch
await send_attributes_report(hass, cluster, {"window_detection_function": True})
assert hass.states.get(entity_id).state == STATE_ON
# turn off at switch
await send_attributes_report(hass, cluster, {"window_detection_function": False})
assert hass.states.get(entity_id).state == STATE_OFF
# turn on from HA
with patch(
"zigpy.zcl.Cluster.write_attributes",
return_value=[zcl_f.Status.SUCCESS, zcl_f.Status.SUCCESS],
):
# turn on via UI
await hass.services.async_call(
SWITCH_DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True
)
assert cluster.write_attributes.mock_calls == [
call({"window_detection_function": True}, manufacturer=None)
]
cluster.write_attributes.reset_mock()
# turn off from HA
with patch(
"zigpy.zcl.Cluster.write_attributes",
return_value=[zcl_f.Status.SUCCESS, zcl_f.Status.SUCCESS],
):
# turn off via UI
await hass.services.async_call(
SWITCH_DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True
)
assert cluster.write_attributes.mock_calls == [
call({"window_detection_function": False}, manufacturer=None)
]
cluster.read_attributes.reset_mock()
await async_setup_component(hass, "homeassistant", {})
await hass.async_block_till_done()
await hass.services.async_call(
"homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True
)
# the mocking doesn't update the attr cache so this flips back to initial value
assert cluster.read_attributes.call_count == 2
assert [
call(
[
"window_detection_function",
],
allow_cache=False,
only_cache=False,
manufacturer=None,
),
call(
[
"window_detection_function_inverter",
],
allow_cache=False,
only_cache=False,
manufacturer=None,
),
] == cluster.read_attributes.call_args_list
cluster.write_attributes.reset_mock()
cluster.write_attributes.side_effect = ZigbeeException
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
SWITCH_DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True
)
assert cluster.write_attributes.mock_calls == [
call({"window_detection_function": False}, manufacturer=None),
call({"window_detection_function": False}, manufacturer=None),
call({"window_detection_function": False}, manufacturer=None),
]
cluster.write_attributes.side_effect = None
# test inverter
cluster.write_attributes.reset_mock()
cluster._attr_cache.update({0xEF02: True})
await hass.services.async_call(
SWITCH_DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True
)
assert cluster.write_attributes.mock_calls == [
call({"window_detection_function": True}, manufacturer=None)
]
cluster.write_attributes.reset_mock()
await hass.services.async_call(
SWITCH_DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True
)
assert cluster.write_attributes.mock_calls == [
call({"window_detection_function": False}, manufacturer=None)
]
# test joining a new switch to the network and HA
await async_test_rejoin(hass, zigpy_device_tuya, [cluster], (0,))
async def test_switch_configurable_custom_on_off_values(
hass: HomeAssistant, zha_device_joined_restored, zigpy_device_mock
) -> None:
"""Test ZHA configurable switch platform."""
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [general.Basic.cluster_id],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH,
}
},
manufacturer="manufacturer",
model="model",
)
(
add_to_registry_v2(zigpy_device.manufacturer, zigpy_device.model)
.adds(WindowDetectionFunctionQuirk.TuyaManufCluster)
.switch(
"window_detection_function",
WindowDetectionFunctionQuirk.TuyaManufCluster.cluster_id,
on_value=3,
off_value=5,
)
)
zigpy_device = _DEVICE_REGISTRY.get_device(zigpy_device)
assert isinstance(zigpy_device, CustomDeviceV2)
cluster = zigpy_device.endpoints[1].tuya_manufacturer
cluster.PLUGGED_ATTR_READS = {"window_detection_function": 5}
update_attribute_cache(cluster)
zha_device = await zha_device_joined_restored(zigpy_device)
entity_id = find_entity_id(Platform.SWITCH, zha_device, hass)
assert entity_id is not None
assert hass.states.get(entity_id).state == STATE_OFF
await async_enable_traffic(hass, [zha_device], enabled=False)
# test that the switch was created and that its state is unavailable
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [zha_device])
# test that the state has changed from unavailable to off
assert hass.states.get(entity_id).state == STATE_OFF
# turn on at switch
await send_attributes_report(hass, cluster, {"window_detection_function": 3})
assert hass.states.get(entity_id).state == STATE_ON
# turn off at switch
await send_attributes_report(hass, cluster, {"window_detection_function": 5})
assert hass.states.get(entity_id).state == STATE_OFF
# turn on from HA
with patch(
"zigpy.zcl.Cluster.write_attributes",
return_value=[zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]],
):
# turn on via UI
await hass.services.async_call(
SWITCH_DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True
)
assert cluster.write_attributes.mock_calls == [
call({"window_detection_function": 3}, manufacturer=None)
]
cluster.write_attributes.reset_mock()
# turn off from HA
with patch(
"zigpy.zcl.Cluster.write_attributes",
return_value=[zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]],
):
# turn off via UI
await hass.services.async_call(
SWITCH_DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True
)
assert cluster.write_attributes.mock_calls == [
call({"window_detection_function": 5}, manufacturer=None)
]
async def test_switch_configurable_custom_on_off_values_force_inverted(
hass: HomeAssistant, zha_device_joined_restored, zigpy_device_mock
) -> None:
"""Test ZHA configurable switch platform."""
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [general.Basic.cluster_id],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH,
}
},
manufacturer="manufacturer2",
model="model2",
)
(
add_to_registry_v2(zigpy_device.manufacturer, zigpy_device.model)
.adds(WindowDetectionFunctionQuirk.TuyaManufCluster)
.switch(
"window_detection_function",
WindowDetectionFunctionQuirk.TuyaManufCluster.cluster_id,
on_value=3,
off_value=5,
force_inverted=True,
)
)
zigpy_device = _DEVICE_REGISTRY.get_device(zigpy_device)
assert isinstance(zigpy_device, CustomDeviceV2)
cluster = zigpy_device.endpoints[1].tuya_manufacturer
cluster.PLUGGED_ATTR_READS = {"window_detection_function": 5}
update_attribute_cache(cluster)
zha_device = await zha_device_joined_restored(zigpy_device)
entity_id = find_entity_id(Platform.SWITCH, zha_device, hass)
assert entity_id is not None
assert hass.states.get(entity_id).state == STATE_ON
await async_enable_traffic(hass, [zha_device], enabled=False)
# test that the switch was created and that its state is unavailable
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [zha_device])
# test that the state has changed from unavailable to off
assert hass.states.get(entity_id).state == STATE_ON
# turn on at switch
await send_attributes_report(hass, cluster, {"window_detection_function": 3})
assert hass.states.get(entity_id).state == STATE_OFF
# turn off at switch
await send_attributes_report(hass, cluster, {"window_detection_function": 5})
assert hass.states.get(entity_id).state == STATE_ON
# turn on from HA
with patch(
"zigpy.zcl.Cluster.write_attributes",
return_value=[zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]],
):
# turn on via UI
await hass.services.async_call(
SWITCH_DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True
)
assert cluster.write_attributes.mock_calls == [
call({"window_detection_function": 5}, manufacturer=None)
]
cluster.write_attributes.reset_mock()
# turn off from HA
with patch(
"zigpy.zcl.Cluster.write_attributes",
return_value=[zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]],
):
# turn off via UI
await hass.services.async_call(
SWITCH_DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True
)
assert cluster.write_attributes.mock_calls == [
call({"window_detection_function": 3}, manufacturer=None)
]
async def test_switch_configurable_custom_on_off_values_inverter_attribute(
hass: HomeAssistant, zha_device_joined_restored, zigpy_device_mock
) -> None:
"""Test ZHA configurable switch platform."""
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [general.Basic.cluster_id],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH,
}
},
manufacturer="manufacturer3",
model="model3",
)
(
add_to_registry_v2(zigpy_device.manufacturer, zigpy_device.model)
.adds(WindowDetectionFunctionQuirk.TuyaManufCluster)
.switch(
"window_detection_function",
WindowDetectionFunctionQuirk.TuyaManufCluster.cluster_id,
on_value=3,
off_value=5,
invert_attribute_name="window_detection_function_inverter",
)
)
zigpy_device = _DEVICE_REGISTRY.get_device(zigpy_device)
assert isinstance(zigpy_device, CustomDeviceV2)
cluster = zigpy_device.endpoints[1].tuya_manufacturer
cluster.PLUGGED_ATTR_READS = {
"window_detection_function": 5,
"window_detection_function_inverter": t.Bool(True),
}
update_attribute_cache(cluster)
zha_device = await zha_device_joined_restored(zigpy_device)
entity_id = find_entity_id(Platform.SWITCH, zha_device, hass)
assert entity_id is not None
assert hass.states.get(entity_id).state == STATE_ON
await async_enable_traffic(hass, [zha_device], enabled=False)
# test that the switch was created and that its state is unavailable
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [zha_device])
# test that the state has changed from unavailable to off
assert hass.states.get(entity_id).state == STATE_ON
# turn on at switch
await send_attributes_report(hass, cluster, {"window_detection_function": 3})
assert hass.states.get(entity_id).state == STATE_OFF
# turn off at switch
await send_attributes_report(hass, cluster, {"window_detection_function": 5})
assert hass.states.get(entity_id).state == STATE_ON
# turn on from HA
with patch(
"zigpy.zcl.Cluster.write_attributes",
return_value=[zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]],
):
# turn on via UI
await hass.services.async_call(
SWITCH_DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True
)
assert cluster.write_attributes.mock_calls == [
call({"window_detection_function": 5}, manufacturer=None)
]
cluster.write_attributes.reset_mock()
# turn off from HA
with patch(
"zigpy.zcl.Cluster.write_attributes",
return_value=[zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]],
):
# turn off via UI
await hass.services.async_call(
SWITCH_DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True
)
assert cluster.write_attributes.mock_calls == [
call({"window_detection_function": 3}, manufacturer=None)
]
WCAttrs = closures.WindowCovering.AttributeDefs
WCT = closures.WindowCovering.WindowCoveringType
WCCS = closures.WindowCovering.ConfigStatus
WCM = closures.WindowCovering.WindowCoveringMode
async def test_cover_inversion_switch(
hass: HomeAssistant, zha_device_joined_restored, zigpy_cover_device
) -> None:
"""Test ZHA cover platform."""
# load up cover domain
cluster = zigpy_cover_device.endpoints[1].window_covering
cluster.PLUGGED_ATTR_READS = {
WCAttrs.current_position_lift_percentage.name: 65,
WCAttrs.current_position_tilt_percentage.name: 42,
WCAttrs.window_covering_type.name: WCT.Tilt_blind_tilt_and_lift,
WCAttrs.config_status.name: WCCS(~WCCS.Open_up_commands_reversed),
WCAttrs.window_covering_mode.name: WCM(WCM.LEDs_display_feedback),
}
update_attribute_cache(cluster)
zha_device = await zha_device_joined_restored(zigpy_cover_device)
assert (
not zha_device.endpoints[1]
.all_cluster_handlers[f"1:0x{cluster.cluster_id:04x}"]
.inverted
)
assert cluster.read_attributes.call_count == 3
assert (
WCAttrs.current_position_lift_percentage.name
in cluster.read_attributes.call_args[0][0]
)
assert (
WCAttrs.current_position_tilt_percentage.name
in cluster.read_attributes.call_args[0][0]
)
entity_id = find_entity_id(Platform.SWITCH, zha_device, hass)
assert entity_id is not None
await async_enable_traffic(hass, [zha_device], enabled=False)
# test that the cover was created and that it is unavailable
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [zha_device])
await hass.async_block_till_done()
# test update
prev_call_count = cluster.read_attributes.call_count
await async_update_entity(hass, entity_id)
assert cluster.read_attributes.call_count == prev_call_count + 1
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_OFF
# test to see the state remains after tilting to 0%
await send_attributes_report(
hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 0}
)
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_OFF
with patch(
"zigpy.zcl.Cluster.write_attributes", return_value=[0x1, zcl_f.Status.SUCCESS]
):
cluster.PLUGGED_ATTR_READS = {
WCAttrs.config_status.name: WCCS.Operational
| WCCS.Open_up_commands_reversed,
}
# turn on from UI
await hass.services.async_call(
SWITCH_DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True
)
assert cluster.write_attributes.call_count == 1
assert cluster.write_attributes.call_args_list[0] == call(
{
WCAttrs.window_covering_mode.name: WCM.Motor_direction_reversed
| WCM.LEDs_display_feedback
},
manufacturer=None,
)
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_ON
cluster.write_attributes.reset_mock()
# turn off from UI
cluster.PLUGGED_ATTR_READS = {
WCAttrs.config_status.name: WCCS.Operational,
}
await hass.services.async_call(
SWITCH_DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True
)
assert cluster.write_attributes.call_count == 1
assert cluster.write_attributes.call_args_list[0] == call(
{WCAttrs.window_covering_mode.name: WCM.LEDs_display_feedback},
manufacturer=None,
)
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_OFF
cluster.write_attributes.reset_mock()
# test that sending the command again does not result in a write
await hass.services.async_call(
SWITCH_DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True
)
assert cluster.write_attributes.call_count == 0
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_OFF
async def test_cover_inversion_switch_not_created(
hass: HomeAssistant, zha_device_joined_restored, zigpy_cover_device
) -> None:
"""Test ZHA cover platform."""
# load up cover domain
cluster = zigpy_cover_device.endpoints[1].window_covering
cluster.PLUGGED_ATTR_READS = {
WCAttrs.current_position_lift_percentage.name: 65,
WCAttrs.current_position_tilt_percentage.name: 42,
WCAttrs.config_status.name: WCCS(~WCCS.Open_up_commands_reversed),
}
update_attribute_cache(cluster)
zha_device = await zha_device_joined_restored(zigpy_cover_device)
assert cluster.read_attributes.call_count == 3
assert (
WCAttrs.current_position_lift_percentage.name
in cluster.read_attributes.call_args[0][0]
)
assert (
WCAttrs.current_position_tilt_percentage.name
in cluster.read_attributes.call_args[0][0]
)
# entity should not be created when mode or config status aren't present
entity_id = find_entity_id(Platform.SWITCH, zha_device, hass)
assert entity_id is None

View File

@ -23,13 +23,25 @@ from homeassistant.components.update import (
DOMAIN as UPDATE_DOMAIN,
SERVICE_INSTALL,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform
from homeassistant.components.zha.helpers import (
ZHADeviceProxy,
ZHAGatewayProxy,
get_zha_gateway,
get_zha_gateway_proxy,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
STATE_OFF,
STATE_ON,
STATE_UNKNOWN,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.setup import async_setup_component
from .common import async_enable_traffic, find_entity_id, update_attribute_cache
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE
from .common import find_entity_id, update_attribute_cache
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
@pytest.fixture(autouse=True)
@ -47,28 +59,32 @@ def update_platform_only():
yield
@pytest.fixture
def zigpy_device(zigpy_device_mock):
"""Device tracker zigpy device."""
endpoints = {
1: {
SIG_EP_INPUT: [general.Basic.cluster_id, general.OnOff.cluster_id],
SIG_EP_OUTPUT: [general.Ota.cluster_id],
SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH,
}
}
return zigpy_device_mock(
endpoints, node_descriptor=b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00"
)
async def setup_test_data(
zha_device_joined_restored,
zigpy_device,
hass: HomeAssistant,
zigpy_device_mock,
skip_attribute_plugs=False,
file_not_found=False,
):
"""Set up test data for the tests."""
gateway = get_zha_gateway(hass)
gateway_proxy: ZHAGatewayProxy = get_zha_gateway_proxy(hass)
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [general.Basic.cluster_id, general.OnOff.cluster_id],
SIG_EP_OUTPUT: [general.Ota.cluster_id],
SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
},
node_descriptor=b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00",
)
gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
fw_version = 0x12345678
installed_fw_version = fw_version - 10
cluster = zigpy_device.endpoints[1].out_clusters[general.Ota.cluster_id]
@ -106,31 +122,28 @@ async def setup_test_data(
cluster.endpoint.device.application.ota.get_ota_image = AsyncMock(
return_value=None if file_not_found else fw_image
)
zha_device_proxy: ZHADeviceProxy = gateway_proxy.get_device_proxy(zigpy_device.ieee)
zha_device_proxy.device.async_update_sw_build_id(installed_fw_version)
zha_device = await zha_device_joined_restored(zigpy_device)
zha_device.async_update_sw_build_id(installed_fw_version)
return zha_device, cluster, fw_image, installed_fw_version
return zha_device_proxy, cluster, fw_image, installed_fw_version
async def test_firmware_update_notification_from_zigpy(
hass: HomeAssistant,
zha_device_joined_restored,
zigpy_device,
setup_zha,
zigpy_device_mock,
) -> None:
"""Test ZHA update platform - firmware update notification."""
await setup_zha()
zha_device, cluster, fw_image, installed_fw_version = await setup_test_data(
zha_device_joined_restored,
zigpy_device,
hass,
zigpy_device_mock,
)
entity_id = find_entity_id(Platform.UPDATE, zha_device, hass)
assert entity_id is not None
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [zha_device])
assert hass.states.get(entity_id).state == STATE_OFF
assert hass.states.get(entity_id).state == STATE_UNKNOWN
# simulate an image available notification
await cluster._handle_query_next_image(
@ -139,7 +152,7 @@ async def test_firmware_update_notification_from_zigpy(
),
general.QueryNextImageCommand(
fw_image.firmware.header.field_control,
zha_device.manufacturer_code,
zha_device.device.manufacturer_code,
fw_image.firmware.header.image_type,
installed_fw_version,
fw_image.firmware.header.header_version,
@ -158,20 +171,20 @@ async def test_firmware_update_notification_from_zigpy(
async def test_firmware_update_notification_from_service_call(
hass: HomeAssistant, zha_device_joined_restored, zigpy_device
hass: HomeAssistant,
setup_zha,
zigpy_device_mock,
) -> None:
"""Test ZHA update platform - firmware update manual check."""
await setup_zha()
zha_device, cluster, fw_image, installed_fw_version = await setup_test_data(
zha_device_joined_restored, zigpy_device
hass,
zigpy_device_mock,
)
entity_id = find_entity_id(Platform.UPDATE, zha_device, hass)
assert entity_id is not None
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [zha_device])
assert hass.states.get(entity_id).state == STATE_OFF
assert hass.states.get(entity_id).state == STATE_UNKNOWN
async def _async_image_notify_side_effect(*args, **kwargs):
await cluster._handle_query_next_image(
@ -180,7 +193,7 @@ async def test_firmware_update_notification_from_service_call(
),
general.QueryNextImageCommand(
fw_image.firmware.header.field_control,
zha_device.manufacturer_code,
zha_device.device.manufacturer_code,
fw_image.firmware.header.image_type,
installed_fw_version,
fw_image.firmware.header.header_version,
@ -245,11 +258,14 @@ def make_packet(zigpy_device, cluster, cmd_name: str, **kwargs):
@patch("zigpy.device.AFTER_OTA_ATTR_READ_DELAY", 0.01)
async def test_firmware_update_success(
hass: HomeAssistant, zha_device_joined_restored, zigpy_device
hass: HomeAssistant,
setup_zha,
zigpy_device_mock,
) -> None:
"""Test ZHA update platform - firmware update success."""
await setup_zha()
zha_device, cluster, fw_image, installed_fw_version = await setup_test_data(
zha_device_joined_restored, zigpy_device
hass, zigpy_device_mock
)
assert installed_fw_version < fw_image.firmware.header.file_version
@ -257,10 +273,7 @@ async def test_firmware_update_success(
entity_id = find_entity_id(Platform.UPDATE, zha_device, hass)
assert entity_id is not None
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [zha_device])
assert hass.states.get(entity_id).state == STATE_OFF
assert hass.states.get(entity_id).state == STATE_UNKNOWN
# simulate an image available notification
await cluster._handle_query_next_image(
@ -269,7 +282,7 @@ async def test_firmware_update_success(
),
general.QueryNextImageCommand(
field_control=fw_image.firmware.header.field_control,
manufacturer_code=zha_device.manufacturer_code,
manufacturer_code=zha_device.device.manufacturer_code,
image_type=fw_image.firmware.header.image_type,
current_file_version=installed_fw_version,
),
@ -289,9 +302,9 @@ async def test_firmware_update_success(
if cluster_id == general.Ota.cluster_id:
hdr, cmd = cluster.deserialize(data)
if isinstance(cmd, general.Ota.ImageNotifyCommand):
zigpy_device.packet_received(
zha_device.device.device.packet_received(
make_packet(
zigpy_device,
zha_device.device.device,
cluster,
general.Ota.ServerCommandDefs.query_next_image.name,
field_control=general.Ota.QueryNextImageCommand.FieldControl.HardwareVersion,
@ -309,9 +322,9 @@ async def test_firmware_update_success(
assert cmd.image_type == fw_image.firmware.header.image_type
assert cmd.file_version == fw_image.firmware.header.file_version
assert cmd.image_size == fw_image.firmware.header.image_size
zigpy_device.packet_received(
zha_device.device.device.packet_received(
make_packet(
zigpy_device,
zha_device.device.device,
cluster,
general.Ota.ServerCommandDefs.image_block.name,
field_control=general.Ota.ImageBlockCommand.FieldControl.RequestNodeAddr,
@ -320,7 +333,7 @@ async def test_firmware_update_success(
file_version=fw_image.firmware.header.file_version,
file_offset=0,
maximum_data_size=40,
request_node_addr=zigpy_device.ieee,
request_node_addr=zha_device.device.device.ieee,
)
)
elif isinstance(
@ -336,9 +349,9 @@ async def test_firmware_update_success(
assert cmd.file_version == fw_image.firmware.header.file_version
assert cmd.file_offset == 0
assert cmd.image_data == fw_image.firmware.serialize()[0:40]
zigpy_device.packet_received(
zha_device.device.device.packet_received(
make_packet(
zigpy_device,
zha_device.device.device,
cluster,
general.Ota.ServerCommandDefs.image_block.name,
field_control=general.Ota.ImageBlockCommand.FieldControl.RequestNodeAddr,
@ -347,7 +360,7 @@ async def test_firmware_update_success(
file_version=fw_image.firmware.header.file_version,
file_offset=40,
maximum_data_size=40,
request_node_addr=zigpy_device.ieee,
request_node_addr=zha_device.device.device.ieee,
)
)
elif cmd.file_offset == 40:
@ -374,9 +387,9 @@ async def test_firmware_update_success(
== f"0x{fw_image.firmware.header.file_version:08x}"
)
zigpy_device.packet_received(
zha_device.device.device.packet_received(
make_packet(
zigpy_device,
zha_device.device.device,
cluster,
general.Ota.ServerCommandDefs.upgrade_end.name,
status=foundation.Status.SUCCESS,
@ -430,7 +443,7 @@ async def test_firmware_update_success(
# If we send a progress notification incorrectly, it won't be handled
entity = hass.data[UPDATE_DOMAIN].get_entity(entity_id)
entity._update_progress(50, 100, 0.50)
entity.entity_data.entity._update_progress(50, 100, 0.50)
state = hass.states.get(entity_id)
assert not attrs[ATTR_IN_PROGRESS]
@ -438,20 +451,20 @@ async def test_firmware_update_success(
async def test_firmware_update_raises(
hass: HomeAssistant, zha_device_joined_restored, zigpy_device
hass: HomeAssistant,
setup_zha,
zigpy_device_mock,
) -> None:
"""Test ZHA update platform - firmware update raises."""
await setup_zha()
zha_device, cluster, fw_image, installed_fw_version = await setup_test_data(
zha_device_joined_restored, zigpy_device
hass, zigpy_device_mock
)
entity_id = find_entity_id(Platform.UPDATE, zha_device, hass)
assert entity_id is not None
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [zha_device])
assert hass.states.get(entity_id).state == STATE_OFF
assert hass.states.get(entity_id).state == STATE_UNKNOWN
# simulate an image available notification
await cluster._handle_query_next_image(
@ -460,7 +473,7 @@ async def test_firmware_update_raises(
),
general.QueryNextImageCommand(
fw_image.firmware.header.field_control,
zha_device.manufacturer_code,
zha_device.device.manufacturer_code,
fw_image.firmware.header.image_type,
installed_fw_version,
fw_image.firmware.header.header_version,
@ -481,9 +494,9 @@ async def test_firmware_update_raises(
if cluster_id == general.Ota.cluster_id:
hdr, cmd = cluster.deserialize(data)
if isinstance(cmd, general.Ota.ImageNotifyCommand):
zigpy_device.packet_received(
zha_device.device.device.packet_received(
make_packet(
zigpy_device,
zha_device.device.device,
cluster,
general.Ota.ServerCommandDefs.query_next_image.name,
field_control=general.Ota.QueryNextImageCommand.FieldControl.HardwareVersion,
@ -532,20 +545,20 @@ async def test_firmware_update_raises(
async def test_firmware_update_no_longer_compatible(
hass: HomeAssistant, zha_device_joined_restored, zigpy_device
hass: HomeAssistant,
setup_zha,
zigpy_device_mock,
) -> None:
"""Test ZHA update platform - firmware update is no longer valid."""
await setup_zha()
zha_device, cluster, fw_image, installed_fw_version = await setup_test_data(
zha_device_joined_restored, zigpy_device
hass, zigpy_device_mock
)
entity_id = find_entity_id(Platform.UPDATE, zha_device, hass)
assert entity_id is not None
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [zha_device])
assert hass.states.get(entity_id).state == STATE_OFF
assert hass.states.get(entity_id).state == STATE_UNKNOWN
# simulate an image available notification
await cluster._handle_query_next_image(
@ -554,7 +567,7 @@ async def test_firmware_update_no_longer_compatible(
),
general.QueryNextImageCommand(
fw_image.firmware.header.field_control,
zha_device.manufacturer_code,
zha_device.device.manufacturer_code,
fw_image.firmware.header.image_type,
installed_fw_version,
fw_image.firmware.header.header_version,
@ -577,9 +590,9 @@ async def test_firmware_update_no_longer_compatible(
if cluster_id == general.Ota.cluster_id:
hdr, cmd = cluster.deserialize(data)
if isinstance(cmd, general.Ota.ImageNotifyCommand):
zigpy_device.packet_received(
zha_device.device.device.packet_received(
make_packet(
zigpy_device,
zha_device.device.device,
cluster,
general.Ota.ServerCommandDefs.query_next_image.name,
field_control=general.Ota.QueryNextImageCommand.FieldControl.HardwareVersion,

View File

@ -10,12 +10,27 @@ from unittest.mock import ANY, AsyncMock, MagicMock, call, patch
from freezegun import freeze_time
import pytest
import voluptuous as vol
from zha.application.const import (
ATTR_CLUSTER_ID,
ATTR_CLUSTER_TYPE,
ATTR_ENDPOINT_ID,
ATTR_ENDPOINT_NAMES,
ATTR_IEEE,
ATTR_MANUFACTURER,
ATTR_NEIGHBORS,
ATTR_QUIRK_APPLIED,
ATTR_TYPE,
CLUSTER_TYPE_IN,
)
from zha.zigbee.cluster_handlers import ClusterBindEvent, ClusterConfigureReportingEvent
from zha.zigbee.device import ClusterHandlerConfigurationComplete
import zigpy.backups
from zigpy.const import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
import zigpy.profiles.zha
import zigpy.types
from zigpy.types.named import EUI64
import zigpy.util
from zigpy.zcl.clusters import general, security
from zigpy.zcl.clusters import closures, general, security
from zigpy.zcl.clusters.general import Groups
import zigpy.zdo.types as zdo_types
@ -25,23 +40,12 @@ from homeassistant.components.websocket_api import (
TYPE_RESULT,
)
from homeassistant.components.zha import DOMAIN
from homeassistant.components.zha.core.const import (
ATTR_CLUSTER_ID,
ATTR_CLUSTER_TYPE,
ATTR_ENDPOINT_ID,
ATTR_ENDPOINT_NAMES,
ATTR_IEEE,
ATTR_MANUFACTURER,
ATTR_MODEL,
ATTR_NEIGHBORS,
ATTR_QUIRK_APPLIED,
ATTR_TYPE,
BINDINGS,
CLUSTER_TYPE_IN,
EZSP_OVERWRITE_EUI64,
GROUP_ID,
GROUP_IDS,
GROUP_NAME,
from homeassistant.components.zha.const import EZSP_OVERWRITE_EUI64
from homeassistant.components.zha.helpers import (
ZHADeviceProxy,
ZHAGatewayProxy,
get_zha_gateway,
get_zha_gateway_proxy,
)
from homeassistant.components.zha.websocket_api import (
ATTR_DURATION,
@ -49,22 +53,19 @@ from homeassistant.components.zha.websocket_api import (
ATTR_QR_CODE,
ATTR_SOURCE_IEEE,
ATTR_TARGET_IEEE,
BINDINGS,
GROUP_ID,
GROUP_IDS,
GROUP_NAME,
ID,
SERVICE_PERMIT,
TYPE,
async_load_api,
)
from homeassistant.const import ATTR_NAME, Platform
from homeassistant.const import ATTR_MODEL, ATTR_NAME, Platform
from homeassistant.core import Context, HomeAssistant
from .conftest import (
FIXTURE_GRP_ID,
FIXTURE_GRP_NAME,
SIG_EP_INPUT,
SIG_EP_OUTPUT,
SIG_EP_PROFILE,
SIG_EP_TYPE,
)
from .conftest import FIXTURE_GRP_ID, FIXTURE_GRP_NAME
from .data import BASE_CUSTOM_CONFIGURATION, CONFIG_WITH_ALARM_OPTIONS
from tests.common import MockConfigEntry, MockUser
@ -93,10 +94,18 @@ def required_platform_only():
@pytest.fixture
async def device_switch(hass, zigpy_device_mock, zha_device_joined):
"""Test ZHA switch platform."""
async def zha_client(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
setup_zha,
zigpy_device_mock,
) -> MockHAClientWebSocket:
"""Get ZHA WebSocket client."""
zigpy_device = zigpy_device_mock(
await setup_zha()
gateway = get_zha_gateway(hass)
zigpy_device_switch = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [general.OnOff.cluster_id, general.Basic.cluster_id],
@ -107,35 +116,8 @@ async def device_switch(hass, zigpy_device_mock, zha_device_joined):
},
ieee=IEEE_SWITCH_DEVICE,
)
zha_device = await zha_device_joined(zigpy_device)
zha_device.available = True
return zha_device
@pytest.fixture
async def device_ias_ace(hass, zigpy_device_mock, zha_device_joined):
"""Test alarm control panel device."""
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [security.IasAce.cluster_id],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.IAS_ANCILLARY_CONTROL,
SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID,
}
},
)
zha_device = await zha_device_joined(zigpy_device)
zha_device.available = True
return zha_device
@pytest.fixture
async def device_groupable(hass, zigpy_device_mock, zha_device_joined):
"""Test ZHA light platform."""
zigpy_device = zigpy_device_mock(
zigpy_device_groupable = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [
@ -150,19 +132,14 @@ async def device_groupable(hass, zigpy_device_mock, zha_device_joined):
},
ieee=IEEE_GROUPABLE_DEVICE,
)
zha_device = await zha_device_joined(zigpy_device)
zha_device.available = True
return zha_device
gateway.get_or_create_device(zigpy_device_switch)
await gateway.async_device_initialized(zigpy_device_switch)
await hass.async_block_till_done(wait_background_tasks=True)
@pytest.fixture
async def zha_client(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
device_switch,
device_groupable,
) -> MockHAClientWebSocket:
"""Get ZHA WebSocket client."""
gateway.get_or_create_device(zigpy_device_groupable)
await gateway.async_device_initialized(zigpy_device_groupable)
await hass.async_block_till_done(wait_background_tasks=True)
# load the ZHA API
async_load_api(hass)
@ -247,7 +224,7 @@ async def test_list_devices(zha_client) -> None:
msg = await zha_client.receive_json()
devices = msg["result"]
assert len(devices) == 2 + 1 # the coordinator is included as well
assert len(devices) == 3 # the coordinator is included as well
msg_id = 100
for device in devices:
@ -284,9 +261,31 @@ async def test_get_zha_config(zha_client) -> None:
async def test_get_zha_config_with_alarm(
hass: HomeAssistant, zha_client, device_ias_ace
hass: HomeAssistant, zha_client, zigpy_device_mock
) -> None:
"""Test getting ZHA custom configuration."""
gateway = get_zha_gateway(hass)
gateway_proxy: ZHAGatewayProxy = get_zha_gateway_proxy(hass)
zigpy_device_ias = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [security.IasAce.cluster_id],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.IAS_ANCILLARY_CONTROL,
SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID,
}
},
)
gateway.get_or_create_device(zigpy_device_ias)
await gateway.async_device_initialized(zigpy_device_ias)
await hass.async_block_till_done(wait_background_tasks=True)
zha_device_proxy: ZHADeviceProxy = gateway_proxy.get_device_proxy(
zigpy_device_ias.ieee
)
await zha_client.send_json({ID: 5, TYPE: "zha/configuration"})
msg = await zha_client.receive_json()
@ -295,7 +294,7 @@ async def test_get_zha_config_with_alarm(
assert configuration == CONFIG_WITH_ALARM_OPTIONS
# test that the alarm options are not in the config when we remove the device
device_ias_ace.gateway.device_removed(device_ias_ace.device)
zha_device_proxy.gateway_proxy.gateway.device_removed(zha_device_proxy.device)
await hass.async_block_till_done()
await zha_client.send_json({ID: 6, TYPE: "zha/configuration"})
@ -390,11 +389,12 @@ async def test_get_group_not_found(zha_client) -> None:
async def test_list_groupable_devices(
zha_client, device_groupable, zigpy_app_controller
hass: HomeAssistant, zha_client, zigpy_app_controller
) -> None:
"""Test getting ZHA devices that have a group cluster."""
# Ensure the coordinator doesn't have a group cluster
coordinator = zigpy_app_controller.get_device(nwk=0x0000)
del coordinator.endpoints[1].in_clusters[Groups.cluster_id]
await zha_client.send_json({ID: 10, TYPE: "zha/devices/groupable"})
@ -425,7 +425,10 @@ async def test_list_groupable_devices(
# Make sure there are no groupable devices when the device is unavailable
# Make device unavailable
device_groupable.available = False
get_zha_gateway_proxy(hass).device_proxies[
EUI64.convert(IEEE_GROUPABLE_DEVICE)
].device.available = False
await hass.async_block_till_done(wait_background_tasks=True)
await zha_client.send_json({ID: 11, TYPE: "zha/devices/groupable"})
@ -1037,3 +1040,101 @@ async def test_websocket_bind_unbind_group(
assert bind_mock.mock_calls == [call(test_group_id, ANY)]
elif command_type == "unbind":
assert unbind_mock.mock_calls == [call(test_group_id, ANY)]
async def test_websocket_reconfigure(
hass: HomeAssistant, zha_client: MockHAClientWebSocket, zigpy_device_mock
) -> None:
"""Test websocket API to reconfigure a device."""
gateway = get_zha_gateway(hass)
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [closures.WindowCovering.cluster_id],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.SHADE,
SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID,
}
},
)
zha_device = gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
zha_device_proxy = get_zha_gateway_proxy(hass).get_device_proxy(zha_device.ieee)
def mock_reconfigure() -> None:
zha_device_proxy.handle_zha_channel_configure_reporting(
ClusterConfigureReportingEvent(
cluster_name="Window Covering",
cluster_id=258,
attributes={
"current_position_lift_percentage": {
"min": 0,
"max": 900,
"id": "current_position_lift_percentage",
"name": "current_position_lift_percentage",
"change": 1,
"status": "SUCCESS",
},
"current_position_tilt_percentage": {
"min": 0,
"max": 900,
"id": "current_position_tilt_percentage",
"name": "current_position_tilt_percentage",
"change": 1,
"status": "SUCCESS",
},
},
cluster_handler_unique_id="28:2c:02:bf:ff:ea:05:68:1:0x0102",
event_type="zha_channel_message",
event="zha_channel_configure_reporting",
)
)
zha_device_proxy.handle_zha_channel_bind(
ClusterBindEvent(
cluster_name="Window Covering",
cluster_id=1,
success=True,
cluster_handler_unique_id="28:2c:02:bf:ff:ea:05:68:1:0x0012",
event_type="zha_channel_message",
event="zha_channel_bind",
)
)
zha_device_proxy.handle_zha_channel_cfg_done(
ClusterHandlerConfigurationComplete(
device_ieee="28:2c:02:bf:ff:ea:05:68",
unique_id="28:2c:02:bf:ff:ea:05:68",
event_type="zha_channel_message",
event="zha_channel_cfg_done",
)
)
with patch.object(
zha_device_proxy.device, "async_configure", side_effect=mock_reconfigure
):
await zha_client.send_json(
{
ID: 6,
TYPE: "zha/devices/reconfigure",
ATTR_IEEE: str(zha_device_proxy.device.ieee),
}
)
messages = []
while len(messages) != 3:
msg = await zha_client.receive_json()
if msg[ID] == 6:
messages.append(msg)
# Ensure the frontend receives progress events
assert {m["event"]["type"] for m in messages} == {
"zha_channel_configure_reporting",
"zha_channel_bind",
"zha_channel_cfg_done",
}

File diff suppressed because it is too large Load Diff