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_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

View file

@ -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

View file

@ -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"

View file

@ -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,
)

View file

@ -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%]"
}
}
}

View file

@ -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": "",
}

View file

@ -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,
},

View file

@ -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