diff --git a/custom_components/mqtt_vacuum_camera/__init__.py b/custom_components/mqtt_vacuum_camera/__init__.py index 069ec9fb..716d45fc 100755 --- a/custom_components/mqtt_vacuum_camera/__init__.py +++ b/custom_components/mqtt_vacuum_camera/__init__.py @@ -15,7 +15,6 @@ ) from homeassistant.core import ServiceCall from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.reload import async_register_admin_service from homeassistant.helpers.storage import STORAGE_DIR @@ -40,8 +39,7 @@ async_rename_room_description, ) -PLATFORMS = [Platform.CAMERA] -CONFIG_SCHEMA = cv.config_entry_only_config_schema +PLATFORMS = [Platform.CAMERA, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) @@ -99,6 +97,7 @@ async def reset_trims(call: ServiceCall) -> None: "Unable to lookup vacuum's entity ID. Was it removed?" ) + _LOGGER.debug(vacuum_entity_id) mqtt_topic_vacuum = get_vacuum_mqtt_topic(vacuum_entity_id, hass) if not mqtt_topic_vacuum: raise ConfigEntryNotReady("MQTT was not ready yet, automatically retrying") @@ -124,14 +123,14 @@ async def reset_trims(call: ServiceCall) -> None: # Forward the setup to the camera platform. await hass.async_create_task( - hass.config_entries.async_forward_entry_setups(entry, ["camera"]) + hass.config_entries.async_forward_entry_setups(entry, ["camera", "sensor"]) ) return True async def async_unload_entry( - hass: core.HomeAssistant, entry: config_entries.ConfigEntry + hass: core.HomeAssistant, entry: config_entries.ConfigEntry ) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): diff --git a/custom_components/mqtt_vacuum_camera/coordinator.py b/custom_components/mqtt_vacuum_camera/coordinator.py index b809627b..e94c5c4f 100644 --- a/custom_components/mqtt_vacuum_camera/coordinator.py +++ b/custom_components/mqtt_vacuum_camera/coordinator.py @@ -16,6 +16,18 @@ from .const import DEFAULT_NAME from .valetudo.MQTT.connector import ValetudoConnector +SENSOR_NO_DATA = { + "mainBrush": 0, + "sideBrush": 0, + "filter": 0, + "sensor": 0, + "currentCleanTime": 0, + "currentCleanArea": 0, + "cleanTime": 0, + "cleanArea": 0, + "cleanCount": 0, +} + _LOGGER = logging.getLogger(__name__) @@ -23,11 +35,11 @@ class MQTTVacuumCoordinator(DataUpdateCoordinator): """Coordinator for MQTT Vacuum Camera.""" def __init__( - self, - hass: HomeAssistant, - entry: ConfigEntry, - vacuum_topic: str, - polling_interval=timedelta(seconds=3), + self, + hass: HomeAssistant, + entry: ConfigEntry, + vacuum_topic: str, + polling_interval=timedelta(seconds=3), ): """Initialize the coordinator.""" super().__init__( @@ -45,11 +57,29 @@ def __init__( self.file_name: str = "" self.connector: Optional[ValetudoConnector] = None self.in_sync_with_camera: bool = False + self.sensor_data = SENSOR_NO_DATA # Initialize shared data and MQTT connector self.shared, self.file_name = self._init_shared_data(self.vacuum_topic) self.stat_up_mqtt() + async def _async_update_data(self): + """Fetch data from the MQTT topics for sensors.""" + try: + async with async_timeout.timeout(10): + # Fetch and process sensor data from the MQTT connector + sensor_data = await self.connector.get_rand256_attributes() + if sensor_data: + # Format the data before returning it + self.sensor_data = await self.async_update_sensor_data(sensor_data) + # self.sensor_data = temp_data + return self.sensor_data + return self.sensor_data + except Exception as err: + _LOGGER.error(f"Error fetching sensor data: {err}") + raise UpdateFailed(f"Error fetching sensor data: {err}") + + def _init_shared_data(self, mqtt_listen_topic: str) -> tuple[CameraShared, str]: """ Initialize the shared data. @@ -96,7 +126,7 @@ def update_shared_data(self, dev_info: DeviceInfo) -> tuple[CameraShared, str]: self.in_sync_with_camera = True return self.shared, self.file_name - async def _async_update_data(self, process: bool = True): + async def async_update_camera_data(self, process: bool = True): """ Fetch data from the MQTT topics. @@ -115,3 +145,36 @@ async def _async_update_data(self, process: bool = True): except Exception as err: _LOGGER.error(f"Error communicating with MQTT or processing data: {err}") raise UpdateFailed(f"Error communicating with MQTT: {err}") from err + + async def async_update_sensor_data(self, sensor_data): + """ + Update the sensor data format before sending to the sensors. + + Args: + sensor_data: The raw sensor data from MQTT. + + Returns: + A dictionary with formatted sensor data. + """ + if sensor_data: + # Assume sensor_data is a dictionary or transform it into the expected format + battery_level = await self.connector.get_battery_level() + vacuum_state = await self.connector.get_vacuum_status() + formatted_data = { + "mainBrush": sensor_data.get("mainBrush", 0), + "sideBrush": sensor_data.get("sideBrush", 0), + "filter": sensor_data.get("filter", 0), + "currentCleanTime": sensor_data.get("currentCleanTime", 0), + "currentCleanArea": sensor_data.get("currentCleanArea", 0), + "cleanTime": sensor_data.get("cleanTime", 0), + "cleanArea": sensor_data.get("cleanArea", 0), + "cleanCount": sensor_data.get("cleanCount", 0), + "battery": battery_level, + "state": vacuum_state, + "last_run_stats": sensor_data.get("last_run_stats", {}), + "last_bin_out": sensor_data.get("last_bin_out", 0), + "last_bin_full": sensor_data.get("last_bin_full", 0), + "last_loaded_map": sensor_data.get("last_loaded_map", "None"), + } + return formatted_data + return SENSOR_NO_DATA diff --git a/custom_components/mqtt_vacuum_camera/sensor.py b/custom_components/mqtt_vacuum_camera/sensor.py new file mode 100644 index 00000000..f87fa2f6 --- /dev/null +++ b/custom_components/mqtt_vacuum_camera/sensor.py @@ -0,0 +1,234 @@ +"""Sensors for Rand256.""" +from __future__ import annotations + +import logging +from collections.abc import Callable +from dataclasses import dataclass +from datetime import timedelta + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import AREA_SQUARE_METERS, PERCENTAGE, UnitOfTime +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import MQTTVacuumCoordinator +SCAN_INTERVAL = timedelta(seconds=3) +SENSOR_NO_DATA = { + "mainBrush": 0, + "sideBrush": 0, + "filter": 0, + "sensor": 0, + "currentCleanTime": 0, + "currentCleanArea": 0, + "cleanTime": 0, + "cleanArea": 0, + "cleanCount": 0, +} +_LOGGER = logging.getLogger(__name__) + +@dataclass +class VacuumSensorDescription(SensorEntityDescription): + """A class that describes vacuum sensor entities.""" + attributes: tuple = () + parent_key: str = None + keys: list[str] = None + value: Callable = None + +SENSOR_TYPES = { + "consumable_main_brush": VacuumSensorDescription( + native_unit_of_measurement=UnitOfTime.SECONDS, + key="mainBrush", + icon="mdi:brush", + device_class=SensorDeviceClass.DURATION, + name="Main brush", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "consumable_side_brush": VacuumSensorDescription( + native_unit_of_measurement=UnitOfTime.SECONDS, + key="sideBrush", + icon="mdi:brush", + device_class=SensorDeviceClass.DURATION, + name="Side brush", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "consumable_filter": VacuumSensorDescription( + native_unit_of_measurement=UnitOfTime.SECONDS, + key="filter", + icon="mdi:air-filter", + device_class=SensorDeviceClass.DURATION, + name="Filter", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "battery": VacuumSensorDescription( + native_unit_of_measurement=PERCENTAGE, + key="battery", + icon="mdi:battery", + name="Battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "current_clean_time": VacuumSensorDescription( + native_unit_of_measurement=UnitOfTime.SECONDS, + key="currentCleanTime", + icon="mdi:timer-sand", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.DURATION, + name="Current clean time", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "current_clean_area": VacuumSensorDescription( + native_unit_of_measurement=AREA_SQUARE_METERS, + key="currentCleanArea", + icon="mdi:texture-box", + name="Current clean area", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "clean_count": VacuumSensorDescription( + native_unit_of_measurement="", + key="cleanCount", + icon="mdi:counter", + name="Total clean count", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "clean_time": VacuumSensorDescription( + native_unit_of_measurement=UnitOfTime.MINUTES.SECONDS, + key="cleanTime", + icon="mdi:timer-sand", + state_class=SensorStateClass.TOTAL_INCREASING, + name="Total clean time", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "state": VacuumSensorDescription( + key="state", + icon="mdi:robot-vacuum", + name="Vacuum state", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "last_run_start": VacuumSensorDescription( + native_unit_of_measurement=UnitOfTime.HOURS.MINUTES.SECONDS, + key="last_run_stats.startTime", + icon="mdi:clock-start", + name="Last run start time", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "last_run_end": VacuumSensorDescription( + native_unit_of_measurement=UnitOfTime.HOURS, + key="last_run_stats.endTime", + icon="mdi:clock-end", + name="Last run end time", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "last_run_duration": VacuumSensorDescription( + native_unit_of_measurement=UnitOfTime.MINUTES, + key="last_run_stats.duration", + icon="mdi:timer", + name="Last run duration", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "last_run_area": VacuumSensorDescription( + native_unit_of_measurement=AREA_SQUARE_METERS, + key="last_run_stats.area", + icon="mdi:texture-box", + name="Last run area", + entity_category=EntityCategory.DIAGNOSTIC, + ), + # Sensors for bin and map-related data + "last_bin_out": VacuumSensorDescription( + native_unit_of_measurement=UnitOfTime.DAYS, + key="last_bin_out", + icon="mdi:delete", + name="Last bin out time", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "last_bin_full": VacuumSensorDescription( + native_unit_of_measurement=UnitOfTime.DAYS, + key="last_bin_full", + icon="mdi:delete-alert", + name="Last bin full time", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "last_loaded_map": VacuumSensorDescription( + native_unit_of_measurement="", + key="last_loaded_map", + icon="mdi:map", + name="Last loaded map", + entity_category=EntityCategory.DIAGNOSTIC, + ), +} + + +class VacuumSensor(CoordinatorEntity, SensorEntity): + """Representation of a vacuum sensor.""" + + entity_description: VacuumSensorDescription + + def __init__(self, coordinator: MQTTVacuumCoordinator, description: VacuumSensorDescription, sensor_type: str): + """Initialize the vacuum sensor.""" + super().__init__(coordinator) + self.entity_description = description + self.coordinator = coordinator + self._attr_native_value = None + self._attr_unique_id = f"{coordinator.file_name}_{sensor_type}" + self.entity_id = f"sensor.{coordinator.file_name}_{sensor_type}" + + @callback + async def async_update(self): + """Update the sensor's state.""" + if self.coordinator.last_update_success: + await self._handle_coordinator_update() + + @property + def should_poll(self) -> bool: + """Indicate if the sensor should poll for updates.""" + return True # This will tell Home Assistant to poll for data + + @callback + async def _extract_attributes(self): + """Return state attributes with valid values.""" + data = self.coordinator.sensor_data + if self.entity_description.parent_key: + data = getattr(data, self.entity_description.key) + if data is None: + return + return { + attr: getattr(data, attr) + for attr in self.entity_description.attributes + if hasattr(data, attr) + } + + @callback + async def _handle_coordinator_update(self): + """Fetch the latest state from the coordinator and update the sensor.""" + data = self.coordinator.sensor_data + _LOGGER.debug(f"{self.coordinator.file_name} getting sensors update: {data}") + if data is None: + data = SENSOR_NO_DATA + + # Fetch the value based on the key in the description + native_value = data.get(self.entity_description.key, 0) + _LOGGER.debug(f"{self.entity_description.key}, return {native_value}") + + if native_value is not None: + self._attr_native_value = native_value + else: + self._attr_native_value = 0 # Set to None if the value is missing or invalid + + self.async_write_ha_state() + + +async def async_setup_entry(hass: HomeAssistant, config_entry, async_add_entities): + """Set up vacuum sensors based on a config entry.""" + coordinator = hass.data["mqtt_vacuum_camera"][config_entry.entry_id]["coordinator"] + + # Create and add sensor entities + sensors = [] + for sensor_type, description in SENSOR_TYPES.items(): + sensors.append(VacuumSensor(coordinator, description, sensor_type)) + + async_add_entities(sensors, update_before_add=False) diff --git a/custom_components/mqtt_vacuum_camera/valetudo/MQTT/connector.py b/custom_components/mqtt_vacuum_camera/valetudo/MQTT/connector.py index 2d7fa7cc..6db1b1a1 100755 --- a/custom_components/mqtt_vacuum_camera/valetudo/MQTT/connector.py +++ b/custom_components/mqtt_vacuum_camera/valetudo/MQTT/connector.py @@ -134,6 +134,12 @@ async def is_data_available(self) -> bool: """Check and Return the data availability.""" return bool(self._data_in) + async def get_rand256_attributes(self): + """If available return the vacuum attributes""" + if bool(self.rrm_attributes): + return self.rrm_attributes + return {} + async def hypfer_handle_image_data(self, msg) -> None: """ Handle new MQTT messages.