From 3489b20e86c5e37112caddbbff133504c7bdeb84 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 11 Feb 2025 18:14:13 +0100 Subject: [PATCH] Refactor SmartThings sensor platform (#138313) --- .../components/smartthings/sensor.py | 1366 +++++++++-------- tests/components/smartthings/test_sensor.py | 27 +- 2 files changed, 716 insertions(+), 677 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index c0b079da070..3a283bb806b 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -2,15 +2,18 @@ from __future__ import annotations -from collections.abc import Sequence -from typing import NamedTuple +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime +from typing import Any from pysmartthings import Attribute, Capability -from pysmartthings.device import DeviceEntity +from pysmartthings.device import DeviceEntity, DeviceStatus from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry @@ -35,530 +38,693 @@ from .const import DATA_BROKERS, DOMAIN from .entity import SmartThingsEntity -class Map(NamedTuple): - """Tuple for mapping Smartthings capabilities to Home Assistant sensors.""" - - attribute: str - name: str - default_unit: str | None - device_class: SensorDeviceClass | None - state_class: SensorStateClass | None - entity_category: EntityCategory | None +def power_attributes(status: DeviceStatus) -> dict[str, Any]: + """Return the power attributes.""" + state = {} + for attribute in ("power_consumption_start", "power_consumption_end"): + value = getattr(status, attribute) + if value is not None: + state[attribute] = value + return state -CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { - Capability.activity_lighting_mode: [ - Map( - Attribute.lighting_mode, - "Activity Lighting Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.air_conditioner_mode: [ - Map( - Attribute.air_conditioner_mode, - "Air Conditioner Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.air_quality_sensor: [ - Map( - Attribute.air_quality, - "Air Quality", - "CAQI", - None, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.alarm: [Map(Attribute.alarm, "Alarm", None, None, None, None)], - Capability.audio_volume: [ - Map(Attribute.volume, "Volume", PERCENTAGE, None, None, None) - ], - Capability.battery: [ - Map( - Attribute.battery, - "Battery", - PERCENTAGE, - SensorDeviceClass.BATTERY, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.body_mass_index_measurement: [ - Map( - Attribute.bmi_measurement, - "Body Mass Index", - f"{UnitOfMass.KILOGRAMS}/{UnitOfArea.SQUARE_METERS}", - None, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.body_weight_measurement: [ - Map( - Attribute.body_weight_measurement, - "Body Weight", - UnitOfMass.KILOGRAMS, - SensorDeviceClass.WEIGHT, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.carbon_dioxide_measurement: [ - Map( - Attribute.carbon_dioxide, - "Carbon Dioxide Measurement", - CONCENTRATION_PARTS_PER_MILLION, - SensorDeviceClass.CO2, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.carbon_monoxide_detector: [ - Map( - Attribute.carbon_monoxide, - "Carbon Monoxide Detector", - None, - None, - None, - None, - ) - ], - Capability.carbon_monoxide_measurement: [ - Map( - Attribute.carbon_monoxide_level, - "Carbon Monoxide Measurement", - CONCENTRATION_PARTS_PER_MILLION, - SensorDeviceClass.CO, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.dishwasher_operating_state: [ - Map( - Attribute.machine_state, "Dishwasher Machine State", None, None, None, None - ), - Map( - Attribute.dishwasher_job_state, - "Dishwasher Job State", - None, - None, - None, - None, - ), - Map( - Attribute.completion_time, - "Dishwasher Completion Time", - None, - SensorDeviceClass.TIMESTAMP, - None, - None, - ), - ], - Capability.dryer_mode: [ - Map( - Attribute.dryer_mode, - "Dryer Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.dryer_operating_state: [ - Map(Attribute.machine_state, "Dryer Machine State", None, None, None, None), - Map(Attribute.dryer_job_state, "Dryer Job State", None, None, None, None), - Map( - Attribute.completion_time, - "Dryer Completion Time", - None, - SensorDeviceClass.TIMESTAMP, - None, - None, - ), - ], - Capability.dust_sensor: [ - Map( - Attribute.fine_dust_level, - "Fine Dust Level", - None, - None, - SensorStateClass.MEASUREMENT, - None, - ), - Map( - Attribute.dust_level, - "Dust Level", - None, - None, - SensorStateClass.MEASUREMENT, - None, - ), - ], - Capability.energy_meter: [ - Map( - Attribute.energy, - "Energy Meter", - UnitOfEnergy.KILO_WATT_HOUR, - SensorDeviceClass.ENERGY, - SensorStateClass.TOTAL_INCREASING, - None, - ) - ], - Capability.equivalent_carbon_dioxide_measurement: [ - Map( - Attribute.equivalent_carbon_dioxide_measurement, - "Equivalent Carbon Dioxide Measurement", - CONCENTRATION_PARTS_PER_MILLION, - SensorDeviceClass.CO2, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.formaldehyde_measurement: [ - Map( - Attribute.formaldehyde_level, - "Formaldehyde Measurement", - CONCENTRATION_PARTS_PER_MILLION, - None, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.gas_meter: [ - Map( - Attribute.gas_meter, - "Gas Meter", - UnitOfEnergy.KILO_WATT_HOUR, - SensorDeviceClass.ENERGY, - SensorStateClass.MEASUREMENT, - None, - ), - Map( - Attribute.gas_meter_calorific, "Gas Meter Calorific", None, None, None, None - ), - Map( - Attribute.gas_meter_time, - "Gas Meter Time", - None, - SensorDeviceClass.TIMESTAMP, - None, - None, - ), - Map( - Attribute.gas_meter_volume, - "Gas Meter Volume", - UnitOfVolume.CUBIC_METERS, - SensorDeviceClass.GAS, - SensorStateClass.MEASUREMENT, - None, - ), - ], - Capability.illuminance_measurement: [ - Map( - Attribute.illuminance, - "Illuminance", - LIGHT_LUX, - SensorDeviceClass.ILLUMINANCE, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.infrared_level: [ - Map( - Attribute.infrared_level, - "Infrared Level", - PERCENTAGE, - None, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.media_input_source: [ - Map(Attribute.input_source, "Media Input Source", None, None, None, None) - ], - Capability.media_playback_repeat: [ - Map( - Attribute.playback_repeat_mode, - "Media Playback Repeat", - None, - None, - None, - None, - ) - ], - Capability.media_playback_shuffle: [ - Map( - Attribute.playback_shuffle, "Media Playback Shuffle", None, None, None, None - ) - ], - Capability.media_playback: [ - Map(Attribute.playback_status, "Media Playback Status", None, None, None, None) - ], - Capability.odor_sensor: [ - Map(Attribute.odor_level, "Odor Sensor", None, None, None, None) - ], - Capability.oven_mode: [ - Map( - Attribute.oven_mode, - "Oven Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.oven_operating_state: [ - Map(Attribute.machine_state, "Oven Machine State", None, None, None, None), - Map(Attribute.oven_job_state, "Oven Job State", None, None, None, None), - Map(Attribute.completion_time, "Oven Completion Time", None, None, None, None), - ], - Capability.oven_setpoint: [ - Map(Attribute.oven_setpoint, "Oven Set Point", None, None, None, None) - ], - Capability.power_consumption_report: [], - Capability.power_meter: [ - Map( - Attribute.power, - "Power Meter", - UnitOfPower.WATT, - SensorDeviceClass.POWER, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.power_source: [ - Map( - Attribute.power_source, - "Power Source", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.refrigeration_setpoint: [ - Map( - Attribute.refrigeration_setpoint, - "Refrigeration Setpoint", - None, - SensorDeviceClass.TEMPERATURE, - None, - None, - ) - ], - Capability.relative_humidity_measurement: [ - Map( - Attribute.humidity, - "Relative Humidity Measurement", - PERCENTAGE, - SensorDeviceClass.HUMIDITY, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.robot_cleaner_cleaning_mode: [ - Map( - Attribute.robot_cleaner_cleaning_mode, - "Robot Cleaner Cleaning Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.robot_cleaner_movement: [ - Map( - Attribute.robot_cleaner_movement, - "Robot Cleaner Movement", - None, - None, - None, - None, - ) - ], - Capability.robot_cleaner_turbo_mode: [ - Map( - Attribute.robot_cleaner_turbo_mode, - "Robot Cleaner Turbo Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.signal_strength: [ - Map( - Attribute.lqi, - "LQI Signal Strength", - None, - None, - SensorStateClass.MEASUREMENT, - EntityCategory.DIAGNOSTIC, - ), - Map( - Attribute.rssi, - "RSSI Signal Strength", - None, - SensorDeviceClass.SIGNAL_STRENGTH, - SensorStateClass.MEASUREMENT, - EntityCategory.DIAGNOSTIC, - ), - ], - Capability.smoke_detector: [ - Map(Attribute.smoke, "Smoke Detector", None, None, None, None) - ], - Capability.temperature_measurement: [ - Map( - Attribute.temperature, - "Temperature Measurement", - None, - SensorDeviceClass.TEMPERATURE, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.thermostat_cooling_setpoint: [ - Map( - Attribute.cooling_setpoint, - "Thermostat Cooling Setpoint", - None, - SensorDeviceClass.TEMPERATURE, - None, - None, - ) - ], - Capability.thermostat_fan_mode: [ - Map( - Attribute.thermostat_fan_mode, - "Thermostat Fan Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.thermostat_heating_setpoint: [ - Map( - Attribute.heating_setpoint, - "Thermostat Heating Setpoint", - None, - SensorDeviceClass.TEMPERATURE, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.thermostat_mode: [ - Map( - Attribute.thermostat_mode, - "Thermostat Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.thermostat_operating_state: [ - Map( - Attribute.thermostat_operating_state, - "Thermostat Operating State", - None, - None, - None, - None, - ) - ], - Capability.thermostat_setpoint: [ - Map( - Attribute.thermostat_setpoint, - "Thermostat Setpoint", - None, - SensorDeviceClass.TEMPERATURE, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.three_axis: [], - Capability.tv_channel: [ - Map(Attribute.tv_channel, "Tv Channel", None, None, None, None), - Map(Attribute.tv_channel_name, "Tv Channel Name", None, None, None, None), - ], - Capability.tvoc_measurement: [ - Map( - Attribute.tvoc_level, - "Tvoc Measurement", - CONCENTRATION_PARTS_PER_MILLION, - None, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.ultraviolet_index: [ - Map( - Attribute.ultraviolet_index, - "Ultraviolet Index", - None, - None, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.voltage_measurement: [ - Map( - Attribute.voltage, - "Voltage Measurement", - UnitOfElectricPotential.VOLT, - SensorDeviceClass.VOLTAGE, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.washer_mode: [ - Map( - Attribute.washer_mode, - "Washer Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.washer_operating_state: [ - Map(Attribute.machine_state, "Washer Machine State", None, None, None, None), - Map(Attribute.washer_job_state, "Washer Job State", None, None, None, None), - Map( - Attribute.completion_time, - "Washer Completion Time", - None, - SensorDeviceClass.TIMESTAMP, - None, - None, - ), - ], +@dataclass(frozen=True, kw_only=True) +class SmartThingsSensorEntityDescription(SensorEntityDescription): + """Describe a SmartThings sensor entity.""" + + value_fn: Callable[[Any], str | float | int | datetime | None] = lambda value: value + extra_state_attributes_fn: Callable[[DeviceStatus], dict[str, Any]] | None = None + unique_id_separator: str = "." + + +CAPABILITY_TO_SENSORS: dict[ + str, dict[str, list[SmartThingsSensorEntityDescription]] +] = { + Capability.activity_lighting_mode: { + Attribute.lighting_mode: [ + SmartThingsSensorEntityDescription( + key=Attribute.lighting_mode, + name="Activity Lighting Mode", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.air_conditioner_mode: { + Attribute.air_conditioner_mode: [ + SmartThingsSensorEntityDescription( + key=Attribute.air_conditioner_mode, + name="Air Conditioner Mode", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.air_quality_sensor: { + Attribute.air_quality: [ + SmartThingsSensorEntityDescription( + key=Attribute.air_quality, + name="Air Quality", + native_unit_of_measurement="CAQI", + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.alarm: { + Attribute.alarm: [ + SmartThingsSensorEntityDescription( + key=Attribute.alarm, + name="Alarm", + ) + ] + }, + Capability.audio_volume: { + Attribute.volume: [ + SmartThingsSensorEntityDescription( + key=Attribute.volume, + name="Volume", + native_unit_of_measurement=PERCENTAGE, + ) + ] + }, + Capability.battery: { + Attribute.battery: [ + SmartThingsSensorEntityDescription( + key=Attribute.battery, + name="Battery", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.body_mass_index_measurement: { + Attribute.bmi_measurement: [ + SmartThingsSensorEntityDescription( + key=Attribute.bmi_measurement, + name="Body Mass Index", + native_unit_of_measurement=f"{UnitOfMass.KILOGRAMS}/{UnitOfArea.SQUARE_METERS}", + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.body_weight_measurement: { + Attribute.body_weight_measurement: [ + SmartThingsSensorEntityDescription( + key=Attribute.body_weight_measurement, + name="Body Weight", + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + device_class=SensorDeviceClass.WEIGHT, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.carbon_dioxide_measurement: { + Attribute.carbon_dioxide: [ + SmartThingsSensorEntityDescription( + key=Attribute.carbon_dioxide, + name="Carbon Dioxide", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.carbon_monoxide_detector: { + Attribute.carbon_monoxide: [ + SmartThingsSensorEntityDescription( + key=Attribute.carbon_monoxide, + name="Carbon Monoxide Detector", + ) + ] + }, + Capability.carbon_monoxide_measurement: { + Attribute.carbon_monoxide_level: [ + SmartThingsSensorEntityDescription( + key=Attribute.carbon_monoxide_level, + name="Carbon Monoxide Level", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.CO, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.dishwasher_operating_state: { + Attribute.machine_state: [ + SmartThingsSensorEntityDescription( + key=Attribute.machine_state, + name="Dishwasher Machine State", + ) + ], + Attribute.dishwasher_job_state: [ + SmartThingsSensorEntityDescription( + key=Attribute.dishwasher_job_state, + name="Dishwasher Job State", + ) + ], + Attribute.completion_time: [ + SmartThingsSensorEntityDescription( + key=Attribute.completion_time, + name="Dishwasher Completion Time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=dt_util.parse_datetime, + ) + ], + }, + Capability.dryer_mode: { + Attribute.dryer_mode: [ + SmartThingsSensorEntityDescription( + key=Attribute.dryer_mode, + name="Dryer Mode", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.dryer_operating_state: { + Attribute.machine_state: [ + SmartThingsSensorEntityDescription( + key=Attribute.machine_state, + name="Dryer Machine State", + ) + ], + Attribute.dryer_job_state: [ + SmartThingsSensorEntityDescription( + key=Attribute.dryer_job_state, + name="Dryer Job State", + ) + ], + Attribute.completion_time: [ + SmartThingsSensorEntityDescription( + key=Attribute.completion_time, + name="Dryer Completion Time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=dt_util.parse_datetime, + ) + ], + }, + Capability.dust_sensor: { + Attribute.fine_dust_level: [ + SmartThingsSensorEntityDescription( + key=Attribute.fine_dust_level, + name="Fine Dust Level", + state_class=SensorStateClass.MEASUREMENT, + ) + ], + Attribute.dust_level: [ + SmartThingsSensorEntityDescription( + key=Attribute.dust_level, + name="Dust Level", + state_class=SensorStateClass.MEASUREMENT, + ) + ], + }, + Capability.energy_meter: { + Attribute.energy: [ + SmartThingsSensorEntityDescription( + key=Attribute.energy, + name="Energy Meter", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ) + ] + }, + Capability.equivalent_carbon_dioxide_measurement: { + Attribute.equivalent_carbon_dioxide_measurement: [ + SmartThingsSensorEntityDescription( + key=Attribute.equivalent_carbon_dioxide_measurement, + name="Equivalent Carbon Dioxide Measurement", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.formaldehyde_measurement: { + Attribute.formaldehyde_level: [ + SmartThingsSensorEntityDescription( + key=Attribute.formaldehyde_level, + name="Formaldehyde Measurement", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.gas_meter: { + Attribute.gas_meter: [ + SmartThingsSensorEntityDescription( + key=Attribute.gas_meter, + name="Gas Meter", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.MEASUREMENT, + ) + ], + Attribute.gas_meter_calorific: [ + SmartThingsSensorEntityDescription( + key=Attribute.gas_meter_calorific, + name="Gas Meter Calorific", + ) + ], + Attribute.gas_meter_time: [ + SmartThingsSensorEntityDescription( + key=Attribute.gas_meter_time, + name="Gas Meter Time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=dt_util.parse_datetime, + ) + ], + Attribute.gas_meter_volume: [ + SmartThingsSensorEntityDescription( + key=Attribute.gas_meter_volume, + name="Gas Meter Volume", + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.MEASUREMENT, + ) + ], + }, + Capability.illuminance_measurement: { + Attribute.illuminance: [ + SmartThingsSensorEntityDescription( + key=Attribute.illuminance, + name="Illuminance", + native_unit_of_measurement=LIGHT_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, + ) + ] + }, + Capability.infrared_level: { + Attribute.infrared_level: [ + SmartThingsSensorEntityDescription( + key=Attribute.infrared_level, + name="Infrared Level", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.media_input_source: { + Attribute.input_source: [ + SmartThingsSensorEntityDescription( + key=Attribute.input_source, + name="Media Input Source", + ) + ] + }, + Capability.media_playback_repeat: { + Attribute.playback_repeat_mode: [ + SmartThingsSensorEntityDescription( + key=Attribute.playback_repeat_mode, + name="Media Playback Repeat", + ) + ] + }, + Capability.media_playback_shuffle: { + Attribute.playback_shuffle: [ + SmartThingsSensorEntityDescription( + key=Attribute.playback_shuffle, + name="Media Playback Shuffle", + ) + ] + }, + Capability.media_playback: { + Attribute.playback_status: [ + SmartThingsSensorEntityDescription( + key=Attribute.playback_status, + name="Media Playback Status", + ) + ] + }, + Capability.odor_sensor: { + Attribute.odor_level: [ + SmartThingsSensorEntityDescription( + key=Attribute.odor_level, + name="Odor Sensor", + ) + ] + }, + Capability.oven_mode: { + Attribute.oven_mode: [ + SmartThingsSensorEntityDescription( + key=Attribute.oven_mode, + name="Oven Mode", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.oven_operating_state: { + Attribute.machine_state: [ + SmartThingsSensorEntityDescription( + key=Attribute.machine_state, + name="Oven Machine State", + ) + ], + Attribute.oven_job_state: [ + SmartThingsSensorEntityDescription( + key=Attribute.oven_job_state, + name="Oven Job State", + ) + ], + Attribute.completion_time: [ + SmartThingsSensorEntityDescription( + key=Attribute.completion_time, + name="Oven Completion Time", + ) + ], + }, + Capability.oven_setpoint: { + Attribute.oven_setpoint: [ + SmartThingsSensorEntityDescription( + key=Attribute.oven_setpoint, + name="Oven Set Point", + ) + ] + }, + Capability.power_consumption_report: { + Attribute.power_consumption: [ + SmartThingsSensorEntityDescription( + key="energy_meter", + name="energy", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_fn=lambda value: ( + val / 1000 if (val := value.get("energy")) is not None else None + ), + ), + SmartThingsSensorEntityDescription( + key="power_meter", + name="power", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + value_fn=lambda value: value.get("power"), + extra_state_attributes_fn=power_attributes, + ), + SmartThingsSensorEntityDescription( + key="deltaEnergy_meter", + name="deltaEnergy", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_fn=lambda value: ( + val / 1000 + if (val := value.get("deltaEnergy")) is not None + else None + ), + ), + SmartThingsSensorEntityDescription( + key="powerEnergy_meter", + name="powerEnergy", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_fn=lambda value: ( + val / 1000 + if (val := value.get("powerEnergy")) is not None + else None + ), + ), + SmartThingsSensorEntityDescription( + key="energySaved_meter", + name="energySaved", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_fn=lambda value: ( + val / 1000 + if (val := value.get("energySaved")) is not None + else None + ), + ), + ] + }, + Capability.power_meter: { + Attribute.power: [ + SmartThingsSensorEntityDescription( + key=Attribute.power, + name="Power Meter", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.power_source: { + Attribute.power_source: [ + SmartThingsSensorEntityDescription( + key=Attribute.power_source, + name="Power Source", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.refrigeration_setpoint: { + Attribute.refrigeration_setpoint: [ + SmartThingsSensorEntityDescription( + key=Attribute.refrigeration_setpoint, + name="Refrigeration Setpoint", + ) + ] + }, + Capability.relative_humidity_measurement: { + Attribute.humidity: [ + SmartThingsSensorEntityDescription( + key=Attribute.humidity, + name="Relative Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.robot_cleaner_cleaning_mode: { + Attribute.robot_cleaner_cleaning_mode: [ + SmartThingsSensorEntityDescription( + key=Attribute.robot_cleaner_cleaning_mode, + name="Robot Cleaner Cleaning Mode", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.robot_cleaner_movement: { + Attribute.robot_cleaner_movement: [ + SmartThingsSensorEntityDescription( + key=Attribute.robot_cleaner_movement, + name="Robot Cleaner Movement", + ) + ] + }, + Capability.robot_cleaner_turbo_mode: { + Attribute.robot_cleaner_turbo_mode: [ + SmartThingsSensorEntityDescription( + key=Attribute.robot_cleaner_turbo_mode, + name="Robot Cleaner Turbo Mode", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.signal_strength: { + Attribute.lqi: [ + SmartThingsSensorEntityDescription( + key=Attribute.lqi, + name="LQI Signal Strength", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ) + ], + Attribute.rssi: [ + SmartThingsSensorEntityDescription( + key=Attribute.rssi, + name="RSSI Signal Strength", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ) + ], + }, + Capability.smoke_detector: { + Attribute.smoke: [ + SmartThingsSensorEntityDescription( + key=Attribute.smoke, + name="Smoke Detector", + ) + ] + }, + Capability.temperature_measurement: { + Attribute.temperature: [ + SmartThingsSensorEntityDescription( + key=Attribute.temperature, + name="Temperature Measurement", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.thermostat_cooling_setpoint: { + Attribute.cooling_setpoint: [ + SmartThingsSensorEntityDescription( + key=Attribute.cooling_setpoint, + name="Thermostat Cooling Setpoint", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ) + ] + }, + Capability.thermostat_fan_mode: { + Attribute.thermostat_fan_mode: [ + SmartThingsSensorEntityDescription( + key=Attribute.thermostat_fan_mode, + name="Thermostat Fan Mode", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.thermostat_heating_setpoint: { + Attribute.heating_setpoint: [ + SmartThingsSensorEntityDescription( + key=Attribute.heating_setpoint, + name="Thermostat Heating Setpoint", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.thermostat_mode: { + Attribute.thermostat_mode: [ + SmartThingsSensorEntityDescription( + key=Attribute.thermostat_mode, + name="Thermostat Mode", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.thermostat_operating_state: { + Attribute.thermostat_operating_state: [ + SmartThingsSensorEntityDescription( + key=Attribute.thermostat_operating_state, + name="Thermostat Operating State", + ) + ] + }, + Capability.thermostat_setpoint: { + Attribute.thermostat_setpoint: [ + SmartThingsSensorEntityDescription( + key=Attribute.thermostat_setpoint, + name="Thermostat Setpoint", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.three_axis: { + Attribute.three_axis: [ + SmartThingsSensorEntityDescription( + key="X Coordinate", + name="X Coordinate", + unique_id_separator=" ", + value_fn=lambda value: value[0], + ), + SmartThingsSensorEntityDescription( + key="Y Coordinate", + name="Y Coordinate", + unique_id_separator=" ", + value_fn=lambda value: value[1], + ), + SmartThingsSensorEntityDescription( + key="Z Coordinate", + name="Z Coordinate", + unique_id_separator=" ", + value_fn=lambda value: value[2], + ), + ] + }, + Capability.tv_channel: { + Attribute.tv_channel: [ + SmartThingsSensorEntityDescription( + key=Attribute.tv_channel, + name="Tv Channel", + ) + ], + Attribute.tv_channel_name: [ + SmartThingsSensorEntityDescription( + key=Attribute.tv_channel_name, + name="Tv Channel Name", + ) + ], + }, + Capability.tvoc_measurement: { + Attribute.tvoc_level: [ + SmartThingsSensorEntityDescription( + key=Attribute.tvoc_level, + name="Tvoc Measurement", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.ultraviolet_index: { + Attribute.ultraviolet_index: [ + SmartThingsSensorEntityDescription( + key=Attribute.ultraviolet_index, + name="Ultraviolet Index", + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.voltage_measurement: { + Attribute.voltage: [ + SmartThingsSensorEntityDescription( + key=Attribute.voltage, + name="Voltage Measurement", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.washer_mode: { + Attribute.washer_mode: [ + SmartThingsSensorEntityDescription( + key=Attribute.washer_mode, + name="Washer Mode", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.washer_operating_state: { + Attribute.machine_state: [ + SmartThingsSensorEntityDescription( + key=Attribute.machine_state, + name="Washer Machine State", + ) + ], + Attribute.washer_job_state: [ + SmartThingsSensorEntityDescription( + key=Attribute.washer_job_state, + name="Washer Job State", + ) + ], + Attribute.completion_time: [ + SmartThingsSensorEntityDescription( + key=Attribute.completion_time, + name="Washer Completion Time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=dt_util.parse_datetime, + ) + ], + }, } UNITS = { "C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT, "lux": LIGHT_LUX, + "mG": None, # Three axis sensors never had a unit, so this removes it for now } -THREE_AXIS_NAMES = ["X Coordinate", "Y Coordinate", "Z Coordinate"] -POWER_CONSUMPTION_REPORT_NAMES = [ - "energy", - "power", - "deltaEnergy", - "powerEnergy", - "energySaved", -] - async def async_setup_entry( hass: HomeAssistant, @@ -567,59 +733,13 @@ async def async_setup_entry( ) -> None: """Add sensors for a config entry.""" broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] - entities: list[SensorEntity] = [] - for device in broker.devices.values(): - for capability in broker.get_assigned(device.device_id, "sensor"): - if capability == Capability.three_axis: - entities.extend( - [ - SmartThingsThreeAxisSensor(device, index) - for index in range(len(THREE_AXIS_NAMES)) - ] - ) - elif capability == Capability.power_consumption_report: - entities.extend( - [ - SmartThingsPowerConsumptionSensor(device, report_name) - for report_name in POWER_CONSUMPTION_REPORT_NAMES - ] - ) - else: - maps = CAPABILITY_TO_SENSORS[capability] - entities.extend( - [ - SmartThingsSensor( - device, - m.attribute, - m.name, - m.default_unit, - m.device_class, - m.state_class, - m.entity_category, - ) - for m in maps - ] - ) - - if broker.any_assigned(device.device_id, "switch"): - for capability in (Capability.energy_meter, Capability.power_meter): - maps = CAPABILITY_TO_SENSORS[capability] - entities.extend( - [ - SmartThingsSensor( - device, - m.attribute, - m.name, - m.default_unit, - m.device_class, - m.state_class, - m.entity_category, - ) - for m in maps - ] - ) - - async_add_entities(entities) + async_add_entities( + SmartThingsSensor(device, attribute, description) + for device in broker.devices.values() + for capability in broker.get_assigned(device.device_id, "sensor") + for attribute, descriptions in CAPABILITY_TO_SENSORS[capability].items() + for description in descriptions + ) def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: @@ -632,107 +752,43 @@ def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: class SmartThingsSensor(SmartThingsEntity, SensorEntity): """Define a SmartThings Sensor.""" + entity_description: SmartThingsSensorEntityDescription + def __init__( self, device: DeviceEntity, attribute: str, - name: str, - default_unit: str | None, - device_class: SensorDeviceClass | None, - state_class: str | None, - entity_category: EntityCategory | None, + entity_description: SmartThingsSensorEntityDescription, ) -> None: """Init the class.""" super().__init__(device) self._attribute = attribute - self._attr_name = f"{device.label} {name}" - self._attr_unique_id = f"{device.device_id}.{attribute}" - self._attr_device_class = device_class - self._default_unit = default_unit - self._attr_state_class = state_class - self._attr_entity_category = entity_category + self._attr_name = f"{device.label} {entity_description.name}" + self._attr_unique_id = f"{device.device_id}{entity_description.unique_id_separator}{entity_description.key}" + self.entity_description = entity_description @property - def native_value(self): + def native_value(self) -> str | float | int | datetime | None: """Return the state of the sensor.""" - value = self._device.status.attributes[self._attribute].value - - if self.device_class != SensorDeviceClass.TIMESTAMP: - return value - - return dt_util.parse_datetime(value) + return self.entity_description.value_fn( + self._device.status.attributes[self._attribute].value + ) @property def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" unit = self._device.status.attributes[self._attribute].unit - return UNITS.get(unit, unit) if unit else self._default_unit - - -class SmartThingsThreeAxisSensor(SmartThingsEntity, SensorEntity): - """Define a SmartThings Three Axis Sensor.""" - - def __init__(self, device, index): - """Init the class.""" - super().__init__(device) - self._index = index - self._attr_name = f"{device.label} {THREE_AXIS_NAMES[index]}" - self._attr_unique_id = f"{device.device_id} {THREE_AXIS_NAMES[index]}" + return ( + UNITS.get(unit, unit) + if unit + else self.entity_description.native_unit_of_measurement + ) @property - def native_value(self): - """Return the state of the sensor.""" - three_axis = self._device.status.attributes[Attribute.three_axis].value - try: - return three_axis[self._index] - except (TypeError, IndexError): - return None - - -class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity): - """Define a SmartThings Sensor.""" - - def __init__( - self, - device: DeviceEntity, - report_name: str, - ) -> None: - """Init the class.""" - super().__init__(device) - self.report_name = report_name - self._attr_name = f"{device.label} {report_name}" - self._attr_unique_id = f"{device.device_id}.{report_name}_meter" - if self.report_name == "power": - self._attr_state_class = SensorStateClass.MEASUREMENT - self._attr_device_class = SensorDeviceClass.POWER - self._attr_native_unit_of_measurement = UnitOfPower.WATT - else: - self._attr_state_class = SensorStateClass.TOTAL_INCREASING - self._attr_device_class = SensorDeviceClass.ENERGY - self._attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR - - @property - def native_value(self): - """Return the state of the sensor.""" - value = self._device.status.attributes[Attribute.power_consumption].value - if value is None or value.get(self.report_name) is None: - return None - if self.report_name == "power": - return value[self.report_name] - return value[self.report_name] / 1000 - - @property - def extra_state_attributes(self): - """Return specific state attributes.""" - if self.report_name == "power": - attributes = [ - "power_consumption_start", - "power_consumption_end", - ] - state_attributes = {} - for attribute in attributes: - value = getattr(self._device.status, attribute) - if value is not None: - state_attributes[attribute] = value - return state_attributes + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return the state attributes.""" + if self.entity_description.extra_state_attributes_fn: + return self.entity_description.extra_state_attributes_fn( + self._device.status + ) return None diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 7e6768e4d7d..a6a48202f1d 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -4,14 +4,9 @@ The only mocking required is of the underlying SmartThings API object so real HTTP calls are not initiated during testing. """ -from pysmartthings import ATTRIBUTES, CAPABILITIES, Attribute, Capability +from pysmartthings import Attribute, Capability -from homeassistant.components.sensor import ( - DEVICE_CLASSES, - DOMAIN as SENSOR_DOMAIN, - STATE_CLASSES, -) -from homeassistant.components.smartthings import sensor +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( @@ -29,20 +24,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from .conftest import setup_platform -async def test_mapping_integrity() -> None: - """Test ensures the map dicts have proper integrity.""" - for capability, maps in sensor.CAPABILITY_TO_SENSORS.items(): - assert capability in CAPABILITIES, capability - for sensor_map in maps: - assert sensor_map.attribute in ATTRIBUTES, sensor_map.attribute - if sensor_map.device_class: - assert sensor_map.device_class in DEVICE_CLASSES, ( - sensor_map.device_class - ) - if sensor_map.state_class: - assert sensor_map.state_class in STATE_CLASSES, sensor_map.state_class - - async def test_entity_state(hass: HomeAssistant, device_factory) -> None: """Tests the state attributes properly match the sensor types.""" device = device_factory("Sensor 1", [Capability.battery], {Attribute.battery: 100}) @@ -75,7 +56,9 @@ async def test_entity_three_axis_invalid_state( ) -> None: """Tests the state attributes properly match the three axis types.""" device = device_factory( - "Three Axis", [Capability.three_axis], {Attribute.three_axis: []} + "Three Axis", + [Capability.three_axis], + {Attribute.three_axis: [None, None, None]}, ) await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) state = hass.states.get("sensor.three_axis_x_coordinate")