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
import logging
from croniter import croniter
import voluptuous as vol
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
@ -14,6 +15,7 @@ from homeassistant.helpers.restore_state import RestoreEntity
from .const import (
ATTR_TARIFF,
CONF_CRON_PATTERN,
CONF_METER,
CONF_METER_NET_CONSUMPTION,
CONF_METER_OFFSET,
@ -40,17 +42,45 @@ ATTR_TARIFFS = "tariffs"
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(
{
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=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.All(
{
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=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_CRON_PATTERN): validate_cron_pattern,
},
period_or_cron,
)
)
CONFIG_SCHEMA = vol.Schema(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,15 +10,49 @@ from homeassistant.components.utility_meter.const import (
SERVICE_SELECT_NEXT_TARIFF,
SERVICE_SELECT_TARIFF,
)
import homeassistant.components.utility_meter.sensor as um_sensor
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_UNIT_OF_MEASUREMENT,
CONF_PLATFORM,
ENERGY_KILO_WATT_HOUR,
EVENT_HOMEASSISTANT_START,
)
from homeassistant.core import State
from homeassistant.setup import async_setup_component
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):
"""Test energy sensor reset service."""
@ -81,6 +115,13 @@ async def test_services(hass):
assert state.state == "1"
# 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"}
await hass.services.async_call(DOMAIN, SERVICE_SELECT_TARIFF, data)
await hass.async_block_till_done()
@ -111,3 +152,82 @@ async def test_services(hass):
state = hass.states.get("sensor.energy_bill_offpeak")
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 (
ATTR_TARIFF,
ATTR_VALUE,
DAILY,
DOMAIN,
HOURLY,
QUARTER_HOURLY,
SERVICE_CALIBRATE_METER,
SERVICE_SELECT_TARIFF,
)
@ -27,6 +30,7 @@ from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT,
ENERGY_KILO_WATT_HOUR,
EVENT_HOMEASSISTANT_START,
STATE_UNAVAILABLE,
)
from homeassistant.core import State
from homeassistant.setup import async_setup_component
@ -162,6 +166,26 @@ async def test_state(hass):
assert state is not None
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):
"""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()
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):
"""Test quarter-hourly reset of meter."""