From e93deaa8aa7b0060e8a838bcbffc5d64835fed34 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Oct 2022 03:50:05 +0200 Subject: [PATCH] Simplify long term statistics by always supporting unit conversion (#79557) --- homeassistant/components/sensor/recorder.py | 186 +++------ tests/components/sensor/test_recorder.py | 441 +++++++++++--------- 2 files changed, 308 insertions(+), 319 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 144502dd81a..1a72444c758 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -23,22 +23,11 @@ from homeassistant.components.recorder.models import ( StatisticMetaData, 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.exceptions import HomeAssistantError from homeassistant.helpers.entity import entity_sources 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 ( ATTR_LAST_RESET, @@ -48,7 +37,6 @@ from . import ( STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, STATE_CLASSES, - SensorDeviceClass, ) _LOGGER = logging.getLogger(__name__) @@ -59,18 +47,6 @@ DEFAULT_STATISTICS = { 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 SEEN_DIP = "sensor_seen_total_increasing_dip" WARN_DIP = "sensor_warn_total_increasing_dip" @@ -154,84 +130,84 @@ def _normalize_states( session: Session, old_metadatas: dict[str, tuple[int, StatisticMetaData]], entity_history: Iterable[State], - device_class: str | None, entity_id: str, ) -> tuple[str | None, str | None, list[tuple[float, State]]]: """Normalize units.""" old_metadata = old_metadatas[entity_id][1] if entity_id in old_metadatas else 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 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 - # in a supported unit, 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)) + # in a unit which can be converted, return the states as they are - if fstates: - all_units = _get_units(fstates) - if len(all_units) > 1: - if WARN_UNSTABLE_UNIT not in hass.data: - hass.data[WARN_UNSTABLE_UNIT] = set() - if entity_id not in hass.data[WARN_UNSTABLE_UNIT]: - hass.data[WARN_UNSTABLE_UNIT].add(entity_id) - extra = "" - if old_metadata: - extra = ( - " and matches the unit of already compiled statistics " - 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, + all_units = _get_units(fstates) + if len(all_units) > 1: + if WARN_UNSTABLE_UNIT not in hass.data: + hass.data[WARN_UNSTABLE_UNIT] = set() + if entity_id not in hass.data[WARN_UNSTABLE_UNIT]: + hass.data[WARN_UNSTABLE_UNIT].add(entity_id) + extra = "" + if old_metadata: + extra = ( + " and matches the unit of already compiled statistics " + f"({old_metadata['unit_of_measurement']})" ) - return None, None, [] - state_unit = fstates[0][1].attributes.get(ATTR_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, [] + state_unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) return state_unit, state_unit, fstates - converter = UNIT_CONVERTERS[device_class] - fstates = [] + converter = statistics.STATISTIC_UNIT_TO_UNIT_CONVERTER[state_unit] + valid_fstates: list[tuple[float, State]] = [] statistics_unit: str | None = None if old_metadata: statistics_unit = old_metadata["unit_of_measurement"] - for state in entity_history: - try: - fstate = _parse_float(state.state) - except ValueError: - continue + for fstate, state in fstates: 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 WARN_UNSUPPORTED_UNIT not in hass.data: hass.data[WARN_UNSUPPORTED_UNIT] = set() if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]: hass.data[WARN_UNSUPPORTED_UNIT].add(entity_id) _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, state_unit, - device_class, + statistics_unit, ) continue if statistics_unit is None: statistics_unit = state_unit - fstates.append( + valid_fstates.append( ( converter.convert( 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: @@ -427,14 +403,12 @@ def _compile_statistics( # noqa: C901 if entity_id not in history_list: continue - device_class = _state.attributes.get(ATTR_DEVICE_CLASS) entity_history = history_list[entity_id] statistics_unit, state_unit, fstates = _normalize_states( hass, session, old_metadatas, entity_history, - device_class, entity_id, ) @@ -467,11 +441,11 @@ def _compile_statistics( # noqa: C901 if entity_id not in hass.data[WARN_UNSTABLE_UNIT]: hass.data[WARN_UNSTABLE_UNIT].add(entity_id) _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 " - "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", - "normalized " if device_class in UNIT_CONVERTERS else "", entity_id, statistics_unit, old_metadata[1]["unit_of_measurement"], @@ -603,7 +577,6 @@ def list_statistic_ids( for state in entities: state_class = state.attributes[ATTR_STATE_CLASS] - device_class = state.attributes.get(ATTR_DEVICE_CLASS) state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) provided_statistics = DEFAULT_STATISTICS[state_class] @@ -620,21 +593,6 @@ def list_statistic_ids( ): 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] = { "has_mean": "mean" in provided_statistics, "has_sum": "sum" in provided_statistics, @@ -643,6 +601,7 @@ def list_statistic_ids( "statistic_id": state.entity_id, "unit_of_measurement": state_unit, } + continue return result @@ -660,7 +619,6 @@ def validate_statistics( for state in sensor_states: entity_id = state.entity_id - device_class = state.attributes.get(ATTR_DEVICE_CLASS) state_class = state.attributes.get(ATTR_STATE_CLASS) state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -684,35 +642,30 @@ def validate_statistics( ) 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: - # The unit has changed - issue_type = ( - "units_changed_can_convert" - if statistics.can_convert_units(metadata_unit, state_unit) - else "units_changed" - ) + # The unit has changed, and it's not possible to convert validation_result[entity_id].append( statistics.ValidationIssue( - issue_type, + "units_changed", { "statistic_id": entity_id, "state_unit": state_unit, "metadata_unit": metadata_unit, + "supported_unit": metadata_unit, }, ) ) - elif metadata_unit not in UNIT_CONVERTERS[device_class].VALID_UNITS: - # The unit in metadata is not supported for this device class - valid_units = ", ".join( - sorted(UNIT_CONVERTERS[device_class].VALID_UNITS) - ) + elif state_unit not in converter.VALID_UNITS: + # The state unit can't be converted to the unit in metadata + valid_units = ", ".join(sorted(converter.VALID_UNITS)) validation_result[entity_id].append( statistics.ValidationIssue( - "unsupported_unit_metadata", + "units_changed", { "statistic_id": entity_id, - "device_class": device_class, + "state_unit": state_unit, "metadata_unit": metadata_unit, "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: # There is no sensor matching the statistics_id validation_result[statistic_id].append( diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 99aa3a3bf8e..8d9e34d005f 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -238,8 +238,8 @@ def test_compile_hourly_statistics_purged_state_changes( @pytest.mark.parametrize("attributes", [TEMPERATURE_SENSOR_ATTRIBUTES]) -def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes): - """Test compiling hourly statistics for unsupported sensor.""" +def test_compile_hourly_statistics_wrong_unit(hass_recorder, caplog, attributes): + """Test compiling hourly statistics for sensor with unit not matching device class.""" zero = dt_util.utcnow() hass = hass_recorder() setup_component(hass, "sensor", {}) @@ -286,6 +286,24 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes "statistics_unit_of_measurement": "°C", "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", "has_mean": True, @@ -320,6 +338,32 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes "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": [ { "statistic_id": "sensor.test6", @@ -835,32 +879,44 @@ def test_compile_hourly_sum_statistics_nan_inf_state( @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", + "energy", + "kWh", + "kWh", + "kWh", + "energy", + 0, "", "bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue", ), ( "sensor.power_consumption", + "power", + "W", + "W", + "W", + "power", + 15, "from integration demo ", "bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+demo%22", ), ( "sensor.custom_sensor", + "energy", + "kWh", + "kWh", + "kWh", + "energy", + 0, "from integration test ", "report it to the custom integration author", ), ], ) @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( hass_recorder, caplog, @@ -873,7 +929,7 @@ def test_compile_hourly_sum_statistics_negative_state( display_unit, statistics_unit, unit_class, - factor, + offset, ): """Test compiling hourly statistics with negative states.""" zero = dt_util.utcnow() @@ -938,8 +994,8 @@ def test_compile_hourly_sum_statistics_negative_state( "mean": None, "min": None, "last_reset": None, - "state": approx(factor * seq[7]), - "sum": approx(factor * 15), # (15 - 10) + (10 - 0) + "state": approx(seq[7]), + "sum": approx(offset + 15), # (20 - 15) + (10 - 0) }, ] 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) 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) assert statistic_ids == [ { @@ -1922,8 +1978,8 @@ def test_compile_hourly_statistics_changing_units_1( do_adhoc_statistics(hass, start=zero + timedelta(minutes=10)) wait_recording_done(hass) assert ( - "The unit of sensor.test1 (cats) does not match the unit of already compiled " - f"statistics ({display_unit})" in caplog.text + "The unit of sensor.test1 (cats) can not be converted to the unit of " + f"previously compiled statistics ({display_unit})" in caplog.text ) statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ @@ -3039,18 +3095,30 @@ def record_states(hass, zero, entity_id, attributes, seq=None): @pytest.mark.parametrize( - "units, attributes, unit", + "units, attributes, unit, unit2, supported_unit", [ - (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), - (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), - (IMPERIAL_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°F"), - (METRIC_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°C"), - (IMPERIAL_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "psi"), - (METRIC_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "Pa"), + (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_supported_device_class( - hass, hass_ws_client, recorder_mock, units, attributes, unit +async def test_validate_statistics_unit_change_device_class( + hass, hass_ws_client, recorder_mock, units, attributes, unit, unit2, supported_unit ): """Test validate_statistics.""" id = 1 @@ -3078,39 +3146,40 @@ async def test_validate_statistics_supported_device_class( # No statistics, no state - empty response 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( "sensor.test", 10, attributes={**attributes, **{"unit_of_measurement": unit}} ) await async_recorder_block_till_done(hass) 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( "sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": "dogs"}} ) await async_recorder_block_till_done(hass) - expected = { - "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) + await assert_validation_result(client, {}) - # Statistics has run, invalid state - expect error + # 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) + 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) # Valid state - empty response @@ -3125,6 +3194,18 @@ async def test_validate_statistics_supported_device_class( 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 = { @@ -3144,7 +3225,7 @@ async def test_validate_statistics_supported_device_class( (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 ): """Test validate_statistics.""" @@ -3173,56 +3254,144 @@ async def test_validate_statistics_supported_device_class_2( # No statistics, no state - empty response await assert_validation_result(client, {}) - # No statistics, valid state - empty response - initial_attributes = {"state_class": "measurement"} + # No statistics, no device class - empty response + initial_attributes = {"state_class": "measurement", "unit_of_measurement": "dogs"} hass.states.async_set("sensor.test", 10, attributes=initial_attributes) await hass.async_block_till_done() 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) 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( - "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) expected = { "sensor.test": [ { "data": { - "device_class": attributes["device_class"], - "metadata_unit": None, - "statistic_id": "sensor.test", - "supported_unit": valid_units, - }, - "type": "unsupported_unit_metadata", - }, - { - "data": { - "device_class": attributes["device_class"], + "metadata_unit": unit, "state_unit": "dogs", "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) @@ -3473,7 +3642,7 @@ async def test_validate_statistics_sensor_removed( "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 ): """Test validate_statistics.""" @@ -3553,6 +3722,7 @@ async def test_validate_statistics_unsupported_device_class( "metadata_unit": "dogs", "state_unit": attributes.get("unit_of_measurement"), "statistic_id": "sensor.test", + "supported_unit": "dogs", }, "type": "units_changed", } @@ -3573,124 +3743,7 @@ async def test_validate_statistics_unsupported_device_class( 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) - - -@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 + # Remove the state - expect error hass.states.async_remove("sensor.test") expected = { "sensor.test": [