Utility meter (#19718)

* initial commit

* test service calls

* lint

* float -> Decimal

* extra tests

* lint

* lint

* lint

* lint

* fix self reset

* clean

* add services

* improve service example description

* add optional paused initialization

* fix

* travis fix

* fix YEARLY

* add tests for previous bug

* address comments and suggestions from @ottowinter

* lint

* remove debug

* add discoverability capabilities

* no need for _hass

* Update homeassistant/components/sensor/utility_meter.py

Co-Authored-By: dgomes <diogogomes@gmail.com>

* Update homeassistant/components/sensor/utility_meter.py

Co-Authored-By: dgomes <diogogomes@gmail.com>

* correct comment

* improve error handling

* address @MartinHjelmare comments

* address @MartinHjelmare comments

* one patch is enought

* follow @ballob suggestion in https://github.com/home-assistant/architecture/issues/131

* fix tests

* review fixes

* major refactor

* lint

* lint

* address comments by @MartinHjelmare

* rename variable
pull/20474/head
Diogo Gomes 2019-01-26 15:33:11 +00:00 committed by Fabian Affolter
parent ed6e349515
commit 1d5ffe9ad5
7 changed files with 713 additions and 0 deletions

View File

@ -0,0 +1,176 @@
"""
Component to track utility consumption over given periods of time.
For more details about this component, please refer to the documentation
at https://www.home-assistant.io/components/utility_meter/
"""
import logging
import voluptuous as vol
from homeassistant.const import (ATTR_ENTITY_ID, CONF_NAME)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import discovery
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from .const import (
DOMAIN, SIGNAL_RESET_METER, METER_TYPES, CONF_SOURCE_SENSOR,
CONF_METER_TYPE, CONF_METER_OFFSET, CONF_TARIFF_ENTITY, CONF_TARIFF,
CONF_TARIFFS, CONF_METER, DATA_UTILITY, SERVICE_RESET,
SERVICE_SELECT_TARIFF, SERVICE_SELECT_NEXT_TARIFF,
ATTR_TARIFF)
_LOGGER = logging.getLogger(__name__)
TARIFF_ICON = "mdi:clock-outline"
ATTR_TARIFFS = 'tariffs'
SERVICE_METER_SCHEMA = vol.Schema({
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
})
SERVICE_SELECT_TARIFF_SCHEMA = SERVICE_METER_SCHEMA.extend({
vol.Required(ATTR_TARIFF): cv.string
})
METER_CONFIG_SCHEMA = vol.Schema({
vol.Required(CONF_SOURCE_SENSOR): cv.entity_id,
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_METER_TYPE): vol.In(METER_TYPES),
vol.Optional(CONF_METER_OFFSET, default=0): cv.positive_int,
vol.Optional(CONF_TARIFFS, default=[]): vol.All(
cv.ensure_list, [cv.string]),
})
CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({
cv.slug: METER_CONFIG_SCHEMA,
}),
}, extra=vol.ALLOW_EXTRA)
async def async_setup(hass, config):
"""Set up an Utility Meter."""
component = EntityComponent(_LOGGER, DOMAIN, hass)
hass.data[DATA_UTILITY] = {}
for meter, conf in config.get(DOMAIN).items():
_LOGGER.debug("Setup %s.%s", DOMAIN, meter)
hass.data[DATA_UTILITY][meter] = conf
if not conf[CONF_TARIFFS]:
# only one entity is required
hass.async_create_task(discovery.async_load_platform(
hass, SENSOR_DOMAIN, DOMAIN,
[{CONF_METER: meter, CONF_NAME: meter}], config))
else:
# create tariff selection
await component.async_add_entities([
TariffSelect(meter, list(conf[CONF_TARIFFS]))
])
hass.data[DATA_UTILITY][meter][CONF_TARIFF_ENTITY] =\
"{}.{}".format(DOMAIN, meter)
# add one meter for each tariff
tariff_confs = []
for tariff in conf[CONF_TARIFFS]:
tariff_confs.append({
CONF_METER: meter,
CONF_NAME: "{} {}".format(meter, tariff),
CONF_TARIFF: tariff,
})
hass.async_create_task(discovery.async_load_platform(
hass, SENSOR_DOMAIN, DOMAIN, tariff_confs, config))
component.async_register_entity_service(
SERVICE_RESET, SERVICE_METER_SCHEMA,
'async_reset_meters'
)
component.async_register_entity_service(
SERVICE_SELECT_TARIFF, SERVICE_SELECT_TARIFF_SCHEMA,
'async_select_tariff'
)
component.async_register_entity_service(
SERVICE_SELECT_NEXT_TARIFF, SERVICE_METER_SCHEMA,
'async_next_tariff'
)
return True
class TariffSelect(RestoreEntity):
"""Representation of a Tariff selector."""
def __init__(self, name, tariffs):
"""Initialize a tariff selector."""
self._name = name
self._current_tariff = None
self._tariffs = tariffs
self._icon = TARIFF_ICON
async def async_added_to_hass(self):
"""Run when entity about to be added."""
await super().async_added_to_hass()
if self._current_tariff is not None:
return
state = await self.async_get_last_state()
if not state or state.state not in self._tariffs:
self._current_tariff = self._tariffs[0]
else:
self._current_tariff = state.state
@property
def should_poll(self):
"""If entity should be polled."""
return False
@property
def name(self):
"""Return the name of the select input."""
return self._name
@property
def icon(self):
"""Return the icon to be used for this entity."""
return self._icon
@property
def state(self):
"""Return the state of the component."""
return self._current_tariff
@property
def state_attributes(self):
"""Return the state attributes."""
return {
ATTR_TARIFFS: self._tariffs,
}
async def async_reset_meters(self):
"""Reset all sensors of this meter."""
async_dispatcher_send(self.hass, SIGNAL_RESET_METER,
self.entity_id)
async def async_select_tariff(self, tariff):
"""Select new option."""
if tariff not in self._tariffs:
_LOGGER.warning('Invalid tariff: %s (possible tariffs: %s)',
tariff, ', '.join(self._tariffs))
return
self._current_tariff = tariff
await self.async_update_ha_state()
async def async_next_tariff(self):
"""Offset current index."""
current_index = self._tariffs.index(self._current_tariff)
new_index = (current_index + 1) % len(self._tariffs)
self._current_tariff = self._tariffs[new_index]
await self.async_update_ha_state()

