1345 lines
49 KiB
Python
1345 lines
49 KiB
Python
"""Helper functions for the ZHA integration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import collections
|
|
from collections.abc import Awaitable, Callable, Coroutine, Mapping
|
|
import copy
|
|
import dataclasses
|
|
import enum
|
|
import functools
|
|
import itertools
|
|
import logging
|
|
import re
|
|
import time
|
|
from types import MappingProxyType
|
|
from typing import TYPE_CHECKING, Any, Concatenate, NamedTuple, cast
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import voluptuous as vol
|
|
from zha.application.const import (
|
|
ATTR_CLUSTER_ID,
|
|
ATTR_DEVICE_IEEE,
|
|
ATTR_TYPE,
|
|
ATTR_UNIQUE_ID,
|
|
CLUSTER_TYPE_IN,
|
|
CLUSTER_TYPE_OUT,
|
|
CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY,
|
|
CONF_DEFAULT_CONSIDER_UNAVAILABLE_MAINS,
|
|
UNKNOWN_MANUFACTURER,
|
|
UNKNOWN_MODEL,
|
|
ZHA_CLUSTER_HANDLER_CFG_DONE,
|
|
ZHA_CLUSTER_HANDLER_MSG,
|
|
ZHA_CLUSTER_HANDLER_MSG_BIND,
|
|
ZHA_CLUSTER_HANDLER_MSG_CFG_RPT,
|
|
ZHA_CLUSTER_HANDLER_MSG_DATA,
|
|
ZHA_EVENT,
|
|
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_RAW_INIT,
|
|
RadioType,
|
|
)
|
|
from zha.application.gateway import (
|
|
ConnectionLostEvent,
|
|
DeviceFullInitEvent,
|
|
DeviceJoinedEvent,
|
|
DeviceLeftEvent,
|
|
DeviceRemovedEvent,
|
|
Gateway,
|
|
GroupEvent,
|
|
RawDeviceInitializedEvent,
|
|
)
|
|
from zha.application.helpers import (
|
|
AlarmControlPanelOptions,
|
|
CoordinatorConfiguration,
|
|
DeviceOptions,
|
|
DeviceOverridesConfiguration,
|
|
LightOptions,
|
|
QuirksConfiguration,
|
|
ZHAConfiguration,
|
|
ZHAData,
|
|
)
|
|
from zha.application.platforms import GroupEntity, PlatformEntity
|
|
from zha.event import EventBase
|
|
from zha.exceptions import ZHAException
|
|
from zha.mixins import LogMixin
|
|
from zha.zigbee.cluster_handlers import ClusterBindEvent, ClusterConfigureReportingEvent
|
|
from zha.zigbee.device import ClusterHandlerConfigurationComplete, Device, ZHAEvent
|
|
from zha.zigbee.group import Group, GroupInfo, GroupMember
|
|
from zigpy.config import (
|
|
CONF_DATABASE,
|
|
CONF_DEVICE,
|
|
CONF_DEVICE_PATH,
|
|
CONF_NWK,
|
|
CONF_NWK_CHANNEL,
|
|
)
|
|
import zigpy.exceptions
|
|
from zigpy.profiles import PROFILES
|
|
import zigpy.types
|
|
from zigpy.types import EUI64
|
|
import zigpy.util
|
|
import zigpy.zcl
|
|
from zigpy.zcl.foundation import CommandSchema
|
|
|
|
from homeassistant import __path__ as HOMEASSISTANT_PATH
|
|
from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import (
|
|
is_multiprotocol_url,
|
|
)
|
|
from homeassistant.components.system_log import LogEntry
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.const import (
|
|
ATTR_AREA_ID,
|
|
ATTR_DEVICE_ID,
|
|
ATTR_ENTITY_ID,
|
|
ATTR_MODEL,
|
|
ATTR_NAME,
|
|
Platform,
|
|
)
|
|
from homeassistant.core import Event, HomeAssistant, callback
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
from homeassistant.helpers import (
|
|
config_validation as cv,
|
|
device_registry as dr,
|
|
entity_registry as er,
|
|
)
|
|
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
from homeassistant.helpers.typing import ConfigType
|
|
|
|
from .const import (
|
|
ATTR_ACTIVE_COORDINATOR,
|
|
ATTR_ATTRIBUTES,
|
|
ATTR_AVAILABLE,
|
|
ATTR_CLUSTER_NAME,
|
|
ATTR_DEVICE_TYPE,
|
|
ATTR_ENDPOINT_NAMES,
|
|
ATTR_IEEE,
|
|
ATTR_LAST_SEEN,
|
|
ATTR_LQI,
|
|
ATTR_MANUFACTURER,
|
|
ATTR_MANUFACTURER_CODE,
|
|
ATTR_NEIGHBORS,
|
|
ATTR_NWK,
|
|
ATTR_POWER_SOURCE,
|
|
ATTR_QUIRK_APPLIED,
|
|
ATTR_QUIRK_CLASS,
|
|
ATTR_QUIRK_ID,
|
|
ATTR_ROUTES,
|
|
ATTR_RSSI,
|
|
ATTR_SIGNATURE,
|
|
ATTR_SUCCESS,
|
|
CONF_ALARM_ARM_REQUIRES_CODE,
|
|
CONF_ALARM_FAILED_TRIES,
|
|
CONF_ALARM_MASTER_CODE,
|
|
CONF_BAUDRATE,
|
|
CONF_CONSIDER_UNAVAILABLE_BATTERY,
|
|
CONF_CONSIDER_UNAVAILABLE_MAINS,
|
|
CONF_CUSTOM_QUIRKS_PATH,
|
|
CONF_DEFAULT_LIGHT_TRANSITION,
|
|
CONF_DEVICE_CONFIG,
|
|
CONF_ENABLE_ENHANCED_LIGHT_TRANSITION,
|
|
CONF_ENABLE_IDENTIFY_ON_JOIN,
|
|
CONF_ENABLE_LIGHT_TRANSITIONING_FLAG,
|
|
CONF_ENABLE_MAINS_STARTUP_POLLING,
|
|
CONF_ENABLE_QUIRKS,
|
|
CONF_FLOW_CONTROL,
|
|
CONF_GROUP_MEMBERS_ASSUME_STATE,
|
|
CONF_RADIO_TYPE,
|
|
CONF_ZIGPY,
|
|
CUSTOM_CONFIGURATION,
|
|
DATA_ZHA,
|
|
DEFAULT_DATABASE_NAME,
|
|
DEVICE_PAIRING_STATUS,
|
|
DOMAIN,
|
|
ZHA_ALARM_OPTIONS,
|
|
ZHA_OPTIONS,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from logging import Filter, LogRecord
|
|
|
|
from .entity import ZHAEntity
|
|
from .update import ZHAFirmwareUpdateCoordinator
|
|
|
|
type _LogFilterType = Filter | Callable[[LogRecord], bool]
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
DEBUG_COMP_BELLOWS = "bellows"
|
|
DEBUG_COMP_ZHA = "homeassistant.components.zha"
|
|
DEBUG_LIB_ZHA = "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_LIB_ZHA: logging.DEBUG,
|
|
}
|
|
DEBUG_RELAY_LOGGERS = [DEBUG_COMP_ZHA, DEBUG_COMP_ZIGPY, DEBUG_LIB_ZHA]
|
|
ZHA_GW_MSG_LOG_ENTRY = "log_entry"
|
|
ZHA_GW_MSG_LOG_OUTPUT = "log_output"
|
|
SIGNAL_REMOVE_ENTITIES = "zha_remove_entities"
|
|
GROUP_ENTITY_DOMAINS = [Platform.LIGHT, Platform.SWITCH, Platform.FAN]
|
|
SIGNAL_ADD_ENTITIES = "zha_add_entities"
|
|
ENTITIES = "entities"
|
|
|
|
RX_ON_WHEN_IDLE = "rx_on_when_idle"
|
|
RELATIONSHIP = "relationship"
|
|
EXTENDED_PAN_ID = "extended_pan_id"
|
|
PERMIT_JOINING = "permit_joining"
|
|
DEPTH = "depth"
|
|
|
|
DEST_NWK = "dest_nwk"
|
|
ROUTE_STATUS = "route_status"
|
|
MEMORY_CONSTRAINED = "memory_constrained"
|
|
MANY_TO_ONE = "many_to_one"
|
|
ROUTE_RECORD_REQUIRED = "route_record_required"
|
|
NEXT_HOP = "next_hop"
|
|
|
|
USER_GIVEN_NAME = "user_given_name"
|
|
DEVICE_REG_ID = "device_reg_id"
|
|
|
|
|
|
class GroupEntityReference(NamedTuple):
|
|
"""Reference to a group entity."""
|
|
|
|
name: str | None
|
|
original_name: str | None
|
|
entity_id: str
|
|
|
|
|
|
class ZHAGroupProxy(LogMixin):
|
|
"""Proxy class to interact with the ZHA group instances."""
|
|
|
|
def __init__(self, group: Group, gateway_proxy: ZHAGatewayProxy) -> None:
|
|
"""Initialize the gateway proxy."""
|
|
self.group: Group = group
|
|
self.gateway_proxy: ZHAGatewayProxy = gateway_proxy
|
|
|
|
@property
|
|
def group_info(self) -> dict[str, Any]:
|
|
"""Return a group description for group."""
|
|
return {
|
|
"name": self.group.name,
|
|
"group_id": self.group.group_id,
|
|
"members": [
|
|
{
|
|
"endpoint_id": member.endpoint_id,
|
|
"device": self.gateway_proxy.device_proxies[
|
|
member.device.ieee
|
|
].zha_device_info,
|
|
"entities": [e._asdict() for e in self.associated_entities(member)],
|
|
}
|
|
for member in self.group.members
|
|
],
|
|
}
|
|
|
|
def associated_entities(self, member: GroupMember) -> list[GroupEntityReference]:
|
|
"""Return the list of entities that were derived from this endpoint."""
|
|
entity_registry = er.async_get(self.gateway_proxy.hass)
|
|
entity_refs: collections.defaultdict[EUI64, list[EntityReference]] = (
|
|
self.gateway_proxy.ha_entity_refs
|
|
)
|
|
|
|
entity_info = []
|
|
|
|
for entity_ref in entity_refs.get(member.device.ieee): # type: ignore[union-attr]
|
|
if not entity_ref.entity_data.is_group_entity:
|
|
continue
|
|
entity = entity_registry.async_get(entity_ref.ha_entity_id)
|
|
|
|
if (
|
|
entity is None
|
|
or entity_ref.entity_data.group_proxy is None
|
|
or entity_ref.entity_data.group_proxy.group.group_id
|
|
!= member.group.group_id
|
|
):
|
|
continue
|
|
|
|
entity_info.append(
|
|
GroupEntityReference(
|
|
name=entity.name,
|
|
original_name=entity.original_name,
|
|
entity_id=entity_ref.ha_entity_id,
|
|
)
|
|
)
|
|
|
|
return entity_info
|
|
|
|
def log(self, level: int, msg: str, *args: Any, **kwargs) -> None:
|
|
"""Log a message."""
|
|
msg = f"[%s](%s): {msg}"
|
|
args = (
|
|
f"0x{self.group.group_id:04x}",
|
|
self.group.endpoint.endpoint_id,
|
|
*args,
|
|
)
|
|
_LOGGER.log(level, msg, *args, **kwargs)
|
|
|
|
|
|
class ZHADeviceProxy(EventBase):
|
|
"""Proxy class to interact with the ZHA device instances."""
|
|
|
|
_ha_device_id: str
|
|
|
|
def __init__(self, device: Device, gateway_proxy: ZHAGatewayProxy) -> None:
|
|
"""Initialize the gateway proxy."""
|
|
super().__init__()
|
|
self.device = device
|
|
self.gateway_proxy = gateway_proxy
|
|
self._unsubs: list[Callable[[], None]] = []
|
|
self._unsubs.append(self.device.on_all_events(self._handle_event_protocol))
|
|
|
|
@property
|
|
def device_id(self) -> str:
|
|
"""Return the HA device registry device id."""
|
|
return self._ha_device_id
|
|
|
|
@device_id.setter
|
|
def device_id(self, device_id: str) -> None:
|
|
"""Set the HA device registry device id."""
|
|
self._ha_device_id = device_id
|
|
|
|
@property
|
|
def device_info(self) -> dict[str, Any]:
|
|
"""Return a device description for device."""
|
|
ieee = str(self.device.ieee)
|
|
time_struct = time.localtime(self.device.last_seen)
|
|
update_time = time.strftime("%Y-%m-%dT%H:%M:%S", time_struct)
|
|
return {
|
|
ATTR_IEEE: ieee,
|
|
ATTR_NWK: self.device.nwk,
|
|
ATTR_MANUFACTURER: self.device.manufacturer,
|
|
ATTR_MODEL: self.device.model,
|
|
ATTR_NAME: self.device.name or ieee,
|
|
ATTR_QUIRK_APPLIED: self.device.quirk_applied,
|
|
ATTR_QUIRK_CLASS: self.device.quirk_class,
|
|
ATTR_QUIRK_ID: self.device.quirk_id,
|
|
ATTR_MANUFACTURER_CODE: self.device.manufacturer_code,
|
|
ATTR_POWER_SOURCE: self.device.power_source,
|
|
ATTR_LQI: self.device.lqi,
|
|
ATTR_RSSI: self.device.rssi,
|
|
ATTR_LAST_SEEN: update_time,
|
|
ATTR_AVAILABLE: self.device.available,
|
|
ATTR_DEVICE_TYPE: self.device.device_type,
|
|
ATTR_SIGNATURE: self.device.zigbee_signature,
|
|
}
|
|
|
|
@property
|
|
def zha_device_info(self) -> dict[str, Any]:
|
|
"""Get ZHA device information."""
|
|
device_info: dict[str, Any] = {}
|
|
device_info.update(self.device_info)
|
|
device_info[ATTR_ACTIVE_COORDINATOR] = self.device.is_active_coordinator
|
|
device_info[ENTITIES] = [
|
|
{
|
|
ATTR_ENTITY_ID: entity_ref.ha_entity_id,
|
|
ATTR_NAME: entity_ref.ha_device_info[ATTR_NAME],
|
|
}
|
|
for entity_ref in self.gateway_proxy.ha_entity_refs[self.device.ieee]
|
|
]
|
|
|
|
topology = self.gateway_proxy.gateway.application_controller.topology
|
|
device_info[ATTR_NEIGHBORS] = [
|
|
{
|
|
ATTR_DEVICE_TYPE: neighbor.device_type.name,
|
|
RX_ON_WHEN_IDLE: neighbor.rx_on_when_idle.name,
|
|
RELATIONSHIP: neighbor.relationship.name,
|
|
EXTENDED_PAN_ID: str(neighbor.extended_pan_id),
|
|
ATTR_IEEE: str(neighbor.ieee),
|
|
ATTR_NWK: str(neighbor.nwk),
|
|
PERMIT_JOINING: neighbor.permit_joining.name,
|
|
DEPTH: str(neighbor.depth),
|
|
ATTR_LQI: str(neighbor.lqi),
|
|
}
|
|
for neighbor in topology.neighbors[self.device.ieee]
|
|
]
|
|
|
|
device_info[ATTR_ROUTES] = [
|
|
{
|
|
DEST_NWK: str(route.DstNWK),
|
|
ROUTE_STATUS: str(route.RouteStatus.name),
|
|
MEMORY_CONSTRAINED: bool(route.MemoryConstrained),
|
|
MANY_TO_ONE: bool(route.ManyToOne),
|
|
ROUTE_RECORD_REQUIRED: bool(route.RouteRecordRequired),
|
|
NEXT_HOP: str(route.NextHop),
|
|
}
|
|
for route in topology.routes[self.device.ieee]
|
|
]
|
|
|
|
# Return endpoint device type Names
|
|
names: list[dict[str, str]] = []
|
|
for endpoint in (
|
|
ep for epid, ep in self.device.device.endpoints.items() if epid
|
|
):
|
|
profile = PROFILES.get(endpoint.profile_id)
|
|
if profile and endpoint.device_type is not None:
|
|
# DeviceType provides undefined enums
|
|
names.append({ATTR_NAME: profile.DeviceType(endpoint.device_type).name})
|
|
else:
|
|
names.append(
|
|
{
|
|
ATTR_NAME: (
|
|
f"unknown {endpoint.device_type} device_type "
|
|
f"of 0x{(endpoint.profile_id or 0xFFFF):04x} profile id"
|
|
)
|
|
}
|
|
)
|
|
device_info[ATTR_ENDPOINT_NAMES] = names
|
|
|
|
device_registry = dr.async_get(self.gateway_proxy.hass)
|
|
reg_device = device_registry.async_get(self.device_id)
|
|
if reg_device is not None:
|
|
device_info[USER_GIVEN_NAME] = reg_device.name_by_user
|
|
device_info[DEVICE_REG_ID] = reg_device.id
|
|
device_info[ATTR_AREA_ID] = reg_device.area_id
|
|
return device_info
|
|
|
|
@callback
|
|
def handle_zha_event(self, zha_event: ZHAEvent) -> None:
|
|
"""Handle a ZHA event."""
|
|
self.gateway_proxy.hass.bus.async_fire(
|
|
ZHA_EVENT,
|
|
{
|
|
ATTR_DEVICE_IEEE: str(zha_event.device_ieee),
|
|
ATTR_UNIQUE_ID: zha_event.unique_id,
|
|
ATTR_DEVICE_ID: self.device_id,
|
|
**zha_event.data,
|
|
},
|
|
)
|
|
|
|
@callback
|
|
def handle_zha_channel_configure_reporting(
|
|
self, event: ClusterConfigureReportingEvent
|
|
) -> None:
|
|
"""Handle a ZHA cluster configure reporting event."""
|
|
async_dispatcher_send(
|
|
self.gateway_proxy.hass,
|
|
ZHA_CLUSTER_HANDLER_MSG,
|
|
{
|
|
ATTR_TYPE: ZHA_CLUSTER_HANDLER_MSG_CFG_RPT,
|
|
ZHA_CLUSTER_HANDLER_MSG_DATA: {
|
|
ATTR_CLUSTER_NAME: event.cluster_name,
|
|
ATTR_CLUSTER_ID: event.cluster_id,
|
|
ATTR_ATTRIBUTES: event.attributes,
|
|
},
|
|
},
|
|
)
|
|
|
|
@callback
|
|
def handle_zha_channel_cfg_done(
|
|
self, event: ClusterHandlerConfigurationComplete
|
|
) -> None:
|
|
"""Handle a ZHA cluster configure reporting event."""
|
|
async_dispatcher_send(
|
|
self.gateway_proxy.hass,
|
|
ZHA_CLUSTER_HANDLER_MSG,
|
|
{
|
|
ATTR_TYPE: ZHA_CLUSTER_HANDLER_CFG_DONE,
|
|
},
|
|
)
|
|
|
|
@callback
|
|
def handle_zha_channel_bind(self, event: ClusterBindEvent) -> None:
|
|
"""Handle a ZHA cluster bind event."""
|
|
async_dispatcher_send(
|
|
self.gateway_proxy.hass,
|
|
ZHA_CLUSTER_HANDLER_MSG,
|
|
{
|
|
ATTR_TYPE: ZHA_CLUSTER_HANDLER_MSG_BIND,
|
|
ZHA_CLUSTER_HANDLER_MSG_DATA: {
|
|
ATTR_CLUSTER_NAME: event.cluster_name,
|
|
ATTR_CLUSTER_ID: event.cluster_id,
|
|
ATTR_SUCCESS: event.success,
|
|
},
|
|
},
|
|
)
|
|
|
|
|
|
class EntityReference(NamedTuple):
|
|
"""Describes an entity reference."""
|
|
|
|
ha_entity_id: str
|
|
entity_data: EntityData
|
|
ha_device_info: dr.DeviceInfo
|
|
remove_future: asyncio.Future[Any]
|
|
|
|
|
|
class ZHAGatewayProxy(EventBase):
|
|
"""Proxy class to interact with the ZHA gateway."""
|
|
|
|
def __init__(
|
|
self, hass: HomeAssistant, config_entry: ConfigEntry, gateway: Gateway
|
|
) -> None:
|
|
"""Initialize the gateway proxy."""
|
|
super().__init__()
|
|
self.hass = hass
|
|
self.config_entry = config_entry
|
|
self.gateway = gateway
|
|
self.device_proxies: dict[EUI64, ZHADeviceProxy] = {}
|
|
self.group_proxies: dict[int, ZHAGroupProxy] = {}
|
|
self._ha_entity_refs: 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: bool = False
|
|
self._log_relay_handler: LogRelayHandler = LogRelayHandler(hass, self)
|
|
self._unsubs: list[Callable[[], None]] = []
|
|
self._unsubs.append(self.gateway.on_all_events(self._handle_event_protocol))
|
|
self._reload_task: asyncio.Task | None = None
|
|
config_entry.async_on_unload(
|
|
self.hass.bus.async_listen(
|
|
er.EVENT_ENTITY_REGISTRY_UPDATED,
|
|
self._handle_entity_registry_updated,
|
|
)
|
|
)
|
|
|
|
@property
|
|
def ha_entity_refs(self) -> collections.defaultdict[EUI64, list[EntityReference]]:
|
|
"""Return entities by ieee."""
|
|
return self._ha_entity_refs
|
|
|
|
def register_entity_reference(
|
|
self,
|
|
ha_entity_id: str,
|
|
entity_data: EntityData,
|
|
ha_device_info: dr.DeviceInfo,
|
|
remove_future: asyncio.Future[Any],
|
|
) -> None:
|
|
"""Record the creation of a hass entity associated with ieee."""
|
|
self._ha_entity_refs[entity_data.device_proxy.device.ieee].append(
|
|
EntityReference(
|
|
ha_entity_id=ha_entity_id,
|
|
entity_data=entity_data,
|
|
ha_device_info=ha_device_info,
|
|
remove_future=remove_future,
|
|
)
|
|
)
|
|
|
|
async def _handle_entity_registry_updated(
|
|
self, event: Event[er.EventEntityRegistryUpdatedData]
|
|
) -> None:
|
|
"""Handle when entity registry updated."""
|
|
entity_id = event.data["entity_id"]
|
|
entity_entry: er.RegistryEntry | None = er.async_get(self.hass).async_get(
|
|
entity_id
|
|
)
|
|
if (
|
|
entity_entry is None
|
|
or entity_entry.config_entry_id != self.config_entry.entry_id
|
|
or entity_entry.device_id is None
|
|
):
|
|
return
|
|
device_entry: dr.DeviceEntry | None = dr.async_get(self.hass).async_get(
|
|
entity_entry.device_id
|
|
)
|
|
assert device_entry
|
|
|
|
ieee_address = next(
|
|
identifier
|
|
for domain, identifier in device_entry.identifiers
|
|
if domain == DOMAIN
|
|
)
|
|
assert ieee_address
|
|
|
|
ieee = EUI64.convert(ieee_address)
|
|
|
|
assert ieee in self.device_proxies
|
|
|
|
zha_device_proxy = self.device_proxies[ieee]
|
|
entity_key = (entity_entry.domain, entity_entry.unique_id)
|
|
if entity_key not in zha_device_proxy.device.platform_entities:
|
|
return
|
|
platform_entity = zha_device_proxy.device.platform_entities[entity_key]
|
|
if entity_entry.disabled:
|
|
platform_entity.disable()
|
|
else:
|
|
platform_entity.enable()
|
|
|
|
async def async_initialize_devices_and_entities(self) -> None:
|
|
"""Initialize devices and entities."""
|
|
for device in self.gateway.devices.values():
|
|
device_proxy = self._async_get_or_create_device_proxy(device)
|
|
self._create_entity_metadata(device_proxy)
|
|
for group in self.gateway.groups.values():
|
|
group_proxy = self._async_get_or_create_group_proxy(group)
|
|
self._create_entity_metadata(group_proxy)
|
|
|
|
await self.gateway.async_initialize_devices_and_entities()
|
|
|
|
@callback
|
|
def handle_connection_lost(self, event: ConnectionLostEvent) -> None:
|
|
"""Handle a connection lost event."""
|
|
|
|
_LOGGER.debug("Connection to the radio was lost: %r", event)
|
|
|
|
# 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),
|
|
)
|
|
|
|
@callback
|
|
def handle_device_joined(self, event: DeviceJoinedEvent) -> None:
|
|
"""Handle a device joined event."""
|
|
async_dispatcher_send(
|
|
self.hass,
|
|
ZHA_GW_MSG,
|
|
{
|
|
ATTR_TYPE: ZHA_GW_MSG_DEVICE_JOINED,
|
|
ZHA_GW_MSG_DEVICE_INFO: {
|
|
ATTR_NWK: event.device_info.nwk,
|
|
ATTR_IEEE: str(event.device_info.ieee),
|
|
DEVICE_PAIRING_STATUS: event.device_info.pairing_status.name,
|
|
},
|
|
},
|
|
)
|
|
|
|
@callback
|
|
def handle_device_removed(self, event: DeviceRemovedEvent) -> None:
|
|
"""Handle a device removed event."""
|
|
zha_device_proxy = self.device_proxies.pop(event.device_info.ieee, None)
|
|
entity_refs = self._ha_entity_refs.pop(event.device_info.ieee, None)
|
|
if zha_device_proxy is not None:
|
|
device_info = zha_device_proxy.zha_device_info
|
|
# zha_device_proxy.async_cleanup_handles()
|
|
async_dispatcher_send(
|
|
self.hass,
|
|
f"{SIGNAL_REMOVE_ENTITIES}_{zha_device_proxy.device.ieee!s}",
|
|
)
|
|
self.hass.async_create_task(
|
|
self._async_remove_device(zha_device_proxy, 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,
|
|
},
|
|
)
|
|
|
|
@callback
|
|
def handle_device_left(self, event: DeviceLeftEvent) -> None:
|
|
"""Handle a device left event."""
|
|
|
|
@callback
|
|
def handle_raw_device_initialized(self, event: RawDeviceInitializedEvent) -> None:
|
|
"""Handle a raw device initialized event."""
|
|
manuf = event.device_info.manufacturer
|
|
async_dispatcher_send(
|
|
self.hass,
|
|
ZHA_GW_MSG,
|
|
{
|
|
ATTR_TYPE: ZHA_GW_MSG_RAW_INIT,
|
|
ZHA_GW_MSG_DEVICE_INFO: {
|
|
ATTR_NWK: str(event.device_info.nwk),
|
|
ATTR_IEEE: str(event.device_info.ieee),
|
|
DEVICE_PAIRING_STATUS: event.device_info.pairing_status.name,
|
|
ATTR_MODEL: (
|
|
event.device_info.model
|
|
if event.device_info.model
|
|
else UNKNOWN_MODEL
|
|
),
|
|
ATTR_MANUFACTURER: manuf if manuf else UNKNOWN_MANUFACTURER,
|
|
ATTR_SIGNATURE: event.device_info.signature,
|
|
},
|
|
},
|
|
)
|
|
|
|
@callback
|
|
def handle_device_fully_initialized(self, event: DeviceFullInitEvent) -> None:
|
|
"""Handle a device fully initialized event."""
|
|
zha_device = self.gateway.get_device(event.device_info.ieee)
|
|
zha_device_proxy = self._async_get_or_create_device_proxy(zha_device)
|
|
|
|
device_info = zha_device_proxy.zha_device_info
|
|
device_info[DEVICE_PAIRING_STATUS] = event.device_info.pairing_status.name
|
|
if event.new_join:
|
|
self._create_entity_metadata(zha_device_proxy)
|
|
async_dispatcher_send(self.hass, SIGNAL_ADD_ENTITIES)
|
|
async_dispatcher_send(
|
|
self.hass,
|
|
ZHA_GW_MSG,
|
|
{
|
|
ATTR_TYPE: ZHA_GW_MSG_DEVICE_FULL_INIT,
|
|
ZHA_GW_MSG_DEVICE_INFO: device_info,
|
|
},
|
|
)
|
|
|
|
@callback
|
|
def handle_group_member_removed(self, event: GroupEvent) -> None:
|
|
"""Handle a group member removed event."""
|
|
zha_group_proxy = self._async_get_or_create_group_proxy(event.group_info)
|
|
zha_group_proxy.info("group_member_removed - group_info: %s", event.group_info)
|
|
self._update_group_entities(event)
|
|
self._send_group_gateway_message(
|
|
zha_group_proxy, ZHA_GW_MSG_GROUP_MEMBER_REMOVED
|
|
)
|
|
|
|
@callback
|
|
def handle_group_member_added(self, event: GroupEvent) -> None:
|
|
"""Handle a group member added event."""
|
|
zha_group_proxy = self._async_get_or_create_group_proxy(event.group_info)
|
|
zha_group_proxy.info("group_member_added - group_info: %s", event.group_info)
|
|
self._send_group_gateway_message(zha_group_proxy, ZHA_GW_MSG_GROUP_MEMBER_ADDED)
|
|
self._update_group_entities(event)
|
|
|
|
@callback
|
|
def handle_group_added(self, event: GroupEvent) -> None:
|
|
"""Handle a group added event."""
|
|
zha_group_proxy = self._async_get_or_create_group_proxy(event.group_info)
|
|
zha_group_proxy.info("group_added")
|
|
self._update_group_entities(event)
|
|
self._send_group_gateway_message(zha_group_proxy, ZHA_GW_MSG_GROUP_ADDED)
|
|
|
|
@callback
|
|
def handle_group_removed(self, event: GroupEvent) -> None:
|
|
"""Handle a group removed event."""
|
|
zha_group_proxy = self.group_proxies.pop(event.group_info.group_id)
|
|
self._send_group_gateway_message(zha_group_proxy, ZHA_GW_MSG_GROUP_REMOVED)
|
|
zha_group_proxy.info("group_removed")
|
|
self._cleanup_group_entity_registry_entries(zha_group_proxy)
|
|
|
|
@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
|
|
|
|
async def shutdown(self) -> None:
|
|
"""Shutdown the gateway proxy."""
|
|
for unsub in self._unsubs:
|
|
unsub()
|
|
await self.gateway.shutdown()
|
|
|
|
def get_device_proxy(self, ieee: EUI64) -> ZHADeviceProxy | None:
|
|
"""Return ZHADevice for given ieee."""
|
|
return self.device_proxies.get(ieee)
|
|
|
|
def get_group_proxy(self, group_id: int | str) -> ZHAGroupProxy | None:
|
|
"""Return Group for given group id."""
|
|
if isinstance(group_id, str):
|
|
for group_proxy in self.group_proxies.values():
|
|
if group_proxy.group.name == group_id:
|
|
return group_proxy
|
|
return None
|
|
return self.group_proxies.get(group_id)
|
|
|
|
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.ha_entity_refs.values()
|
|
):
|
|
if entity_id == entity_reference.ha_entity_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.ha_entity_refs:
|
|
entity_refs = self.ha_entity_refs.get(entity.zha_device.ieee)
|
|
self.ha_entity_refs[entity.zha_device.ieee] = [
|
|
e
|
|
for e in entity_refs # type: ignore[union-attr]
|
|
if e.ha_entity_id != entity.entity_id
|
|
]
|
|
|
|
def _async_get_or_create_device_proxy(self, zha_device: Device) -> ZHADeviceProxy:
|
|
"""Get or create a ZHA device."""
|
|
if (zha_device_proxy := self.device_proxies.get(zha_device.ieee)) is None:
|
|
zha_device_proxy = ZHADeviceProxy(zha_device, self)
|
|
self.device_proxies[zha_device_proxy.device.ieee] = zha_device_proxy
|
|
|
|
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_proxy.device_id = device_registry_device.id
|
|
return zha_device_proxy
|
|
|
|
def _async_get_or_create_group_proxy(self, group_info: GroupInfo) -> ZHAGroupProxy:
|
|
"""Get or create a ZHA group."""
|
|
zha_group_proxy = self.group_proxies.get(group_info.group_id)
|
|
if zha_group_proxy is None:
|
|
zha_group_proxy = ZHAGroupProxy(
|
|
self.gateway.groups[group_info.group_id], self
|
|
)
|
|
self.group_proxies[group_info.group_id] = zha_group_proxy
|
|
return zha_group_proxy
|
|
|
|
def _create_entity_metadata(
|
|
self, proxy_object: ZHADeviceProxy | ZHAGroupProxy
|
|
) -> None:
|
|
"""Create HA entity metadata."""
|
|
ha_zha_data = get_zha_data(self.hass)
|
|
coordinator_proxy = self.device_proxies[
|
|
self.gateway.coordinator_zha_device.ieee
|
|
]
|
|
|
|
if isinstance(proxy_object, ZHADeviceProxy):
|
|
for entity in proxy_object.device.platform_entities.values():
|
|
ha_zha_data.platforms[Platform(entity.PLATFORM)].append(
|
|
EntityData(
|
|
entity=entity, device_proxy=proxy_object, group_proxy=None
|
|
)
|
|
)
|
|
else:
|
|
for entity in proxy_object.group.group_entities.values():
|
|
ha_zha_data.platforms[Platform(entity.PLATFORM)].append(
|
|
EntityData(
|
|
entity=entity,
|
|
device_proxy=coordinator_proxy,
|
|
group_proxy=proxy_object,
|
|
)
|
|
)
|
|
|
|
def _cleanup_group_entity_registry_entries(
|
|
self, zha_group_proxy: ZHAGroupProxy
|
|
) -> 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{zha_group_proxy.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.gateway.coordinator_zha_device
|
|
coordinator_proxy = self.device_proxies[
|
|
self.gateway.coordinator_zha_device.ieee
|
|
]
|
|
all_group_entity_entries = er.async_entries_for_device(
|
|
entity_registry,
|
|
coordinator_proxy.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)
|
|
|
|
def _update_group_entities(self, group_event: GroupEvent) -> None:
|
|
"""Update group entities when a group event is received."""
|
|
async_dispatcher_send(
|
|
self.hass,
|
|
f"{SIGNAL_REMOVE_ENTITIES}_group_{group_event.group_info.group_id}",
|
|
)
|
|
self._create_entity_metadata(
|
|
self.group_proxies[group_event.group_info.group_id]
|
|
)
|
|
async_dispatcher_send(self.hass, SIGNAL_ADD_ENTITIES)
|
|
|
|
def _send_group_gateway_message(
|
|
self, zha_group_proxy: ZHAGroupProxy, gateway_message_type: str
|
|
) -> None:
|
|
"""Send the gateway event for a zigpy group event."""
|
|
async_dispatcher_send(
|
|
self.hass,
|
|
ZHA_GW_MSG,
|
|
{
|
|
ATTR_TYPE: gateway_message_type,
|
|
ZHA_GW_MSG_GROUP_INFO: zha_group_proxy.group_info,
|
|
},
|
|
)
|
|
|
|
async def _async_remove_device(
|
|
self, device: ZHADeviceProxy, 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)
|
|
|
|
|
|
@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(),
|
|
DEBUG_LIB_ZHA: logging.getLogger(DEBUG_LIB_ZHA).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])
|
|
logging.getLogger(DEBUG_LIB_ZHA).setLevel(levels[DEBUG_LIB_ZHA])
|
|
|
|
|
|
class LogRelayHandler(logging.Handler):
|
|
"""Log handler for error messages."""
|
|
|
|
def __init__(self, hass: HomeAssistant, gateway: ZHAGatewayProxy) -> 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(
|
|
rf"(?:{re.escape(hass_path)}|{re.escape(config_dir)})/(.*)"
|
|
)
|
|
|
|
def emit(self, record: LogRecord) -> None:
|
|
"""Relay log message via dispatcher."""
|
|
entry = LogEntry(
|
|
record, self.paths_re, 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()},
|
|
)
|
|
|
|
|
|
@dataclasses.dataclass(kw_only=True, slots=True)
|
|
class HAZHAData:
|
|
"""ZHA data stored in `hass.data`."""
|
|
|
|
yaml_config: ConfigType = dataclasses.field(default_factory=dict)
|
|
config_entry: ConfigEntry | None = dataclasses.field(default=None)
|
|
device_trigger_cache: dict[str, tuple[str, dict]] = dataclasses.field(
|
|
default_factory=dict
|
|
)
|
|
gateway_proxy: ZHAGatewayProxy | None = dataclasses.field(default=None)
|
|
platforms: collections.defaultdict[Platform, list] = dataclasses.field(
|
|
default_factory=lambda: collections.defaultdict(list)
|
|
)
|
|
update_coordinator: ZHAFirmwareUpdateCoordinator | None = dataclasses.field(
|
|
default=None
|
|
)
|
|
|
|
|
|
@dataclasses.dataclass(kw_only=True, slots=True)
|
|
class EntityData:
|
|
"""ZHA entity data."""
|
|
|
|
entity: PlatformEntity | GroupEntity
|
|
device_proxy: ZHADeviceProxy
|
|
group_proxy: ZHAGroupProxy | None = dataclasses.field(default=None)
|
|
|
|
@property
|
|
def is_group_entity(self) -> bool:
|
|
"""Return if this is a group entity."""
|
|
return self.group_proxy is not None and isinstance(self.entity, GroupEntity)
|
|
|
|
|
|
def get_zha_data(hass: HomeAssistant) -> HAZHAData:
|
|
"""Get the global ZHA data object."""
|
|
if DATA_ZHA not in hass.data:
|
|
hass.data[DATA_ZHA] = HAZHAData()
|
|
|
|
return hass.data[DATA_ZHA]
|
|
|
|
|
|
def get_zha_gateway(hass: HomeAssistant) -> Gateway:
|
|
"""Get the ZHA gateway object."""
|
|
if (gateway_proxy := get_zha_data(hass).gateway_proxy) is None:
|
|
raise ValueError("No gateway object exists")
|
|
|
|
return gateway_proxy.gateway
|
|
|
|
|
|
def get_zha_gateway_proxy(hass: HomeAssistant) -> ZHAGatewayProxy:
|
|
"""Get the ZHA gateway object."""
|
|
if (gateway_proxy := get_zha_data(hass).gateway_proxy) is None:
|
|
raise ValueError("No gateway object exists")
|
|
|
|
return gateway_proxy
|
|
|
|
|
|
def get_config_entry(hass: HomeAssistant) -> ConfigEntry:
|
|
"""Get the ZHA gateway object."""
|
|
if (gateway_proxy := get_zha_data(hass).gateway_proxy) is None:
|
|
raise ValueError("No gateway object exists to retrieve the config entry from.")
|
|
|
|
return gateway_proxy.config_entry
|
|
|
|
|
|
@callback
|
|
def async_get_zha_device_proxy(hass: HomeAssistant, device_id: str) -> ZHADeviceProxy:
|
|
"""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_proxy = get_zha_gateway_proxy(hass)
|
|
ieee_address = next(
|
|
identifier
|
|
for domain, identifier in registry_device.identifiers
|
|
if domain == DOMAIN
|
|
)
|
|
ieee = EUI64.convert(ieee_address)
|
|
return zha_gateway_proxy.device_proxies[ieee]
|
|
|
|
|
|
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
|
|
|
|
|
|
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_add_entities(
|
|
_async_add_entities: AddEntitiesCallback,
|
|
entity_class: type[ZHAEntity],
|
|
entities: list[EntityData],
|
|
**kwargs,
|
|
) -> None:
|
|
"""Add entities helper."""
|
|
if not entities:
|
|
return
|
|
|
|
entities_to_add: list[ZHAEntity] = []
|
|
for entity_data in entities:
|
|
try:
|
|
entities_to_add.append(entity_class(entity_data))
|
|
# broad exception to prevent a single entity from preventing an entire platform from loading
|
|
# this can potentially be caused by a misbehaving device or a bad quirk. Not ideal but the
|
|
# alternative is adding try/catch to each entity class __init__ method with a specific exception
|
|
except Exception: # noqa: BLE001
|
|
_LOGGER.exception(
|
|
"Error while adding entity from entity data: %s", entity_data
|
|
)
|
|
_async_add_entities(entities_to_add, update_before_add=False)
|
|
for entity in entities_to_add:
|
|
if not entity.enabled:
|
|
entity.entity_data.entity.disable()
|
|
entities.clear()
|
|
|
|
|
|
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
|
|
|
|
|
|
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_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,
|
|
vol.Required(CONF_ENABLE_MAINS_STARTUP_POLLING, default=True): cv.boolean,
|
|
},
|
|
extra=vol.REMOVE_EXTRA,
|
|
)
|
|
|
|
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,
|
|
}
|
|
)
|
|
|
|
|
|
def create_zha_config(hass: HomeAssistant, ha_zha_data: HAZHAData) -> ZHAData:
|
|
"""Create ZHA lib configuration from HA config objects."""
|
|
|
|
# ensure that we have the necessary HA configuration data
|
|
assert ha_zha_data.config_entry is not None
|
|
assert ha_zha_data.yaml_config is not None
|
|
|
|
# Remove brackets around IP addresses, this no longer works in CPython 3.11.4
|
|
# This will be removed in 2023.11.0
|
|
path = ha_zha_data.config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH]
|
|
cleaned_path = _clean_serial_port_path(path)
|
|
|
|
if path != cleaned_path:
|
|
_LOGGER.debug("Cleaned serial port path %r -> %r", path, cleaned_path)
|
|
ha_zha_data.config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] = cleaned_path
|
|
hass.config_entries.async_update_entry(
|
|
ha_zha_data.config_entry, data=ha_zha_data.config_entry.data
|
|
)
|
|
|
|
# deep copy the yaml config to avoid modifying the original and to safely
|
|
# pass it to the ZHA library
|
|
app_config = copy.deepcopy(ha_zha_data.yaml_config.get(CONF_ZIGPY, {}))
|
|
database = ha_zha_data.yaml_config.get(
|
|
CONF_DATABASE,
|
|
hass.config.path(DEFAULT_DATABASE_NAME),
|
|
)
|
|
app_config[CONF_DATABASE] = database
|
|
app_config[CONF_DEVICE] = ha_zha_data.config_entry.data[CONF_DEVICE]
|
|
|
|
radio_type = RadioType[ha_zha_data.config_entry.data[CONF_RADIO_TYPE]]
|
|
|
|
# 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
|
|
|
|
options: MappingProxyType[str, Any] = ha_zha_data.config_entry.options.get(
|
|
CUSTOM_CONFIGURATION, {}
|
|
)
|
|
zha_options = CONF_ZHA_OPTIONS_SCHEMA(options.get(ZHA_OPTIONS, {}))
|
|
ha_acp_options = CONF_ZHA_ALARM_SCHEMA(options.get(ZHA_ALARM_OPTIONS, {}))
|
|
light_options: LightOptions = LightOptions(
|
|
default_light_transition=zha_options.get(CONF_DEFAULT_LIGHT_TRANSITION),
|
|
enable_enhanced_light_transition=zha_options.get(
|
|
CONF_ENABLE_ENHANCED_LIGHT_TRANSITION
|
|
),
|
|
enable_light_transitioning_flag=zha_options.get(
|
|
CONF_ENABLE_LIGHT_TRANSITIONING_FLAG
|
|
),
|
|
group_members_assume_state=zha_options.get(CONF_GROUP_MEMBERS_ASSUME_STATE),
|
|
)
|
|
device_options: DeviceOptions = DeviceOptions(
|
|
enable_identify_on_join=zha_options.get(CONF_ENABLE_IDENTIFY_ON_JOIN),
|
|
consider_unavailable_mains=zha_options.get(CONF_CONSIDER_UNAVAILABLE_MAINS),
|
|
consider_unavailable_battery=zha_options.get(CONF_CONSIDER_UNAVAILABLE_BATTERY),
|
|
enable_mains_startup_polling=zha_options.get(CONF_ENABLE_MAINS_STARTUP_POLLING),
|
|
)
|
|
acp_options: AlarmControlPanelOptions = AlarmControlPanelOptions(
|
|
master_code=ha_acp_options.get(CONF_ALARM_MASTER_CODE),
|
|
failed_tries=ha_acp_options.get(CONF_ALARM_FAILED_TRIES),
|
|
arm_requires_code=ha_acp_options.get(CONF_ALARM_ARM_REQUIRES_CODE),
|
|
)
|
|
coord_config: CoordinatorConfiguration = CoordinatorConfiguration(
|
|
path=app_config[CONF_DEVICE][CONF_DEVICE_PATH],
|
|
baudrate=app_config[CONF_DEVICE][CONF_BAUDRATE],
|
|
flow_control=app_config[CONF_DEVICE][CONF_FLOW_CONTROL],
|
|
radio_type=radio_type.name,
|
|
)
|
|
quirks_config: QuirksConfiguration = QuirksConfiguration(
|
|
enabled=ha_zha_data.yaml_config.get(CONF_ENABLE_QUIRKS, True),
|
|
custom_quirks_path=ha_zha_data.yaml_config.get(CONF_CUSTOM_QUIRKS_PATH),
|
|
)
|
|
overrides_config: dict[str, DeviceOverridesConfiguration] = {}
|
|
overrides: dict[str, dict[str, Any]] = cast(
|
|
dict[str, dict[str, Any]], ha_zha_data.yaml_config.get(CONF_DEVICE_CONFIG)
|
|
)
|
|
if overrides is not None:
|
|
for unique_id, override in overrides.items():
|
|
overrides_config[unique_id] = DeviceOverridesConfiguration(
|
|
type=override["type"],
|
|
)
|
|
|
|
return ZHAData(
|
|
zigpy_config=app_config,
|
|
config=ZHAConfiguration(
|
|
light_options=light_options,
|
|
device_options=device_options,
|
|
alarm_control_panel_options=acp_options,
|
|
coordinator_configuration=coord_config,
|
|
quirks_configuration=quirks_config,
|
|
device_overrides=overrides_config,
|
|
),
|
|
local_timezone=ZoneInfo(hass.config.time_zone),
|
|
)
|
|
|
|
|
|
def convert_zha_error_to_ha_error[**_P, _EntityT: ZHAEntity](
|
|
func: Callable[Concatenate[_EntityT, _P], Awaitable[None]],
|
|
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
|
|
"""Decorate ZHA commands and re-raises ZHAException as HomeAssistantError."""
|
|
|
|
@functools.wraps(func)
|
|
async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
|
try:
|
|
return await func(self, *args, **kwargs)
|
|
except ZHAException as err:
|
|
raise HomeAssistantError(err) from err
|
|
|
|
return handler
|
|
|
|
|
|
def exclude_none_values(obj: Mapping[str, Any]) -> dict[str, Any]:
|
|
"""Return a new dictionary excluding keys with None values."""
|
|
return {k: v for k, v in obj.items() if v is not None}
|