From fb28665cfa5e4e49212ed1b1263fab9405bded66 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Wed, 25 Aug 2021 20:52:39 +0100 Subject: [PATCH] Add "cron patterns" to define utility_meter cycles (#46795) Co-authored-by: J. Nick Koston --- .../components/utility_meter/__init__.py | 50 ++++++-- .../components/utility_meter/const.py | 2 + .../components/utility_meter/manifest.json | 1 + .../components/utility_meter/sensor.py | 38 +++++- requirements_all.txt | 3 + requirements_test.txt | 1 + requirements_test_all.txt | 3 + tests/components/utility_meter/test_init.py | 120 ++++++++++++++++++ tests/components/utility_meter/test_sensor.py | 62 +++++++++ 9 files changed, 263 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index 25aa6018d44..32ed90a9111 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -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( diff --git a/homeassistant/components/utility_meter/const.py b/homeassistant/components/utility_meter/const.py index 39fd952327b..3be6fa9a061 100644 --- a/homeassistant/components/utility_meter/const.py +++ b/homeassistant/components/utility_meter/const.py @@ -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" diff --git a/homeassistant/components/utility_meter/manifest.json b/homeassistant/components/utility_meter/manifest.json index 06f2b60297b..a1ba3b6d370 100644 --- a/homeassistant/components/utility_meter/manifest.json +++ b/homeassistant/components/utility_meter/manifest.json @@ -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" diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index e0bd33006d3..ee3fed02a6b 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -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 diff --git a/requirements_all.txt b/requirements_all.txt index 40bdcc7f7a2..b9611f50d76 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test.txt b/requirements_test.txt index 73b34913f89..86114cc02b1 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3742698484b..23ea1648560 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index c422d3b5c1f..aa6de34f611 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -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) diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index a2d15c595b0..5627daec7f8 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -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."""