"""Support for Tuya binary sensors.""" from __future__ import annotations from dataclasses import dataclass from tuya_sharing import CustomerDevice, Manager from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.json import json_loads from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType from .entity import TuyaEntity @dataclass(frozen=True) class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes a Tuya binary sensor.""" # DPCode, to use. If None, the key will be used as DPCode dpcode: DPCode | None = None # Value or values to consider binary sensor to be "on" on_value: bool | float | int | str | set[bool | float | int | str] = True # For DPType.BITMAP, the bitmap_key is used to extract the bit mask bitmap_key: str | None = None # Commonly used sensors TAMPER_BINARY_SENSOR = TuyaBinarySensorEntityDescription( key=DPCode.TEMPER_ALARM, name="Tamper", device_class=BinarySensorDeviceClass.TAMPER, entity_category=EntityCategory.DIAGNOSTIC, ) # All descriptions can be found here. Mostly the Boolean data types in the # default status set of each category (that don't have a set instruction) # end up being a binary sensor. BINARY_SENSORS: dict[DeviceCategory, tuple[TuyaBinarySensorEntityDescription, ...]] = { DeviceCategory.CO2BJ: ( TuyaBinarySensorEntityDescription( key=DPCode.CO2_STATE, device_class=BinarySensorDeviceClass.SAFETY, on_value="alarm", ), TAMPER_BINARY_SENSOR, ), DeviceCategory.COBJ: ( TuyaBinarySensorEntityDescription( key=DPCode.CO_STATE, device_class=BinarySensorDeviceClass.SAFETY, on_value="1", ), TuyaBinarySensorEntityDescription( key=DPCode.CO_STATUS, device_class=BinarySensorDeviceClass.SAFETY, on_value="alarm", ), TAMPER_BINARY_SENSOR, ), DeviceCategory.CS: ( TuyaBinarySensorEntityDescription( key="tankfull", dpcode=DPCode.FAULT, device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, bitmap_key="tankfull", translation_key="tankfull", ), TuyaBinarySensorEntityDescription( key="defrost", dpcode=DPCode.FAULT, device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, bitmap_key="defrost", translation_key="defrost", ), TuyaBinarySensorEntityDescription( key="wet", dpcode=DPCode.FAULT, device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, bitmap_key="wet", translation_key="wet", ), ), DeviceCategory.CWWSQ: ( TuyaBinarySensorEntityDescription( key=DPCode.FEED_STATE, translation_key="feeding", on_value="feeding", ), ), DeviceCategory.DGNBJ: ( TuyaBinarySensorEntityDescription( key=DPCode.GAS_SENSOR_STATE, device_class=BinarySensorDeviceClass.GAS, on_value="alarm", ), TuyaBinarySensorEntityDescription( key=DPCode.CH4_SENSOR_STATE, translation_key="methane", device_class=BinarySensorDeviceClass.GAS, on_value="alarm", ), TuyaBinarySensorEntityDescription( key=DPCode.VOC_STATE, translation_key="voc", device_class=BinarySensorDeviceClass.SAFETY, on_value="alarm", ), TuyaBinarySensorEntityDescription( key=DPCode.PM25_STATE, translation_key="pm25", device_class=BinarySensorDeviceClass.SAFETY, on_value="alarm", ), TuyaBinarySensorEntityDescription( key=DPCode.CO_STATE, translation_key="carbon_monoxide", device_class=BinarySensorDeviceClass.SAFETY, on_value="alarm", ), TuyaBinarySensorEntityDescription( key=DPCode.CO2_STATE, translation_key="carbon_dioxide", device_class=BinarySensorDeviceClass.SAFETY, on_value="alarm", ), TuyaBinarySensorEntityDescription( key=DPCode.CH2O_STATE, translation_key="formaldehyde", device_class=BinarySensorDeviceClass.SAFETY, on_value="alarm", ), TuyaBinarySensorEntityDescription( key=DPCode.DOORCONTACT_STATE, device_class=BinarySensorDeviceClass.DOOR, ), TuyaBinarySensorEntityDescription( key=DPCode.WATERSENSOR_STATE, device_class=BinarySensorDeviceClass.MOISTURE, on_value="alarm", ), TuyaBinarySensorEntityDescription( key=DPCode.PRESSURE_STATE, translation_key="pressure", on_value="alarm", ), TuyaBinarySensorEntityDescription( key=DPCode.SMOKE_SENSOR_STATE, device_class=BinarySensorDeviceClass.SMOKE, on_value="alarm", ), TAMPER_BINARY_SENSOR, ), DeviceCategory.HPS: ( TuyaBinarySensorEntityDescription( key=DPCode.PRESENCE_STATE, device_class=BinarySensorDeviceClass.OCCUPANCY, on_value={"presence", "small_move", "large_move", "peaceful"}, ), ), DeviceCategory.JQBJ: ( TuyaBinarySensorEntityDescription( key=DPCode.CH2O_STATE, device_class=BinarySensorDeviceClass.SAFETY, on_value="alarm", ), TAMPER_BINARY_SENSOR, ), DeviceCategory.JWBJ: ( TuyaBinarySensorEntityDescription( key=DPCode.CH4_SENSOR_STATE, device_class=BinarySensorDeviceClass.GAS, on_value="alarm", ), TAMPER_BINARY_SENSOR, ), DeviceCategory.LDCG: ( TuyaBinarySensorEntityDescription( key=DPCode.TEMPER_ALARM, device_class=BinarySensorDeviceClass.TAMPER, entity_category=EntityCategory.DIAGNOSTIC, ), TAMPER_BINARY_SENSOR, ), DeviceCategory.MC: ( TuyaBinarySensorEntityDescription( key=DPCode.STATUS, device_class=BinarySensorDeviceClass.DOOR, on_value={"open", "opened"}, ), ), DeviceCategory.MCS: ( TuyaBinarySensorEntityDescription( key=DPCode.DOORCONTACT_STATE, device_class=BinarySensorDeviceClass.DOOR, ), TuyaBinarySensorEntityDescription( key=DPCode.SWITCH, # Used by non-standard contact sensor implementations device_class=BinarySensorDeviceClass.DOOR, ), TAMPER_BINARY_SENSOR, ), DeviceCategory.MK: ( TuyaBinarySensorEntityDescription( key=DPCode.CLOSED_OPENED_KIT, device_class=BinarySensorDeviceClass.LOCK, on_value={"AQAB"}, ), ), DeviceCategory.PIR: ( TuyaBinarySensorEntityDescription( key=DPCode.PIR, device_class=BinarySensorDeviceClass.MOTION, on_value="pir", ), TAMPER_BINARY_SENSOR, ), DeviceCategory.PM2_5: ( TuyaBinarySensorEntityDescription( key=DPCode.PM25_STATE, device_class=BinarySensorDeviceClass.SAFETY, on_value="alarm", ), TAMPER_BINARY_SENSOR, ), DeviceCategory.QXJ: (TAMPER_BINARY_SENSOR,), DeviceCategory.RQBJ: ( TuyaBinarySensorEntityDescription( key=DPCode.GAS_SENSOR_STATUS, device_class=BinarySensorDeviceClass.GAS, on_value="alarm", ), TuyaBinarySensorEntityDescription( key=DPCode.GAS_SENSOR_STATE, device_class=BinarySensorDeviceClass.GAS, on_value="1", ), TAMPER_BINARY_SENSOR, ), DeviceCategory.SGBJ: ( TuyaBinarySensorEntityDescription( key=DPCode.CHARGE_STATE, device_class=BinarySensorDeviceClass.BATTERY_CHARGING, ), TAMPER_BINARY_SENSOR, ), DeviceCategory.SJ: ( TuyaBinarySensorEntityDescription( key=DPCode.WATERSENSOR_STATE, device_class=BinarySensorDeviceClass.MOISTURE, on_value={"1", "alarm"}, ), TAMPER_BINARY_SENSOR, ), DeviceCategory.SOS: ( TuyaBinarySensorEntityDescription( key=DPCode.SOS_STATE, device_class=BinarySensorDeviceClass.SAFETY, ), TAMPER_BINARY_SENSOR, ), DeviceCategory.VOC: ( TuyaBinarySensorEntityDescription( key=DPCode.VOC_STATE, device_class=BinarySensorDeviceClass.SAFETY, on_value="alarm", ), TAMPER_BINARY_SENSOR, ), DeviceCategory.WG2: ( TuyaBinarySensorEntityDescription( key=DPCode.MASTER_STATE, device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, on_value="alarm", ), ), DeviceCategory.WK: ( TuyaBinarySensorEntityDescription( key=DPCode.VALVE_STATE, translation_key="valve", on_value="open", ), ), DeviceCategory.WKF: ( TuyaBinarySensorEntityDescription( key=DPCode.WINDOW_STATE, device_class=BinarySensorDeviceClass.WINDOW, on_value="opened", ), ), DeviceCategory.WSDCG: (TAMPER_BINARY_SENSOR,), DeviceCategory.YLCG: ( TuyaBinarySensorEntityDescription( key=DPCode.PRESSURE_STATE, on_value="alarm", ), TAMPER_BINARY_SENSOR, ), DeviceCategory.YWBJ: ( TuyaBinarySensorEntityDescription( key=DPCode.SMOKE_SENSOR_STATUS, device_class=BinarySensorDeviceClass.SMOKE, on_value="alarm", ), TuyaBinarySensorEntityDescription( key=DPCode.SMOKE_SENSOR_STATE, device_class=BinarySensorDeviceClass.SMOKE, on_value={"1", "alarm"}, ), TAMPER_BINARY_SENSOR, ), DeviceCategory.ZD: ( TuyaBinarySensorEntityDescription( key=f"{DPCode.SHOCK_STATE}_vibration", dpcode=DPCode.SHOCK_STATE, device_class=BinarySensorDeviceClass.VIBRATION, on_value="vibration", ), TuyaBinarySensorEntityDescription( key=f"{DPCode.SHOCK_STATE}_drop", dpcode=DPCode.SHOCK_STATE, translation_key="drop", on_value="drop", ), TuyaBinarySensorEntityDescription( key=f"{DPCode.SHOCK_STATE}_tilt", dpcode=DPCode.SHOCK_STATE, translation_key="tilt", on_value="tilt", ), ), } def _get_bitmap_bit_mask( device: CustomerDevice, dpcode: str, bitmap_key: str | None ) -> int | None: """Get the bit mask for a given bitmap description.""" if ( bitmap_key is None or (status_range := device.status_range.get(dpcode)) is None or status_range.type != DPType.BITMAP or not isinstance(bitmap_values := json_loads(status_range.values), dict) or not isinstance(bitmap_labels := bitmap_values.get("label"), list) or bitmap_key not in bitmap_labels ): return None return bitmap_labels.index(bitmap_key) async def async_setup_entry( hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya binary sensor dynamically through Tuya discovery.""" manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya binary sensor.""" entities: list[TuyaBinarySensorEntity] = [] for device_id in device_ids: device = manager.device_map[device_id] if descriptions := BINARY_SENSORS.get(device.category): for description in descriptions: dpcode = description.dpcode or description.key if dpcode in device.status: mask = _get_bitmap_bit_mask( device, dpcode, description.bitmap_key ) if ( description.bitmap_key is None # Regular binary sensor or mask is not None # Bitmap sensor with valid mask ): entities.append( TuyaBinarySensorEntity( device, manager, description, mask, ) ) async_add_entities(entities) async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) ) class TuyaBinarySensorEntity(TuyaEntity, BinarySensorEntity): """Tuya Binary Sensor Entity.""" entity_description: TuyaBinarySensorEntityDescription def __init__( self, device: CustomerDevice, device_manager: Manager, description: TuyaBinarySensorEntityDescription, bit_mask: int | None = None, ) -> None: """Init Tuya binary sensor.""" super().__init__(device, device_manager) self.entity_description = description self._attr_unique_id = f"{super().unique_id}{description.key}" self._bit_mask = bit_mask @property def is_on(self) -> bool: """Return true if sensor is on.""" dpcode = self.entity_description.dpcode or self.entity_description.key if dpcode not in self.device.status: return False if self._bit_mask is not None: # For bitmap sensors, check the specific bit mask return (self.device.status[dpcode] & (1 << self._bit_mask)) != 0 if isinstance(self.entity_description.on_value, set): return self.device.status[dpcode] in self.entity_description.on_value return self.device.status[dpcode] == self.entity_description.on_value