Simplify long term statistics by always supporting unit conversion (#79557)

This commit is contained in:
Erik Montnemery 2022-10-04 03:50:05 +02:00 committed by GitHub
parent eda6f13f8a
commit e93deaa8aa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 308 additions and 319 deletions

View File

@ -23,22 +23,11 @@ from homeassistant.components.recorder.models import (
StatisticMetaData, StatisticMetaData,
StatisticResult, StatisticResult,
) )
from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT
from homeassistant.core import HomeAssistant, State from homeassistant.core import HomeAssistant, State
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import entity_sources from homeassistant.helpers.entity import entity_sources
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import (
BaseUnitConverter,
DistanceConverter,
EnergyConverter,
MassConverter,
PowerConverter,
PressureConverter,
SpeedConverter,
TemperatureConverter,
VolumeConverter,
)
from . import ( from . import (
ATTR_LAST_RESET, ATTR_LAST_RESET,
@ -48,7 +37,6 @@ from . import (
STATE_CLASS_TOTAL, STATE_CLASS_TOTAL,
STATE_CLASS_TOTAL_INCREASING, STATE_CLASS_TOTAL_INCREASING,
STATE_CLASSES, STATE_CLASSES,
SensorDeviceClass,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -59,18 +47,6 @@ DEFAULT_STATISTICS = {
STATE_CLASS_TOTAL_INCREASING: {"sum"}, STATE_CLASS_TOTAL_INCREASING: {"sum"},
} }
UNIT_CONVERTERS: dict[str, type[BaseUnitConverter]] = {
SensorDeviceClass.DISTANCE: DistanceConverter,
SensorDeviceClass.ENERGY: EnergyConverter,
SensorDeviceClass.GAS: VolumeConverter,
SensorDeviceClass.POWER: PowerConverter,
SensorDeviceClass.PRESSURE: PressureConverter,
SensorDeviceClass.SPEED: SpeedConverter,
SensorDeviceClass.TEMPERATURE: TemperatureConverter,
SensorDeviceClass.VOLUME: VolumeConverter,
SensorDeviceClass.WEIGHT: MassConverter,
}
# Keep track of entities for which a warning about decreasing value has been logged # Keep track of entities for which a warning about decreasing value has been logged
SEEN_DIP = "sensor_seen_total_increasing_dip" SEEN_DIP = "sensor_seen_total_increasing_dip"
WARN_DIP = "sensor_warn_total_increasing_dip" WARN_DIP = "sensor_warn_total_increasing_dip"
@ -154,84 +130,84 @@ def _normalize_states(
session: Session, session: Session,
old_metadatas: dict[str, tuple[int, StatisticMetaData]], old_metadatas: dict[str, tuple[int, StatisticMetaData]],
entity_history: Iterable[State], entity_history: Iterable[State],
device_class: str | None,
entity_id: str, entity_id: str,
) -> tuple[str | None, str | None, list[tuple[float, State]]]: ) -> tuple[str | None, str | None, list[tuple[float, State]]]:
"""Normalize units.""" """Normalize units."""
old_metadata = old_metadatas[entity_id][1] if entity_id in old_metadatas else None old_metadata = old_metadatas[entity_id][1] if entity_id in old_metadatas else None
state_unit: str | None = None state_unit: str | None = None
if device_class not in UNIT_CONVERTERS or ( fstates: list[tuple[float, State]] = []
for state in entity_history:
try:
fstate = _parse_float(state.state)
except (ValueError, TypeError): # TypeError to guard for NULL state in DB
continue
fstates.append((fstate, state))
if not fstates:
return None, None, fstates
state_unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT)
if state_unit not in statistics.STATISTIC_UNIT_TO_UNIT_CONVERTER or (
old_metadata old_metadata
and old_metadata["unit_of_measurement"] and old_metadata["unit_of_measurement"]
not in UNIT_CONVERTERS[device_class].VALID_UNITS not in statistics.STATISTIC_UNIT_TO_UNIT_CONVERTER
): ):
# We're either not normalizing this device class or this entity is not stored # We're either not normalizing this device class or this entity is not stored
# in a supported unit, return the states as they are # in a unit which can be converted, return the states as they are
fstates = []
for state in entity_history:
try:
fstate = _parse_float(state.state)
except (ValueError, TypeError): # TypeError to guard for NULL state in DB
continue
fstates.append((fstate, state))
if fstates: all_units = _get_units(fstates)
all_units = _get_units(fstates) if len(all_units) > 1:
if len(all_units) > 1: if WARN_UNSTABLE_UNIT not in hass.data:
if WARN_UNSTABLE_UNIT not in hass.data: hass.data[WARN_UNSTABLE_UNIT] = set()
hass.data[WARN_UNSTABLE_UNIT] = set() if entity_id not in hass.data[WARN_UNSTABLE_UNIT]:
if entity_id not in hass.data[WARN_UNSTABLE_UNIT]: hass.data[WARN_UNSTABLE_UNIT].add(entity_id)
hass.data[WARN_UNSTABLE_UNIT].add(entity_id) extra = ""
extra = "" if old_metadata:
if old_metadata: extra = (
extra = ( " and matches the unit of already compiled statistics "
" and matches the unit of already compiled statistics " f"({old_metadata['unit_of_measurement']})"
f"({old_metadata['unit_of_measurement']})"
)
_LOGGER.warning(
"The unit of %s is changing, got multiple %s, generation of long term "
"statistics will be suppressed unless the unit is stable%s. "
"Go to %s to fix this",
entity_id,
all_units,
extra,
LINK_DEV_STATISTICS,
) )
return None, None, [] _LOGGER.warning(
state_unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) "The unit of %s is changing, got multiple %s, generation of long term "
"statistics will be suppressed unless the unit is stable%s. "
"Go to %s to fix this",
entity_id,
all_units,
extra,
LINK_DEV_STATISTICS,
)
return None, None, []
state_unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT)
return state_unit, state_unit, fstates return state_unit, state_unit, fstates
converter = UNIT_CONVERTERS[device_class] converter = statistics.STATISTIC_UNIT_TO_UNIT_CONVERTER[state_unit]
fstates = [] valid_fstates: list[tuple[float, State]] = []
statistics_unit: str | None = None statistics_unit: str | None = None
if old_metadata: if old_metadata:
statistics_unit = old_metadata["unit_of_measurement"] statistics_unit = old_metadata["unit_of_measurement"]
for state in entity_history: for fstate, state in fstates:
try:
fstate = _parse_float(state.state)
except ValueError:
continue
state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
# Exclude unsupported units from statistics # Exclude states with unsupported unit from statistics
if state_unit not in converter.VALID_UNITS: if state_unit not in converter.VALID_UNITS:
if WARN_UNSUPPORTED_UNIT not in hass.data: if WARN_UNSUPPORTED_UNIT not in hass.data:
hass.data[WARN_UNSUPPORTED_UNIT] = set() hass.data[WARN_UNSUPPORTED_UNIT] = set()
if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]: if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]:
hass.data[WARN_UNSUPPORTED_UNIT].add(entity_id) hass.data[WARN_UNSUPPORTED_UNIT].add(entity_id)
_LOGGER.warning( _LOGGER.warning(
"%s has unit %s which is unsupported for device_class %s", "%s has unit %s which can't be converted to %s",
entity_id, entity_id,
state_unit, state_unit,
device_class, statistics_unit,
) )
continue continue
if statistics_unit is None: if statistics_unit is None:
statistics_unit = state_unit statistics_unit = state_unit
fstates.append( valid_fstates.append(
( (
converter.convert( converter.convert(
fstate, from_unit=state_unit, to_unit=statistics_unit fstate, from_unit=state_unit, to_unit=statistics_unit
@ -240,7 +216,7 @@ def _normalize_states(
) )
) )
return statistics_unit, state_unit, fstates return statistics_unit, state_unit, valid_fstates
def _suggest_report_issue(hass: HomeAssistant, entity_id: str) -> str: def _suggest_report_issue(hass: HomeAssistant, entity_id: str) -> str:
@ -427,14 +403,12 @@ def _compile_statistics( # noqa: C901
if entity_id not in history_list: if entity_id not in history_list:
continue continue
device_class = _state.attributes.get(ATTR_DEVICE_CLASS)
entity_history = history_list[entity_id] entity_history = history_list[entity_id]
statistics_unit, state_unit, fstates = _normalize_states( statistics_unit, state_unit, fstates = _normalize_states(
hass, hass,
session, session,
old_metadatas, old_metadatas,
entity_history, entity_history,
device_class,
entity_id, entity_id,
) )
@ -467,11 +441,11 @@ def _compile_statistics( # noqa: C901
if entity_id not in hass.data[WARN_UNSTABLE_UNIT]: if entity_id not in hass.data[WARN_UNSTABLE_UNIT]:
hass.data[WARN_UNSTABLE_UNIT].add(entity_id) hass.data[WARN_UNSTABLE_UNIT].add(entity_id)
_LOGGER.warning( _LOGGER.warning(
"The %sunit of %s (%s) does not match the unit of already " "The unit of %s (%s) can not be converted to the unit of previously "
"compiled statistics (%s). Generation of long term statistics " "compiled statistics (%s). Generation of long term statistics "
"will be suppressed unless the unit changes back to %s. " "will be suppressed unless the unit changes back to %s or a "
"compatible unit. "
"Go to %s to fix this", "Go to %s to fix this",
"normalized " if device_class in UNIT_CONVERTERS else "",
entity_id, entity_id,
statistics_unit, statistics_unit,
old_metadata[1]["unit_of_measurement"], old_metadata[1]["unit_of_measurement"],
@ -603,7 +577,6 @@ def list_statistic_ids(
for state in entities: for state in entities:
state_class = state.attributes[ATTR_STATE_CLASS] state_class = state.attributes[ATTR_STATE_CLASS]
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
provided_statistics = DEFAULT_STATISTICS[state_class] provided_statistics = DEFAULT_STATISTICS[state_class]
@ -620,21 +593,6 @@ def list_statistic_ids(
): ):
continue continue
if device_class not in UNIT_CONVERTERS:
result[state.entity_id] = {
"has_mean": "mean" in provided_statistics,
"has_sum": "sum" in provided_statistics,
"name": None,
"source": RECORDER_DOMAIN,
"statistic_id": state.entity_id,
"unit_of_measurement": state_unit,
}
continue
converter = UNIT_CONVERTERS[device_class]
if state_unit not in converter.VALID_UNITS:
continue
result[state.entity_id] = { result[state.entity_id] = {
"has_mean": "mean" in provided_statistics, "has_mean": "mean" in provided_statistics,
"has_sum": "sum" in provided_statistics, "has_sum": "sum" in provided_statistics,
@ -643,6 +601,7 @@ def list_statistic_ids(
"statistic_id": state.entity_id, "statistic_id": state.entity_id,
"unit_of_measurement": state_unit, "unit_of_measurement": state_unit,
} }
continue
return result return result
@ -660,7 +619,6 @@ def validate_statistics(
for state in sensor_states: for state in sensor_states:
entity_id = state.entity_id entity_id = state.entity_id
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
state_class = state.attributes.get(ATTR_STATE_CLASS) state_class = state.attributes.get(ATTR_STATE_CLASS)
state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
@ -684,35 +642,30 @@ def validate_statistics(
) )
metadata_unit = metadata[1]["unit_of_measurement"] metadata_unit = metadata[1]["unit_of_measurement"]
if device_class not in UNIT_CONVERTERS: converter = statistics.STATISTIC_UNIT_TO_UNIT_CONVERTER.get(metadata_unit)
if not converter:
if state_unit != metadata_unit: if state_unit != metadata_unit:
# The unit has changed # The unit has changed, and it's not possible to convert
issue_type = (
"units_changed_can_convert"
if statistics.can_convert_units(metadata_unit, state_unit)
else "units_changed"
)
validation_result[entity_id].append( validation_result[entity_id].append(
statistics.ValidationIssue( statistics.ValidationIssue(
issue_type, "units_changed",
{ {
"statistic_id": entity_id, "statistic_id": entity_id,
"state_unit": state_unit, "state_unit": state_unit,
"metadata_unit": metadata_unit, "metadata_unit": metadata_unit,
"supported_unit": metadata_unit,
}, },
) )
) )
elif metadata_unit not in UNIT_CONVERTERS[device_class].VALID_UNITS: elif state_unit not in converter.VALID_UNITS:
# The unit in metadata is not supported for this device class # The state unit can't be converted to the unit in metadata
valid_units = ", ".join( valid_units = ", ".join(sorted(converter.VALID_UNITS))
sorted(UNIT_CONVERTERS[device_class].VALID_UNITS)
)
validation_result[entity_id].append( validation_result[entity_id].append(
statistics.ValidationIssue( statistics.ValidationIssue(
"unsupported_unit_metadata", "units_changed",
{ {
"statistic_id": entity_id, "statistic_id": entity_id,
"device_class": device_class, "state_unit": state_unit,
"metadata_unit": metadata_unit, "metadata_unit": metadata_unit,
"supported_unit": valid_units, "supported_unit": valid_units,
}, },
@ -728,23 +681,6 @@ def validate_statistics(
) )
) )
if (
state_class in STATE_CLASSES
and device_class in UNIT_CONVERTERS
and state_unit not in UNIT_CONVERTERS[device_class].VALID_UNITS
):
# The unit in the state is not supported for this device class
validation_result[entity_id].append(
statistics.ValidationIssue(
"unsupported_unit_state",
{
"statistic_id": entity_id,
"device_class": device_class,
"state_unit": state_unit,
},
)
)
for statistic_id in sensor_statistic_ids - sensor_entity_ids: for statistic_id in sensor_statistic_ids - sensor_entity_ids:
# There is no sensor matching the statistics_id # There is no sensor matching the statistics_id
validation_result[statistic_id].append( validation_result[statistic_id].append(

View File

@ -238,8 +238,8 @@ def test_compile_hourly_statistics_purged_state_changes(
@pytest.mark.parametrize("attributes", [TEMPERATURE_SENSOR_ATTRIBUTES]) @pytest.mark.parametrize("attributes", [TEMPERATURE_SENSOR_ATTRIBUTES])
def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes): def test_compile_hourly_statistics_wrong_unit(hass_recorder, caplog, attributes):
"""Test compiling hourly statistics for unsupported sensor.""" """Test compiling hourly statistics for sensor with unit not matching device class."""
zero = dt_util.utcnow() zero = dt_util.utcnow()
hass = hass_recorder() hass = hass_recorder()
setup_component(hass, "sensor", {}) setup_component(hass, "sensor", {})
@ -286,6 +286,24 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes
"statistics_unit_of_measurement": "°C", "statistics_unit_of_measurement": "°C",
"unit_class": "temperature", "unit_class": "temperature",
}, },
{
"has_mean": True,
"has_sum": False,
"name": None,
"source": "recorder",
"statistic_id": "sensor.test2",
"statistics_unit_of_measurement": "invalid",
"unit_class": None,
},
{
"has_mean": True,
"has_sum": False,
"name": None,
"source": "recorder",
"statistic_id": "sensor.test3",
"statistics_unit_of_measurement": None,
"unit_class": None,
},
{ {
"statistic_id": "sensor.test6", "statistic_id": "sensor.test6",
"has_mean": True, "has_mean": True,
@ -320,6 +338,32 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes
"sum": None, "sum": None,
} }
], ],
"sensor.test2": [
{
"statistic_id": "sensor.test2",
"start": process_timestamp_to_utc_isoformat(zero),
"end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)),
"mean": 13.05084745762712,
"min": -10.0,
"max": 30.0,
"last_reset": None,
"state": None,
"sum": None,
}
],
"sensor.test3": [
{
"statistic_id": "sensor.test3",
"start": process_timestamp_to_utc_isoformat(zero),
"end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)),
"mean": 13.05084745762712,
"min": -10.0,
"max": 30.0,
"last_reset": None,
"state": None,
"sum": None,
}
],
"sensor.test6": [ "sensor.test6": [
{ {
"statistic_id": "sensor.test6", "statistic_id": "sensor.test6",
@ -835,32 +879,44 @@ def test_compile_hourly_sum_statistics_nan_inf_state(
@pytest.mark.parametrize( @pytest.mark.parametrize(
"entity_id,warning_1,warning_2", "entity_id, device_class, state_unit, display_unit, statistics_unit, unit_class, offset, warning_1, warning_2",
[ [
( (
"sensor.test1", "sensor.test1",
"energy",
"kWh",
"kWh",
"kWh",
"energy",
0,
"", "",
"bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue", "bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue",
), ),
( (
"sensor.power_consumption", "sensor.power_consumption",
"power",
"W",
"W",
"W",
"power",
15,
"from integration demo ", "from integration demo ",
"bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+demo%22", "bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+demo%22",
), ),
( (
"sensor.custom_sensor", "sensor.custom_sensor",
"energy",
"kWh",
"kWh",
"kWh",
"energy",
0,
"from integration test ", "from integration test ",
"report it to the custom integration author", "report it to the custom integration author",
), ),
], ],
) )
@pytest.mark.parametrize("state_class", ["total_increasing"]) @pytest.mark.parametrize("state_class", ["total_increasing"])
@pytest.mark.parametrize(
"device_class, state_unit, display_unit, statistics_unit, unit_class, factor",
[
("energy", "kWh", "kWh", "kWh", "energy", 1),
],
)
def test_compile_hourly_sum_statistics_negative_state( def test_compile_hourly_sum_statistics_negative_state(
hass_recorder, hass_recorder,
caplog, caplog,
@ -873,7 +929,7 @@ def test_compile_hourly_sum_statistics_negative_state(
display_unit, display_unit,
statistics_unit, statistics_unit,
unit_class, unit_class,
factor, offset,
): ):
"""Test compiling hourly statistics with negative states.""" """Test compiling hourly statistics with negative states."""
zero = dt_util.utcnow() zero = dt_util.utcnow()
@ -938,8 +994,8 @@ def test_compile_hourly_sum_statistics_negative_state(
"mean": None, "mean": None,
"min": None, "min": None,
"last_reset": None, "last_reset": None,
"state": approx(factor * seq[7]), "state": approx(seq[7]),
"sum": approx(factor * 15), # (15 - 10) + (10 - 0) "sum": approx(offset + 15), # (20 - 15) + (10 - 0)
}, },
] ]
assert "Error while processing event StatisticsTask" not in caplog.text assert "Error while processing event StatisticsTask" not in caplog.text
@ -1889,7 +1945,7 @@ def test_compile_hourly_statistics_changing_units_1(
do_adhoc_statistics(hass, start=zero) do_adhoc_statistics(hass, start=zero)
wait_recording_done(hass) wait_recording_done(hass)
assert "does not match the unit of already compiled" not in caplog.text assert "can not be converted to the unit of previously" not in caplog.text
statistic_ids = list_statistic_ids(hass) statistic_ids = list_statistic_ids(hass)
assert statistic_ids == [ assert statistic_ids == [
{ {
@ -1922,8 +1978,8 @@ def test_compile_hourly_statistics_changing_units_1(
do_adhoc_statistics(hass, start=zero + timedelta(minutes=10)) do_adhoc_statistics(hass, start=zero + timedelta(minutes=10))
wait_recording_done(hass) wait_recording_done(hass)
assert ( assert (
"The unit of sensor.test1 (cats) does not match the unit of already compiled " "The unit of sensor.test1 (cats) can not be converted to the unit of "
f"statistics ({display_unit})" in caplog.text f"previously compiled statistics ({display_unit})" in caplog.text
) )
statistic_ids = list_statistic_ids(hass) statistic_ids = list_statistic_ids(hass)
assert statistic_ids == [ assert statistic_ids == [
@ -3039,18 +3095,30 @@ def record_states(hass, zero, entity_id, attributes, seq=None):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"units, attributes, unit", "units, attributes, unit, unit2, supported_unit",
[ [
(IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"),
(METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"),
(IMPERIAL_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°F"), (IMPERIAL_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°F", "K", "K, °C, °F"),
(METRIC_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°C"), (METRIC_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°C", "K", "K, °C, °F"),
(IMPERIAL_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "psi"), (
(METRIC_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "Pa"), IMPERIAL_SYSTEM,
PRESSURE_SENSOR_ATTRIBUTES,
"psi",
"bar",
"Pa, bar, cbar, hPa, inHg, kPa, mbar, mmHg, psi",
),
(
METRIC_SYSTEM,
PRESSURE_SENSOR_ATTRIBUTES,
"Pa",
"bar",
"Pa, bar, cbar, hPa, inHg, kPa, mbar, mmHg, psi",
),
], ],
) )
async def test_validate_statistics_supported_device_class( async def test_validate_statistics_unit_change_device_class(
hass, hass_ws_client, recorder_mock, units, attributes, unit hass, hass_ws_client, recorder_mock, units, attributes, unit, unit2, supported_unit
): ):
"""Test validate_statistics.""" """Test validate_statistics."""
id = 1 id = 1
@ -3078,39 +3146,40 @@ async def test_validate_statistics_supported_device_class(
# No statistics, no state - empty response # No statistics, no state - empty response
await assert_validation_result(client, {}) await assert_validation_result(client, {})
# No statistics, valid state - empty response # No statistics, unit in state matching device class - empty response
hass.states.async_set( hass.states.async_set(
"sensor.test", 10, attributes={**attributes, **{"unit_of_measurement": unit}} "sensor.test", 10, attributes={**attributes, **{"unit_of_measurement": unit}}
) )
await async_recorder_block_till_done(hass) await async_recorder_block_till_done(hass)
await assert_validation_result(client, {}) await assert_validation_result(client, {})
# No statistics, invalid state - expect error # No statistics, unit in state not matching device class - empty response
hass.states.async_set( hass.states.async_set(
"sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": "dogs"}} "sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": "dogs"}}
) )
await async_recorder_block_till_done(hass) await async_recorder_block_till_done(hass)
expected = { await assert_validation_result(client, {})
"sensor.test": [
{
"data": {
"device_class": attributes["device_class"],
"state_unit": "dogs",
"statistic_id": "sensor.test",
},
"type": "unsupported_unit_state",
}
],
}
await assert_validation_result(client, expected)
# Statistics has run, invalid state - expect error # Statistics has run, incompatible unit - expect error
await async_recorder_block_till_done(hass) await async_recorder_block_till_done(hass)
do_adhoc_statistics(hass, start=now) do_adhoc_statistics(hass, start=now)
hass.states.async_set( hass.states.async_set(
"sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": "dogs"}} "sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": "dogs"}}
) )
await async_recorder_block_till_done(hass) await async_recorder_block_till_done(hass)
expected = {
"sensor.test": [
{
"data": {
"metadata_unit": unit,
"state_unit": "dogs",
"statistic_id": "sensor.test",
"supported_unit": supported_unit,
},
"type": "units_changed",
}
],
}
await assert_validation_result(client, expected) await assert_validation_result(client, expected)
# Valid state - empty response # Valid state - empty response
@ -3125,6 +3194,18 @@ async def test_validate_statistics_supported_device_class(
await async_recorder_block_till_done(hass) await async_recorder_block_till_done(hass)
await assert_validation_result(client, {}) await assert_validation_result(client, {})
# Valid state in compatible unit - empty response
hass.states.async_set(
"sensor.test", 13, attributes={**attributes, **{"unit_of_measurement": unit2}}
)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
# Valid state, statistic runs again - empty response
do_adhoc_statistics(hass, start=now)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
# Remove the state - empty response # Remove the state - empty response
hass.states.async_remove("sensor.test") hass.states.async_remove("sensor.test")
expected = { expected = {
@ -3144,7 +3225,7 @@ async def test_validate_statistics_supported_device_class(
(IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W, kW"), (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W, kW"),
], ],
) )
async def test_validate_statistics_supported_device_class_2( async def test_validate_statistics_unit_change_device_class_2(
hass, hass_ws_client, recorder_mock, units, attributes, valid_units hass, hass_ws_client, recorder_mock, units, attributes, valid_units
): ):
"""Test validate_statistics.""" """Test validate_statistics."""
@ -3173,56 +3254,144 @@ async def test_validate_statistics_supported_device_class_2(
# No statistics, no state - empty response # No statistics, no state - empty response
await assert_validation_result(client, {}) await assert_validation_result(client, {})
# No statistics, valid state - empty response # No statistics, no device class - empty response
initial_attributes = {"state_class": "measurement"} initial_attributes = {"state_class": "measurement", "unit_of_measurement": "dogs"}
hass.states.async_set("sensor.test", 10, attributes=initial_attributes) hass.states.async_set("sensor.test", 10, attributes=initial_attributes)
await hass.async_block_till_done() await hass.async_block_till_done()
await assert_validation_result(client, {}) await assert_validation_result(client, {})
# Statistics has run, device class set - expect error # Statistics has run, device class set not matching unit - empty response
do_adhoc_statistics(hass, start=now) do_adhoc_statistics(hass, start=now)
await async_recorder_block_till_done(hass) await async_recorder_block_till_done(hass)
hass.states.async_set("sensor.test", 12, attributes=attributes)
await hass.async_block_till_done()
expected = {
"sensor.test": [
{
"data": {
"device_class": attributes["device_class"],
"metadata_unit": None,
"statistic_id": "sensor.test",
"supported_unit": valid_units,
},
"type": "unsupported_unit_metadata",
}
],
}
await assert_validation_result(client, expected)
# Invalid state too, expect double errors
hass.states.async_set( hass.states.async_set(
"sensor.test", 13, attributes={**attributes, **{"unit_of_measurement": "dogs"}} "sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": "dogs"}}
)
await hass.async_block_till_done()
await assert_validation_result(client, {})
@pytest.mark.parametrize(
"units, attributes, unit, unit2, supported_unit",
[
(IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"),
(METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"),
(IMPERIAL_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°F", "K", "K, °C, °F"),
(METRIC_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°C", "K", "K, °C, °F"),
(
IMPERIAL_SYSTEM,
PRESSURE_SENSOR_ATTRIBUTES,
"psi",
"bar",
"Pa, bar, cbar, hPa, inHg, kPa, mbar, mmHg, psi",
),
(
METRIC_SYSTEM,
PRESSURE_SENSOR_ATTRIBUTES,
"Pa",
"bar",
"Pa, bar, cbar, hPa, inHg, kPa, mbar, mmHg, psi",
),
],
)
async def test_validate_statistics_unit_change_no_device_class(
hass, hass_ws_client, recorder_mock, units, attributes, unit, unit2, supported_unit
):
"""Test validate_statistics."""
id = 1
attributes = dict(attributes)
attributes.pop("device_class")
def next_id():
nonlocal id
id += 1
return id
async def assert_validation_result(client, expected_result):
await client.send_json(
{"id": next_id(), "type": "recorder/validate_statistics"}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == expected_result
now = dt_util.utcnow()
hass.config.units = units
await async_setup_component(hass, "sensor", {})
await async_recorder_block_till_done(hass)
client = await hass_ws_client()
# No statistics, no state - empty response
await assert_validation_result(client, {})
# No statistics, unit in state matching device class - empty response
hass.states.async_set(
"sensor.test", 10, attributes={**attributes, **{"unit_of_measurement": unit}}
)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
# No statistics, unit in state not matching device class - empty response
hass.states.async_set(
"sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": "dogs"}}
)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
# Statistics has run, incompatible unit - expect error
await async_recorder_block_till_done(hass)
do_adhoc_statistics(hass, start=now)
hass.states.async_set(
"sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": "dogs"}}
) )
await async_recorder_block_till_done(hass) await async_recorder_block_till_done(hass)
expected = { expected = {
"sensor.test": [ "sensor.test": [
{ {
"data": { "data": {
"device_class": attributes["device_class"], "metadata_unit": unit,
"metadata_unit": None,
"statistic_id": "sensor.test",
"supported_unit": valid_units,
},
"type": "unsupported_unit_metadata",
},
{
"data": {
"device_class": attributes["device_class"],
"state_unit": "dogs", "state_unit": "dogs",
"statistic_id": "sensor.test", "statistic_id": "sensor.test",
"supported_unit": supported_unit,
}, },
"type": "unsupported_unit_state", "type": "units_changed",
}, }
],
}
await assert_validation_result(client, expected)
# Valid state - empty response
hass.states.async_set(
"sensor.test", 13, attributes={**attributes, **{"unit_of_measurement": unit}}
)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
# Valid state, statistic runs again - empty response
do_adhoc_statistics(hass, start=now)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
# Valid state in compatible unit - empty response
hass.states.async_set(
"sensor.test", 13, attributes={**attributes, **{"unit_of_measurement": unit2}}
)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
# Valid state, statistic runs again - empty response
do_adhoc_statistics(hass, start=now)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
# Remove the state - empty response
hass.states.async_remove("sensor.test")
expected = {
"sensor.test": [
{
"data": {"statistic_id": "sensor.test"},
"type": "no_state",
}
], ],
} }
await assert_validation_result(client, expected) await assert_validation_result(client, expected)
@ -3473,7 +3642,7 @@ async def test_validate_statistics_sensor_removed(
"attributes", "attributes",
[BATTERY_SENSOR_ATTRIBUTES, NONE_SENSOR_ATTRIBUTES], [BATTERY_SENSOR_ATTRIBUTES, NONE_SENSOR_ATTRIBUTES],
) )
async def test_validate_statistics_unsupported_device_class( async def test_validate_statistics_unit_change_no_conversion(
hass, recorder_mock, hass_ws_client, attributes hass, recorder_mock, hass_ws_client, attributes
): ):
"""Test validate_statistics.""" """Test validate_statistics."""
@ -3553,6 +3722,7 @@ async def test_validate_statistics_unsupported_device_class(
"metadata_unit": "dogs", "metadata_unit": "dogs",
"state_unit": attributes.get("unit_of_measurement"), "state_unit": attributes.get("unit_of_measurement"),
"statistic_id": "sensor.test", "statistic_id": "sensor.test",
"supported_unit": "dogs",
}, },
"type": "units_changed", "type": "units_changed",
} }
@ -3573,124 +3743,7 @@ async def test_validate_statistics_unsupported_device_class(
await async_recorder_block_till_done(hass) await async_recorder_block_till_done(hass)
await assert_validation_result(client, {}) await assert_validation_result(client, {})
# Remove the state - empty response # Remove the state - expect error
hass.states.async_remove("sensor.test")
expected = {
"sensor.test": [
{
"data": {"statistic_id": "sensor.test"},
"type": "no_state",
}
],
}
await assert_validation_result(client, expected)
@pytest.mark.parametrize(
"attributes",
[KW_SENSOR_ATTRIBUTES],
)
async def test_validate_statistics_unsupported_device_class_2(
hass, recorder_mock, hass_ws_client, attributes
):
"""Test validate_statistics."""
id = 1
def next_id():
nonlocal id
id += 1
return id
async def assert_validation_result(client, expected_result):
await client.send_json(
{"id": next_id(), "type": "recorder/validate_statistics"}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == expected_result
async def assert_statistic_ids(expected_result):
with session_scope(hass=hass) as session:
db_states = list(session.query(StatisticsMeta))
assert len(db_states) == len(expected_result)
for i in range(len(db_states)):
assert db_states[i].statistic_id == expected_result[i]["statistic_id"]
assert (
db_states[i].unit_of_measurement
== expected_result[i]["unit_of_measurement"]
)
now = dt_util.utcnow()
await async_setup_component(hass, "sensor", {})
await async_recorder_block_till_done(hass)
client = await hass_ws_client()
# No statistics, no state - empty response
await assert_validation_result(client, {})
# No statistics, original unit - empty response
hass.states.async_set("sensor.test", 10, attributes=attributes)
await assert_validation_result(client, {})
# No statistics, changed unit - empty response
hass.states.async_set(
"sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": "W"}}
)
await assert_validation_result(client, {})
# Run statistics, no statistics will be generated because of conflicting units
await async_recorder_block_till_done(hass)
do_adhoc_statistics(hass, start=now)
await async_recorder_block_till_done(hass)
await assert_statistic_ids([])
# No statistics, changed unit - empty response
hass.states.async_set(
"sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": "W"}}
)
await assert_validation_result(client, {})
# Run statistics one hour later, only the "W" state will be considered
await async_recorder_block_till_done(hass)
do_adhoc_statistics(hass, start=now + timedelta(hours=1))
await async_recorder_block_till_done(hass)
await assert_statistic_ids(
[{"statistic_id": "sensor.test", "unit_of_measurement": "W"}]
)
await assert_validation_result(client, {})
# Change back to original unit - expect error
hass.states.async_set("sensor.test", 13, attributes=attributes)
await async_recorder_block_till_done(hass)
expected = {
"sensor.test": [
{
"data": {
"metadata_unit": "W",
"state_unit": "kW",
"statistic_id": "sensor.test",
},
"type": "units_changed_can_convert",
}
],
}
await assert_validation_result(client, expected)
# Changed unit - empty response
hass.states.async_set(
"sensor.test", 14, attributes={**attributes, **{"unit_of_measurement": "W"}}
)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
# Valid state, statistic runs again - empty response
await async_recorder_block_till_done(hass)
do_adhoc_statistics(hass, start=now)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
# Remove the state - empty response
hass.states.async_remove("sensor.test") hass.states.async_remove("sensor.test")
expected = { expected = {
"sensor.test": [ "sensor.test": [