Add "cron patterns" to define utility_meter cycles (#46795)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Diogo Gomes 2021-08-25 20:52:39 +01:00 committed by GitHub
parent 2f7a7b0309
commit fb28665cfa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 263 additions and 17 deletions

View File

@ -2,6 +2,7 @@
from datetime import timedelta from datetime import timedelta
import logging import logging
from croniter import croniter
import voluptuous as vol import voluptuous as vol
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
@ -14,6 +15,7 @@ from homeassistant.helpers.restore_state import RestoreEntity
from .const import ( from .const import (
ATTR_TARIFF, ATTR_TARIFF,
CONF_CRON_PATTERN,
CONF_METER, CONF_METER,
CONF_METER_NET_CONSUMPTION, CONF_METER_NET_CONSUMPTION,
CONF_METER_OFFSET, CONF_METER_OFFSET,
@ -40,17 +42,45 @@ ATTR_TARIFFS = "tariffs"
DEFAULT_OFFSET = timedelta(hours=0) DEFAULT_OFFSET = timedelta(hours=0)
def validate_cron_pattern(pattern):
"""Check that the pattern is well-formed."""
if croniter.is_valid(pattern):
return pattern
raise vol.Invalid("Invalid pattern")
def period_or_cron(config):
"""Check that if cron pattern is used, then meter type and offsite must be removed."""
if CONF_CRON_PATTERN in config and CONF_METER_TYPE in config:
raise vol.Invalid(f"Use <{CONF_CRON_PATTERN}> or <{CONF_METER_TYPE}>")
if (
CONF_CRON_PATTERN in config
and CONF_METER_OFFSET in config
and config[CONF_METER_OFFSET] != DEFAULT_OFFSET
):
raise vol.Invalid(
f"When <{CONF_CRON_PATTERN}> is used <{CONF_METER_OFFSET}> has no meaning"
)
return config
METER_CONFIG_SCHEMA = vol.Schema( METER_CONFIG_SCHEMA = vol.Schema(
{ vol.All(
vol.Required(CONF_SOURCE_SENSOR): cv.entity_id, {
vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_SOURCE_SENSOR): cv.entity_id,
vol.Optional(CONF_METER_TYPE): vol.In(METER_TYPES), vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_METER_OFFSET, default=DEFAULT_OFFSET): vol.All( vol.Optional(CONF_METER_TYPE): vol.In(METER_TYPES),
cv.time_period, cv.positive_timedelta vol.Optional(CONF_METER_OFFSET, default=DEFAULT_OFFSET): vol.All(
), cv.time_period, cv.positive_timedelta
vol.Optional(CONF_METER_NET_CONSUMPTION, default=False): cv.boolean, ),
vol.Optional(CONF_TARIFFS, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_METER_NET_CONSUMPTION, default=False): cv.boolean,
} vol.Optional(CONF_TARIFFS, default=[]): vol.All(
cv.ensure_list, [cv.string]
),
vol.Optional(CONF_CRON_PATTERN): validate_cron_pattern,
},
period_or_cron,
)
) )
CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema(

View File

@ -32,9 +32,11 @@ CONF_PAUSED = "paused"
CONF_TARIFFS = "tariffs" CONF_TARIFFS = "tariffs"
CONF_TARIFF = "tariff" CONF_TARIFF = "tariff"
CONF_TARIFF_ENTITY = "tariff_entity" CONF_TARIFF_ENTITY = "tariff_entity"
CONF_CRON_PATTERN = "cron"
ATTR_TARIFF = "tariff" ATTR_TARIFF = "tariff"
ATTR_VALUE = "value" ATTR_VALUE = "value"
ATTR_CRON_PATTERN = "cron pattern"
SIGNAL_START_PAUSE_METER = "utility_meter_start_pause" SIGNAL_START_PAUSE_METER = "utility_meter_start_pause"
SIGNAL_RESET_METER = "utility_meter_reset" SIGNAL_RESET_METER = "utility_meter_reset"

View File

@ -2,6 +2,7 @@
"domain": "utility_meter", "domain": "utility_meter",
"name": "Utility Meter", "name": "Utility Meter",
"documentation": "https://www.home-assistant.io/integrations/utility_meter", "documentation": "https://www.home-assistant.io/integrations/utility_meter",
"requirements": ["croniter==1.0.6"],
"codeowners": ["@dgomes"], "codeowners": ["@dgomes"],
"quality_scale": "internal", "quality_scale": "internal",
"iot_class": "local_push" "iot_class": "local_push"

View File

@ -1,8 +1,9 @@
"""Utility meter from sensors providing raw data.""" """Utility meter from sensors providing raw data."""
from datetime import date, timedelta from datetime import date, datetime, timedelta
from decimal import Decimal, DecimalException from decimal import Decimal, DecimalException
import logging import logging
from croniter import croniter
import voluptuous as vol import voluptuous as vol
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
@ -25,6 +26,7 @@ from homeassistant.core import callback
from homeassistant.helpers import entity_platform from homeassistant.helpers import entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.event import ( from homeassistant.helpers.event import (
async_track_point_in_time,
async_track_state_change_event, async_track_state_change_event,
async_track_time_change, async_track_time_change,
) )
@ -32,8 +34,10 @@ from homeassistant.helpers.restore_state import RestoreEntity
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from .const import ( from .const import (
ATTR_CRON_PATTERN,
ATTR_VALUE, ATTR_VALUE,
BIMONTHLY, BIMONTHLY,
CONF_CRON_PATTERN,
CONF_METER, CONF_METER,
CONF_METER_NET_CONSUMPTION, CONF_METER_NET_CONSUMPTION,
CONF_METER_OFFSET, CONF_METER_OFFSET,
@ -91,6 +95,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
conf_meter_tariff_entity = hass.data[DATA_UTILITY][meter].get( conf_meter_tariff_entity = hass.data[DATA_UTILITY][meter].get(
CONF_TARIFF_ENTITY CONF_TARIFF_ENTITY
) )
conf_cron_pattern = hass.data[DATA_UTILITY][meter].get(CONF_CRON_PATTERN)
meters.append( meters.append(
UtilityMeterSensor( UtilityMeterSensor(
@ -101,6 +106,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
conf_meter_net_consumption, conf_meter_net_consumption,
conf.get(CONF_TARIFF), conf.get(CONF_TARIFF),
conf_meter_tariff_entity, conf_meter_tariff_entity,
conf_cron_pattern,
) )
) )
@ -127,6 +133,7 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity):
net_consumption, net_consumption,
tariff=None, tariff=None,
tariff_entity=None, tariff_entity=None,
cron_pattern=None,
): ):
"""Initialize the Utility Meter sensor.""" """Initialize the Utility Meter sensor."""
self._sensor_source_id = source_entity self._sensor_source_id = source_entity
@ -141,6 +148,7 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity):
self._unit_of_measurement = None self._unit_of_measurement = None
self._period = meter_type self._period = meter_type
self._period_offset = meter_offset self._period_offset = meter_offset
self._cron_pattern = cron_pattern
self._sensor_net_consumption = net_consumption self._sensor_net_consumption = net_consumption
self._tariff = tariff self._tariff = tariff
self._tariff_entity = tariff_entity self._tariff_entity = tariff_entity
@ -207,29 +215,37 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity):
async def _async_reset_meter(self, event): async def _async_reset_meter(self, event):
"""Determine cycle - Helper function for larger than daily cycles.""" """Determine cycle - Helper function for larger than daily cycles."""
now = dt_util.now().date() now = dt_util.now().date()
if ( if self._cron_pattern is not None:
async_track_point_in_time(
self.hass,
self._async_reset_meter,
croniter(self._cron_pattern, dt_util.now()).get_next(datetime),
)
elif (
self._period == WEEKLY self._period == WEEKLY
and now != now - timedelta(days=now.weekday()) + self._period_offset and now != now - timedelta(days=now.weekday()) + self._period_offset
): ):
return return
if ( elif (
self._period == MONTHLY self._period == MONTHLY
and now != date(now.year, now.month, 1) + self._period_offset and now != date(now.year, now.month, 1) + self._period_offset
): ):
return return
if ( elif (
self._period == BIMONTHLY self._period == BIMONTHLY
and now and now
!= date(now.year, (((now.month - 1) // 2) * 2 + 1), 1) + self._period_offset != date(now.year, (((now.month - 1) // 2) * 2 + 1), 1) + self._period_offset
): ):
return return
if ( elif (
self._period == QUARTERLY self._period == QUARTERLY
and now and now
!= date(now.year, (((now.month - 1) // 3) * 3 + 1), 1) + self._period_offset != date(now.year, (((now.month - 1) // 3) * 3 + 1), 1) + self._period_offset
): ):
return return
if self._period == YEARLY and now != date(now.year, 1, 1) + self._period_offset: elif (
self._period == YEARLY and now != date(now.year, 1, 1) + self._period_offset
):
return return
await self.async_reset_meter(self._tariff_entity) await self.async_reset_meter(self._tariff_entity)
@ -253,7 +269,13 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity):
"""Handle entity which will be added.""" """Handle entity which will be added."""
await super().async_added_to_hass() await super().async_added_to_hass()
if self._period == QUARTER_HOURLY: if self._cron_pattern is not None:
async_track_point_in_time(
self.hass,
self._async_reset_meter,
croniter(self._cron_pattern, dt_util.now()).get_next(datetime),
)
elif self._period == QUARTER_HOURLY:
for quarter in range(4): for quarter in range(4):
async_track_time_change( async_track_time_change(
self.hass, self.hass,
@ -360,6 +382,8 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity):
} }
if self._period is not None: if self._period is not None:
state_attr[ATTR_PERIOD] = self._period state_attr[ATTR_PERIOD] = self._period
if self._cron_pattern is not None:
state_attr[ATTR_CRON_PATTERN] = self._cron_pattern
if self._tariff is not None: if self._tariff is not None:
state_attr[ATTR_TARIFF] = self._tariff state_attr[ATTR_TARIFF] = self._tariff
return state_attr return state_attr

View File

@ -486,6 +486,9 @@ construct==2.10.56
# homeassistant.components.coronavirus # homeassistant.components.coronavirus
coronavirus==1.1.1 coronavirus==1.1.1
# homeassistant.components.utility_meter
croniter==1.0.6
# homeassistant.components.datadog # homeassistant.components.datadog
datadog==0.15.0 datadog==0.15.0

View File

@ -28,6 +28,7 @@ responses==0.12.0
respx==0.17.0 respx==0.17.0
stdlib-list==0.7.0 stdlib-list==0.7.0
tqdm==4.49.0 tqdm==4.49.0
types-croniter==1.0.0
types-backports==0.1.3 types-backports==0.1.3
types-certifi==0.1.4 types-certifi==0.1.4
types-chardet==0.1.5 types-chardet==0.1.5

View File

@ -282,6 +282,9 @@ construct==2.10.56
# homeassistant.components.coronavirus # homeassistant.components.coronavirus
coronavirus==1.1.1 coronavirus==1.1.1
# homeassistant.components.utility_meter
croniter==1.0.6
# homeassistant.components.datadog # homeassistant.components.datadog
datadog==0.15.0 datadog==0.15.0

View File

@ -10,15 +10,49 @@ from homeassistant.components.utility_meter.const import (
SERVICE_SELECT_NEXT_TARIFF, SERVICE_SELECT_NEXT_TARIFF,
SERVICE_SELECT_TARIFF, SERVICE_SELECT_TARIFF,
) )
import homeassistant.components.utility_meter.sensor as um_sensor
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_ID, ATTR_ENTITY_ID,
ATTR_UNIT_OF_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT,
CONF_PLATFORM,
ENERGY_KILO_WATT_HOUR, ENERGY_KILO_WATT_HOUR,
EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_START,
) )
from homeassistant.core import State
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from tests.common import mock_restore_cache
async def test_restore_state(hass):
"""Test utility sensor restore state."""
config = {
"utility_meter": {
"energy_bill": {
"source": "sensor.energy",
"tariffs": ["onpeak", "midpeak", "offpeak"],
}
}
}
mock_restore_cache(
hass,
[
State(
"utility_meter.energy_bill",
"midpeak",
),
],
)
assert await async_setup_component(hass, DOMAIN, config)
assert await async_setup_component(hass, SENSOR_DOMAIN, config)
await hass.async_block_till_done()
# restore from cache
state = hass.states.get("utility_meter.energy_bill")
assert state.state == "midpeak"
async def test_services(hass): async def test_services(hass):
"""Test energy sensor reset service.""" """Test energy sensor reset service."""
@ -81,6 +115,13 @@ async def test_services(hass):
assert state.state == "1" assert state.state == "1"
# Change tariff # Change tariff
data = {ATTR_ENTITY_ID: "utility_meter.energy_bill", ATTR_TARIFF: "wrong_tariff"}
await hass.services.async_call(DOMAIN, SERVICE_SELECT_TARIFF, data)
await hass.async_block_till_done()
# Inexisting tariff, ignoring
assert hass.states.get("utility_meter.energy_bill").state != "wrong_tariff"
data = {ATTR_ENTITY_ID: "utility_meter.energy_bill", ATTR_TARIFF: "peak"} data = {ATTR_ENTITY_ID: "utility_meter.energy_bill", ATTR_TARIFF: "peak"}
await hass.services.async_call(DOMAIN, SERVICE_SELECT_TARIFF, data) await hass.services.async_call(DOMAIN, SERVICE_SELECT_TARIFF, data)
await hass.async_block_till_done() await hass.async_block_till_done()
@ -111,3 +152,82 @@ async def test_services(hass):
state = hass.states.get("sensor.energy_bill_offpeak") state = hass.states.get("sensor.energy_bill_offpeak")
assert state.state == "0" assert state.state == "0"
async def test_cron(hass, legacy_patchable_time):
"""Test cron pattern and offset fails."""
config = {
"utility_meter": {
"energy_bill": {
"source": "sensor.energy",
"cron": "*/5 * * * *",
}
}
}
assert await async_setup_component(hass, DOMAIN, config)
async def test_cron_and_meter(hass, legacy_patchable_time):
"""Test cron pattern and meter type fails."""
config = {
"utility_meter": {
"energy_bill": {
"source": "sensor.energy",
"cycle": "hourly",
"cron": "0 0 1 * *",
}
}
}
assert not await async_setup_component(hass, DOMAIN, config)
async def test_both_cron_and_meter(hass, legacy_patchable_time):
"""Test cron pattern and meter type passes in different meter."""
config = {
"utility_meter": {
"energy_bill": {
"source": "sensor.energy",
"cron": "0 0 1 * *",
},
"water_bill": {
"source": "sensor.water",
"cycle": "hourly",
},
}
}
assert await async_setup_component(hass, DOMAIN, config)
async def test_cron_and_offset(hass, legacy_patchable_time):
"""Test cron pattern and offset fails."""
config = {
"utility_meter": {
"energy_bill": {
"source": "sensor.energy",
"offset": {"days": 1},
"cron": "0 0 1 * *",
}
}
}
assert not await async_setup_component(hass, DOMAIN, config)
async def test_bad_cron(hass, legacy_patchable_time):
"""Test bad cron pattern."""
config = {
"utility_meter": {"energy_bill": {"source": "sensor.energy", "cron": "*"}}
}
assert not await async_setup_component(hass, DOMAIN, config)
async def test_setup_missing_discovery(hass):
"""Test setup with configuration missing discovery_info."""
assert not await um_sensor.async_setup_platform(hass, {CONF_PLATFORM: DOMAIN}, None)

View File

@ -11,7 +11,10 @@ from homeassistant.components.sensor import (
from homeassistant.components.utility_meter.const import ( from homeassistant.components.utility_meter.const import (
ATTR_TARIFF, ATTR_TARIFF,
ATTR_VALUE, ATTR_VALUE,
DAILY,
DOMAIN, DOMAIN,
HOURLY,
QUARTER_HOURLY,
SERVICE_CALIBRATE_METER, SERVICE_CALIBRATE_METER,
SERVICE_SELECT_TARIFF, SERVICE_SELECT_TARIFF,
) )
@ -27,6 +30,7 @@ from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT,
ENERGY_KILO_WATT_HOUR, ENERGY_KILO_WATT_HOUR,
EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_START,
STATE_UNAVAILABLE,
) )
from homeassistant.core import State from homeassistant.core import State
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
@ -162,6 +166,26 @@ async def test_state(hass):
assert state is not None assert state is not None
assert state.state == "0.123" assert state.state == "0.123"
# test invalid state
entity_id = config[DOMAIN]["energy_bill"]["source"]
hass.states.async_set(
entity_id, "*", {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}
)
await hass.async_block_till_done()
state = hass.states.get("sensor.energy_bill_midpeak")
assert state is not None
assert state.state == "0.123"
# test unavailable source
entity_id = config[DOMAIN]["energy_bill"]["source"]
hass.states.async_set(
entity_id, STATE_UNAVAILABLE, {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}
)
await hass.async_block_till_done()
state = hass.states.get("sensor.energy_bill_midpeak")
assert state is not None
assert state.state == "0.123"
async def test_device_class(hass): async def test_device_class(hass):
"""Test utility device_class.""" """Test utility device_class."""
@ -421,6 +445,44 @@ async def _test_self_reset(hass, config, start_time, expect_reset=True):
start_time_str = dt_util.parse_datetime(start_time).isoformat() start_time_str = dt_util.parse_datetime(start_time).isoformat()
assert state.attributes.get("last_reset") == start_time_str assert state.attributes.get("last_reset") == start_time_str
# Check next day when nothing should happen for weekly, monthly, bimonthly and yearly
if config["utility_meter"]["energy_bill"].get("cycle") in [
QUARTER_HOURLY,
HOURLY,
DAILY,
]:
now += timedelta(minutes=5)
else:
now += timedelta(days=5)
with alter_time(now):
async_fire_time_changed(hass, now)
await hass.async_block_till_done()
hass.states.async_set(
entity_id,
10,
{ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR},
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 == "7"
else:
assert state.attributes.get("last_period") == 0
assert state.state == "9"
async def test_self_reset_cron_pattern(hass, legacy_patchable_time):
"""Test cron pattern reset of meter."""
config = {
"utility_meter": {
"energy_bill": {"source": "sensor.energy", "cron": "0 0 1 * *"}
}
}
await _test_self_reset(hass, config, "2017-01-31T23:59:00.000000+00:00")
async def test_self_reset_quarter_hourly(hass, legacy_patchable_time): async def test_self_reset_quarter_hourly(hass, legacy_patchable_time):
"""Test quarter-hourly reset of meter.""" """Test quarter-hourly reset of meter."""