diff --git a/.coveragerc b/.coveragerc index 43dc6edafc5..003b4908b17 100644 --- a/.coveragerc +++ b/.coveragerc @@ -816,6 +816,7 @@ omit = homeassistant/components/motionblinds_ble/cover.py homeassistant/components/motionblinds_ble/entity.py homeassistant/components/motionblinds_ble/select.py + homeassistant/components/motionblinds_ble/sensor.py homeassistant/components/motionmount/__init__.py homeassistant/components/motionmount/binary_sensor.py homeassistant/components/motionmount/entity.py diff --git a/homeassistant/components/motionblinds_ble/__init__.py b/homeassistant/components/motionblinds_ble/__init__.py index 1b664eeede3..76ceac1097c 100644 --- a/homeassistant/components/motionblinds_ble/__init__.py +++ b/homeassistant/components/motionblinds_ble/__init__.py @@ -34,7 +34,12 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.COVER, Platform.SELECT] +PLATFORMS: list[Platform] = [ + Platform.BUTTON, + Platform.COVER, + Platform.SELECT, + Platform.SENSOR, +] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/motionblinds_ble/const.py b/homeassistant/components/motionblinds_ble/const.py index 0b4a2a7f947..6b958170a4a 100644 --- a/homeassistant/components/motionblinds_ble/const.py +++ b/homeassistant/components/motionblinds_ble/const.py @@ -1,8 +1,12 @@ """Constants for the Motionblinds Bluetooth integration.""" +ATTR_BATTERY = "battery" +ATTR_CALIBRATION = "calibration" ATTR_CONNECT = "connect" +ATTR_CONNECTION = "connection" ATTR_DISCONNECT = "disconnect" ATTR_FAVORITE = "favorite" +ATTR_SIGNAL_STRENGTH = "signal_strength" ATTR_SPEED = "speed" CONF_LOCAL_NAME = "local_name" diff --git a/homeassistant/components/motionblinds_ble/icons.json b/homeassistant/components/motionblinds_ble/icons.json index c8d2b085d75..7a7561360a2 100644 --- a/homeassistant/components/motionblinds_ble/icons.json +++ b/homeassistant/components/motionblinds_ble/icons.json @@ -15,6 +15,14 @@ "speed": { "default": "mdi:run-fast" } + }, + "sensor": { + "calibration": { + "default": "mdi:tune" + }, + "connection": { + "default": "mdi:bluetooth-connect" + } } } } diff --git a/homeassistant/components/motionblinds_ble/sensor.py b/homeassistant/components/motionblinds_ble/sensor.py new file mode 100644 index 00000000000..fbab5d06251 --- /dev/null +++ b/homeassistant/components/motionblinds_ble/sensor.py @@ -0,0 +1,195 @@ +"""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, + ), +) + + +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() diff --git a/homeassistant/components/motionblinds_ble/strings.json b/homeassistant/components/motionblinds_ble/strings.json index ab26f26ce44..d6532f12386 100644 --- a/homeassistant/components/motionblinds_ble/strings.json +++ b/homeassistant/components/motionblinds_ble/strings.json @@ -67,6 +67,25 @@ "3": "High" } } + }, + "sensor": { + "connection": { + "name": "Connection status", + "state": { + "connected": "Connected", + "disconnected": "Disconnected", + "connecting": "Connecting", + "disconnecting": "Disconnecting" + } + }, + "calibration": { + "name": "Calibration status", + "state": { + "calibrated": "Calibrated", + "uncalibrated": "Uncalibrated", + "calibrating": "Calibration in progress" + } + } } } }