From b0ff51b0ef794946ff6ecf7892778b85893c88c2 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 29 Jan 2019 07:25:36 +0000 Subject: [PATCH] Add an Integration sensor (#19703) * initial version * Tested * set state added * lint * lint * remove artifacts * Use Decimal instead of float * simplify * travis lint fix * addres comments by @ottowinter * remove job * better sanity check * lower error -> warning * hound * fix state validation * refactor energy -> integration * address @MartinHjelmare comments * new style string format * remove async_set_state * patching the source function --- .../components/sensor/integration.py | 172 ++++++++++++++++++ tests/components/sensor/test_integration.py | 104 +++++++++++ 2 files changed, 276 insertions(+) create mode 100644 homeassistant/components/sensor/integration.py create mode 100644 tests/components/sensor/test_integration.py diff --git a/homeassistant/components/sensor/integration.py b/homeassistant/components/sensor/integration.py new file mode 100644 index 00000000000..9426730be35 --- /dev/null +++ b/homeassistant/components/sensor/integration.py @@ -0,0 +1,172 @@ +""" +Numeric integration of data coming from a source sensor over time. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.integration/ +""" +import logging + +from decimal import Decimal, DecimalException +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_NAME, ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, STATE_UNAVAILABLE) +from homeassistant.core import callback +from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.restore_state import RestoreEntity + +_LOGGER = logging.getLogger(__name__) + +ATTR_SOURCE_ID = 'source' + +CONF_SOURCE_SENSOR = 'source' +CONF_ROUND_DIGITS = 'round' +CONF_UNIT_PREFIX = 'unit_prefix' +CONF_UNIT_TIME = 'unit_time' +CONF_UNIT_OF_MEASUREMENT = 'unit' + +# SI Metric prefixes +UNIT_PREFIXES = {None: 1, + "k": 10**3, + "G": 10**6, + "T": 10**9} + +# SI Time prefixes +UNIT_TIME = {'s': 1, + 'min': 60, + 'h': 60*60, + 'd': 24*60*60} + +ICON = 'mdi:char-histogram' + +DEFAULT_ROUND = 3 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_SOURCE_SENSOR): cv.entity_id, + vol.Optional(CONF_ROUND_DIGITS, default=DEFAULT_ROUND): vol.Coerce(int), + vol.Optional(CONF_UNIT_PREFIX, default=None): vol.In(UNIT_PREFIXES), + vol.Optional(CONF_UNIT_TIME, default='h'): vol.In(UNIT_TIME), + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string +}) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the integration sensor.""" + integral = IntegrationSensor(config[CONF_SOURCE_SENSOR], + config.get(CONF_NAME), + config[CONF_ROUND_DIGITS], + config[CONF_UNIT_PREFIX], + config[CONF_UNIT_TIME], + config.get(CONF_UNIT_OF_MEASUREMENT)) + + async_add_entities([integral]) + + +class IntegrationSensor(RestoreEntity): + """Representation of an integration sensor.""" + + def __init__(self, source_entity, name, round_digits, unit_prefix, + unit_time, unit_of_measurement): + """Initialize the integration sensor.""" + self._sensor_source_id = source_entity + self._round_digits = round_digits + self._state = 0 + + self._name = name if name is not None\ + else '{} integral'.format(source_entity) + + if unit_of_measurement is None: + self._unit_template = "{}{}{}".format( + "" if unit_prefix is None else unit_prefix, + "{}", + unit_time) + # we postpone the definition of unit_of_measurement to later + self._unit_of_measurement = None + else: + self._unit_of_measurement = unit_of_measurement + + self._unit_prefix = UNIT_PREFIXES[unit_prefix] + self._unit_time = UNIT_TIME[unit_time] + + async def async_added_to_hass(self): + """Handle entity which will be added.""" + await super().async_added_to_hass() + state = await self.async_get_last_state() + if state: + try: + self._state = Decimal(state.state) + except ValueError as err: + _LOGGER.warning("Could not restore last state: %s", err) + + @callback + def calc_integration(entity, old_state, new_state): + """Handle the sensor state changes.""" + if old_state is None or\ + old_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] or\ + new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: + return + + if self._unit_of_measurement is None: + unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + self._unit_of_measurement = self._unit_template.format( + "" if unit is None else unit) + + try: + # integration as the Riemann integral of previous measures. + elapsed_time = (new_state.last_updated + - old_state.last_updated).total_seconds() + area = (Decimal(new_state.state) + + Decimal(old_state.state))*Decimal(elapsed_time)/2 + integral = area / (self._unit_prefix * self._unit_time) + + assert isinstance(integral, Decimal) + except ValueError as err: + _LOGGER.warning("While calculating integration: %s", err) + except DecimalException as err: + _LOGGER.warning("Invalid state (%s > %s): %s", + old_state.state, new_state.state, err) + except AssertionError as err: + _LOGGER.error("Could not calculate integral: %s", err) + else: + self._state += integral + self.async_schedule_update_ha_state() + + async_track_state_change( + self.hass, self._sensor_source_id, calc_integration) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return round(self._state, self._round_digits) + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit_of_measurement + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + state_attr = { + ATTR_SOURCE_ID: self._sensor_source_id, + } + return state_attr + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return ICON diff --git a/tests/components/sensor/test_integration.py b/tests/components/sensor/test_integration.py new file mode 100644 index 00000000000..bb4a02c042b --- /dev/null +++ b/tests/components/sensor/test_integration.py @@ -0,0 +1,104 @@ +"""The tests for the integration sensor platform.""" +from datetime import timedelta +from unittest.mock import patch + +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + + +async def test_state(hass): + """Test integration sensor state.""" + config = { + 'sensor': { + 'platform': 'integration', + 'name': 'integration', + 'source': 'sensor.power', + 'unit': 'kWh', + 'round': 2, + } + } + + assert await async_setup_component(hass, 'sensor', config) + + entity_id = config['sensor']['source'] + hass.states.async_set(entity_id, 1, {}) + await hass.async_block_till_done() + + now = dt_util.utcnow() + timedelta(seconds=3600) + with patch('homeassistant.util.dt.utcnow', + return_value=now): + hass.states.async_set(entity_id, 1, {}, force_update=True) + await hass.async_block_till_done() + + state = hass.states.get('sensor.integration') + assert state is not None + + # Testing a power sensor at 1 KiloWatts for 1hour = 1kWh + assert round(float(state.state), config['sensor']['round']) == 1.0 + + assert state.attributes.get('unit_of_measurement') == 'kWh' + + +async def test_prefix(hass): + """Test integration sensor state using a power source.""" + config = { + 'sensor': { + 'platform': 'integration', + 'name': 'integration', + 'source': 'sensor.power', + 'round': 2, + 'unit_prefix': 'k' + } + } + + assert await async_setup_component(hass, 'sensor', config) + + entity_id = config['sensor']['source'] + hass.states.async_set(entity_id, 1000, {'unit_of_measurement': 'W'}) + await hass.async_block_till_done() + + now = dt_util.utcnow() + timedelta(seconds=3600) + with patch('homeassistant.util.dt.utcnow', + return_value=now): + hass.states.async_set(entity_id, 1000, {'unit_of_measurement': 'W'}, + force_update=True) + await hass.async_block_till_done() + + state = hass.states.get('sensor.integration') + assert state is not None + + # Testing a power sensor at 1000 Watts for 1hour = 1kWh + assert round(float(state.state), config['sensor']['round']) == 1.0 + assert state.attributes.get('unit_of_measurement') == 'kWh' + + +async def test_suffix(hass): + """Test integration sensor state using a network counter source.""" + config = { + 'sensor': { + 'platform': 'integration', + 'name': 'integration', + 'source': 'sensor.bytes_per_second', + 'round': 2, + 'unit_prefix': 'k', + 'unit_time': 's' + } + } + + assert await async_setup_component(hass, 'sensor', config) + + entity_id = config['sensor']['source'] + hass.states.async_set(entity_id, 1000, {}) + await hass.async_block_till_done() + + now = dt_util.utcnow() + timedelta(seconds=10) + with patch('homeassistant.util.dt.utcnow', + return_value=now): + hass.states.async_set(entity_id, 1000, {}, force_update=True) + await hass.async_block_till_done() + + state = hass.states.get('sensor.integration') + assert state is not None + + # Testing a network speed sensor at 1000 bytes/s over 10s = 10kbytes + assert round(float(state.state), config['sensor']['round']) == 10.0