Add periodically resetting meter option to utility meter (#88446)

* Use last valid state if meter is not periodically resetting

* Fix unload of entry, used during options flow submit

* Adjustments based on code review

* Move DecimalException handling to validation method

* Add test for invalid new state in calculate_adjustment method
This commit is contained in:
Wesley Vos 2023-03-28 17:09:20 +02:00 committed by GitHub
parent e45eab600f
commit 478a1d5e9a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 510 additions and 49 deletions

View File

@ -21,6 +21,7 @@ from .const import (
CONF_METER_DELTA_VALUES, CONF_METER_DELTA_VALUES,
CONF_METER_NET_CONSUMPTION, CONF_METER_NET_CONSUMPTION,
CONF_METER_OFFSET, CONF_METER_OFFSET,
CONF_METER_PERIODICALLY_RESETTING,
CONF_METER_TYPE, CONF_METER_TYPE,
CONF_SOURCE_SENSOR, CONF_SOURCE_SENSOR,
CONF_TARIFF, 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_DELTA_VALUES, default=False): cv.boolean,
vol.Optional(CONF_METER_NET_CONSUMPTION, 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( vol.Optional(CONF_TARIFFS, default=[]): vol.All(
cv.ensure_list, vol.Unique(), [cv.string] 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: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """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( if unload_ok := await hass.config_entries.async_unload_platforms(
entry, entry,
( platforms_to_unload,
Platform.SELECT,
Platform.SENSOR,
),
): ):
hass.data[DATA_UTILITY].pop(entry.entry_id) hass.data[DATA_UTILITY].pop(entry.entry_id)
return unload_ok 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

View File

@ -21,6 +21,7 @@ from .const import (
CONF_METER_DELTA_VALUES, CONF_METER_DELTA_VALUES,
CONF_METER_NET_CONSUMPTION, CONF_METER_NET_CONSUMPTION,
CONF_METER_OFFSET, CONF_METER_OFFSET,
CONF_METER_PERIODICALLY_RESETTING,
CONF_METER_TYPE, CONF_METER_TYPE,
CONF_SOURCE_SENSOR, CONF_SOURCE_SENSOR,
CONF_TARIFFS, CONF_TARIFFS,
@ -64,6 +65,9 @@ OPTIONS_SCHEMA = vol.Schema(
vol.Required(CONF_SOURCE_SENSOR): selector.EntitySelector( vol.Required(CONF_SOURCE_SENSOR): selector.EntitySelector(
selector.EntitySelectorConfig(domain=SENSOR_DOMAIN), selector.EntitySelectorConfig(domain=SENSOR_DOMAIN),
), ),
vol.Required(
CONF_METER_PERIODICALLY_RESETTING,
): selector.BooleanSelector(),
} }
) )
@ -95,6 +99,10 @@ CONFIG_SCHEMA = vol.Schema(
vol.Required( vol.Required(
CONF_METER_DELTA_VALUES, default=False CONF_METER_DELTA_VALUES, default=False
): selector.BooleanSelector(), ): selector.BooleanSelector(),
vol.Required(
CONF_METER_PERIODICALLY_RESETTING,
default=True,
): selector.BooleanSelector(),
} }
) )
@ -110,6 +118,8 @@ OPTIONS_FLOW = {
class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
"""Handle a config or options flow for Utility Meter.""" """Handle a config or options flow for Utility Meter."""
VERSION = 2
config_flow = CONFIG_FLOW config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW options_flow = OPTIONS_FLOW

View File

@ -32,6 +32,7 @@ CONF_METER_TYPE = "cycle"
CONF_METER_OFFSET = "offset" CONF_METER_OFFSET = "offset"
CONF_METER_DELTA_VALUES = "delta_values" CONF_METER_DELTA_VALUES = "delta_values"
CONF_METER_NET_CONSUMPTION = "net_consumption" CONF_METER_NET_CONSUMPTION = "net_consumption"
CONF_METER_PERIODICALLY_RESETTING = "periodically_resetting"
CONF_PAUSED = "paused" CONF_PAUSED = "paused"
CONF_TARIFFS = "tariffs" CONF_TARIFFS = "tariffs"
CONF_TARIFF = "tariff" CONF_TARIFF = "tariff"

View File

@ -27,7 +27,7 @@ from homeassistant.const import (
STATE_UNKNOWN, STATE_UNKNOWN,
UnitOfEnergy, 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 import entity_platform, entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -50,6 +50,7 @@ from .const import (
CONF_METER_DELTA_VALUES, CONF_METER_DELTA_VALUES,
CONF_METER_NET_CONSUMPTION, CONF_METER_NET_CONSUMPTION,
CONF_METER_OFFSET, CONF_METER_OFFSET,
CONF_METER_PERIODICALLY_RESETTING,
CONF_METER_TYPE, CONF_METER_TYPE,
CONF_SOURCE_SENSOR, CONF_SOURCE_SENSOR,
CONF_TARIFF, CONF_TARIFF,
@ -85,6 +86,7 @@ ATTR_SOURCE_ID = "source"
ATTR_STATUS = "status" ATTR_STATUS = "status"
ATTR_PERIOD = "meter_period" ATTR_PERIOD = "meter_period"
ATTR_LAST_PERIOD = "last_period" ATTR_LAST_PERIOD = "last_period"
ATTR_LAST_VALID_STATE = "last_valid_state"
ATTR_TARIFF = "tariff" ATTR_TARIFF = "tariff"
DEVICE_CLASS_MAP = { DEVICE_CLASS_MAP = {
@ -127,6 +129,7 @@ async def async_setup_entry(
meter_type = None meter_type = None
name = config_entry.title name = config_entry.title
net_consumption = config_entry.options[CONF_METER_NET_CONSUMPTION] 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] tariff_entity = hass.data[DATA_UTILITY][entry_id][CONF_TARIFF_ENTITY]
meters = [] meters = []
@ -142,6 +145,7 @@ async def async_setup_entry(
name=name, name=name,
net_consumption=net_consumption, net_consumption=net_consumption,
parent_meter=entry_id, parent_meter=entry_id,
periodically_resetting=periodically_resetting,
source_entity=source_entity_id, source_entity=source_entity_id,
tariff_entity=tariff_entity, tariff_entity=tariff_entity,
tariff=None, tariff=None,
@ -160,6 +164,7 @@ async def async_setup_entry(
name=f"{name} {tariff}", name=f"{name} {tariff}",
net_consumption=net_consumption, net_consumption=net_consumption,
parent_meter=entry_id, parent_meter=entry_id,
periodically_resetting=periodically_resetting,
source_entity=source_entity_id, source_entity=source_entity_id,
tariff_entity=tariff_entity, tariff_entity=tariff_entity,
tariff=tariff, tariff=tariff,
@ -223,6 +228,9 @@ async def async_setup_platform(
conf_meter_net_consumption = hass.data[DATA_UTILITY][meter][ conf_meter_net_consumption = hass.data[DATA_UTILITY][meter][
CONF_METER_NET_CONSUMPTION 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_meter_tariff_entity = hass.data[DATA_UTILITY][meter].get(
CONF_TARIFF_ENTITY CONF_TARIFF_ENTITY
) )
@ -235,6 +243,7 @@ async def async_setup_platform(
name=conf_sensor_name, name=conf_sensor_name,
net_consumption=conf_meter_net_consumption, net_consumption=conf_meter_net_consumption,
parent_meter=meter, parent_meter=meter,
periodically_resetting=conf_meter_periodically_resetting,
source_entity=conf_meter_source, source_entity=conf_meter_source,
tariff_entity=conf_meter_tariff_entity, tariff_entity=conf_meter_tariff_entity,
tariff=conf_sensor_tariff, tariff=conf_sensor_tariff,
@ -262,6 +271,7 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData):
last_period: Decimal last_period: Decimal
last_reset: datetime | None last_reset: datetime | None
last_valid_state: Decimal | None
status: str status: str
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
@ -270,6 +280,9 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData):
data["last_period"] = str(self.last_period) data["last_period"] = str(self.last_period)
if isinstance(self.last_reset, (datetime)): if isinstance(self.last_reset, (datetime)):
data["last_reset"] = self.last_reset.isoformat() 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 data["status"] = self.status
return data return data
@ -284,6 +297,11 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData):
try: try:
last_period: Decimal = Decimal(restored["last_period"]) last_period: Decimal = Decimal(restored["last_period"])
last_reset: datetime | None = dt_util.parse_datetime(restored["last_reset"]) 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"] status: str = restored["status"]
except KeyError: except KeyError:
# restored is a dict, but does not have all values # restored is a dict, but does not have all values
@ -297,6 +315,7 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData):
extra.native_unit_of_measurement, extra.native_unit_of_measurement,
last_period, last_period,
last_reset, last_reset,
last_valid_state,
status, status,
) )
@ -316,6 +335,7 @@ class UtilityMeterSensor(RestoreSensor):
name, name,
net_consumption, net_consumption,
parent_meter, parent_meter,
periodically_resetting,
source_entity, source_entity,
tariff_entity, tariff_entity,
tariff, tariff,
@ -330,6 +350,7 @@ class UtilityMeterSensor(RestoreSensor):
self._state = None self._state = None
self._last_period = Decimal(0) self._last_period = Decimal(0)
self._last_reset = dt_util.utcnow() self._last_reset = dt_util.utcnow()
self._last_valid_state = None
self._collecting = None self._collecting = None
self._name = name self._name = name
self._unit_of_measurement = None self._unit_of_measurement = None
@ -346,6 +367,7 @@ class UtilityMeterSensor(RestoreSensor):
self._cron_pattern = cron_pattern self._cron_pattern = cron_pattern
self._sensor_delta_values = delta_values self._sensor_delta_values = delta_values
self._sensor_net_consumption = net_consumption self._sensor_net_consumption = net_consumption
self._sensor_periodically_resetting = periodically_resetting
self._tariff = tariff self._tariff = tariff
self._tariff_entity = tariff_entity self._tariff_entity = tariff_entity
@ -355,53 +377,70 @@ class UtilityMeterSensor(RestoreSensor):
self._state = 0 self._state = 0
self.async_write_ha_state() self.async_write_ha_state()
@callback @staticmethod
def async_reading(self, event): def _validate_state(state: State | None) -> Decimal | None:
"""Handle the sensor state changes.""" """Parse the state as a Decimal if available. Throws DecimalException if the state is not a number."""
old_state = event.data.get("old_state") try:
new_state = event.data.get("new_state") 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 # 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][ for sensor in self.hass.data[DATA_UTILITY][self._parent_meter][
DATA_TARIFF_SENSORS DATA_TARIFF_SENSORS
]: ]:
sensor.start(source_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) sensor.start(new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT))
if ( if (
new_state is None adjustment := self.calculate_adjustment(old_state, new_state)
or new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] ) is not None and (self._sensor_net_consumption or adjustment >= 0):
or ( # If net_consumption is off, the adjustment must be non-negative
not self._sensor_delta_values 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
and (
old_state is None
or old_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]
)
)
):
return
self._unit_of_measurement = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) self._last_valid_state = new_state_val
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.async_write_ha_state() self.async_write_ha_state()
@callback @callback
@ -422,6 +461,11 @@ class UtilityMeterSensor(RestoreSensor):
self._collecting() self._collecting()
self._collecting = None 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( _LOGGER.debug(
"%s - %s - source <%s>", "%s - %s - source <%s>",
self._name, self._name,
@ -484,6 +528,7 @@ class UtilityMeterSensor(RestoreSensor):
self._unit_of_measurement = last_sensor_data.native_unit_of_measurement self._unit_of_measurement = last_sensor_data.native_unit_of_measurement
self._last_period = last_sensor_data.last_period self._last_period = last_sensor_data.last_period
self._last_reset = last_sensor_data.last_reset self._last_reset = last_sensor_data.last_reset
self._last_valid_state = last_sensor_data.last_valid_state
if last_sensor_data.status == COLLECTING: if last_sensor_data.status == COLLECTING:
# Null lambda to allow cancelling the collection on tariff change # Null lambda to allow cancelling the collection on tariff change
self._collecting = lambda: None self._collecting = lambda: None
@ -508,6 +553,12 @@ class UtilityMeterSensor(RestoreSensor):
and is_number(state.attributes[ATTR_LAST_PERIOD]) and is_number(state.attributes[ATTR_LAST_PERIOD])
else Decimal(0) 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( self._last_reset = dt_util.as_utc(
dt_util.parse_datetime(state.attributes.get(ATTR_LAST_RESET)) 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_SOURCE_ID: self._sensor_source_id,
ATTR_STATUS: PAUSED if self._collecting is None else COLLECTING, ATTR_STATUS: PAUSED if self._collecting is None else COLLECTING,
ATTR_LAST_PERIOD: str(self._last_period), ATTR_LAST_PERIOD: str(self._last_period),
ATTR_LAST_VALID_STATE: str(self._last_valid_state),
} }
if self._period is not None: if self._period is not None:
state_attr[ATTR_PERIOD] = self._period state_attr[ATTR_PERIOD] = self._period
@ -620,6 +672,7 @@ class UtilityMeterSensor(RestoreSensor):
self.native_unit_of_measurement, self.native_unit_of_measurement,
self._last_period, self._last_period,
self._last_reset, self._last_reset,
self._last_valid_state,
PAUSED if self._collecting is None else COLLECTING, PAUSED if self._collecting is None else COLLECTING,
) )

View File

@ -9,6 +9,7 @@
"cycle": "Meter reset cycle", "cycle": "Meter reset cycle",
"delta_values": "Delta values", "delta_values": "Delta values",
"name": "Name", "name": "Name",
"periodically_resetting": "Periodically resetting",
"net_consumption": "Net consumption", "net_consumption": "Net consumption",
"offset": "Meter reset offset", "offset": "Meter reset offset",
"source": "Input sensor", "source": "Input sensor",
@ -17,6 +18,7 @@
"data_description": { "data_description": {
"delta_values": "Enable if the source values are delta values since the last reading instead of absolute values.", "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.", "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.", "offset": "Offset the day of a monthly meter reset.",
"tariffs": "A list of supported tariffs, leave empty if only a single tariff is needed." "tariffs": "A list of supported tariffs, leave empty if only a single tariff is needed."
} }
@ -27,7 +29,11 @@
"step": { "step": {
"init": { "init": {
"data": { "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%]"
} }
} }
} }

View File

@ -47,6 +47,7 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None:
"name": "Electricity meter", "name": "Electricity meter",
"net_consumption": False, "net_consumption": False,
"offset": 0, "offset": 0,
"periodically_resetting": True,
"source": input_sensor_entity_id, "source": input_sensor_entity_id,
"tariffs": [], "tariffs": [],
} }
@ -60,6 +61,7 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None:
"name": "Electricity meter", "name": "Electricity meter",
"net_consumption": False, "net_consumption": False,
"offset": 0, "offset": 0,
"periodically_resetting": True,
"source": input_sensor_entity_id, "source": input_sensor_entity_id,
"tariffs": [], "tariffs": [],
} }
@ -96,6 +98,7 @@ async def test_tariffs(hass: HomeAssistant) -> None:
"delta_values": False, "delta_values": False,
"name": "Electricity meter", "name": "Electricity meter",
"net_consumption": False, "net_consumption": False,
"periodically_resetting": True,
"offset": 0, "offset": 0,
"source": input_sensor_entity_id, "source": input_sensor_entity_id,
"tariffs": ["cat", "dog", "horse", "cow"], "tariffs": ["cat", "dog", "horse", "cow"],
@ -109,6 +112,7 @@ async def test_tariffs(hass: HomeAssistant) -> None:
"name": "Electricity meter", "name": "Electricity meter",
"net_consumption": False, "net_consumption": False,
"offset": 0, "offset": 0,
"periodically_resetting": True,
"source": input_sensor_entity_id, "source": input_sensor_entity_id,
"tariffs": ["cat", "dog", "horse", "cow"], "tariffs": ["cat", "dog", "horse", "cow"],
} }
@ -136,6 +140,57 @@ async def test_tariffs(hass: HomeAssistant) -> None:
assert result["errors"]["base"] == "tariffs_not_unique" 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): def get_suggested(schema, key):
"""Get suggested value for key in voluptuous schema.""" """Get suggested value for key in voluptuous schema."""
for k in schema: for k in schema:
@ -162,6 +217,7 @@ async def test_options(hass: HomeAssistant) -> None:
"name": "Electricity meter", "name": "Electricity meter",
"net_consumption": False, "net_consumption": False,
"offset": 0, "offset": 0,
"periodically_resetting": True,
"source": input_sensor1_entity_id, "source": input_sensor1_entity_id,
"tariffs": "", "tariffs": "",
}, },
@ -176,10 +232,11 @@ async def test_options(hass: HomeAssistant) -> None:
assert result["step_id"] == "init" assert result["step_id"] == "init"
schema = result["data_schema"].schema schema = result["data_schema"].schema
assert get_suggested(schema, "source") == input_sensor1_entity_id 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 = await hass.config_entries.options.async_configure(
result["flow_id"], 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["type"] == FlowResultType.CREATE_ENTRY
assert result["data"] == { assert result["data"] == {
@ -188,6 +245,7 @@ async def test_options(hass: HomeAssistant) -> None:
"name": "Electricity meter", "name": "Electricity meter",
"net_consumption": False, "net_consumption": False,
"offset": 0, "offset": 0,
"periodically_resetting": False,
"source": input_sensor2_entity_id, "source": input_sensor2_entity_id,
"tariffs": "", "tariffs": "",
} }
@ -198,6 +256,7 @@ async def test_options(hass: HomeAssistant) -> None:
"name": "Electricity meter", "name": "Electricity meter",
"net_consumption": False, "net_consumption": False,
"offset": 0, "offset": 0,
"periodically_resetting": False,
"source": input_sensor2_entity_id, "source": input_sensor2_entity_id,
"tariffs": "", "tariffs": "",
} }

View File

@ -186,6 +186,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None:
"name": "Energy bill", "name": "Energy bill",
"net_consumption": False, "net_consumption": False,
"offset": 0, "offset": 0,
"periodically_resetting": True,
"source": "sensor.energy", "source": "sensor.energy",
"tariffs": ["peak", "offpeak"], "tariffs": ["peak", "offpeak"],
}, },
@ -202,6 +203,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None:
"name": "Energy bill2", "name": "Energy bill2",
"net_consumption": False, "net_consumption": False,
"offset": 0, "offset": 0,
"periodically_resetting": True,
"source": "sensor.energy", "source": "sensor.energy",
"tariffs": ["peak", "offpeak"], "tariffs": ["peak", "offpeak"],
}, },
@ -413,6 +415,7 @@ async def test_setup_and_remove_config_entry(
"name": "Electricity meter", "name": "Electricity meter",
"net_consumption": False, "net_consumption": False,
"offset": 0, "offset": 0,
"periodically_resetting": True,
"source": input_sensor_entity_id, "source": input_sensor_entity_id,
"tariffs": tariffs, "tariffs": tariffs,
}, },

View File

@ -14,6 +14,7 @@ from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
SensorStateClass, SensorStateClass,
) )
from homeassistant.components.utility_meter import DEFAULT_OFFSET
from homeassistant.components.utility_meter.const import ( from homeassistant.components.utility_meter.const import (
ATTR_VALUE, ATTR_VALUE,
DAILY, DAILY,
@ -24,9 +25,11 @@ from homeassistant.components.utility_meter.const import (
) )
from homeassistant.components.utility_meter.sensor import ( from homeassistant.components.utility_meter.sensor import (
ATTR_LAST_RESET, ATTR_LAST_RESET,
ATTR_LAST_VALID_STATE,
ATTR_STATUS, ATTR_STATUS,
COLLECTING, COLLECTING,
PAUSED, PAUSED,
UtilityMeterSensor,
) )
from homeassistant.const import ( from homeassistant.const import (
ATTR_DEVICE_CLASS, ATTR_DEVICE_CLASS,
@ -50,7 +53,7 @@ from tests.common import (
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def set_utc(hass): def set_utc(hass: HomeAssistant):
"""Set timezone to UTC.""" """Set timezone to UTC."""
hass.config.set_time_zone("UTC") hass.config.set_time_zone("UTC")
@ -77,6 +80,7 @@ def set_utc(hass):
"name": "Energy bill", "name": "Energy bill",
"net_consumption": False, "net_consumption": False,
"offset": 0, "offset": 0,
"periodically_resetting": True,
"source": "sensor.energy", "source": "sensor.energy",
"tariffs": ["onpeak", "midpeak", "offpeak"], "tariffs": ["onpeak", "midpeak", "offpeak"],
}, },
@ -272,6 +276,7 @@ async def test_not_unique_tariffs(hass: HomeAssistant, yaml_config) -> None:
"name": "Energy bill", "name": "Energy bill",
"net_consumption": False, "net_consumption": False,
"offset": 0, "offset": 0,
"periodically_resetting": True,
"source": "sensor.energy", "source": "sensor.energy",
"tariffs": ["onpeak", "midpeak", "offpeak"], "tariffs": ["onpeak", "midpeak", "offpeak"],
}, },
@ -430,6 +435,7 @@ async def test_entity_name(hass: HomeAssistant, yaml_config, entity_id, name) ->
"name": "Energy meter", "name": "Energy meter",
"net_consumption": True, "net_consumption": True,
"offset": 0, "offset": 0,
"periodically_resetting": True,
"source": "sensor.energy", "source": "sensor.energy",
"tariffs": [], "tariffs": [],
}, },
@ -439,6 +445,7 @@ async def test_entity_name(hass: HomeAssistant, yaml_config, entity_id, name) ->
"name": "Gas meter", "name": "Gas meter",
"net_consumption": False, "net_consumption": False,
"offset": 0, "offset": 0,
"periodically_resetting": True,
"source": "sensor.gas", "source": "sensor.gas",
"tariffs": [], "tariffs": [],
}, },
@ -516,6 +523,7 @@ async def test_device_class(
"name": "Energy bill", "name": "Energy bill",
"net_consumption": False, "net_consumption": False,
"offset": 0, "offset": 0,
"periodically_resetting": True,
"source": "sensor.energy", "source": "sensor.energy",
"tariffs": ["onpeak", "midpeak", "offpeak", "superpeak"], "tariffs": ["onpeak", "midpeak", "offpeak", "superpeak"],
}, },
@ -552,6 +560,7 @@ async def test_restore_state(
"native_unit_of_measurement": "kWh", "native_unit_of_measurement": "kWh",
"last_reset": last_reset, "last_reset": last_reset,
"last_period": "7", "last_period": "7",
"last_valid_state": "None",
"status": "paused", "status": "paused",
}, },
), ),
@ -562,6 +571,7 @@ async def test_restore_state(
attributes={ attributes={
ATTR_STATUS: PAUSED, ATTR_STATUS: PAUSED,
ATTR_LAST_RESET: last_reset, ATTR_LAST_RESET: last_reset,
ATTR_LAST_VALID_STATE: None,
ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR,
}, },
), ),
@ -571,6 +581,7 @@ async def test_restore_state(
"decimal_str": "3", "decimal_str": "3",
}, },
"native_unit_of_measurement": "kWh", "native_unit_of_measurement": "kWh",
"last_valid_state": "None",
}, },
), ),
( (
@ -580,6 +591,7 @@ async def test_restore_state(
attributes={ attributes={
ATTR_STATUS: COLLECTING, ATTR_STATUS: COLLECTING,
ATTR_LAST_RESET: last_reset, ATTR_LAST_RESET: last_reset,
ATTR_LAST_VALID_STATE: None,
ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR,
}, },
), ),
@ -589,6 +601,7 @@ async def test_restore_state(
"decimal_str": "3f", "decimal_str": "3f",
}, },
"native_unit_of_measurement": "kWh", "native_unit_of_measurement": "kWh",
"last_valid_state": "None",
}, },
), ),
( (
@ -598,6 +611,7 @@ async def test_restore_state(
attributes={ attributes={
ATTR_STATUS: COLLECTING, ATTR_STATUS: COLLECTING,
ATTR_LAST_RESET: last_reset, ATTR_LAST_RESET: last_reset,
ATTR_LAST_VALID_STATE: None,
ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR,
}, },
), ),
@ -625,15 +639,18 @@ async def test_restore_state(
assert state.state == "3" assert state.state == "3"
assert state.attributes.get("status") == PAUSED assert state.attributes.get("status") == PAUSED
assert state.attributes.get("last_reset") == last_reset 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 assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR
state = hass.states.get("sensor.energy_bill_midpeak") state = hass.states.get("sensor.energy_bill_midpeak")
assert state.state == "5" assert state.state == "5"
assert state.attributes.get("last_valid_state") == "None"
state = hass.states.get("sensor.energy_bill_offpeak") state = hass.states.get("sensor.energy_bill_offpeak")
assert state.state == "6" assert state.state == "6"
assert state.attributes.get("status") == COLLECTING assert state.attributes.get("status") == COLLECTING
assert state.attributes.get("last_reset") == last_reset 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 assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR
state = hass.states.get("sensor.energy_bill_superpeak") state = hass.states.get("sensor.energy_bill_superpeak")
@ -675,6 +692,7 @@ async def test_restore_state(
"name": "Energy bill", "name": "Energy bill",
"net_consumption": True, "net_consumption": True,
"offset": 0, "offset": 0,
"periodically_resetting": True,
"source": "sensor.energy", "source": "sensor.energy",
"tariffs": [], "tariffs": [],
}, },
@ -829,6 +847,7 @@ async def test_non_net_consumption(
"name": "Energy bill", "name": "Energy bill",
"net_consumption": False, "net_consumption": False,
"offset": 0, "offset": 0,
"periodically_resetting": True,
"source": "sensor.energy", "source": "sensor.energy",
"tariffs": [], "tariffs": [],
}, },
@ -884,7 +903,7 @@ async def test_delta_values(
force_update=True, force_update=True,
) )
await hass.async_block_till_done() 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) now += timedelta(seconds=30)
with freeze_time(now): with freeze_time(now):
@ -918,6 +937,272 @@ async def test_delta_values(
assert state.state == "9" 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): def gen_config(cycle, offset=None):
"""Generate configuration.""" """Generate configuration."""
config = { config = {
@ -932,7 +1217,9 @@ def gen_config(cycle, offset=None):
return config 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.""" """Test energy sensor self reset."""
now = dt_util.parse_datetime(start_time) now = dt_util.parse_datetime(start_time)
with freeze_time(now): with freeze_time(now):
@ -1142,3 +1429,27 @@ async def test_bad_offset(hass: HomeAssistant) -> None:
assert not await async_setup_component( assert not await async_setup_component(
hass, DOMAIN, gen_config("monthly", timedelta(days=31)) 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