View File

@ -0,0 +1,30 @@
"""Constants for the utility meter component."""
DOMAIN = 'utility_meter'
HOURLY = 'hourly'
DAILY = 'daily'
WEEKLY = 'weekly'
MONTHLY = 'monthly'
YEARLY = 'yearly'
METER_TYPES = [HOURLY, DAILY, WEEKLY, MONTHLY, YEARLY]
DATA_UTILITY = 'utility_meter_data'
CONF_METER = 'meter'
CONF_SOURCE_SENSOR = 'source'
CONF_METER_TYPE = 'cycle'
CONF_METER_OFFSET = 'offset'
CONF_PAUSED = 'paused'
CONF_TARIFFS = 'tariffs'
CONF_TARIFF = 'tariff'
CONF_TARIFF_ENTITY = 'tariff_entity'
ATTR_TARIFF = 'tariff'
SIGNAL_START_PAUSE_METER = 'utility_meter_start_pause'
SIGNAL_RESET_METER = 'utility_meter_reset'
SERVICE_RESET = 'reset'
SERVICE_SELECT_TARIFF = 'select_tariff'
SERVICE_SELECT_NEXT_TARIFF = 'next_tariff'

View File

@ -0,0 +1,243 @@
"""
Utility meter from sensors providing raw data.
For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/sensor.utility_meter/
"""
import logging
from decimal import Decimal, DecimalException
import homeassistant.util.dt as dt_util
from homeassistant.const import (
CONF_NAME, ATTR_UNIT_OF_MEASUREMENT,
EVENT_HOMEASSISTANT_START, STATE_UNKNOWN, STATE_UNAVAILABLE)
from homeassistant.core import callback
from homeassistant.helpers.event import (
async_track_state_change, async_track_time_change)
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect)
from homeassistant.helpers.restore_state import RestoreEntity
from .const import (
DATA_UTILITY, SIGNAL_RESET_METER,
HOURLY, DAILY, WEEKLY, MONTHLY, YEARLY,
CONF_SOURCE_SENSOR, CONF_METER_TYPE, CONF_METER_OFFSET,
CONF_TARIFF, CONF_TARIFF_ENTITY, CONF_METER)
_LOGGER = logging.getLogger(__name__)
ATTR_SOURCE_ID = 'source'
ATTR_STATUS = 'status'
ATTR_PERIOD = 'meter_period'
ATTR_LAST_PERIOD = 'last_period'
ATTR_LAST_RESET = 'last_reset'
ATTR_TARIFF = 'tariff'
ICON = 'mdi:counter'
PRECISION = 3
PAUSED = 'paused'
COLLECTING = 'collecting'
async def async_setup_platform(hass, config, async_add_entities,
discovery_info=None):
"""Set up the utility meter sensor."""
if discovery_info is None:
_LOGGER.error("This platform is only available through discovery")
return
meters = []
for conf in discovery_info:
meter = conf[CONF_METER]
conf_meter_source = hass.data[DATA_UTILITY][meter][CONF_SOURCE_SENSOR]
conf_meter_type = hass.data[DATA_UTILITY][meter].get(CONF_METER_TYPE)
conf_meter_offset = hass.data[DATA_UTILITY][meter][CONF_METER_OFFSET]
conf_meter_tariff_entity = hass.data[DATA_UTILITY][meter].get(
CONF_TARIFF_ENTITY)
meters.append(UtilityMeterSensor(conf_meter_source,
conf.get(CONF_NAME),
conf_meter_type,
conf_meter_offset,
conf.get(CONF_TARIFF),
conf_meter_tariff_entity))
async_add_entities(meters)
class UtilityMeterSensor(RestoreEntity):
"""Representation of an utility meter sensor."""
def __init__(self, source_entity, name, meter_type, meter_offset=0,
tariff=None, tariff_entity=None):
"""Initialize the Utility Meter sensor."""
self._sensor_source_id = source_entity
self._state = 0
self._last_period = 0
self._last_reset = dt_util.now()
self._collecting = None
if name:
self._name = name
else:
self._name = '{} meter'.format(source_entity)
self._unit_of_measurement = None
self._period = meter_type
self._period_offset = meter_offset
self._tariff = tariff
self._tariff_entity = tariff_entity
@callback
def async_reading(self, entity, old_state, new_state):
"""Handle the sensor state changes."""
if any([old_state is None,
old_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE],
new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]]):
return
if self._unit_of_measurement is None and\
new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is not None:
self._unit_of_measurement = new_state.attributes.get(
ATTR_UNIT_OF_MEASUREMENT)
try:
diff = Decimal(new_state.state) - Decimal(old_state.state)
if diff < 0:
# Source sensor just rolled over for unknow reasons,
return
self._state += diff
except ValueError as err:
_LOGGER.warning("While processing state changes: %s", err)
except DecimalException as err:
_LOGGER.warning("Invalid state (%s > %s): %s",
old_state.state, new_state.state, err)
self.async_schedule_update_ha_state()
@callback
def async_tariff_change(self, entity, old_state, new_state):
"""Handle tariff changes."""
if self._tariff == new_state.state:
self._collecting = async_track_state_change(
self.hass, self._sensor_source_id, self.async_reading)
else:
self._collecting()
self._collecting = None
_LOGGER.debug("%s - %s - source <%s>", self._name,
COLLECTING if self._collecting is not None
else PAUSED, self._sensor_source_id)
self.async_schedule_update_ha_state()
async def _async_reset_meter(self, event):
"""Determine cycle - Helper function for larger then daily cycles."""
now = dt_util.now()
if self._period == WEEKLY and now.weekday() != self._period_offset:
return
if self._period == MONTHLY and\
now.day != (1 + self._period_offset):
return
if self._period == YEARLY and\
(now.month != (1 + self._period_offset) or now.day != 1):
return
await self.async_reset_meter(self._tariff_entity)
async def async_reset_meter(self, entity_id):
"""Reset meter."""
if self._tariff_entity != entity_id:
return
_LOGGER.debug("Reset utility meter <%s>", self.entity_id)
self._last_reset = dt_util.now()
self._last_period = str(self._state)
self._state = 0
await self.async_update_ha_state()
async def async_added_to_hass(self):
"""Handle entity which will be added."""
await super().async_added_to_hass()
if self._period == HOURLY:
async_track_time_change(self.hass, self._async_reset_meter,
minute=self._period_offset, second=0)
elif self._period == DAILY:
async_track_time_change(self.hass, self._async_reset_meter,
hour=self._period_offset, minute=0,
second=0)
elif self._period in [WEEKLY, MONTHLY, YEARLY]:
async_track_time_change(self.hass, self._async_reset_meter,
hour=0, minute=0, second=0)
async_dispatcher_connect(
self.hass, SIGNAL_RESET_METER, self.async_reset_meter)
state = await self.async_get_last_state()
if state:
self._state = Decimal(state.state)
self._unit_of_measurement = state.attributes.get(
ATTR_UNIT_OF_MEASUREMENT)
self._last_period = state.attributes.get(ATTR_LAST_PERIOD)
self._last_reset = state.attributes.get(ATTR_LAST_RESET)
await self.async_update_ha_state()
if state.attributes.get(ATTR_STATUS) == PAUSED:
# Fake cancelation function to init the meter paused
self._collecting = lambda: None
@callback
def async_source_tracking(event):
"""Wait for source to be ready, then start meter."""
if self._tariff_entity is not None:
_LOGGER.debug("track %s", self._tariff_entity)
async_track_state_change(self.hass, self._tariff_entity,
self.async_tariff_change)
tariff_entity_state = self.hass.states.get(self._tariff_entity)
if self._tariff != tariff_entity_state.state:
return
self._collecting = async_track_state_change(
self.hass, self._sensor_source_id, self.async_reading)
self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_START, async_source_tracking)
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def state(self):
"""Return the state of the sensor."""
return self._state
@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,
ATTR_STATUS: PAUSED if self._collecting is None else COLLECTING,
ATTR_LAST_PERIOD: self._last_period,
ATTR_LAST_RESET: self._last_reset,
}
if self._period is not None:
state_attr[ATTR_PERIOD] = self._period
if self._tariff is not None:
state_attr[ATTR_TARIFF] = self._tariff
return state_attr
@property
def icon(self):
"""Return the icon to use in the frontend, if any."""
return ICON

