"""Provides a sensor for Home Connect.""" import contextlib from dataclasses import dataclass from datetime import datetime, timedelta import logging from typing import cast from homeconnect.api import HomeConnectError from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTime, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify import homeassistant.util.dt as dt_util from .api import ConfigEntryAuth from .const import ( ATTR_VALUE, BSH_DOOR_STATE, BSH_OPERATION_STATE, BSH_OPERATION_STATE_FINISHED, BSH_OPERATION_STATE_PAUSE, BSH_OPERATION_STATE_RUN, COFFEE_EVENT_BEAN_CONTAINER_EMPTY, COFFEE_EVENT_DRIP_TRAY_FULL, COFFEE_EVENT_WATER_TANK_EMPTY, DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY, DISHWASHER_EVENT_SALT_NEARLY_EMPTY, DOMAIN, REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR, REFRIGERATION_EVENT_TEMP_ALARM_FREEZER, ) from .entity import HomeConnectEntity _LOGGER = logging.getLogger(__name__) EVENT_OPTIONS = ["confirmed", "off", "present"] @dataclass(frozen=True, kw_only=True) class HomeConnectSensorEntityDescription(SensorEntityDescription): """Entity Description class for sensors.""" default_value: str | None = None appliance_types: tuple[str, ...] | None = None sign: int = 1 BSH_PROGRAM_SENSORS = ( HomeConnectSensorEntityDescription( key="BSH.Common.Option.RemainingProgramTime", device_class=SensorDeviceClass.TIMESTAMP, sign=1, translation_key="program_finish_time", ), HomeConnectSensorEntityDescription( key="BSH.Common.Option.Duration", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, sign=1, ), HomeConnectSensorEntityDescription( key="BSH.Common.Option.ProgramProgress", native_unit_of_measurement=PERCENTAGE, sign=1, translation_key="program_progress", ), ) SENSORS = ( HomeConnectSensorEntityDescription( key=BSH_OPERATION_STATE, device_class=SensorDeviceClass.ENUM, options=[ "inactive", "ready", "delayedstart", "run", "pause", "actionrequired", "finished", "error", "aborting", ], translation_key="operation_state", ), HomeConnectSensorEntityDescription( key=BSH_DOOR_STATE, device_class=SensorDeviceClass.ENUM, options=[ "closed", "locked", "open", ], translation_key="door", ), HomeConnectSensorEntityDescription( key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterCoffee", state_class=SensorStateClass.TOTAL_INCREASING, translation_key="coffee_counter", ), HomeConnectSensorEntityDescription( key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterPowderCoffee", state_class=SensorStateClass.TOTAL_INCREASING, translation_key="powder_coffee_counter", ), HomeConnectSensorEntityDescription( key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterHotWater", native_unit_of_measurement=UnitOfVolume.MILLILITERS, device_class=SensorDeviceClass.VOLUME, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="hot_water_counter", ), HomeConnectSensorEntityDescription( key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterHotWaterCups", state_class=SensorStateClass.TOTAL_INCREASING, translation_key="hot_water_cups_counter", ), HomeConnectSensorEntityDescription( key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterHotMilk", state_class=SensorStateClass.TOTAL_INCREASING, translation_key="hot_milk_counter", ), HomeConnectSensorEntityDescription( key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterFrothyMilk", state_class=SensorStateClass.TOTAL_INCREASING, translation_key="frothy_milk_counter", ), HomeConnectSensorEntityDescription( key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterMilk", state_class=SensorStateClass.TOTAL_INCREASING, translation_key="milk_counter", ), HomeConnectSensorEntityDescription( key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterCoffeeAndMilk", state_class=SensorStateClass.TOTAL_INCREASING, translation_key="coffee_and_milk_counter", ), HomeConnectSensorEntityDescription( key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterRistrettoEspresso", state_class=SensorStateClass.TOTAL_INCREASING, translation_key="ristretto_espresso_counter", ), HomeConnectSensorEntityDescription( key="BSH.Common.Status.BatteryLevel", device_class=SensorDeviceClass.BATTERY, translation_key="battery_level", ), HomeConnectSensorEntityDescription( key="BSH.Common.Status.Video.CameraState", device_class=SensorDeviceClass.ENUM, options=[ "disabled", "sleeping", "ready", "streaminglocal", "streamingcloud", "streaminglocalancloud", "error", ], translation_key="camera_state", ), HomeConnectSensorEntityDescription( key="ConsumerProducts.CleaningRobot.Status.LastSelectedMap", device_class=SensorDeviceClass.ENUM, options=[ "tempmap", "map1", "map2", "map3", ], translation_key="last_selected_map", ), ) EVENT_SENSORS = ( HomeConnectSensorEntityDescription( key=REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", translation_key="freezer_door_alarm", appliance_types=("FridgeFreezer", "Freezer"), ), HomeConnectSensorEntityDescription( key=REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", translation_key="refrigerator_door_alarm", appliance_types=("FridgeFreezer", "Refrigerator"), ), HomeConnectSensorEntityDescription( key=REFRIGERATION_EVENT_TEMP_ALARM_FREEZER, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", translation_key="freezer_temperature_alarm", appliance_types=("FridgeFreezer", "Freezer"), ), HomeConnectSensorEntityDescription( key=COFFEE_EVENT_BEAN_CONTAINER_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", translation_key="bean_container_empty", appliance_types=("CoffeeMaker",), ), HomeConnectSensorEntityDescription( key=COFFEE_EVENT_WATER_TANK_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", translation_key="water_tank_empty", appliance_types=("CoffeeMaker",), ), HomeConnectSensorEntityDescription( key=COFFEE_EVENT_DRIP_TRAY_FULL, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", translation_key="drip_tray_full", appliance_types=("CoffeeMaker",), ), HomeConnectSensorEntityDescription( key=DISHWASHER_EVENT_SALT_NEARLY_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", translation_key="salt_nearly_empty", appliance_types=("Dishwasher",), ), HomeConnectSensorEntityDescription( key=DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", translation_key="rinse_aid_nearly_empty", appliance_types=("Dishwasher",), ), ) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Home Connect sensor.""" def get_entities() -> list[SensorEntity]: """Get a list of entities.""" entities: list[SensorEntity] = [] hc_api: ConfigEntryAuth = hass.data[DOMAIN][config_entry.entry_id] for device in hc_api.devices: entities.extend( HomeConnectSensor( device, description, ) for description in EVENT_SENSORS if description.appliance_types and device.appliance.type in description.appliance_types ) with contextlib.suppress(HomeConnectError): if device.appliance.get_programs_available(): entities.extend( HomeConnectSensor(device, desc) for desc in BSH_PROGRAM_SENSORS ) entities.extend( HomeConnectSensor(device, description) for description in SENSORS if description.key in device.appliance.status ) return entities async_add_entities(await hass.async_add_executor_job(get_entities), True) class HomeConnectSensor(HomeConnectEntity, SensorEntity): """Sensor class for Home Connect.""" entity_description: HomeConnectSensorEntityDescription @property def available(self) -> bool: """Return true if the sensor is available.""" return self._attr_native_value is not None async def async_update(self) -> None: """Update the sensor's status.""" appliance_status = self.device.appliance.status if ( self.bsh_key not in appliance_status or ATTR_VALUE not in appliance_status[self.bsh_key] ): self._attr_native_value = self.entity_description.default_value _LOGGER.debug("Updated, new state: %s", self._attr_native_value) return status = appliance_status[self.bsh_key] match self.device_class: case SensorDeviceClass.TIMESTAMP: if ATTR_VALUE not in status: self._attr_native_value = None elif ( self._attr_native_value is not None and self.entity_description.sign == 1 and isinstance(self._attr_native_value, datetime) and self._attr_native_value < dt_util.utcnow() ): # if the date is supposed to be in the future but we're # already past it, set state to None. self._attr_native_value = None elif ( BSH_OPERATION_STATE in (appliance_status := self.device.appliance.status) and ATTR_VALUE in appliance_status[BSH_OPERATION_STATE] and appliance_status[BSH_OPERATION_STATE][ATTR_VALUE] in [ BSH_OPERATION_STATE_RUN, BSH_OPERATION_STATE_PAUSE, BSH_OPERATION_STATE_FINISHED, ] ): seconds = self.entity_description.sign * float(status[ATTR_VALUE]) self._attr_native_value = dt_util.utcnow() + timedelta( seconds=seconds ) else: self._attr_native_value = None case SensorDeviceClass.ENUM: # Value comes back as an enum, we only really care about the # last part, so split it off # https://developer.home-connect.com/docs/status/operation_state self._attr_native_value = slugify( cast(str, status.get(ATTR_VALUE)).split(".")[-1] ) case _: self._attr_native_value = status.get(ATTR_VALUE) _LOGGER.debug("Updated, new state: %s", self._attr_native_value)