diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 45ef8ea5c17..5db085343bc 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -1,13 +1,16 @@ """Helper sensor for calculating utility costs.""" from __future__ import annotations +import copy from dataclasses import dataclass import logging from typing import Any, Final, Literal, TypeVar, cast from homeassistant.components.sensor import ( + ATTR_LAST_RESET, ATTR_STATE_CLASS, DEVICE_CLASS_MONETARY, + STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) @@ -18,14 +21,19 @@ from homeassistant.const import ( ENERGY_WATT_HOUR, VOLUME_CUBIC_METERS, ) -from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.core import HomeAssistant, State, callback, split_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +import homeassistant.util.dt as dt_util from .const import DOMAIN from .data import EnergyManager, async_get_manager +SUPPORTED_STATE_CLASSES = [ + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +] _LOGGER = logging.getLogger(__name__) @@ -206,15 +214,16 @@ class EnergyCostSensor(SensorEntity): f"{config[adapter.entity_energy_key]}_{adapter.entity_id_suffix}" ) self._attr_device_class = DEVICE_CLASS_MONETARY - self._attr_state_class = STATE_CLASS_TOTAL_INCREASING + self._attr_state_class = STATE_CLASS_MEASUREMENT self._config = config - self._last_energy_sensor_state: StateType | None = None + self._last_energy_sensor_state: State | None = None self._cur_value = 0.0 - def _reset(self, energy_state: StateType) -> None: + def _reset(self, energy_state: State) -> None: """Reset the cost sensor.""" self._attr_native_value = 0.0 self._cur_value = 0.0 + self._attr_last_reset = dt_util.utcnow() self._last_energy_sensor_state = energy_state self.async_write_ha_state() @@ -228,9 +237,8 @@ class EnergyCostSensor(SensorEntity): if energy_state is None: return - if ( - state_class := energy_state.attributes.get(ATTR_STATE_CLASS) - ) != STATE_CLASS_TOTAL_INCREASING: + state_class = energy_state.attributes.get(ATTR_STATE_CLASS) + if state_class not in SUPPORTED_STATE_CLASSES: if not self._wrong_state_class_reported: self._wrong_state_class_reported = True _LOGGER.warning( @@ -240,6 +248,13 @@ class EnergyCostSensor(SensorEntity): ) return + # last_reset must be set if the sensor is STATE_CLASS_MEASUREMENT + if ( + state_class == STATE_CLASS_MEASUREMENT + and ATTR_LAST_RESET not in energy_state.attributes + ): + return + try: energy = float(energy_state.state) except ValueError: @@ -273,7 +288,7 @@ class EnergyCostSensor(SensorEntity): if self._last_energy_sensor_state is None: # Initialize as it's the first time all required entities are in place. - self._reset(energy_state.state) + self._reset(energy_state) return energy_unit = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -298,20 +313,29 @@ class EnergyCostSensor(SensorEntity): ) return - if reset_detected( + if state_class != STATE_CLASS_TOTAL_INCREASING and energy_state.attributes.get( + ATTR_LAST_RESET + ) != self._last_energy_sensor_state.attributes.get(ATTR_LAST_RESET): + # Energy meter was reset, reset cost sensor too + energy_state_copy = copy.copy(energy_state) + energy_state_copy.state = "0.0" + self._reset(energy_state_copy) + elif state_class == STATE_CLASS_TOTAL_INCREASING and reset_detected( self.hass, cast(str, self._config[self._adapter.entity_energy_key]), energy, - float(self._last_energy_sensor_state), + float(self._last_energy_sensor_state.state), ): # Energy meter was reset, reset cost sensor too - self._reset(0) + energy_state_copy = copy.copy(energy_state) + energy_state_copy.state = "0.0" + self._reset(energy_state_copy) # Update with newly incurred cost - old_energy_value = float(self._last_energy_sensor_state) + old_energy_value = float(self._last_energy_sensor_state.state) self._cur_value += (energy - old_energy_value) * energy_price self._attr_native_value = round(self._cur_value, 2) - self._last_energy_sensor_state = energy_state.state + self._last_energy_sensor_state = energy_state async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index 9ee6df30b5e..7097788aa30 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -113,7 +113,11 @@ def _async_validate_usage_stat( state_class = state.attributes.get("state_class") - if state_class != sensor.STATE_CLASS_TOTAL_INCREASING: + supported_state_classes = [ + sensor.STATE_CLASS_MEASUREMENT, + sensor.STATE_CLASS_TOTAL_INCREASING, + ] + if state_class not in supported_state_classes: result.append( ValidationIssue( "entity_unexpected_state_class_total_increasing", @@ -140,16 +144,13 @@ def _async_validate_price_entity( return try: - value: float | None = float(state.state) + float(state.state) except ValueError: result.append( ValidationIssue("entity_state_non_numeric", entity_id, state.state) ) return - if value is not None and value < 0: - result.append(ValidationIssue("entity_negative_state", entity_id, value)) - unit = state.attributes.get("unit_of_measurement") if unit is None or not unit.endswith( @@ -203,7 +204,11 @@ def _async_validate_cost_entity( state_class = state.attributes.get("state_class") - if state_class != sensor.STATE_CLASS_TOTAL_INCREASING: + supported_state_classes = [ + sensor.STATE_CLASS_MEASUREMENT, + sensor.STATE_CLASS_TOTAL_INCREASING, + ] + if state_class not in supported_state_classes: result.append( ValidationIssue( "entity_unexpected_state_class_total_increasing", entity_id, state_class diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index f91ddd92206..1f0da2e45a6 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -78,7 +78,7 @@ async def test_cost_sensor_no_states(hass, hass_storage) -> None: ), ], ) -async def test_cost_sensor_price_entity( +async def test_cost_sensor_price_entity_total_increasing( hass, hass_storage, hass_ws_client, @@ -90,7 +90,7 @@ async def test_cost_sensor_price_entity( cost_sensor_entity_id, flow_type, ) -> None: - """Test energy cost price from sensor entity.""" + """Test energy cost price from total_increasing type sensor entity.""" def _compile_statistics(_): return compile_statistics(hass, now, now + timedelta(seconds=1)) @@ -137,6 +137,7 @@ async def test_cost_sensor_price_entity( } now = dt_util.utcnow() + last_reset_cost_sensor = now.isoformat() # Optionally initialize dependent entities if initial_energy is not None: @@ -153,7 +154,9 @@ async def test_cost_sensor_price_entity( state = hass.states.get(cost_sensor_entity_id) assert state.state == initial_cost assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + if initial_cost != "unknown": + assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == "measurement" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" # Optional late setup of dependent entities @@ -169,7 +172,8 @@ async def test_cost_sensor_price_entity( state = hass.states.get(cost_sensor_entity_id) assert state.state == "0.0" assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == "measurement" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" # # Unique ID temp disabled @@ -186,6 +190,7 @@ async def test_cost_sensor_price_entity( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "10.0" # 0 EUR + (10-0) kWh * 1 EUR/kWh = 10 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor # Nothing happens when price changes if price_entity is not None: @@ -200,6 +205,7 @@ async def test_cost_sensor_price_entity( assert msg["success"] state = hass.states.get(cost_sensor_entity_id) assert state.state == "10.0" # 10 EUR + (10-10) kWh * 2 EUR/kWh = 10 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor # Additional consumption is using the new price hass.states.async_set( @@ -210,6 +216,7 @@ async def test_cost_sensor_price_entity( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "19.0" # 10 EUR + (14.5-10) kWh * 2 EUR/kWh = 19 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor # Check generated statistics await async_wait_recording_done_without_instance(hass) @@ -226,6 +233,7 @@ async def test_cost_sensor_price_entity( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "18.0" # 19 EUR + (14-14.5) kWh * 2 EUR/kWh = 18 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor # Energy sensor is reset, with initial state at 4kWh, 0 kWh is used as zero-point hass.states.async_set( @@ -236,6 +244,8 @@ async def test_cost_sensor_price_entity( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "8.0" # 0 EUR + (4-0) kWh * 2 EUR/kWh = 8 EUR + assert state.attributes["last_reset"] != last_reset_cost_sensor + last_reset_cost_sensor = state.attributes["last_reset"] # Energy use bumped to 10 kWh hass.states.async_set( @@ -246,6 +256,213 @@ async def test_cost_sensor_price_entity( await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "20.0" # 8 EUR + (10-4) kWh * 2 EUR/kWh = 20 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor + + # Check generated statistics + await async_wait_recording_done_without_instance(hass) + statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) + assert cost_sensor_entity_id in statistics + assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 38.0 + + +@pytest.mark.parametrize("initial_energy,initial_cost", [(0, "0.0"), (None, "unknown")]) +@pytest.mark.parametrize( + "price_entity,fixed_price", [("sensor.energy_price", None), (None, 1)] +) +@pytest.mark.parametrize( + "usage_sensor_entity_id,cost_sensor_entity_id,flow_type", + [ + ("sensor.energy_consumption", "sensor.energy_consumption_cost", "flow_from"), + ( + "sensor.energy_production", + "sensor.energy_production_compensation", + "flow_to", + ), + ], +) +@pytest.mark.parametrize("energy_state_class", ["measurement"]) +async def test_cost_sensor_price_entity_total( + hass, + hass_storage, + hass_ws_client, + initial_energy, + initial_cost, + price_entity, + fixed_price, + usage_sensor_entity_id, + cost_sensor_entity_id, + flow_type, + energy_state_class, +) -> None: + """Test energy cost price from total type sensor entity.""" + + def _compile_statistics(_): + return compile_statistics(hass, now, now + timedelta(seconds=1)) + + energy_attributes = { + ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_STATE_CLASS: energy_state_class, + } + + await async_init_recorder_component(hass) + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.energy_consumption", + "entity_energy_from": "sensor.energy_consumption", + "stat_cost": None, + "entity_energy_price": price_entity, + "number_energy_price": fixed_price, + } + ] + if flow_type == "flow_from" + else [], + "flow_to": [ + { + "stat_energy_to": "sensor.energy_production", + "entity_energy_to": "sensor.energy_production", + "stat_compensation": None, + "entity_energy_price": price_entity, + "number_energy_price": fixed_price, + } + ] + if flow_type == "flow_to" + else [], + "cost_adjustment_day": 0, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + now = dt_util.utcnow() + last_reset = dt_util.utc_from_timestamp(0).isoformat() + last_reset_cost_sensor = now.isoformat() + + # Optionally initialize dependent entities + if initial_energy is not None: + hass.states.async_set( + usage_sensor_entity_id, + initial_energy, + {**energy_attributes, **{"last_reset": last_reset}}, + ) + hass.states.async_set("sensor.energy_price", "1") + + with patch("homeassistant.util.dt.utcnow", return_value=now): + await setup_integration(hass) + + state = hass.states.get(cost_sensor_entity_id) + assert state.state == initial_cost + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY + if initial_cost != "unknown": + assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == "measurement" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" + + # Optional late setup of dependent entities + if initial_energy is None: + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set( + usage_sensor_entity_id, + "0", + {**energy_attributes, **{"last_reset": last_reset}}, + ) + await hass.async_block_till_done() + + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "0.0" + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY + assert state.attributes["last_reset"] == last_reset_cost_sensor + assert state.attributes[ATTR_STATE_CLASS] == "measurement" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" + + # # Unique ID temp disabled + # # entity_registry = er.async_get(hass) + # # entry = entity_registry.async_get(cost_sensor_entity_id) + # # assert entry.unique_id == "energy_energy_consumption cost" + + # Energy use bumped to 10 kWh + hass.states.async_set( + usage_sensor_entity_id, + "10", + {**energy_attributes, **{"last_reset": last_reset}}, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "10.0" # 0 EUR + (10-0) kWh * 1 EUR/kWh = 10 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor + + # Nothing happens when price changes + if price_entity is not None: + hass.states.async_set(price_entity, "2") + await hass.async_block_till_done() + else: + energy_data = copy.deepcopy(energy_data) + energy_data["energy_sources"][0][flow_type][0]["number_energy_price"] = 2 + client = await hass_ws_client(hass) + await client.send_json({"id": 5, "type": "energy/save_prefs", **energy_data}) + msg = await client.receive_json() + assert msg["success"] + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "10.0" # 10 EUR + (10-10) kWh * 2 EUR/kWh = 10 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor + + # Additional consumption is using the new price + hass.states.async_set( + usage_sensor_entity_id, + "14.5", + {**energy_attributes, **{"last_reset": last_reset}}, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "19.0" # 10 EUR + (14.5-10) kWh * 2 EUR/kWh = 19 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor + + # Check generated statistics + await async_wait_recording_done_without_instance(hass) + statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) + assert cost_sensor_entity_id in statistics + assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 19.0 + + # Energy sensor has a small dip + hass.states.async_set( + usage_sensor_entity_id, + "14", + {**energy_attributes, **{"last_reset": last_reset}}, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "18.0" # 19 EUR + (14-14.5) kWh * 2 EUR/kWh = 18 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor + + # Energy sensor is reset, with initial state at 4kWh, 0 kWh is used as zero-point + last_reset = (now + timedelta(seconds=1)).isoformat() + hass.states.async_set( + usage_sensor_entity_id, + "4", + {**energy_attributes, **{"last_reset": last_reset}}, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "8.0" # 0 EUR + (4-0) kWh * 2 EUR/kWh = 8 EUR + assert state.attributes["last_reset"] != last_reset_cost_sensor + last_reset_cost_sensor = state.attributes["last_reset"] + + # Energy use bumped to 10 kWh + hass.states.async_set( + usage_sensor_entity_id, + "10", + {**energy_attributes, **{"last_reset": last_reset}}, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "20.0" # 8 EUR + (10-4) kWh * 2 EUR/kWh = 20 EUR + assert state.attributes["last_reset"] == last_reset_cost_sensor # Check generated statistics await async_wait_recording_done_without_instance(hass) @@ -285,6 +502,7 @@ async def test_cost_sensor_handle_wh(hass, hass_storage) -> None: now = dt_util.utcnow() + # Initial state: 10kWh hass.states.async_set( "sensor.energy_consumption", 10000, @@ -297,7 +515,7 @@ async def test_cost_sensor_handle_wh(hass, hass_storage) -> None: state = hass.states.get("sensor.energy_consumption_cost") assert state.state == "0.0" - # Energy use bumped to 10 kWh + # Energy use bumped by 10 kWh hass.states.async_set( "sensor.energy_consumption", 20000, @@ -362,7 +580,7 @@ async def test_cost_sensor_handle_gas(hass, hass_storage) -> None: async def test_cost_sensor_wrong_state_class( hass, hass_storage, caplog, state_class ) -> None: - """Test energy sensor rejects wrong state_class.""" + """Test energy sensor rejects state_class with wrong state_class.""" energy_attributes = { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_STATE_CLASS: state_class, @@ -418,3 +636,61 @@ async def test_cost_sensor_wrong_state_class( state = hass.states.get("sensor.energy_consumption_cost") assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize("state_class", ["measurement"]) +async def test_cost_sensor_state_class_measurement_no_reset( + hass, hass_storage, caplog, state_class +) -> None: + """Test energy sensor rejects state_class with no last_reset.""" + energy_attributes = { + ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_STATE_CLASS: state_class, + } + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.energy_consumption", + "entity_energy_from": "sensor.energy_consumption", + "stat_cost": None, + "entity_energy_price": None, + "number_energy_price": 0.5, + } + ], + "flow_to": [], + "cost_adjustment_day": 0, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + now = dt_util.utcnow() + + hass.states.async_set( + "sensor.energy_consumption", + 10000, + energy_attributes, + ) + + with patch("homeassistant.util.dt.utcnow", return_value=now): + await setup_integration(hass) + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == STATE_UNKNOWN + + # Energy use bumped to 10 kWh + hass.states.async_set( + "sensor.energy_consumption", + 20000, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == STATE_UNKNOWN diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index 31f40bd24ea..8c67f3eabaf 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -382,15 +382,6 @@ async def test_validation_grid_price_not_exist(hass, mock_energy_manager): "value": "123,123.12", }, ), - ( - "-100", - "$/kWh", - { - "type": "entity_negative_state", - "identifier": "sensor.grid_price_1", - "value": -100.0, - }, - ), ( "123", "$/Ws", @@ -414,7 +405,7 @@ async def test_validation_grid_price_errors( hass.states.async_set( "sensor.grid_price_1", state, - {"unit_of_measurement": unit, "state_class": "total_increasing"}, + {"unit_of_measurement": unit, "state_class": "measurement"}, ) await mock_energy_manager.async_update( {