View File

@ -0,0 +1,25 @@
# Describes the format for available switch services
reset:
description: Resets the counter of an utility meter.
fields:
entity_id:
description: Name(s) of the utility meter to reset
example: 'utility_meter.energy'
next_tariff:
description: Changes the tariff to the next one.
fields:
entity_id:
description: Name(s) of entities to reset
example: 'utility_meter.energy'
select_tariff:
description: selects the current tariff of an utility meter.
fields:
entity_id:
description: Name of the entity to set the tariff for
example: 'utility_meter.energy'
tariff:
description: Name of the tariff to switch to
example: 'offpeak'

View File

@ -0,0 +1 @@
"""Tests for Utility Meter component."""

View File

@ -0,0 +1,102 @@
"""The tests for the utility_meter component."""
import logging
from datetime import timedelta
from unittest.mock import patch
from homeassistant.const import (
EVENT_HOMEASSISTANT_START, ATTR_ENTITY_ID)
from homeassistant.components.utility_meter.const import (
SERVICE_RESET, SERVICE_SELECT_TARIFF, SERVICE_SELECT_NEXT_TARIFF,
ATTR_TARIFF)
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
from homeassistant.components.utility_meter.const import DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
_LOGGER = logging.getLogger(__name__)
async def test_services(hass):
"""Test energy sensor reset service."""
config = {
'utility_meter': {
'energy_bill': {
'source': 'sensor.energy',
'cycle': 'hourly',
'tariffs': ['peak', 'offpeak'],
}
}
}
assert await async_setup_component(hass, DOMAIN, config)
assert await async_setup_component(hass, SENSOR_DOMAIN, config)
await hass.async_block_till_done()
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
entity_id = config[DOMAIN]['energy_bill']['source']
hass.states.async_set(entity_id, 1, {"unit_of_measurement": "kWh"})
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, 3, {"unit_of_measurement": "kWh"},
force_update=True)
await hass.async_block_till_done()
state = hass.states.get('sensor.energy_bill_peak')
assert state.state == '2'
state = hass.states.get('sensor.energy_bill_offpeak')
assert state.state == '0'
# Next tariff
data = {ATTR_ENTITY_ID: 'utility_meter.energy_bill'}
await hass.services.async_call(DOMAIN,
SERVICE_SELECT_NEXT_TARIFF, data)
await hass.async_block_till_done()
now += timedelta(seconds=10)
with patch('homeassistant.util.dt.utcnow',
return_value=now):
hass.states.async_set(entity_id, 4, {"unit_of_measurement": "kWh"},
force_update=True)
await hass.async_block_till_done()
state = hass.states.get('sensor.energy_bill_peak')
assert state.state == '2'
state = hass.states.get('sensor.energy_bill_offpeak')
assert state.state == '1'
# Change tariff
data = {ATTR_ENTITY_ID: 'utility_meter.energy_bill',
ATTR_TARIFF: 'peak'}
await hass.services.async_call(DOMAIN,
SERVICE_SELECT_TARIFF, data)
await hass.async_block_till_done()
now += timedelta(seconds=10)
with patch('homeassistant.util.dt.utcnow',
return_value=now):
hass.states.async_set(entity_id, 5, {"unit_of_measurement": "kWh"},
force_update=True)
await hass.async_block_till_done()
state = hass.states.get('sensor.energy_bill_peak')
assert state.state == '3'
state = hass.states.get('sensor.energy_bill_offpeak')
assert state.state == '1'
# Reset meters
data = {ATTR_ENTITY_ID: 'utility_meter.energy_bill'}
await hass.services.async_call(DOMAIN, SERVICE_RESET, data)
await hass.async_block_till_done()
state = hass.states.get('sensor.energy_bill_peak')
assert state.state == '0'
state = hass.states.get('sensor.energy_bill_offpeak')
assert state.state == '0'

