From 2b2cddb5f0024c03aacad0f6053afc90f994764f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 30 Jul 2021 06:50:02 +0200 Subject: [PATCH] Extract smartthings switch energy attributes into sensors (#53719) --- .../components/smartthings/__init__.py | 3 +- .../components/smartthings/sensor.py | 263 ++++++++++++++---- .../components/smartthings/switch.py | 12 +- tests/components/smartthings/test_sensor.py | 46 ++- tests/components/smartthings/test_switch.py | 8 +- 5 files changed, 257 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 231cfa95263..bc64b173f20 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -9,6 +9,7 @@ import logging from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError from pysmartapp.event import EVENT_TYPE_DEVICE from pysmartthings import Attribute, Capability, SmartThings +from pysmartthings.device import DeviceEntity from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( @@ -412,7 +413,7 @@ class DeviceBroker: class SmartThingsEntity(Entity): """Defines a SmartThings entity.""" - def __init__(self, device): + def __init__(self, device: DeviceEntity) -> None: """Initialize the instance.""" self._device = device self._dispatcher_remove = None diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index b8bb071fc0a..7a7f9a51855 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -3,18 +3,26 @@ from __future__ import annotations from collections import namedtuple from collections.abc import Sequence +from datetime import datetime from pysmartthings import Attribute, Capability +from pysmartthings.device import DeviceEntity -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity from homeassistant.const import ( AREA_SQUARE_METERS, CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CO, + DEVICE_CLASS_CO2, + DEVICE_CLASS_ENERGY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_POWER, + DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, + DEVICE_CLASS_VOLTAGE, ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, LIGHT_LUX, @@ -25,26 +33,27 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, VOLUME_CUBIC_METERS, ) +from homeassistant.util.dt import utc_from_timestamp from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN -Map = namedtuple("map", "attribute name default_unit device_class") +Map = namedtuple("map", "attribute name default_unit device_class state_class") CAPABILITY_TO_SENSORS = { Capability.activity_lighting_mode: [ - Map(Attribute.lighting_mode, "Activity Lighting Mode", None, None) + Map(Attribute.lighting_mode, "Activity Lighting Mode", None, None, None) ], Capability.air_conditioner_mode: [ - Map(Attribute.air_conditioner_mode, "Air Conditioner Mode", None, None) + Map(Attribute.air_conditioner_mode, "Air Conditioner Mode", None, None, None) ], Capability.air_quality_sensor: [ - Map(Attribute.air_quality, "Air Quality", "CAQI", None) + Map(Attribute.air_quality, "Air Quality", "CAQI", None, STATE_CLASS_MEASUREMENT) ], - Capability.alarm: [Map(Attribute.alarm, "Alarm", None, None)], - Capability.audio_volume: [Map(Attribute.volume, "Volume", PERCENTAGE, None)], + Capability.alarm: [Map(Attribute.alarm, "Alarm", None, None, None)], + Capability.audio_volume: [Map(Attribute.volume, "Volume", PERCENTAGE, None, None)], Capability.battery: [ - Map(Attribute.battery, "Battery", PERCENTAGE, DEVICE_CLASS_BATTERY) + Map(Attribute.battery, "Battery", PERCENTAGE, DEVICE_CLASS_BATTERY, None) ], Capability.body_mass_index_measurement: [ Map( @@ -52,57 +61,80 @@ CAPABILITY_TO_SENSORS = { "Body Mass Index", f"{MASS_KILOGRAMS}/{AREA_SQUARE_METERS}", None, + STATE_CLASS_MEASUREMENT, ) ], Capability.body_weight_measurement: [ - Map(Attribute.body_weight_measurement, "Body Weight", MASS_KILOGRAMS, None) + Map( + Attribute.body_weight_measurement, + "Body Weight", + MASS_KILOGRAMS, + None, + STATE_CLASS_MEASUREMENT, + ) ], Capability.carbon_dioxide_measurement: [ Map( Attribute.carbon_dioxide, "Carbon Dioxide Measurement", CONCENTRATION_PARTS_PER_MILLION, - None, + DEVICE_CLASS_CO2, + STATE_CLASS_MEASUREMENT, ) ], Capability.carbon_monoxide_detector: [ - Map(Attribute.carbon_monoxide, "Carbon Monoxide Detector", None, None) + Map(Attribute.carbon_monoxide, "Carbon Monoxide Detector", None, None, None) ], Capability.carbon_monoxide_measurement: [ Map( Attribute.carbon_monoxide_level, "Carbon Monoxide Measurement", CONCENTRATION_PARTS_PER_MILLION, - None, + DEVICE_CLASS_CO, + STATE_CLASS_MEASUREMENT, ) ], Capability.dishwasher_operating_state: [ - Map(Attribute.machine_state, "Dishwasher Machine State", None, None), - Map(Attribute.dishwasher_job_state, "Dishwasher Job State", None, None), + Map(Attribute.machine_state, "Dishwasher Machine State", None, None, None), + Map(Attribute.dishwasher_job_state, "Dishwasher Job State", None, None, None), Map( Attribute.completion_time, "Dishwasher Completion Time", None, DEVICE_CLASS_TIMESTAMP, + None, ), ], - Capability.dryer_mode: [Map(Attribute.dryer_mode, "Dryer Mode", None, None)], + Capability.dryer_mode: [Map(Attribute.dryer_mode, "Dryer Mode", None, None, None)], Capability.dryer_operating_state: [ - Map(Attribute.machine_state, "Dryer Machine State", None, None), - Map(Attribute.dryer_job_state, "Dryer Job State", None, None), + Map(Attribute.machine_state, "Dryer Machine State", None, None, None), + Map(Attribute.dryer_job_state, "Dryer Job State", None, None, None), Map( Attribute.completion_time, "Dryer Completion Time", None, DEVICE_CLASS_TIMESTAMP, + None, ), ], Capability.dust_sensor: [ - Map(Attribute.fine_dust_level, "Fine Dust Level", None, None), - Map(Attribute.dust_level, "Dust Level", None, None), + Map( + Attribute.fine_dust_level, + "Fine Dust Level", + None, + None, + STATE_CLASS_MEASUREMENT, + ), + Map(Attribute.dust_level, "Dust Level", None, None, STATE_CLASS_MEASUREMENT), ], Capability.energy_meter: [ - Map(Attribute.energy, "Energy Meter", ENERGY_KILO_WATT_HOUR, None) + Map( + Attribute.energy, + "Energy Meter", + ENERGY_KILO_WATT_HOUR, + DEVICE_CLASS_ENERGY, + STATE_CLASS_MEASUREMENT, + ) ], Capability.equivalent_carbon_dioxide_measurement: [ Map( @@ -110,6 +142,7 @@ CAPABILITY_TO_SENSORS = { "Equivalent Carbon Dioxide Measurement", CONCENTRATION_PARTS_PER_MILLION, None, + STATE_CLASS_MEASUREMENT, ) ], Capability.formaldehyde_measurement: [ @@ -118,50 +151,94 @@ CAPABILITY_TO_SENSORS = { "Formaldehyde Measurement", CONCENTRATION_PARTS_PER_MILLION, None, + STATE_CLASS_MEASUREMENT, ) ], Capability.gas_meter: [ - Map(Attribute.gas_meter, "Gas Meter", ENERGY_KILO_WATT_HOUR, None), - Map(Attribute.gas_meter_calorific, "Gas Meter Calorific", None, None), - Map(Attribute.gas_meter_time, "Gas Meter Time", None, DEVICE_CLASS_TIMESTAMP), - Map(Attribute.gas_meter_volume, "Gas Meter Volume", VOLUME_CUBIC_METERS, None), + Map( + Attribute.gas_meter, + "Gas Meter", + ENERGY_KILO_WATT_HOUR, + None, + STATE_CLASS_MEASUREMENT, + ), + Map(Attribute.gas_meter_calorific, "Gas Meter Calorific", None, None, None), + Map( + Attribute.gas_meter_time, + "Gas Meter Time", + None, + DEVICE_CLASS_TIMESTAMP, + None, + ), + Map( + Attribute.gas_meter_volume, + "Gas Meter Volume", + VOLUME_CUBIC_METERS, + None, + STATE_CLASS_MEASUREMENT, + ), ], Capability.illuminance_measurement: [ - Map(Attribute.illuminance, "Illuminance", LIGHT_LUX, DEVICE_CLASS_ILLUMINANCE) + Map( + Attribute.illuminance, + "Illuminance", + LIGHT_LUX, + DEVICE_CLASS_ILLUMINANCE, + STATE_CLASS_MEASUREMENT, + ) ], Capability.infrared_level: [ - Map(Attribute.infrared_level, "Infrared Level", PERCENTAGE, None) + Map( + Attribute.infrared_level, + "Infrared Level", + PERCENTAGE, + None, + STATE_CLASS_MEASUREMENT, + ) ], Capability.media_input_source: [ - Map(Attribute.input_source, "Media Input Source", None, None) + Map(Attribute.input_source, "Media Input Source", None, None, None) ], Capability.media_playback_repeat: [ - Map(Attribute.playback_repeat_mode, "Media Playback Repeat", None, None) + Map(Attribute.playback_repeat_mode, "Media Playback Repeat", None, None, None) ], Capability.media_playback_shuffle: [ - Map(Attribute.playback_shuffle, "Media Playback Shuffle", None, None) + Map(Attribute.playback_shuffle, "Media Playback Shuffle", None, None, None) ], Capability.media_playback: [ - Map(Attribute.playback_status, "Media Playback Status", None, None) + Map(Attribute.playback_status, "Media Playback Status", None, None, None) ], - Capability.odor_sensor: [Map(Attribute.odor_level, "Odor Sensor", None, None)], - Capability.oven_mode: [Map(Attribute.oven_mode, "Oven Mode", None, None)], + Capability.odor_sensor: [ + Map(Attribute.odor_level, "Odor Sensor", None, None, None) + ], + Capability.oven_mode: [Map(Attribute.oven_mode, "Oven Mode", None, None, None)], Capability.oven_operating_state: [ - Map(Attribute.machine_state, "Oven Machine State", None, None), - Map(Attribute.oven_job_state, "Oven Job State", None, None), - Map(Attribute.completion_time, "Oven Completion Time", None, None), + Map(Attribute.machine_state, "Oven Machine State", None, None, None), + Map(Attribute.oven_job_state, "Oven Job State", None, None, None), + Map(Attribute.completion_time, "Oven Completion Time", None, None, None), ], Capability.oven_setpoint: [ - Map(Attribute.oven_setpoint, "Oven Set Point", None, None) + Map(Attribute.oven_setpoint, "Oven Set Point", None, None, None) + ], + Capability.power_meter: [ + Map( + Attribute.power, + "Power Meter", + POWER_WATT, + DEVICE_CLASS_POWER, + STATE_CLASS_MEASUREMENT, + ) + ], + Capability.power_source: [ + Map(Attribute.power_source, "Power Source", None, None, None) ], - Capability.power_meter: [Map(Attribute.power, "Power Meter", POWER_WATT, None)], - Capability.power_source: [Map(Attribute.power_source, "Power Source", None, None)], Capability.refrigeration_setpoint: [ Map( Attribute.refrigeration_setpoint, "Refrigeration Setpoint", None, DEVICE_CLASS_TEMPERATURE, + None, ) ], Capability.relative_humidity_measurement: [ @@ -170,6 +247,7 @@ CAPABILITY_TO_SENSORS = { "Relative Humidity Measurement", PERCENTAGE, DEVICE_CLASS_HUMIDITY, + STATE_CLASS_MEASUREMENT, ) ], Capability.robot_cleaner_cleaning_mode: [ @@ -178,25 +256,43 @@ CAPABILITY_TO_SENSORS = { "Robot Cleaner Cleaning Mode", None, None, + None, ) ], Capability.robot_cleaner_movement: [ - Map(Attribute.robot_cleaner_movement, "Robot Cleaner Movement", None, None) + Map( + Attribute.robot_cleaner_movement, "Robot Cleaner Movement", None, None, None + ) ], Capability.robot_cleaner_turbo_mode: [ - Map(Attribute.robot_cleaner_turbo_mode, "Robot Cleaner Turbo Mode", None, None) + Map( + Attribute.robot_cleaner_turbo_mode, + "Robot Cleaner Turbo Mode", + None, + None, + None, + ) ], Capability.signal_strength: [ - Map(Attribute.lqi, "LQI Signal Strength", None, None), - Map(Attribute.rssi, "RSSI Signal Strength", None, None), + Map(Attribute.lqi, "LQI Signal Strength", None, None, STATE_CLASS_MEASUREMENT), + Map( + Attribute.rssi, + "RSSI Signal Strength", + None, + DEVICE_CLASS_SIGNAL_STRENGTH, + STATE_CLASS_MEASUREMENT, + ), + ], + Capability.smoke_detector: [ + Map(Attribute.smoke, "Smoke Detector", None, None, None) ], - Capability.smoke_detector: [Map(Attribute.smoke, "Smoke Detector", None, None)], Capability.temperature_measurement: [ Map( Attribute.temperature, "Temperature Measurement", None, DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, ) ], Capability.thermostat_cooling_setpoint: [ @@ -205,10 +301,11 @@ CAPABILITY_TO_SENSORS = { "Thermostat Cooling Setpoint", None, DEVICE_CLASS_TEMPERATURE, + None, ) ], Capability.thermostat_fan_mode: [ - Map(Attribute.thermostat_fan_mode, "Thermostat Fan Mode", None, None) + Map(Attribute.thermostat_fan_mode, "Thermostat Fan Mode", None, None, None) ], Capability.thermostat_heating_setpoint: [ Map( @@ -216,10 +313,11 @@ CAPABILITY_TO_SENSORS = { "Thermostat Heating Setpoint", None, DEVICE_CLASS_TEMPERATURE, + None, ) ], Capability.thermostat_mode: [ - Map(Attribute.thermostat_mode, "Thermostat Mode", None, None) + Map(Attribute.thermostat_mode, "Thermostat Mode", None, None, None) ], Capability.thermostat_operating_state: [ Map( @@ -227,6 +325,7 @@ CAPABILITY_TO_SENSORS = { "Thermostat Operating State", None, None, + None, ) ], Capability.thermostat_setpoint: [ @@ -235,12 +334,13 @@ CAPABILITY_TO_SENSORS = { "Thermostat Setpoint", None, DEVICE_CLASS_TEMPERATURE, + None, ) ], Capability.three_axis: [], Capability.tv_channel: [ - Map(Attribute.tv_channel, "Tv Channel", None, None), - Map(Attribute.tv_channel_name, "Tv Channel Name", None, None), + Map(Attribute.tv_channel, "Tv Channel", None, None, None), + Map(Attribute.tv_channel_name, "Tv Channel Name", None, None, None), ], Capability.tvoc_measurement: [ Map( @@ -248,23 +348,39 @@ CAPABILITY_TO_SENSORS = { "Tvoc Measurement", CONCENTRATION_PARTS_PER_MILLION, None, + STATE_CLASS_MEASUREMENT, ) ], Capability.ultraviolet_index: [ - Map(Attribute.ultraviolet_index, "Ultraviolet Index", None, None) + Map( + Attribute.ultraviolet_index, + "Ultraviolet Index", + None, + None, + STATE_CLASS_MEASUREMENT, + ) ], Capability.voltage_measurement: [ - Map(Attribute.voltage, "Voltage Measurement", ELECTRIC_POTENTIAL_VOLT, None) + Map( + Attribute.voltage, + "Voltage Measurement", + ELECTRIC_POTENTIAL_VOLT, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + ) + ], + Capability.washer_mode: [ + Map(Attribute.washer_mode, "Washer Mode", None, None, None) ], - Capability.washer_mode: [Map(Attribute.washer_mode, "Washer Mode", None, None)], Capability.washer_operating_state: [ - Map(Attribute.machine_state, "Washer Machine State", None, None), - Map(Attribute.washer_job_state, "Washer Job State", None, None), + Map(Attribute.machine_state, "Washer Machine State", None, None, None), + Map(Attribute.washer_job_state, "Washer Job State", None, None, None), Map( Attribute.completion_time, "Washer Completion Time", None, DEVICE_CLASS_TIMESTAMP, + None, ), ], } @@ -292,11 +408,34 @@ async def async_setup_entry(hass, config_entry, async_add_entities): sensors.extend( [ SmartThingsSensor( - device, m.attribute, m.name, m.default_unit, m.device_class + device, + m.attribute, + m.name, + m.default_unit, + m.device_class, + m.state_class, ) 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] + sensors.extend( + [ + SmartThingsSensor( + device, + m.attribute, + m.name, + m.default_unit, + m.device_class, + m.state_class, + ) + for m in maps + ] + ) + async_add_entities(sensors) @@ -311,14 +450,21 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): """Define a SmartThings Sensor.""" def __init__( - self, device, attribute: str, name: str, default_unit: str, device_class: str - ): + self, + device: DeviceEntity, + attribute: str, + name: str, + default_unit: str, + device_class: str, + state_class: str | None, + ) -> None: """Init the class.""" super().__init__(device) self._attribute = attribute self._name = name self._device_class = device_class self._default_unit = default_unit + self._attr_state_class = state_class @property def name(self) -> str: @@ -346,6 +492,13 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): unit = self._device.status.attributes[self._attribute].unit return UNITS.get(unit, unit) if unit else self._default_unit + @property + def last_reset(self) -> datetime | None: + """Return the time when the sensor was last reset, if any.""" + if self._attribute == Attribute.energy: + return utc_from_timestamp(0) + return None + class SmartThingsThreeAxisSensor(SmartThingsEntity, SensorEntity): """Define a SmartThings Three Axis Sensor.""" diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index 7b8364d9ba3..7e92ba4f663 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Sequence -from pysmartthings import Attribute, Capability +from pysmartthings import Capability from homeassistant.components.switch import SwitchEntity @@ -48,16 +48,6 @@ class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): # the entity state ahead of receiving the confirming push updates self.async_write_ha_state() - @property - def current_power_w(self): - """Return the current power usage in W.""" - return self._device.status.attributes[Attribute.power].value - - @property - def today_energy_kwh(self): - """Return the today total energy usage in kWh.""" - return self._device.status.attributes[Attribute.energy].value - @property def is_on(self) -> bool: """Return true if light is on.""" diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 4af88e27fe4..ffb577c903a 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -6,7 +6,11 @@ real HTTP calls are not initiated during testing. """ from pysmartthings import ATTRIBUTES, CAPABILITIES, Attribute, Capability -from homeassistant.components.sensor import DEVICE_CLASSES, DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor import ( + DEVICE_CLASSES, + DOMAIN as SENSOR_DOMAIN, + STATE_CLASSES, +) from homeassistant.components.smartthings import sensor from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE from homeassistant.config_entries import ConfigEntryState @@ -33,6 +37,8 @@ async def test_mapping_integrity(): 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, device_factory): @@ -95,6 +101,44 @@ async def test_entity_and_device_attributes(hass, device_factory): assert entry.manufacturer == "Unavailable" +async def test_energy_sensors_for_switch_device(hass, device_factory): + """Test the attributes of the entity are correct.""" + # Arrange + device = device_factory( + "Switch_1", + [Capability.switch, Capability.power_meter, Capability.energy_meter], + {Attribute.switch: "off", Attribute.power: 355, Attribute.energy: 11.422}, + ) + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + # Act + await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) + # Assert + state = hass.states.get("sensor.switch_1_energy_meter") + assert state + assert state.state == "11.422" + entry = entity_registry.async_get("sensor.switch_1_energy_meter") + assert entry + assert entry.unique_id == f"{device.device_id}.{Attribute.energy}" + entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) + assert entry + assert entry.name == device.label + assert entry.model == device.device_type_name + assert entry.manufacturer == "Unavailable" + + state = hass.states.get("sensor.switch_1_power_meter") + assert state + assert state.state == "355" + entry = entity_registry.async_get("sensor.switch_1_power_meter") + assert entry + assert entry.unique_id == f"{device.device_id}.{Attribute.power}" + entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) + assert entry + assert entry.name == device.label + assert entry.model == device.device_type_name + assert entry.manufacturer == "Unavailable" + + async def test_update_from_signal(hass, device_factory): """Test the binary_sensor updates when receiving a signal.""" # Arrange diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py index 7c202fad12e..c884d601baf 100644 --- a/tests/components/smartthings/test_switch.py +++ b/tests/components/smartthings/test_switch.py @@ -7,11 +7,7 @@ real HTTP calls are not initiated during testing. from pysmartthings import Attribute, Capability from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.components.switch import ( - ATTR_CURRENT_POWER_W, - ATTR_TODAY_ENERGY_KWH, - DOMAIN as SWITCH_DOMAIN, -) +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -72,8 +68,6 @@ async def test_turn_on(hass, device_factory): state = hass.states.get("switch.switch_1") assert state is not None assert state.state == "on" - assert state.attributes[ATTR_CURRENT_POWER_W] == 355 - assert state.attributes[ATTR_TODAY_ENERGY_KWH] == 11.422 async def test_update_from_signal(hass, device_factory):