Files
ha-core/homeassistant/components/zha/binary_sensor.py
Caius-Bonus 7f7128adbf Add Danfoss Ally thermostat and derivatives to ZHA (#86907)
* zha integration: Add danfoss specific clusters and attributes; add thermostat.pi_heating_demand and thermostat_ui.keypad_lockout

* zha integration: fix Danfoss thermostat viewing direction not working because of use of bitmap8 instead of enum8

* ZHA Integration: add missing ThermostatChannelSensor

* ZHA integration: format using black

* zha integration: fix flake8 issues

* ZHA danfoss: Add MinHeatSetpointLimit, MaxHeatSetpointLimit, add reporting and read config for danfoss and keypad_lockout.

* ZHA danfoss: fix mypy complaining about type of _attr_entity_category

* ZHA danfoss: ruff fix

* fix tests

* pylint: disable-next=hass-invalid-inheritance

* fix pylint tests

* refactoring

* remove scheduled setpoint

* remove scheduled setpoint in manufacturer specific

* refactor

* fix tests

* change cluster ids

* remove custom clusters

* code quality

* match clusters in manufacturerspecific on quirk class

* fix comment

* fix match on quirk in manufacturerspecific.py

* correctly extend cluster handlers in manufacturerspecific.py and remove workaround for illegal use of attribute updated signals in climate.py

* fix style

* allow non-danfoss thermostats to work in manufacturerspecific.py

* correct order of init of parent and subclasses in manufacturerspecific.py

* improve entity names

* fix pylint

* explicitly state changing size of tuple

* ignore tuple size change error

* really ignore error

* initial

* fix tests

* match on specific name and quirk name

* don't restructure file as it is out of scope

* move back

* remove unnecessary change

* fix tests

* fix tests

* remove code duplication

* reduce code duplication

* empty line

* remove unused variable

* end file on newline

* comply with recent PRs

* correctly initialize all attributes

* comply with recent PRs

* make class variables private

* forgot one reference

* swap 2 lines for consistency

* reorder 2 lines

* fix tests

* align with recent PR

* store cluster handlers in only one place

* edit tests

* use correct device for quirk id

* change quirk id

* fix tests

* even if there is a quirk id, it doesn't have to have a specific cluster handler

* add tests

* use quirk id for manufacturer specific cluster handlers

* use quirk_ids instead of quirks_classes

* rename quirk_id

* rename quirk_id

* forgot to rename here

* rename id

* add tests

* fix tests

* fix tests

* use quirk ids from zha_quirks

* use quirk id from zha_quirks

* wrong translation

* sync changes with ZCL branch

* sync

* style

* merge error

* move bitmapSensor

* merge error

* merge error

* watch the capitals

* fix entity categories

* more decapitalization

* translate BitmapSensor

* translate all enums

* translate all enums

* don't convert camelcase to snakecase

* don't change enums at all

* remove comments

* fix bitmaps and add enum for algorithm scale factor

* improve readability if bitmapsensor

* fix capitals

* better setpoint response time

* feedback

* lowercase every enum to adhere to the translation_key standard

* remove enum state translations and use enums from quirks

* correctly capitalize OrientationEnum

* bump zha dependencies; this will have to be done in a separate PR, but this aids review

* accidentally removed enum

* tests

* comment

* Migrate reporting and ZCL attribute config out of `__init__`

* hvac.py shouldn't be changed in this pull request

* change wording comment

* I forgot I changed the size of the tuple.

---------

Co-authored-by: puddly <32534428+puddly@users.noreply.github.com>
2024-06-12 12:48:37 -04:00

382 lines
13 KiB
Python

"""Binary sensors on Zigbee Home Automation networks."""
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.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,
SIGNAL_ADD_ENTITIES,
SIGNAL_ATTR_UPDATED,
)
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(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Zigbee Home Automation binary sensor from config entry."""
zha_data = get_zha_data(hass)
entities_to_create = zha_data.platforms[Platform.BINARY_SENSOR]
unsub = async_dispatcher_connect(
hass,
SIGNAL_ADD_ENTITIES,
functools.partial(
discovery.async_add_entities, async_add_entities, entities_to_create
),
)
config_entry.async_on_unload(unsub)
class BinarySensor(ZhaEntity, BinarySensorEntity):
"""ZHA BinarySensor."""
_attribute_name: str
def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs) -> None:
"""Initialize the ZHA binary sensor."""
self._cluster_handler = cluster_handlers[0]
if 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,
)
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