View File

@ -0,0 +1,136 @@
"""The tests for the utility_meter sensor platform."""
import logging
from datetime import timedelta
from unittest.mock import patch
from contextlib import contextmanager
from tests.common import async_fire_time_changed
from homeassistant.const import EVENT_HOMEASSISTANT_START
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
from homeassistant.components.utility_meter.const import DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
_LOGGER = logging.getLogger(__name__)
@contextmanager
def alter_time(retval):
"""Manage multiple time mocks."""
patch1 = patch("homeassistant.util.dt.utcnow", return_value=retval)
patch2 = patch("homeassistant.util.dt.now", return_value=retval)
with patch1, patch2:
yield
async def test_state(hass):
"""Test utility sensor state."""
config = {
'utility_meter': {
'energy_bill': {
'source': 'sensor.energy',
}
}
}
assert await async_setup_component(hass, DOMAIN, config)
assert await async_setup_component(hass, SENSOR_DOMAIN, config)
await hass.async_block_till_done()
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
entity_id = config[DOMAIN]['energy_bill']['source']
hass.states.async_set(entity_id, 2, {"unit_of_measurement": "kWh"})
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, 3, {"unit_of_measurement": "kWh"},
force_update=True)
await hass.async_block_till_done()
state = hass.states.get('sensor.energy_bill')
assert state is not None
assert state.state == '1'
async def _test_self_reset(hass, cycle, start_time, expect_reset=True):
"""Test energy sensor self reset."""
config = {
'utility_meter': {
'energy_bill': {
'source': 'sensor.energy',
'cycle': cycle
}
}
}
assert await async_setup_component(hass, DOMAIN, config)
assert await async_setup_component(hass, SENSOR_DOMAIN, config)
await hass.async_block_till_done()
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
entity_id = config[DOMAIN]['energy_bill']['source']
now = dt_util.parse_datetime(start_time)
with alter_time(now):
async_fire_time_changed(hass, now)
hass.states.async_set(entity_id, 1, {"unit_of_measurement": "kWh"})
await hass.async_block_till_done()
now += timedelta(seconds=30)
with alter_time(now):
async_fire_time_changed(hass, now)
hass.states.async_set(entity_id, 3, {"unit_of_measurement": "kWh"},
force_update=True)
await hass.async_block_till_done()
now += timedelta(seconds=30)
with alter_time(now):
async_fire_time_changed(hass, now)
await hass.async_block_till_done()
hass.states.async_set(entity_id, 6, {"unit_of_measurement": "kWh"},
force_update=True)
await hass.async_block_till_done()
state = hass.states.get('sensor.energy_bill')
if expect_reset:
assert state.attributes.get('last_period') == '2'
assert state.state == '3'
else:
assert state.attributes.get('last_period') == 0
assert state.state == '5'
async def test_self_reset_hourly(hass):
"""Test hourly reset of meter."""
await _test_self_reset(hass, 'hourly', "2017-12-31T23:59:00.000000+00:00")
async def test_self_reset_daily(hass):
"""Test daily reset of meter."""
await _test_self_reset(hass, 'daily', "2017-12-31T23:59:00.000000+00:00")
async def test_self_reset_weekly(hass):
"""Test weekly reset of meter."""
await _test_self_reset(hass, 'weekly', "2017-12-31T23:59:00.000000+00:00")
async def test_self_reset_monthly(hass):
"""Test monthly reset of meter."""
await _test_self_reset(hass, 'monthly', "2017-12-31T23:59:00.000000+00:00")
async def test_self_reset_yearly(hass):
"""Test yearly reset of meter."""
await _test_self_reset(hass, 'yearly', "2017-12-31T23:59:00.000000+00:00")
async def test_self_no_reset_yearly(hass):
"""Test yearly reset of meter does not occur after 1st January."""
await _test_self_reset(hass, 'yearly', "2018-01-01T23:59:00.000000+00:00",
expect_reset=False)