diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index c436ea757ac..11e58fca775 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -21,6 +21,7 @@ from .const import ( CONF_METER_DELTA_VALUES, CONF_METER_NET_CONSUMPTION, CONF_METER_OFFSET, + CONF_METER_PERIODICALLY_RESETTING, CONF_METER_TYPE, CONF_SOURCE_SENSOR, CONF_TARIFF, @@ -83,6 +84,7 @@ METER_CONFIG_SCHEMA = vol.Schema( ), vol.Optional(CONF_METER_DELTA_VALUES, default=False): cv.boolean, vol.Optional(CONF_METER_NET_CONSUMPTION, default=False): cv.boolean, + vol.Optional(CONF_METER_PERIODICALLY_RESETTING, default=True): cv.boolean, vol.Optional(CONF_TARIFFS, default=[]): vol.All( cv.ensure_list, vol.Unique(), [cv.string] ), @@ -221,13 +223,29 @@ async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + platforms_to_unload = [Platform.SENSOR] + if entry.options.get(CONF_TARIFFS): + platforms_to_unload.append(Platform.SELECT) + if unload_ok := await hass.config_entries.async_unload_platforms( entry, - ( - Platform.SELECT, - Platform.SENSOR, - ), + platforms_to_unload, ): hass.data[DATA_UTILITY].pop(entry.entry_id) return unload_ok + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version == 1: + new = {**config_entry.options} + new[CONF_METER_PERIODICALLY_RESETTING] = True + config_entry.version = 2 + hass.config_entries.async_update_entry(config_entry, options=new) + + _LOGGER.info("Migration to version %s successful", config_entry.version) + + return True diff --git a/homeassistant/components/utility_meter/config_flow.py b/homeassistant/components/utility_meter/config_flow.py index c1f82e902d2..eb5c19941dc 100644 --- a/homeassistant/components/utility_meter/config_flow.py +++ b/homeassistant/components/utility_meter/config_flow.py @@ -21,6 +21,7 @@ from .const import ( CONF_METER_DELTA_VALUES, CONF_METER_NET_CONSUMPTION, CONF_METER_OFFSET, + CONF_METER_PERIODICALLY_RESETTING, CONF_METER_TYPE, CONF_SOURCE_SENSOR, CONF_TARIFFS, @@ -64,6 +65,9 @@ OPTIONS_SCHEMA = vol.Schema( vol.Required(CONF_SOURCE_SENSOR): selector.EntitySelector( selector.EntitySelectorConfig(domain=SENSOR_DOMAIN), ), + vol.Required( + CONF_METER_PERIODICALLY_RESETTING, + ): selector.BooleanSelector(), } ) @@ -95,6 +99,10 @@ CONFIG_SCHEMA = vol.Schema( vol.Required( CONF_METER_DELTA_VALUES, default=False ): selector.BooleanSelector(), + vol.Required( + CONF_METER_PERIODICALLY_RESETTING, + default=True, + ): selector.BooleanSelector(), } ) @@ -110,6 +118,8 @@ OPTIONS_FLOW = { class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow for Utility Meter.""" + VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW diff --git a/homeassistant/components/utility_meter/const.py b/homeassistant/components/utility_meter/const.py index 9b85e9e3ae9..f8a4c2d4b75 100644 --- a/homeassistant/components/utility_meter/const.py +++ b/homeassistant/components/utility_meter/const.py @@ -32,6 +32,7 @@ CONF_METER_TYPE = "cycle" CONF_METER_OFFSET = "offset" CONF_METER_DELTA_VALUES = "delta_values" CONF_METER_NET_CONSUMPTION = "net_consumption" +CONF_METER_PERIODICALLY_RESETTING = "periodically_resetting" CONF_PAUSED = "paused" CONF_TARIFFS = "tariffs" CONF_TARIFF = "tariff" diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 066a3cd6e10..dad2d8dfaf3 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfEnergy, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, State, callback 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 @@ -50,6 +50,7 @@ from .const import ( CONF_METER_DELTA_VALUES, CONF_METER_NET_CONSUMPTION, CONF_METER_OFFSET, + CONF_METER_PERIODICALLY_RESETTING, CONF_METER_TYPE, CONF_SOURCE_SENSOR, CONF_TARIFF, @@ -85,6 +86,7 @@ ATTR_SOURCE_ID = "source" ATTR_STATUS = "status" ATTR_PERIOD = "meter_period" ATTR_LAST_PERIOD = "last_period" +ATTR_LAST_VALID_STATE = "last_valid_state" ATTR_TARIFF = "tariff" DEVICE_CLASS_MAP = { @@ -127,6 +129,7 @@ async def async_setup_entry( meter_type = None name = config_entry.title net_consumption = config_entry.options[CONF_METER_NET_CONSUMPTION] + periodically_resetting = config_entry.options[CONF_METER_PERIODICALLY_RESETTING] tariff_entity = hass.data[DATA_UTILITY][entry_id][CONF_TARIFF_ENTITY] meters = [] @@ -142,6 +145,7 @@ async def async_setup_entry( name=name, net_consumption=net_consumption, parent_meter=entry_id, + periodically_resetting=periodically_resetting, source_entity=source_entity_id, tariff_entity=tariff_entity, tariff=None, @@ -160,6 +164,7 @@ async def async_setup_entry( name=f"{name} {tariff}", net_consumption=net_consumption, parent_meter=entry_id, + periodically_resetting=periodically_resetting, source_entity=source_entity_id, tariff_entity=tariff_entity, tariff=tariff, @@ -223,6 +228,9 @@ async def async_setup_platform( conf_meter_net_consumption = hass.data[DATA_UTILITY][meter][ CONF_METER_NET_CONSUMPTION ] + conf_meter_periodically_resetting = hass.data[DATA_UTILITY][meter][ + CONF_METER_PERIODICALLY_RESETTING + ] conf_meter_tariff_entity = hass.data[DATA_UTILITY][meter].get( CONF_TARIFF_ENTITY ) @@ -235,6 +243,7 @@ async def async_setup_platform( name=conf_sensor_name, net_consumption=conf_meter_net_consumption, parent_meter=meter, + periodically_resetting=conf_meter_periodically_resetting, source_entity=conf_meter_source, tariff_entity=conf_meter_tariff_entity, tariff=conf_sensor_tariff, @@ -262,6 +271,7 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): last_period: Decimal last_reset: datetime | None + last_valid_state: Decimal | None status: str def as_dict(self) -> dict[str, Any]: @@ -270,6 +280,9 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): data["last_period"] = str(self.last_period) if isinstance(self.last_reset, (datetime)): data["last_reset"] = self.last_reset.isoformat() + data["last_valid_state"] = ( + str(self.last_valid_state) if self.last_valid_state else None + ) data["status"] = self.status return data @@ -284,6 +297,11 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): try: last_period: Decimal = Decimal(restored["last_period"]) last_reset: datetime | None = dt_util.parse_datetime(restored["last_reset"]) + last_valid_state: Decimal | None = ( + Decimal(restored["last_valid_state"]) + if restored.get("last_valid_state") + else None + ) status: str = restored["status"] except KeyError: # restored is a dict, but does not have all values @@ -297,6 +315,7 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): extra.native_unit_of_measurement, last_period, last_reset, + last_valid_state, status, ) @@ -316,6 +335,7 @@ class UtilityMeterSensor(RestoreSensor): name, net_consumption, parent_meter, + periodically_resetting, source_entity, tariff_entity, tariff, @@ -330,6 +350,7 @@ class UtilityMeterSensor(RestoreSensor): self._state = None self._last_period = Decimal(0) self._last_reset = dt_util.utcnow() + self._last_valid_state = None self._collecting = None self._name = name self._unit_of_measurement = None @@ -346,6 +367,7 @@ class UtilityMeterSensor(RestoreSensor): self._cron_pattern = cron_pattern self._sensor_delta_values = delta_values self._sensor_net_consumption = net_consumption + self._sensor_periodically_resetting = periodically_resetting self._tariff = tariff self._tariff_entity = tariff_entity @@ -355,53 +377,70 @@ class UtilityMeterSensor(RestoreSensor): self._state = 0 self.async_write_ha_state() - @callback - def async_reading(self, event): - """Handle the sensor state changes.""" - old_state = event.data.get("old_state") - new_state = event.data.get("new_state") + @staticmethod + def _validate_state(state: State | None) -> Decimal | None: + """Parse the state as a Decimal if available. Throws DecimalException if the state is not a number.""" + try: + return ( + None + if state is None or state.state in [STATE_UNAVAILABLE, STATE_UNKNOWN] + else Decimal(state.state) + ) + except DecimalException: + return None - if self._state is None and new_state.state: + def calculate_adjustment( + self, old_state: State | None, new_state: State + ) -> Decimal | None: + """Calculate the adjustment based on the old and new state.""" + + # First check if the new_state is valid (see discussion in PR #88446) + if (new_state_val := self._validate_state(new_state)) is None: + _LOGGER.warning("Invalid state %s", new_state.state) + return None + + if self._sensor_delta_values: + return new_state_val + + if ( + not self._sensor_periodically_resetting + and self._last_valid_state is not None + ): # Fallback to old_state if sensor is periodically resetting but last_valid_state is None + return new_state_val - self._last_valid_state + + if (old_state_val := self._validate_state(old_state)) is not None: + return new_state_val - old_state_val + _LOGGER.warning( + "Invalid state (%s > %s)", + old_state.state if old_state else None, + new_state_val, + ) + return None + + @callback + def async_reading(self, event: Event): + """Handle the sensor state changes.""" + old_state: State | None = event.data.get("old_state") + new_state: State = event.data.get("new_state") # type: ignore[assignment] # a state change event always has a new state + + if (new_state_val := self._validate_state(new_state)) is None: + _LOGGER.warning("Invalid state %s", new_state.state) + return + + if self._state is None: # First state update initializes the utility_meter sensors - source_state = self.hass.states.get(self._sensor_source_id) for sensor in self.hass.data[DATA_UTILITY][self._parent_meter][ DATA_TARIFF_SENSORS ]: - sensor.start(source_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) + sensor.start(new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) if ( - new_state is None - or new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] - or ( - not self._sensor_delta_values - and ( - old_state is None - or old_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] - ) - ) - ): - return + adjustment := self.calculate_adjustment(old_state, new_state) + ) is not None and (self._sensor_net_consumption or adjustment >= 0): + # If net_consumption is off, the adjustment must be non-negative + self._state += adjustment # type: ignore[operator] # self._state will be set to by the start function if it is None, therefore it always has a valid Decimal value at this line - self._unit_of_measurement = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - - try: - if self._sensor_delta_values: - adjustment = Decimal(new_state.state) - else: - adjustment = Decimal(new_state.state) - Decimal(old_state.state) - - if (not self._sensor_net_consumption) and adjustment < 0: - # Source sensor just rolled over for unknown reasons, - return - self._state += adjustment - - except DecimalException as err: - if self._sensor_delta_values: - _LOGGER.warning("Invalid adjustment of %s: %s", new_state.state, err) - else: - _LOGGER.warning( - "Invalid state (%s > %s): %s", old_state.state, new_state.state, err - ) + self._last_valid_state = new_state_val self.async_write_ha_state() @callback @@ -422,6 +461,11 @@ class UtilityMeterSensor(RestoreSensor): self._collecting() self._collecting = None + # Reset the last_valid_state during state change because if the last state before the tariff change was invalid, + # there is no way to know how much "adjustment" counts for which tariff. Therefore, we set the last_valid_state + # to None and let the fallback mechanism handle the case that the old state was valid + self._last_valid_state = None + _LOGGER.debug( "%s - %s - source <%s>", self._name, @@ -484,6 +528,7 @@ class UtilityMeterSensor(RestoreSensor): self._unit_of_measurement = last_sensor_data.native_unit_of_measurement self._last_period = last_sensor_data.last_period self._last_reset = last_sensor_data.last_reset + self._last_valid_state = last_sensor_data.last_valid_state if last_sensor_data.status == COLLECTING: # Null lambda to allow cancelling the collection on tariff change self._collecting = lambda: None @@ -508,6 +553,12 @@ class UtilityMeterSensor(RestoreSensor): and is_number(state.attributes[ATTR_LAST_PERIOD]) else Decimal(0) ) + self._last_valid_state = ( + Decimal(state.attributes[ATTR_LAST_VALID_STATE]) + if state.attributes.get(ATTR_LAST_VALID_STATE) + and is_number(state.attributes[ATTR_LAST_VALID_STATE]) + else None + ) self._last_reset = dt_util.as_utc( dt_util.parse_datetime(state.attributes.get(ATTR_LAST_RESET)) ) @@ -590,6 +641,7 @@ class UtilityMeterSensor(RestoreSensor): ATTR_SOURCE_ID: self._sensor_source_id, ATTR_STATUS: PAUSED if self._collecting is None else COLLECTING, ATTR_LAST_PERIOD: str(self._last_period), + ATTR_LAST_VALID_STATE: str(self._last_valid_state), } if self._period is not None: state_attr[ATTR_PERIOD] = self._period @@ -620,6 +672,7 @@ class UtilityMeterSensor(RestoreSensor): self.native_unit_of_measurement, self._last_period, self._last_reset, + self._last_valid_state, PAUSED if self._collecting is None else COLLECTING, ) diff --git a/homeassistant/components/utility_meter/strings.json b/homeassistant/components/utility_meter/strings.json index e9f8e7f2505..1eeacbae800 100644 --- a/homeassistant/components/utility_meter/strings.json +++ b/homeassistant/components/utility_meter/strings.json @@ -9,6 +9,7 @@ "cycle": "Meter reset cycle", "delta_values": "Delta values", "name": "Name", + "periodically_resetting": "Periodically resetting", "net_consumption": "Net consumption", "offset": "Meter reset offset", "source": "Input sensor", @@ -17,6 +18,7 @@ "data_description": { "delta_values": "Enable if the source values are delta values since the last reading instead of absolute values.", "net_consumption": "Enable if the source is a net meter, meaning it can both increase and decrease.", + "periodically_resetting": "Enable if the source may periodically reset to 0, for example at boot of the measuring device. If disabled, new readings are directly recorded after data inavailability.", "offset": "Offset the day of a monthly meter reset.", "tariffs": "A list of supported tariffs, leave empty if only a single tariff is needed." } @@ -27,7 +29,11 @@ "step": { "init": { "data": { - "source": "[%key:component::utility_meter::config::step::user::data::source%]" + "source": "[%key:component::utility_meter::config::step::user::data::source%]", + "periodically_resetting": "[%key:component::utility_meter::config::step::user::data::periodically_resetting%]" + }, + "data_description": { + "periodically_resetting": "[%key:component::utility_meter::config::step::user::data_description::periodically_resetting%]" } } } diff --git a/tests/components/utility_meter/test_config_flow.py b/tests/components/utility_meter/test_config_flow.py index 8deb7601aa6..302d3879a04 100644 --- a/tests/components/utility_meter/test_config_flow.py +++ b/tests/components/utility_meter/test_config_flow.py @@ -47,6 +47,7 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": input_sensor_entity_id, "tariffs": [], } @@ -60,6 +61,7 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": input_sensor_entity_id, "tariffs": [], } @@ -96,6 +98,7 @@ async def test_tariffs(hass: HomeAssistant) -> None: "delta_values": False, "name": "Electricity meter", "net_consumption": False, + "periodically_resetting": True, "offset": 0, "source": input_sensor_entity_id, "tariffs": ["cat", "dog", "horse", "cow"], @@ -109,6 +112,7 @@ async def test_tariffs(hass: HomeAssistant) -> None: "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": input_sensor_entity_id, "tariffs": ["cat", "dog", "horse", "cow"], } @@ -136,6 +140,57 @@ async def test_tariffs(hass: HomeAssistant) -> None: assert result["errors"]["base"] == "tariffs_not_unique" +async def test_non_periodically_resetting(hass: HomeAssistant) -> None: + """Test periodically resetting.""" + input_sensor_entity_id = "sensor.input" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "cycle": "monthly", + "name": "Electricity meter", + "offset": 0, + "periodically_resetting": False, + "source": input_sensor_entity_id, + "tariffs": [], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Electricity meter" + assert result["data"] == {} + assert result["options"] == { + "cycle": "monthly", + "delta_values": False, + "name": "Electricity meter", + "net_consumption": False, + "periodically_resetting": False, + "offset": 0, + "source": input_sensor_entity_id, + "tariffs": [], + } + + 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, + "periodically_resetting": False, + "source": input_sensor_entity_id, + "tariffs": [], + } + + def get_suggested(schema, key): """Get suggested value for key in voluptuous schema.""" for k in schema: @@ -162,6 +217,7 @@ async def test_options(hass: HomeAssistant) -> None: "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": input_sensor1_entity_id, "tariffs": "", }, @@ -176,10 +232,11 @@ async def test_options(hass: HomeAssistant) -> None: assert result["step_id"] == "init" schema = result["data_schema"].schema assert get_suggested(schema, "source") == input_sensor1_entity_id + assert get_suggested(schema, "periodically_resetting") is True result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"source": input_sensor2_entity_id}, + user_input={"source": input_sensor2_entity_id, "periodically_resetting": False}, ) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["data"] == { @@ -188,6 +245,7 @@ async def test_options(hass: HomeAssistant) -> None: "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": False, "source": input_sensor2_entity_id, "tariffs": "", } @@ -198,6 +256,7 @@ async def test_options(hass: HomeAssistant) -> None: "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": False, "source": input_sensor2_entity_id, "tariffs": "", } diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index ad4fc5e6e9a..5c8d8d4253c 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -186,6 +186,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None: "name": "Energy bill", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": ["peak", "offpeak"], }, @@ -202,6 +203,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None: "name": "Energy bill2", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": ["peak", "offpeak"], }, @@ -413,6 +415,7 @@ async def test_setup_and_remove_config_entry( "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": input_sensor_entity_id, "tariffs": tariffs, }, diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index c56010e36e5..d84099b4d66 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorStateClass, ) +from homeassistant.components.utility_meter import DEFAULT_OFFSET from homeassistant.components.utility_meter.const import ( ATTR_VALUE, DAILY, @@ -24,9 +25,11 @@ from homeassistant.components.utility_meter.const import ( ) from homeassistant.components.utility_meter.sensor import ( ATTR_LAST_RESET, + ATTR_LAST_VALID_STATE, ATTR_STATUS, COLLECTING, PAUSED, + UtilityMeterSensor, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -50,7 +53,7 @@ from tests.common import ( @pytest.fixture(autouse=True) -def set_utc(hass): +def set_utc(hass: HomeAssistant): """Set timezone to UTC.""" hass.config.set_time_zone("UTC") @@ -77,6 +80,7 @@ def set_utc(hass): "name": "Energy bill", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": ["onpeak", "midpeak", "offpeak"], }, @@ -272,6 +276,7 @@ async def test_not_unique_tariffs(hass: HomeAssistant, yaml_config) -> None: "name": "Energy bill", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": ["onpeak", "midpeak", "offpeak"], }, @@ -430,6 +435,7 @@ async def test_entity_name(hass: HomeAssistant, yaml_config, entity_id, name) -> "name": "Energy meter", "net_consumption": True, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": [], }, @@ -439,6 +445,7 @@ async def test_entity_name(hass: HomeAssistant, yaml_config, entity_id, name) -> "name": "Gas meter", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.gas", "tariffs": [], }, @@ -516,6 +523,7 @@ async def test_device_class( "name": "Energy bill", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": ["onpeak", "midpeak", "offpeak", "superpeak"], }, @@ -552,6 +560,7 @@ async def test_restore_state( "native_unit_of_measurement": "kWh", "last_reset": last_reset, "last_period": "7", + "last_valid_state": "None", "status": "paused", }, ), @@ -562,6 +571,7 @@ async def test_restore_state( attributes={ ATTR_STATUS: PAUSED, ATTR_LAST_RESET: last_reset, + ATTR_LAST_VALID_STATE: None, ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, }, ), @@ -571,6 +581,7 @@ async def test_restore_state( "decimal_str": "3", }, "native_unit_of_measurement": "kWh", + "last_valid_state": "None", }, ), ( @@ -580,6 +591,7 @@ async def test_restore_state( attributes={ ATTR_STATUS: COLLECTING, ATTR_LAST_RESET: last_reset, + ATTR_LAST_VALID_STATE: None, ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, }, ), @@ -589,6 +601,7 @@ async def test_restore_state( "decimal_str": "3f", }, "native_unit_of_measurement": "kWh", + "last_valid_state": "None", }, ), ( @@ -598,6 +611,7 @@ async def test_restore_state( attributes={ ATTR_STATUS: COLLECTING, ATTR_LAST_RESET: last_reset, + ATTR_LAST_VALID_STATE: None, ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, }, ), @@ -625,15 +639,18 @@ async def test_restore_state( assert state.state == "3" assert state.attributes.get("status") == PAUSED assert state.attributes.get("last_reset") == last_reset + assert state.attributes.get("last_valid_state") == "None" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR state = hass.states.get("sensor.energy_bill_midpeak") assert state.state == "5" + assert state.attributes.get("last_valid_state") == "None" state = hass.states.get("sensor.energy_bill_offpeak") assert state.state == "6" assert state.attributes.get("status") == COLLECTING assert state.attributes.get("last_reset") == last_reset + assert state.attributes.get("last_valid_state") == "None" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR state = hass.states.get("sensor.energy_bill_superpeak") @@ -675,6 +692,7 @@ async def test_restore_state( "name": "Energy bill", "net_consumption": True, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": [], }, @@ -829,6 +847,7 @@ async def test_non_net_consumption( "name": "Energy bill", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": [], }, @@ -884,7 +903,7 @@ async def test_delta_values( force_update=True, ) await hass.async_block_till_done() - assert "Invalid adjustment of None" in caplog.text + assert "Invalid state None" in caplog.text now += timedelta(seconds=30) with freeze_time(now): @@ -918,6 +937,272 @@ async def test_delta_values( assert state.state == "9" +@pytest.mark.parametrize( + ("yaml_config", "config_entry_config"), + ( + ( + { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + "periodically_resetting": False, + } + } + }, + None, + ), + ( + None, + { + "cycle": "none", + "delta_values": False, + "name": "Energy bill", + "net_consumption": False, + "offset": 0, + "periodically_resetting": False, + "source": "sensor.energy", + "tariffs": [], + }, + ), + ), +) +async def test_non_periodically_resetting( + hass: HomeAssistant, yaml_config, config_entry_config +) -> None: + """Test utility meter "non periodically resetting" mode.""" + # Home assistant is not runnit yet + hass.state = CoreState.not_running + + now = dt_util.utcnow() + with freeze_time(now): + 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"], + version=2, + ) + 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) + + async_fire_time_changed(hass, now) + hass.states.async_set( + entity_id, 1, {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR} + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state.attributes.get("status") == PAUSED + + now += timedelta(seconds=30) + with freeze_time(now): + async_fire_time_changed(hass, now) + hass.states.async_set( + entity_id, + 3, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR}, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state.state == "2" + assert state.attributes.get("last_valid_state") == "3" + assert state.attributes.get("status") == COLLECTING + + now += timedelta(seconds=30) + with freeze_time(now): + async_fire_time_changed(hass, now) + hass.states.async_set( + entity_id, + STATE_UNKNOWN, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR}, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state.state == "2" + assert state.attributes.get("last_valid_state") == "3" + assert state.attributes.get("status") == COLLECTING + + now += timedelta(seconds=30) + with freeze_time(now): + async_fire_time_changed(hass, now) + hass.states.async_set( + entity_id, + 6, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR}, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state.state == "5" + assert state.attributes.get("last_valid_state") == "6" + assert state.attributes.get("status") == COLLECTING + + now += timedelta(seconds=30) + with freeze_time(now): + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + hass.states.async_set( + entity_id, + 9, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR}, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state.state == "8" + assert state.attributes.get("last_valid_state") == "9" + assert state.attributes.get("status") == COLLECTING + + +@pytest.mark.parametrize( + ("yaml_config", "config_entry_config"), + ( + ( + { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + "periodically_resetting": False, + "tariffs": ["low", "high"], + } + } + }, + None, + ), + ( + None, + { + "cycle": "none", + "delta_values": False, + "name": "Energy bill", + "net_consumption": False, + "offset": 0, + "periodically_resetting": False, + "source": "sensor.energy", + "tariffs": ["low", "high"], + }, + ), + ), +) +async def test_non_periodically_resetting_meter_with_tariffs( + hass: HomeAssistant, yaml_config, config_entry_config +) -> None: + """Test test_non_periodically_resetting_meter_with_tariffs.""" + 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"], + version=2, + ) + 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() + + hass.states.async_set( + entity_id, 2, {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR} + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill_low") + assert state is not None + assert state.state == "0" + assert state.attributes.get("status") == COLLECTING + assert state.attributes.get("last_valid_state") == "2" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR + + state = hass.states.get("sensor.energy_bill_high") + assert state is not None + assert state.state == "0" + assert state.attributes.get("status") == PAUSED + assert state.attributes.get("last_valid_state") == "None" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR + + 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: UnitOfEnergy.KILO_WATT_HOUR}, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill_low") + assert state is not None + assert state.state == "1" + assert state.attributes.get("last_valid_state") == "3" + assert state.attributes.get("status") == COLLECTING + + state = hass.states.get("sensor.energy_bill_high") + assert state is not None + assert state.state == "0" + assert state.attributes.get("last_valid_state") == "None" + assert state.attributes.get("status") == PAUSED + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.energy_bill", "option": "high"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill_low") + assert state.attributes.get("last_valid_state") == "None" + assert state.attributes.get("status") == PAUSED + + state = hass.states.get("sensor.energy_bill_high") + assert state.attributes.get("last_valid_state") == "None" + assert state.attributes.get("status") == COLLECTING + + now = dt_util.utcnow() + timedelta(seconds=20) + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set( + entity_id, + 6, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR}, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill_low") + assert state is not None + assert state.state == "1" + assert state.attributes.get("last_valid_state") == "None" + assert state.attributes.get("status") == PAUSED + + state = hass.states.get("sensor.energy_bill_high") + assert state is not None + assert state.state == "3" + assert state.attributes.get("last_valid_state") == "6" + assert state.attributes.get("status") == COLLECTING + + def gen_config(cycle, offset=None): """Generate configuration.""" config = { @@ -932,7 +1217,9 @@ def gen_config(cycle, offset=None): return config -async def _test_self_reset(hass, config, start_time, expect_reset=True): +async def _test_self_reset( + hass: HomeAssistant, config, start_time, expect_reset=True +) -> None: """Test energy sensor self reset.""" now = dt_util.parse_datetime(start_time) with freeze_time(now): @@ -1142,3 +1429,27 @@ async def test_bad_offset(hass: HomeAssistant) -> None: assert not await async_setup_component( hass, DOMAIN, gen_config("monthly", timedelta(days=31)) ) + + +def test_calculate_adjustment_invalid_new_state( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that calculate_adjustment method returns None if the new state is invalid.""" + mock_sensor = UtilityMeterSensor( + cron_pattern=None, + delta_values=False, + meter_offset=DEFAULT_OFFSET, + meter_type=DAILY, + name="Test utility meter", + net_consumption=False, + parent_meter="sensor.test", + periodically_resetting=True, + unique_id="test_utility_meter", + source_entity="sensor.test", + tariff=None, + tariff_entity=None, + ) + + new_state: State = State(entity_id="sensor.test", state="unknown") + assert mock_sensor.calculate_adjustment(None, new_state) is None + assert "Invalid state unknown" in caplog.text