mirror of
https://github.com/home-assistant/core.git
synced 2026-05-05 04:14:32 +02:00
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:
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -1,6 +0,0 @@
|
||||
"""Core module for Zigbee Home Automation."""
|
||||
|
||||
from .device import ZHADevice
|
||||
from .gateway import ZHAGateway
|
||||
|
||||
__all__ = ["ZHADevice", "ZHAGateway"]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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."""
|
||||
@@ -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",)
|
||||
@@ -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."""
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
@@ -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."""
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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."""
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
@@ -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()
|
||||
@@ -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)
|
||||
]
|
||||
@@ -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()},
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
+151
-1317
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-21
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
+103
-1552
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user