"""Sensor entities for the Motionblinds BLE integration.""" from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass import logging from math import ceil from typing import Generic, TypeVar from motionblindsble.const import ( MotionBlindType, MotionCalibrationType, MotionConnectionType, ) from motionblindsble.device import MotionDevice from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ( ATTR_BATTERY, ATTR_CALIBRATION, ATTR_CONNECTION, ATTR_SIGNAL_STRENGTH, CONF_MAC_CODE, DOMAIN, ) from .entity import MotionblindsBLEEntity _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 _T = TypeVar("_T") @dataclass(frozen=True, kw_only=True) class MotionblindsBLESensorEntityDescription(SensorEntityDescription, Generic[_T]): """Entity description of a sensor entity with initial_value attribute.""" initial_value: str | None = None register_callback_func: Callable[ [MotionDevice], Callable[[Callable[[_T | None], None]], None] ] value_func: Callable[[_T | None], StateType] is_supported: Callable[[MotionDevice], bool] = lambda device: True SENSORS: tuple[MotionblindsBLESensorEntityDescription, ...] = ( MotionblindsBLESensorEntityDescription[MotionConnectionType]( key=ATTR_CONNECTION, translation_key=ATTR_CONNECTION, device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, options=["connected", "connecting", "disconnected", "disconnecting"], initial_value=MotionConnectionType.DISCONNECTED.value, register_callback_func=lambda device: device.register_connection_callback, value_func=lambda value: value.value if value else None, ), MotionblindsBLESensorEntityDescription[MotionCalibrationType]( key=ATTR_CALIBRATION, translation_key=ATTR_CALIBRATION, device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, options=["calibrated", "uncalibrated", "calibrating"], register_callback_func=lambda device: device.register_calibration_callback, value_func=lambda value: value.value if value else None, is_supported=lambda device: device.blind_type in {MotionBlindType.CURTAIN, MotionBlindType.VERTICAL}, ), MotionblindsBLESensorEntityDescription[int]( key=ATTR_SIGNAL_STRENGTH, device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, register_callback_func=lambda device: device.register_signal_strength_callback, value_func=lambda value: value, entity_registry_enabled_default=False, ), ) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up sensor entities based on a config entry.""" device: MotionDevice = hass.data[DOMAIN][entry.entry_id] entities: list[SensorEntity] = [ MotionblindsBLESensorEntity(device, entry, description) for description in SENSORS if description.is_supported(device) ] entities.append(BatterySensor(device, entry)) async_add_entities(entities) class MotionblindsBLESensorEntity(MotionblindsBLEEntity, SensorEntity, Generic[_T]): """Representation of a sensor entity.""" entity_description: MotionblindsBLESensorEntityDescription[_T] def __init__( self, device: MotionDevice, entry: ConfigEntry, entity_description: MotionblindsBLESensorEntityDescription[_T], ) -> None: """Initialize the sensor entity.""" super().__init__( device, entry, entity_description, unique_id_suffix=entity_description.key ) self._attr_native_value = entity_description.initial_value async def async_added_to_hass(self) -> None: """Log sensor entity information.""" _LOGGER.debug( "(%s) Setting up %s sensor entity", self.entry.data[CONF_MAC_CODE], self.entity_description.key.replace("_", " "), ) def async_callback(value: _T | None) -> None: """Update the sensor value.""" self._attr_native_value = self.entity_description.value_func(value) self.async_write_ha_state() self.entity_description.register_callback_func(self.device)(async_callback) class BatterySensor(MotionblindsBLEEntity, SensorEntity): """Representation of a battery sensor entity.""" def __init__( self, device: MotionDevice, entry: ConfigEntry, ) -> None: """Initialize the sensor entity.""" entity_description = SensorEntityDescription( key=ATTR_BATTERY, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ) super().__init__(device, entry, entity_description) async def async_added_to_hass(self) -> None: """Register device callbacks.""" await super().async_added_to_hass() self.device.register_battery_callback(self.async_update_battery) @callback def async_update_battery( self, battery_percentage: int | None, is_charging: bool | None, is_wired: bool | None, ) -> None: """Update the battery sensor value and icon.""" self._attr_native_value = battery_percentage if battery_percentage is None: # Battery percentage is unknown self._attr_icon = "mdi:battery-unknown" elif is_wired: # Motor is wired and does not have a battery self._attr_icon = "mdi:power-plug-outline" elif battery_percentage > 90 and not is_charging: # Full battery icon if battery > 90% and not charging self._attr_icon = "mdi:battery" elif battery_percentage <= 5 and not is_charging: # Empty battery icon with alert if battery <= 5% and not charging self._attr_icon = "mdi:battery-alert-variant-outline" else: battery_icon_prefix = ( "mdi:battery-charging" if is_charging else "mdi:battery" ) battery_percentage_multiple_ten = ceil(battery_percentage / 10) * 10 self._attr_icon = f"{battery_icon_prefix}-{battery_percentage_multiple_ten}" self.async_write_ha_state()