HEX
Server: Apache/2.4.63 (Unix)
System: Linux Synopilou92 4.4.302+ #72806 SMP Mon Jul 21 23:16:00 CST 2025 x86_64
User: pilou92 (1026)
PHP: 8.0.30
Disabled: NONE
Upload Files
File: /volume1/docker/homeassistant2025/custom_components/roborock/sensor.py
"""Support for Roborock sensors."""
from __future__ import annotations

import logging
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from enum import Enum

from roborock.roborock_message import RoborockDataProtocol

from homeassistant.components.sensor import (
    SensorDeviceClass,
    SensorEntity,
    SensorEntityDescription,
    SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfArea, PERCENTAGE, UnitOfTime
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util, slugify
from roborock import DeviceProp

from . import EntryData
from .const import DOMAIN
from .coordinator import RoborockDataUpdateCoordinator
from .device import RoborockCoordinatedEntity
from .roborock_typing import RoborockHassDeviceInfo

_LOGGER = logging.getLogger(__name__)

ATTR_BATTERY = "battery"
ATTR_DND_START = "start"
ATTR_DND_END = "end"
ATTR_LAST_CLEAN_TIME = "duration"
ATTR_LAST_CLEAN_AREA = "area"
ATTR_STATUS_ERROR = "error"
ATTR_STATUS_CLEAN_TIME = "clean_time"
ATTR_STATUS_CLEAN_AREA = "clean_area"
ATTR_LAST_CLEAN_START = "start"
ATTR_LAST_CLEAN_END = "end"
ATTR_CLEAN_SUMMARY_TOTAL_DURATION = "total_duration"
ATTR_CLEAN_SUMMARY_TOTAL_AREA = "total_area"
ATTR_CLEAN_SUMMARY_COUNT = "count"
ATTR_CLEAN_SUMMARY_DUST_COLLECTION_COUNT = "dust_collection_count"
ATTR_CONSUMABLE_STATUS_MAIN_BRUSH_LEFT = "main_brush_left"
ATTR_CONSUMABLE_STATUS_SIDE_BRUSH_LEFT = "side_brush_left"
ATTR_CONSUMABLE_STATUS_FILTER_LEFT = "filter_left"
ATTR_CONSUMABLE_STATUS_SENSOR_DIRTY_LEFT = "sensor_dirty_left"
ATTR_DOCK_STATUS = "dock_status"
ATTR_DOCK_WASHING_MODE = "dock_washing_mode"
ATTR_DOCK_DUST_COLLECTION_MODE = "dock_dust_collection_mode"
ATTR_DOCK_MOP_WASH_MODE = "dock_mop_wash_mode"
ATTR_SELECTED_MAP = "map_selected"
ATTR_CURRENT_ROOM = "room"
ATTR_MOP_DRYING_REMAINING_TIME = "rdt"
ATTR_CLEANING_PROGRESS = "clean_percent"


@dataclass
class RoborockSensorDescription(SensorEntityDescription):
    """A class that describes sensor entities."""

    attributes: tuple = ()
    parent_key: str = None
    keys: list[str] = None
    value: Callable = None
    protocol_listener: RoborockDataProtocol | None = None


VACUUM_SENSORS = {
    f"last_clean_{ATTR_LAST_CLEAN_START}": RoborockSensorDescription(
        key="begin",
        icon="mdi:clock-time-twelve",
        name="Last clean start",
        translation_key="last_clean_start",
        device_class=SensorDeviceClass.TIMESTAMP,
        parent_key="last_clean_record",
        entity_category=EntityCategory.DIAGNOSTIC,
    ),
    f"last_clean_{ATTR_LAST_CLEAN_END}": RoborockSensorDescription(
        key="end",
        icon="mdi:clock-time-twelve",
        device_class=SensorDeviceClass.TIMESTAMP,
        parent_key="last_clean_record",
        name="Last clean end",
        translation_key="last_clean_end",
        entity_category=EntityCategory.DIAGNOSTIC,
    ),
    f"last_clean_{ATTR_LAST_CLEAN_TIME}": RoborockSensorDescription(
        native_unit_of_measurement=UnitOfTime.SECONDS,
        key="duration",
        icon="mdi:timer-sand",
        device_class=SensorDeviceClass.DURATION,
        parent_key="last_clean_record",
        name="Last clean duration",
        translation_key="last_clean_duration",
        entity_category=EntityCategory.DIAGNOSTIC,
    ),
    f"last_clean_{ATTR_LAST_CLEAN_AREA}": RoborockSensorDescription(
        native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
        key="area",
        value=lambda value, _: round(value / 1000000, 1),
        icon="mdi:texture-box",
        parent_key="last_clean_record",
        name="Last clean area",
        translation_key="last_clean_area",
        entity_category=EntityCategory.DIAGNOSTIC,
    ),
    f"current_{ATTR_STATUS_ERROR}": RoborockSensorDescription(
        key="error_code",
        icon="mdi:alert",
        name="Current error",
        translation_key="current_error",
        attributes=("error_code",),
        parent_key="status",
        entity_category=EntityCategory.DIAGNOSTIC,
        protocol_listener=RoborockDataProtocol.ERROR_CODE,
    ),
    f"current_{ATTR_STATUS_CLEAN_TIME}": RoborockSensorDescription(
        native_unit_of_measurement=UnitOfTime.SECONDS,
        key="clean_time",
        icon="mdi:timer-sand",
        device_class=SensorDeviceClass.DURATION,
        parent_key="status",
        name="Current clean duration",
        translation_key="current_clean_duration",
        entity_category=EntityCategory.DIAGNOSTIC,
    ),
    f"current_{ATTR_STATUS_CLEAN_AREA}": RoborockSensorDescription(
        native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
        icon="mdi:texture-box",
        key="clean_area",
        value=lambda value, _: round(value / 1000000, 1),
        parent_key="status",
        entity_category=EntityCategory.DIAGNOSTIC,
        name="Current clean area",
        translation_key="current_clean_area",
    ),
    f"current_{ATTR_SELECTED_MAP}": RoborockSensorDescription(
        key="map_status",
        value=lambda value, device_info:
        slugify(device_info.map_mapping.get((value - 3) // 4))
        if device_info and device_info.map_mapping else None,
        icon="mdi:floor-plan",
        parent_key="status",
        name="Current selected map",
        translation_key="current_map_selected",
        entity_category=EntityCategory.DIAGNOSTIC,
    ),
    f"current_{ATTR_CURRENT_ROOM}": RoborockSensorDescription(
        # TODO: Find a better way of doing this
        key="state",
        value=lambda _, device_info:
        slugify(device_info.room_mapping.get(device_info.current_room))
        if device_info.room_mapping and device_info.current_room else None,
        icon="mdi:floor-plan",
        parent_key="status",
        name="Current room",
        translation_key="current_room",
        entity_category=EntityCategory.DIAGNOSTIC,
        protocol_listener=RoborockDataProtocol.STATE,
    ),
    f"clean_history_{ATTR_CLEAN_SUMMARY_TOTAL_DURATION}": RoborockSensorDescription(
        native_unit_of_measurement=UnitOfTime.SECONDS,
        device_class=SensorDeviceClass.DURATION,
        key="clean_time",
        icon="mdi:timer-sand",
        parent_key="clean_summary",
        name="Total duration",
        translation_key="total_duration",
        entity_category=EntityCategory.DIAGNOSTIC,
    ),
    f"clean_history_{ATTR_CLEAN_SUMMARY_TOTAL_AREA}": RoborockSensorDescription(
        native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
        key="clean_area",
        value=lambda value, _: round(value / 1000000, 1),
        icon="mdi:texture-box",
        parent_key="clean_summary",
        name="Total clean area",
        translation_key="total_clean_area",
        entity_category=EntityCategory.DIAGNOSTIC,
    ),
    f"clean_history_{ATTR_CLEAN_SUMMARY_COUNT}": RoborockSensorDescription(
        native_unit_of_measurement="",
        key="clean_count",
        icon="mdi:counter",
        state_class=SensorStateClass.TOTAL_INCREASING,
        parent_key="clean_summary",
        name="Total clean count",
        translation_key="total_clean_count",
        entity_category=EntityCategory.DIAGNOSTIC,
    ),
    f"clean_history_{ATTR_CLEAN_SUMMARY_DUST_COLLECTION_COUNT}": RoborockSensorDescription(
        native_unit_of_measurement="",
        key="dust_collection_count",
        icon="mdi:counter",
        state_class=SensorStateClass.TOTAL_INCREASING,
        parent_key="clean_summary",
        name="Total dust collection count",
        translation_key="total_dust_collection_count",
        entity_category=EntityCategory.DIAGNOSTIC,
    ),
    f"consumable_{ATTR_CONSUMABLE_STATUS_MAIN_BRUSH_LEFT}": RoborockSensorDescription(
        native_unit_of_measurement=UnitOfTime.SECONDS,
        key="main_brush_time_left",
        icon="mdi:brush",
        device_class=SensorDeviceClass.DURATION,
        parent_key="consumable",
        name="Main brush left",
        translation_key="main_brush_left",
        entity_category=EntityCategory.DIAGNOSTIC,
        protocol_listener=RoborockDataProtocol.MAIN_BRUSH_WORK_TIME,
    ),
    f"consumable_{ATTR_CONSUMABLE_STATUS_SIDE_BRUSH_LEFT}": RoborockSensorDescription(
        native_unit_of_measurement=UnitOfTime.SECONDS,
        key="side_brush_time_left",
        icon="mdi:brush",
        device_class=SensorDeviceClass.DURATION,
        parent_key="consumable",
        name="Side brush left",
        translation_key="side_brush_left",
        entity_category=EntityCategory.DIAGNOSTIC,
        protocol_listener=RoborockDataProtocol.SIDE_BRUSH_WORK_TIME,
    ),
    f"consumable_{ATTR_CONSUMABLE_STATUS_FILTER_LEFT}": RoborockSensorDescription(
        native_unit_of_measurement=UnitOfTime.SECONDS,
        key="filter_time_left",
        icon="mdi:air-filter",
        device_class=SensorDeviceClass.DURATION,
        parent_key="consumable",
        name="Filter left",
        translation_key="filter_left",
        entity_category=EntityCategory.DIAGNOSTIC,
        protocol_listener=RoborockDataProtocol.FILTER_WORK_TIME,
    ),
    f"consumable_{ATTR_CONSUMABLE_STATUS_SENSOR_DIRTY_LEFT}": RoborockSensorDescription(
        native_unit_of_measurement=UnitOfTime.SECONDS,
        key="sensor_time_left",
        icon="mdi:eye-outline",
        device_class=SensorDeviceClass.DURATION,
        parent_key="consumable",
        name="Sensor dirty left",
        translation_key="sensor_dirty_left",
        entity_category=EntityCategory.DIAGNOSTIC,
    ),
    f"current_{ATTR_DOCK_STATUS}": RoborockSensorDescription(
        key="dock_error_status",
        icon="mdi:garage-open",
        parent_key="status",
        name="Dock status",
        translation_key="dock_status",
        entity_category=EntityCategory.DIAGNOSTIC,
    ),
    f"current_{ATTR_CLEANING_PROGRESS}": RoborockSensorDescription(
        native_unit_of_measurement=PERCENTAGE,
        key="clean_percent",
        icon="mdi:progress-check",
        parent_key="status",
        name="Cleaning progress",
        translation_key="clean_percent",
        entity_category=EntityCategory.DIAGNOSTIC,
    ),
    f"current_{ATTR_BATTERY}": RoborockSensorDescription(
        native_unit_of_measurement=PERCENTAGE,
        key="battery",
        icon="mdi:battery",
        name="Battery",
        translation_key="battery",
        device_class=SensorDeviceClass.BATTERY,
        parent_key="status",
        entity_category=EntityCategory.DIAGNOSTIC,
    ),
}

VACUUM_WITH_DOCK_SENSORS = {
    **VACUUM_SENSORS,
    f"current_{ATTR_DOCK_WASHING_MODE}": RoborockSensorDescription(
        key="wash_towel_mode",
        value=lambda value, _: value.wash_mode.value,
        icon="mdi:water",
        parent_key="dock_summary",
        name="Dock washing mode",
        translation_key="dock_washing_mode",
        entity_category=EntityCategory.DIAGNOSTIC,
    ),
    f"current_{ATTR_DOCK_DUST_COLLECTION_MODE}": RoborockSensorDescription(
        key="dust_collection_mode",
        value=lambda value, _: value.mode.value,
        parent_key="dock_summary",
        name="Dock dust collection mode",
        translation_key="dock_dust_collection_mode",
        entity_category=EntityCategory.DIAGNOSTIC,
    ),
    f"current_{ATTR_DOCK_MOP_WASH_MODE}": RoborockSensorDescription(
        key="smart_wash_params",
        value=lambda value, _: value.wash_interval,
        icon="mdi:water",
        parent_key="dock_summary",
        name="Dock mop wash mode interval",
        translation_key="dock_mop_wash_mode_interval",
        entity_category=EntityCategory.DIAGNOSTIC,
    ),
    f"current_{ATTR_MOP_DRYING_REMAINING_TIME}": RoborockSensorDescription(
        native_unit_of_measurement=UnitOfTime.SECONDS,
        key="rdt",
        icon="mdi:timer-sand",
        device_class=SensorDeviceClass.DURATION,
        parent_key="status",
        name="Mop drying remaining time",
        translation_key="mop_drying_remaining_time",
        entity_category=EntityCategory.DIAGNOSTIC,
    ),
}


async def async_setup_entry(
        hass: HomeAssistant,
        config_entry: ConfigEntry,
        async_add_entities: AddEntitiesCallback,
) -> None:
    """Set up the Roborock vacuum sensors."""
    domain_data: EntryData = hass.data[DOMAIN][
        config_entry.entry_id
    ]

    entities: list[RoborockSensor] = []
    for _device_id, device_entry_data in domain_data.get("devices").items():
        coordinator = device_entry_data["coordinator"]
        device_info = coordinator.data
        unique_id = slugify(device_info.device.duid)
        if device_info:
            device_prop = device_info.props
            if device_prop:
                sensors = VACUUM_SENSORS
                if device_prop.dock_summary:
                    sensors = VACUUM_WITH_DOCK_SENSORS
                for sensor, description in sensors.items():
                    parent_key_data = getattr(device_prop, description.parent_key)
                    if parent_key_data is None:
                        _LOGGER.debug(
                            "It seems the %s does not support the %s as the initial value is None",
                            device_info.model,
                            sensor,
                        )
                        continue
                    entities.append(
                        RoborockSensor(
                            f"{sensor}_{unique_id}",
                            device_info,
                            coordinator,
                            description,
                        )
                    )
        else:
            _LOGGER.warning("Failed setting up sensors no Roborock data")

    async_add_entities(entities)


class RoborockSensor(RoborockCoordinatedEntity, SensorEntity):
    """Representation of a Roborock sensor."""

    entity_description: RoborockSensorDescription

    def __init__(
            self,
            unique_id: str,
            device_info: RoborockHassDeviceInfo,
            coordinator: RoborockDataUpdateCoordinator,
            description: RoborockSensorDescription,
    ) -> None:
        """Initialize the entity."""
        SensorEntity.__init__(self)
        RoborockCoordinatedEntity.__init__(self, device_info, coordinator, unique_id)
        self.entity_description = description
        self._attr_native_value = self._determine_native_value()
        self._attr_extra_state_attributes = self._extract_attributes(
            coordinator.data.props
        )
        if (protocol := self.entity_description.protocol_listener) is not None:
            self.api.add_listener(protocol, self._update_from_listener, self.api.cache)


    @callback
    def _extract_attributes(self, data: DeviceProp):
        """Return state attributes with valid values."""
        if self.entity_description.parent_key:
            data = getattr(data, self.entity_description.parent_key)
            if data is None:
                return
        return {
            attr: getattr(data, attr)
            for attr in self.entity_description.attributes
            if hasattr(data, attr)
        }

    @callback
    def _handle_coordinator_update(self):
        """Fetch state from the device."""
        native_value = self._determine_native_value()
        # Sometimes (quite rarely) the device returns None as the sensor value so we
        # check that the value: before updating the state.
        if native_value is not None:
            data = self.coordinator.data.props
            self._attr_native_value = native_value
            self._attr_extra_state_attributes = self._extract_attributes(data)
            super()._handle_coordinator_update()

    def _determine_native_value(self):
        """Determine native value."""
        data = self.coordinator.data.props
        if data is None:
            return
        if self.entity_description.parent_key:
            data = getattr(data, self.entity_description.parent_key)
            if data is None:
                return

        if self.entity_description.keys:
            native_value = [getattr(data, key) for key in self.entity_description.keys]
            if not any(native_value):
                native_value = None
        else:
            native_value = getattr(data, self.entity_description.key)

        if native_value is not None:
            if isinstance(native_value, Enum):
                native_value = native_value.name
            if self.entity_description.value:
                device_info = self.coordinator.data
                native_value = self.entity_description.value(native_value, device_info)
            if self.device_class == SensorDeviceClass.TIMESTAMP and (
                    native_datetime := datetime.fromtimestamp(native_value)
            ):
                native_value = native_datetime.astimezone(dt_util.UTC)

        return native_value