diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index 6cd6cc46933..0195ee53fc8 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -7,10 +7,12 @@ import voluptuous as vol from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, Platform +from homeassistant.core import HomeAssistant, split_entity_id +from homeassistant.helpers import discovery, entity_registry as er import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -30,6 +32,8 @@ from .const import ( DATA_UTILITY, DOMAIN, METER_TYPES, + SERVICE_RESET, + SIGNAL_RESET_METER, ) _LOGGER = logging.getLogger(__name__) @@ -100,6 +104,34 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DATA_UTILITY] = {} + async def async_reset_meters(service_call): + """Reset all sensors of a meter.""" + entity_id = service_call.data["entity_id"] + + domain = split_entity_id(entity_id)[0] + if domain == DOMAIN: + for entity in hass.data[DATA_LEGACY_COMPONENT].entities: + if entity_id == entity.entity_id: + _LOGGER.debug( + "forward reset meter from %s to %s", + entity_id, + entity.tracked_entity_id, + ) + entity_id = entity.tracked_entity_id + + _LOGGER.debug("reset meter %s", entity_id) + async_dispatcher_send(hass, SIGNAL_RESET_METER, entity_id) + + hass.services.async_register( + DOMAIN, + SERVICE_RESET, + async_reset_meters, + vol.Schema({ATTR_ENTITY_ID: cv.entity_id}), + ) + + if DOMAIN not in config: + return True + for meter, conf in config[DOMAIN].items(): _LOGGER.debug("Setup %s.%s", DOMAIN, meter) @@ -151,3 +183,59 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Utility Meter from a config entry.""" + entity_registry = er.async_get(hass) + hass.data[DATA_UTILITY][entry.entry_id] = {} + hass.data[DATA_UTILITY][entry.entry_id][DATA_TARIFF_SENSORS] = [] + + try: + er.async_validate_entity_id(entity_registry, entry.options[CONF_SOURCE_SENSOR]) + except vol.Invalid: + # The entity is identified by an unknown entity registry ID + _LOGGER.error( + "Failed to setup utility_meter for unknown entity %s", + entry.options[CONF_SOURCE_SENSOR], + ) + return False + + if not entry.options.get(CONF_TARIFFS): + # Only a single meter sensor is required + hass.data[DATA_UTILITY][entry.entry_id][CONF_TARIFF_ENTITY] = None + hass.config_entries.async_setup_platforms(entry, (Platform.SENSOR,)) + else: + # Create tariff selection + one meter sensor for each tariff + entity_entry = entity_registry.async_get_or_create( + Platform.SELECT, DOMAIN, entry.entry_id, suggested_object_id=entry.title + ) + hass.data[DATA_UTILITY][entry.entry_id][ + CONF_TARIFF_ENTITY + ] = entity_entry.entity_id + hass.config_entries.async_setup_platforms( + entry, (Platform.SELECT, Platform.SENSOR) + ) + + entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) + + return True + + +async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update listener, called when the config entry options are changed.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms( + entry, + ( + Platform.SELECT, + Platform.SENSOR, + ), + ): + hass.data[DATA_UTILITY].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/utility_meter/config_flow.py b/homeassistant/components/utility_meter/config_flow.py new file mode 100644 index 00000000000..caf7d3c8d00 --- /dev/null +++ b/homeassistant/components/utility_meter/config_flow.py @@ -0,0 +1,119 @@ +"""Config flow for Utility Meter integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT +from homeassistant.helpers import selector +from homeassistant.helpers.helper_config_entry_flow import ( + HelperConfigFlowHandler, + HelperFlowError, + HelperFlowFormStep, + HelperFlowMenuStep, +) + +from .const import ( + BIMONTHLY, + CONF_METER_DELTA_VALUES, + CONF_METER_NET_CONSUMPTION, + CONF_METER_OFFSET, + CONF_METER_TYPE, + CONF_SOURCE_SENSOR, + CONF_TARIFFS, + DAILY, + DOMAIN, + HOURLY, + MONTHLY, + QUARTER_HOURLY, + QUARTERLY, + WEEKLY, + YEARLY, +) + +METER_TYPES = [ + {"value": "none", "label": "No cycle"}, + {"value": QUARTER_HOURLY, "label": "Every 15 minutes"}, + {"value": HOURLY, "label": "Hourly"}, + {"value": DAILY, "label": "Daily"}, + {"value": WEEKLY, "label": "Weekly"}, + {"value": MONTHLY, "label": "Monthly"}, + {"value": BIMONTHLY, "label": "Every two months"}, + {"value": QUARTERLY, "label": "Quarterly"}, + {"value": YEARLY, "label": "Yearly"}, +] + + +def _validate_config(data: Any) -> Any: + """Validate config.""" + tariffs: list[str] + if not data[CONF_TARIFFS]: + tariffs = [] + else: + tariffs = data[CONF_TARIFFS].split(",") + try: + vol.Unique()(tariffs) + except vol.Invalid as exc: + raise HelperFlowError("tariffs_not_unique") from exc + + return data + + +OPTIONS_SCHEMA = vol.Schema( + { + vol.Required(CONF_SOURCE_SENSOR): selector.selector( + {"entity": {"domain": "sensor"}}, + ), + } +) + +CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): selector.selector({"text": {}}), + vol.Required(CONF_SOURCE_SENSOR): selector.selector( + {"entity": {"domain": "sensor"}}, + ), + vol.Required(CONF_METER_TYPE): selector.selector( + {"select": {"options": METER_TYPES}} + ), + vol.Required(CONF_METER_OFFSET, default=0): selector.selector( + { + "number": { + "min": 0, + "max": 28, + "mode": "box", + CONF_UNIT_OF_MEASUREMENT: "days", + } + } + ), + vol.Optional(CONF_TARIFFS): selector.selector({"text": {}}), + vol.Required(CONF_METER_NET_CONSUMPTION, default=False): selector.selector( + {"boolean": {}} + ), + vol.Required(CONF_METER_DELTA_VALUES, default=False): selector.selector( + {"boolean": {}} + ), + } +) + +CONFIG_FLOW: dict[str, HelperFlowFormStep | HelperFlowMenuStep] = { + "user": HelperFlowFormStep(CONFIG_SCHEMA, validate_user_input=_validate_config) +} + +OPTIONS_FLOW: dict[str, HelperFlowFormStep | HelperFlowMenuStep] = { + "init": HelperFlowFormStep(OPTIONS_SCHEMA) +} + + +class ConfigFlowHandler(HelperConfigFlowHandler, domain=DOMAIN): + """Handle a config or options flow for Utility Meter.""" + + config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + + return cast(str, options[CONF_NAME]) diff --git a/homeassistant/components/utility_meter/manifest.json b/homeassistant/components/utility_meter/manifest.json index fb880f567d1..cbbb2c820b1 100644 --- a/homeassistant/components/utility_meter/manifest.json +++ b/homeassistant/components/utility_meter/manifest.json @@ -1,10 +1,18 @@ { "domain": "utility_meter", + "integration_type": "helper", "name": "Utility Meter", "documentation": "https://www.home-assistant.io/integrations/utility_meter", - "requirements": ["croniter==1.0.6"], - "codeowners": ["@dgomes"], + "requirements": [ + "croniter==1.0.6" + ], + "codeowners": [ + "@dgomes" + ], "quality_scale": "internal", "iot_class": "local_push", - "loggers": ["croniter"] + "loggers": [ + "croniter" + ], + "config_flow": true } diff --git a/homeassistant/components/utility_meter/select.py b/homeassistant/components/utility_meter/select.py index b523d72aba4..e47f0626f6e 100644 --- a/homeassistant/components/utility_meter/select.py +++ b/homeassistant/components/utility_meter/select.py @@ -12,11 +12,12 @@ from homeassistant.components.select.const import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE -from homeassistant.core import Event, callback, split_entity_id +from homeassistant.core import Event, HomeAssistant, callback, split_entity_id from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.restore_state import RestoreEntity @@ -26,17 +27,34 @@ from .const import ( CONF_METER, CONF_TARIFFS, DATA_LEGACY_COMPONENT, - DOMAIN, - SERVICE_RESET, SERVICE_SELECT_NEXT_TARIFF, SERVICE_SELECT_TARIFF, - SIGNAL_RESET_METER, TARIFF_ICON, ) _LOGGER = logging.getLogger(__name__) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize Utility Meter config entry.""" + name = config_entry.title + + # Remove when frontend list selector is available + if not config_entry.options.get(CONF_TARIFFS): + tariffs = [] + else: + tariffs = config_entry.options[CONF_TARIFFS].split(",") + + legacy_add_entities = None + unique_id = config_entry.entry_id + tariff_select = TariffSelect(name, tariffs, legacy_add_entities, unique_id) + async_add_entities([tariff_select]) + + async def async_setup_platform(hass, conf, async_add_entities, discovery_info=None): """Set up the utility meter select.""" legacy_component = hass.data[DATA_LEGACY_COMPONENT] @@ -46,35 +64,11 @@ async def async_setup_platform(hass, conf, async_add_entities, discovery_info=No discovery_info[CONF_METER], discovery_info[CONF_TARIFFS], legacy_component.async_add_entities, + None, ) ] ) - async def async_reset_meters(service_call): - """Reset all sensors of a meter.""" - entity_id = service_call.data["entity_id"] - - domain = split_entity_id(entity_id)[0] - if domain == DOMAIN: - for entity in legacy_component.entities: - if entity_id == entity.entity_id: - _LOGGER.debug( - "forward reset meter from %s to %s", - entity_id, - entity.tracked_entity_id, - ) - entity_id = entity.tracked_entity_id - - _LOGGER.debug("reset meter %s", entity_id) - async_dispatcher_send(hass, SIGNAL_RESET_METER, entity_id) - - hass.services.async_register( - DOMAIN, - SERVICE_RESET, - async_reset_meters, - vol.Schema({ATTR_ENTITY_ID: cv.entity_id}), - ) - legacy_component.async_register_entity_service( SERVICE_SELECT_TARIFF, {vol.Required(ATTR_TARIFF): cv.string}, @@ -89,9 +83,10 @@ async def async_setup_platform(hass, conf, async_add_entities, discovery_info=No class TariffSelect(SelectEntity, RestoreEntity): """Representation of a Tariff selector.""" - def __init__(self, name, tariffs, add_legacy_entities): + def __init__(self, name, tariffs, add_legacy_entities, unique_id): """Initialize a tariff selector.""" self._attr_name = name + self._attr_unique_id = unique_id self._current_tariff = None self._tariffs = tariffs self._attr_icon = TARIFF_ICON @@ -112,7 +107,8 @@ class TariffSelect(SelectEntity, RestoreEntity): """Run when entity about to be added.""" await super().async_added_to_hass() - await self._add_legacy_entities([LegacyTariffSelect(self.entity_id)]) + if self._add_legacy_entities: + await self._add_legacy_entities([LegacyTariffSelect(self.entity_id)]) state = await self.async_get_last_state() if not state or state.state not in self._tariffs: diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index ec137968bc5..7800c5035fb 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -1,7 +1,7 @@ """Utility meter from sensors providing raw data.""" from __future__ import annotations -from datetime import datetime +from datetime import datetime, timedelta from decimal import Decimal, DecimalException, InvalidOperation import logging @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, @@ -24,7 +25,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_platform +from homeassistant.helpers import entity_platform, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( @@ -49,6 +50,7 @@ from .const import ( CONF_SOURCE_SENSOR, CONF_TARIFF, CONF_TARIFF_ENTITY, + CONF_TARIFFS, DAILY, DATA_TARIFF_SENSORS, DATA_UTILITY, @@ -93,6 +95,84 @@ PAUSED = "paused" COLLECTING = "collecting" +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize Utility Meter config entry.""" + entry_id = config_entry.entry_id + registry = er.async_get(hass) + # Validate + resolve entity registry id to entity_id + source_entity_id = er.async_validate_entity_id( + registry, config_entry.options[CONF_SOURCE_SENSOR] + ) + + cron_pattern = None + delta_values = config_entry.options[CONF_METER_DELTA_VALUES] + meter_offset = timedelta(days=config_entry.options[CONF_METER_OFFSET]) + meter_type = config_entry.options[CONF_METER_TYPE] + if meter_type == "none": + meter_type = None + name = config_entry.title + net_consumption = config_entry.options[CONF_METER_NET_CONSUMPTION] + tariff_entity = hass.data[DATA_UTILITY][entry_id][CONF_TARIFF_ENTITY] + + meters = [] + + # Remove when frontend list selector is available + if not config_entry.options.get(CONF_TARIFFS): + tariffs = [] + else: + tariffs = config_entry.options[CONF_TARIFFS].split(",") + + if not tariffs: + # Add single sensor, not gated by a tariff selector + meter_sensor = UtilityMeterSensor( + cron_pattern=cron_pattern, + delta_values=delta_values, + meter_offset=meter_offset, + meter_type=meter_type, + name=name, + net_consumption=net_consumption, + parent_meter=entry_id, + source_entity=source_entity_id, + tariff_entity=tariff_entity, + tariff=None, + unique_id=entry_id, + ) + meters.append(meter_sensor) + hass.data[DATA_UTILITY][entry_id][DATA_TARIFF_SENSORS].append(meter_sensor) + else: + # Add sensors for each tariff + for tariff in tariffs: + meter_sensor = UtilityMeterSensor( + cron_pattern=cron_pattern, + delta_values=delta_values, + meter_offset=meter_offset, + meter_type=meter_type, + name=f"{name} {tariff}", + net_consumption=net_consumption, + parent_meter=entry_id, + source_entity=source_entity_id, + tariff_entity=tariff_entity, + tariff=tariff, + unique_id=f"{entry_id}_{tariff}", + ) + meters.append(meter_sensor) + hass.data[DATA_UTILITY][entry_id][DATA_TARIFF_SENSORS].append(meter_sensor) + + async_add_entities(meters) + + platform = entity_platform.async_get_current_platform() + + platform.async_register_entity_service( + SERVICE_CALIBRATE_METER, + {vol.Required(ATTR_VALUE): vol.Coerce(Decimal)}, + "async_calibrate", + ) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -121,16 +201,17 @@ async def async_setup_platform( ) conf_cron_pattern = hass.data[DATA_UTILITY][meter].get(CONF_CRON_PATTERN) meter_sensor = UtilityMeterSensor( - meter, - conf_meter_source, - conf.get(CONF_NAME), - conf_meter_type, - conf_meter_offset, - conf_meter_delta_values, - conf_meter_net_consumption, - conf.get(CONF_TARIFF), - conf_meter_tariff_entity, - conf_cron_pattern, + cron_pattern=conf_cron_pattern, + delta_values=conf_meter_delta_values, + meter_offset=conf_meter_offset, + meter_type=conf_meter_type, + name=conf.get(CONF_NAME), + net_consumption=conf_meter_net_consumption, + parent_meter=meter, + source_entity=conf_meter_source, + tariff_entity=conf_meter_tariff_entity, + tariff=conf.get(CONF_TARIFF), + unique_id=None, ) meters.append(meter_sensor) @@ -152,18 +233,21 @@ class UtilityMeterSensor(RestoreEntity, SensorEntity): def __init__( self, + *, + cron_pattern, + delta_values, + meter_offset, + meter_type, + name, + net_consumption, parent_meter, source_entity, - name, - meter_type, - meter_offset, - delta_values, - net_consumption, - tariff=None, - tariff_entity=None, - cron_pattern=None, + tariff_entity, + tariff, + unique_id, ): """Initialize the Utility Meter sensor.""" + self._attr_unique_id = unique_id self._parent_meter = parent_meter self._sensor_source_id = source_entity self._state = None diff --git a/homeassistant/components/utility_meter/strings.json b/homeassistant/components/utility_meter/strings.json new file mode 100644 index 00000000000..468e2065c68 --- /dev/null +++ b/homeassistant/components/utility_meter/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "step": { + "user": { + "title": "New Utility Meter", + "description": "The utility meter sensor provides functionality to track consumptions of various utilities (e.g., energy, gas, water, heating) over a configured period of time, typically monthly. The utility meter sensor also supports splitting the consumption by tariffs.\nMeter reset offset allows offsetting the day of monthly meter reset.\nSupported tariffs is a comma separated list of supported tariffs, leave empty if only a single tariff is needed.", + "data": { + "cycle": "Meter reset cycle", + "delta_values": "Delta values", + "name": "Name", + "net_consumption": "Net consumption", + "offset": "Meter reset offset", + "source": "Input sensor", + "tariffs": "Supported tariffs" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "source": "[%key:component::utility_meter::config::step::user::data::source%]" + } + } + } + } +} diff --git a/homeassistant/components/utility_meter/translations/en.json b/homeassistant/components/utility_meter/translations/en.json new file mode 100644 index 00000000000..1ac1445069e --- /dev/null +++ b/homeassistant/components/utility_meter/translations/en.json @@ -0,0 +1,28 @@ +{ + "config": { + "step": { + "user": { + "data": { + "cycle": "Meter reset cycle", + "delta_values": "Delta values", + "name": "Name", + "net_consumption": "Net consumption", + "offset": "Meter reset offset", + "source": "Input sensor", + "tariffs": "Supported tariffs" + }, + "description": "The utility meter sensor provides functionality to track consumptions of various utilities (e.g., energy, gas, water, heating) over a configured period of time, typically monthly. The utility meter sensor also supports splitting the consumption by tariffs.\nMeter reset offset allows offsetting the day of monthly meter reset.\nSupported tariffs is a comma separated list of supported tariffs, leave empty if only a single tariff is needed.", + "title": "New Utility Meter" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "source": "Input sensor" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b8a5651a9e8..2f8339f4fe5 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -405,6 +405,7 @@ FLOWS = { "min_max", "switch_as_x", "threshold", - "tod" + "tod", + "utility_meter" ] } diff --git a/tests/components/utility_meter/test_config_flow.py b/tests/components/utility_meter/test_config_flow.py new file mode 100644 index 00000000000..dd2d99617c6 --- /dev/null +++ b/tests/components/utility_meter/test_config_flow.py @@ -0,0 +1,209 @@ +"""Test the Utility Meter config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.utility_meter.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize("platform", ("sensor",)) +async def test_config_flow(hass: HomeAssistant, platform) -> None: + """Test the config flow.""" + input_sensor_entity_id = "sensor.input" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.utility_meter.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "cycle": "monthly", + "name": "Electricity meter", + "offset": 0, + "source": input_sensor_entity_id, + "tariffs": "", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Electricity meter" + assert result["data"] == {} + assert result["options"] == { + "cycle": "monthly", + "delta_values": False, + "name": "Electricity meter", + "net_consumption": False, + "offset": 0, + "source": input_sensor_entity_id, + "tariffs": "", + } + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.data == {} + assert config_entry.options == { + "cycle": "monthly", + "delta_values": False, + "name": "Electricity meter", + "net_consumption": False, + "offset": 0, + "source": input_sensor_entity_id, + "tariffs": "", + } + assert config_entry.title == "Electricity meter" + + +async def test_tariffs(hass: HomeAssistant) -> None: + """Test tariffs.""" + input_sensor_entity_id = "sensor.input" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "cycle": "monthly", + "name": "Electricity meter", + "offset": 0, + "source": input_sensor_entity_id, + "tariffs": "cat,dog,horse,cow", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Electricity meter" + assert result["data"] == {} + assert result["options"] == { + "cycle": "monthly", + "delta_values": False, + "name": "Electricity meter", + "net_consumption": False, + "offset": 0, + "source": input_sensor_entity_id, + "tariffs": "cat,dog,horse,cow", + } + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.data == {} + assert config_entry.options == { + "cycle": "monthly", + "delta_values": False, + "name": "Electricity meter", + "net_consumption": False, + "offset": 0, + "source": input_sensor_entity_id, + "tariffs": "cat,dog,horse,cow", + } + assert config_entry.title == "Electricity meter" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "cycle": "monthly", + "name": "Electricity meter", + "offset": 0, + "source": input_sensor_entity_id, + "tariffs": "cat,cat,cat,cat", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"]["base"] == "tariffs_not_unique" + + +def get_suggested(schema, key): + """Get suggested value for key in voluptuous schema.""" + for k in schema.keys(): + if k == key: + if k.description is None or "suggested_value" not in k.description: + return None + return k.description["suggested_value"] + # Wanted key absent from schema + raise Exception + + +async def test_options(hass: HomeAssistant) -> None: + """Test reconfiguring.""" + input_sensor1_entity_id = "sensor.input1" + input_sensor2_entity_id = "sensor.input2" + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "cycle": "monthly", + "delta_values": False, + "name": "Electricity meter", + "net_consumption": False, + "offset": 0, + "source": input_sensor1_entity_id, + "tariffs": "", + }, + title="Electricity meter", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "init" + schema = result["data_schema"].schema + assert get_suggested(schema, "source") == input_sensor1_entity_id + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"source": input_sensor2_entity_id}, + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + "cycle": "monthly", + "delta_values": False, + "name": "Electricity meter", + "net_consumption": False, + "offset": 0, + "source": input_sensor2_entity_id, + "tariffs": "", + } + assert config_entry.data == {} + assert config_entry.options == { + "cycle": "monthly", + "delta_values": False, + "name": "Electricity meter", + "net_consumption": False, + "offset": 0, + "source": input_sensor2_entity_id, + "tariffs": "", + } + assert config_entry.title == "Electricity meter" + + # Check config entry is reloaded with new options + await hass.async_block_till_done() + state = hass.states.get("sensor.electricity_meter") + assert state.attributes["source"] == input_sensor2_entity_id diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index 8b600865d44..925ff00f323 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -1,7 +1,11 @@ """The tests for the utility_meter component.""" +from __future__ import annotations + from datetime import timedelta from unittest.mock import patch +import pytest + from homeassistant.components.select.const import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, @@ -22,12 +26,14 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, Platform, ) -from homeassistant.core import State +from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import ServiceNotFound +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import mock_restore_cache +from tests.common import MockConfigEntry, mock_restore_cache async def test_restore_state(hass): @@ -168,8 +174,138 @@ async def test_services(hass): assert state.state == "4" +async def test_services_config_entry(hass): + """Test energy sensor reset service.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "cycle": "monthly", + "delta_values": False, + "name": "Energy bill", + "net_consumption": False, + "offset": 0, + "source": "sensor.energy", + "tariffs": "peak,offpeak", + }, + title="Energy bill", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "cycle": "monthly", + "delta_values": False, + "name": "Energy bill2", + "net_consumption": False, + "offset": 0, + "source": "sensor.energy", + "tariffs": "peak,offpeak", + }, + title="Energy bill2", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + entity_id = "sensor.energy" + hass.states.async_set( + entity_id, 1, {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR} + ) + 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, + {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + 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 - only supported on legacy entity + with pytest.raises(ServiceNotFound): + 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() + + # Change tariff + data = {ATTR_ENTITY_ID: "select.energy_bill", "option": "offpeak"} + await hass.services.async_call(SELECT_DOMAIN, SERVICE_SELECT_OPTION, 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, + {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + 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: "select.energy_bill", "option": "wrong_tariff"} + await hass.services.async_call(SELECT_DOMAIN, SERVICE_SELECT_OPTION, data) + await hass.async_block_till_done() + + # Inexisting tariff, ignoring + assert hass.states.get("select.energy_bill").state != "wrong_tariff" + + data = {ATTR_ENTITY_ID: "select.energy_bill", "option": "peak"} + await hass.services.async_call(SELECT_DOMAIN, SERVICE_SELECT_OPTION, 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, + {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + 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: "select.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" + + # meanwhile energy_bill2_peak accumulated all kWh + state = hass.states.get("sensor.energy_bill2_peak") + assert state.state == "4" + + async def test_cron(hass, legacy_patchable_time): - """Test cron pattern and offset fails.""" + """Test cron pattern.""" config = { "utility_meter": { @@ -327,3 +463,61 @@ async def test_legacy_support(hass): await hass.services.async_call(DOMAIN, SERVICE_RESET, data) await hass.async_block_till_done() assert reset_calls == ["select.energy_bill"] + + +@pytest.mark.parametrize( + "tariffs,expected_entities", + ( + ( + "", + ["sensor.electricity_meter"], + ), + ( + "high,low", + [ + "sensor.electricity_meter_low", + "sensor.electricity_meter_high", + "select.electricity_meter", + ], + ), + ), +) +async def test_setup_and_remove_config_entry( + hass: HomeAssistant, tariffs: str, expected_entities: list[str] +) -> None: + """Test setting up and removing a config entry.""" + input_sensor_entity_id = "sensor.input" + registry = er.async_get(hass) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "cycle": "monthly", + "delta_values": False, + "name": "Electricity meter", + "net_consumption": False, + "offset": 0, + "source": input_sensor_entity_id, + "tariffs": tariffs, + }, + title="Electricity meter", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == len(expected_entities) + assert len(registry.entities) == len(expected_entities) + for entity in expected_entities: + assert hass.states.get(entity) + assert entity in registry.entities + + # Remove the config entry + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + + # Check the state and entity registry entry are removed + assert len(hass.states.async_all()) == 0 + assert len(registry.entities) == 0 diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index df8e1c5e6a1..04610f6c2f4 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -3,6 +3,8 @@ from contextlib import contextmanager from datetime import timedelta from unittest.mock import patch +import pytest + from homeassistant.components.select.const import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, @@ -39,7 +41,7 @@ from homeassistant.core import State from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed, mock_restore_cache +from tests.common import MockConfigEntry, async_fire_time_changed, mock_restore_cache @contextmanager @@ -52,24 +54,55 @@ def alter_time(retval): yield -async def test_state(hass): - """Test utility sensor state.""" - config = { - "utility_meter": { - "energy_bill": { +@pytest.mark.parametrize( + "yaml_config,config_entry_config", + ( + ( + { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + "tariffs": ["onpeak", "midpeak", "offpeak"], + } + } + }, + None, + ), + ( + None, + { + "cycle": "none", + "delta_values": False, + "name": "Energy bill", + "net_consumption": False, + "offset": 0, "source": "sensor.energy", - "tariffs": ["onpeak", "midpeak", "offpeak"], - } - } - } - - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + "tariffs": "onpeak,midpeak,offpeak", + }, + ), + ), +) +async def test_state(hass, yaml_config, config_entry_config): + """Test utility sensor state.""" + if yaml_config: + assert await async_setup_component(hass, DOMAIN, yaml_config) + await hass.async_block_till_done() + entity_id = yaml_config[DOMAIN]["energy_bill"]["source"] + else: + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options=config_entry_config, + title=config_entry_config["name"], + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + entity_id = config_entry_config["source"] hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() - entity_id = config[DOMAIN]["energy_bill"]["source"] hass.states.async_set( entity_id, 2, {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR} ) @@ -175,7 +208,6 @@ async def test_state(hass): 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} ) @@ -185,7 +217,6 @@ async def test_state(hass): 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} ) @@ -195,19 +226,51 @@ async def test_state(hass): assert state.state == "0.123" -async def test_init(hass): - """Test utility sensor state initializtion.""" - config = { - "utility_meter": { - "energy_bill": { +@pytest.mark.parametrize( + "yaml_config,config_entry_config", + ( + ( + { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + "tariffs": ["onpeak", "midpeak", "offpeak"], + } + } + }, + None, + ), + ( + None, + { + "cycle": "none", + "delta_values": False, + "name": "Energy bill", + "net_consumption": False, + "offset": 0, "source": "sensor.energy", - "tariffs": ["onpeak", "midpeak", "offpeak"], - } - } - } - - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + "tariffs": "onpeak,midpeak,offpeak", + }, + ), + ), +) +async def test_init(hass, yaml_config, config_entry_config): + """Test utility sensor state initializtion.""" + if yaml_config: + assert await async_setup_component(hass, DOMAIN, yaml_config) + await hass.async_block_till_done() + entity_id = yaml_config[DOMAIN]["energy_bill"]["source"] + else: + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options=config_entry_config, + title=config_entry_config["name"], + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + entity_id = config_entry_config["source"] hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() @@ -220,7 +283,6 @@ async def test_init(hass): assert state is not None assert state.state == STATE_UNKNOWN - entity_id = config[DOMAIN]["energy_bill"]["source"] hass.states.async_set( entity_id, 2, {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR} ) @@ -238,31 +300,74 @@ async def test_init(hass): assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR -async def test_device_class(hass): +@pytest.mark.parametrize( + "yaml_config,config_entry_configs", + ( + ( + { + "utility_meter": { + "energy_meter": { + "source": "sensor.energy", + "net_consumption": True, + }, + "gas_meter": { + "source": "sensor.gas", + }, + } + }, + None, + ), + ( + None, + [ + { + "cycle": "none", + "delta_values": False, + "name": "Energy meter", + "net_consumption": True, + "offset": 0, + "source": "sensor.energy", + "tariffs": "", + }, + { + "cycle": "none", + "delta_values": False, + "name": "Gas meter", + "net_consumption": False, + "offset": 0, + "source": "sensor.gas", + "tariffs": "", + }, + ], + ), + ), +) +async def test_device_class(hass, yaml_config, config_entry_configs): """Test utility device_class.""" - config = { - "utility_meter": { - "energy_meter": { - "source": "sensor.energy", - "net_consumption": True, - }, - "gas_meter": { - "source": "sensor.gas", - }, - } - } + if yaml_config: + assert await async_setup_component(hass, DOMAIN, yaml_config) + await hass.async_block_till_done() + else: + for config_entry_config in config_entry_configs: + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options=config_entry_config, + title=config_entry_config["name"], + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + entity_id_energy = "sensor.energy" + entity_id_gas = "sensor.gas" hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() - entity_id_energy = config[DOMAIN]["energy_meter"]["source"] hass.states.async_set( entity_id_energy, 2, {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR} ) - entity_id_gas = config[DOMAIN]["gas_meter"]["source"] hass.states.async_set( entity_id_gas, 2, {ATTR_UNIT_OF_MEASUREMENT: "some_archaic_unit"} ) @@ -283,17 +388,37 @@ async def test_device_class(hass): assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "some_archaic_unit" -async def test_restore_state(hass): +@pytest.mark.parametrize( + "yaml_config,config_entry_config", + ( + ( + { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + "tariffs": ["onpeak", "midpeak", "offpeak"], + } + } + }, + None, + ), + ( + None, + { + "cycle": "none", + "delta_values": False, + "name": "Energy bill", + "net_consumption": False, + "offset": 0, + "source": "sensor.energy", + "tariffs": "onpeak,midpeak,offpeak", + }, + ), + ), +) +async def test_restore_state(hass, yaml_config, config_entry_config): """Test utility sensor restore state.""" last_reset = "2020-12-21T00:00:00.013073+00:00" - config = { - "utility_meter": { - "energy_bill": { - "source": "sensor.energy", - "tariffs": ["onpeak", "midpeak", "offpeak"], - } - } - } mock_restore_cache( hass, [ @@ -322,8 +447,19 @@ async def test_restore_state(hass): ], ) - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + if yaml_config: + assert await async_setup_component(hass, DOMAIN, yaml_config) + await hass.async_block_till_done() + else: + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options=config_entry_config, + title=config_entry_config["name"], + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() # restore from cache state = hass.states.get("sensor.energy_bill_onpeak") @@ -355,19 +491,53 @@ async def test_restore_state(hass): assert state.attributes.get("status") == PAUSED -async def test_net_consumption(hass): +@pytest.mark.parametrize( + "yaml_config,config_entry_config", + ( + ( + { + "utility_meter": { + "energy_bill": { + "net_consumption": True, + "source": "sensor.energy", + } + } + }, + None, + ), + ( + None, + { + "cycle": "none", + "delta_values": False, + "name": "Energy bill", + "net_consumption": True, + "offset": 0, + "source": "sensor.energy", + "tariffs": "", + }, + ), + ), +) +async def test_net_consumption(hass, yaml_config, config_entry_config): """Test utility sensor state.""" - config = { - "utility_meter": { - "energy_bill": {"source": "sensor.energy", "net_consumption": True} - } - } - - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + if yaml_config: + assert await async_setup_component(hass, DOMAIN, yaml_config) + await hass.async_block_till_done() + entity_id = yaml_config[DOMAIN]["energy_bill"]["source"] + else: + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options=config_entry_config, + title=config_entry_config["name"], + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + entity_id = config_entry_config["source"] hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - entity_id = config[DOMAIN]["energy_bill"]["source"] hass.states.async_set( entity_id, 2, {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR} ) @@ -389,19 +559,53 @@ async def test_net_consumption(hass): assert state.state == "-1" -async def test_non_net_consumption(hass): +@pytest.mark.parametrize( + "yaml_config,config_entry_config", + ( + ( + { + "utility_meter": { + "energy_bill": { + "net_consumption": False, + "source": "sensor.energy", + } + } + }, + None, + ), + ( + None, + { + "cycle": "none", + "delta_values": False, + "name": "Energy bill", + "net_consumption": False, + "offset": 0, + "source": "sensor.energy", + "tariffs": "", + }, + ), + ), +) +async def test_non_net_consumption(hass, yaml_config, config_entry_config): """Test utility sensor state.""" - config = { - "utility_meter": { - "energy_bill": {"source": "sensor.energy", "net_consumption": False} - } - } - - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + if yaml_config: + assert await async_setup_component(hass, DOMAIN, yaml_config) + await hass.async_block_till_done() + entity_id = yaml_config[DOMAIN]["energy_bill"]["source"] + else: + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options=config_entry_config, + title=config_entry_config["name"], + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + entity_id = config_entry_config["source"] hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - entity_id = config[DOMAIN]["energy_bill"]["source"] hass.states.async_set( entity_id, 2, {ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR} ) @@ -423,21 +627,55 @@ async def test_non_net_consumption(hass): assert state.state == "0" -async def test_delta_values(hass): +@pytest.mark.parametrize( + "yaml_config,config_entry_config", + ( + ( + { + "utility_meter": { + "energy_bill": { + "delta_values": True, + "source": "sensor.energy", + } + } + }, + None, + ), + ( + None, + { + "cycle": "none", + "delta_values": True, + "name": "Energy bill", + "net_consumption": False, + "offset": 0, + "source": "sensor.energy", + "tariffs": "", + }, + ), + ), +) +async def test_delta_values(hass, yaml_config, config_entry_config): """Test utility meter "delta_values" mode.""" - config = { - "utility_meter": { - "energy_bill": {"source": "sensor.energy", "delta_values": True} - } - } - now = dt_util.utcnow() with alter_time(now): - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + if yaml_config: + assert await async_setup_component(hass, DOMAIN, yaml_config) + await hass.async_block_till_done() + entity_id = yaml_config[DOMAIN]["energy_bill"]["source"] + else: + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options=config_entry_config, + title=config_entry_config["name"], + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + entity_id = config_entry_config["source"] hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - entity_id = config[DOMAIN]["energy_bill"]["source"] async_fire_time_changed(hass, now) hass.states.async_set(