From dae00c70dec2adabc4d309608ae2a34568967dc6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 20 Sep 2022 23:43:57 +0200 Subject: [PATCH] Allow selecting display unit when fetching statistics (#78578) --- homeassistant/components/recorder/core.py | 10 +- .../components/recorder/statistics.py | 227 +++-- homeassistant/components/recorder/tasks.py | 7 +- .../components/recorder/websocket_api.py | 79 +- tests/components/demo/test_init.py | 2 + tests/components/recorder/test_statistics.py | 35 +- .../components/recorder/test_websocket_api.py | 827 +++++++++++++++++- tests/components/sensor/test_recorder.py | 285 +++--- 8 files changed, 1231 insertions(+), 241 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 88b63e93733..8277828abbc 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -480,10 +480,16 @@ class Recorder(threading.Thread): @callback def async_adjust_statistics( - self, statistic_id: str, start_time: datetime, sum_adjustment: float + self, + statistic_id: str, + start_time: datetime, + sum_adjustment: float, + display_unit: str, ) -> None: """Adjust statistics.""" - self.queue_task(AdjustStatisticsTask(statistic_id, start_time, sum_adjustment)) + self.queue_task( + AdjustStatisticsTask(statistic_id, start_time, sum_adjustment, display_unit) + ) @callback def async_clear_statistics(self, statistic_ids: list[str]) -> None: diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 8585ca37fac..f720a2cd62e 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -6,6 +6,7 @@ from collections.abc import Callable, Iterable import contextlib import dataclasses from datetime import datetime, timedelta +from functools import partial from itertools import chain, groupby import json import logging @@ -25,11 +26,12 @@ import voluptuous as vol from homeassistant.const import ( ENERGY_KILO_WATT_HOUR, + ENERGY_MEGA_WATT_HOUR, + ENERGY_WATT_HOUR, POWER_KILO_WATT, POWER_WATT, PRESSURE_PA, TEMP_CELSIUS, - VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS, ) from homeassistant.core import Event, HomeAssistant, callback, valid_entity_id @@ -41,7 +43,6 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType import homeassistant.util.dt as dt_util import homeassistant.util.pressure as pressure_util import homeassistant.util.temperature as temperature_util -from homeassistant.util.unit_system import UnitSystem import homeassistant.util.volume as volume_util from .const import DOMAIN, MAX_ROWS_TO_PURGE, SupportedDialect @@ -131,65 +132,138 @@ QUERY_STATISTIC_META_ID = [ ] -def _convert_power(value: float | None, state_unit: str, _: UnitSystem) -> float | None: - """Convert power in W to to_unit.""" +def _convert_energy_from_kwh(to_unit: str, value: float | None) -> float | None: + """Convert energy in kWh to to_unit.""" if value is None: return None - if state_unit == POWER_KILO_WATT: + if to_unit == ENERGY_MEGA_WATT_HOUR: + return value / 1000 + if to_unit == ENERGY_WATT_HOUR: + return value * 1000 + return value + + +def _convert_energy_to_kwh(from_unit: str, value: float) -> float: + """Convert energy in from_unit to kWh.""" + if from_unit == ENERGY_MEGA_WATT_HOUR: + return value * 1000 + if from_unit == ENERGY_WATT_HOUR: return value / 1000 return value -def _convert_pressure( - value: float | None, state_unit: str, _: UnitSystem -) -> float | None: +def _convert_power_from_w(to_unit: str, value: float | None) -> float | None: + """Convert power in W to to_unit.""" + if value is None: + return None + if to_unit == POWER_KILO_WATT: + return value / 1000 + return value + + +def _convert_pressure_from_pa(to_unit: str, value: float | None) -> float | None: """Convert pressure in Pa to to_unit.""" if value is None: return None - return pressure_util.convert(value, PRESSURE_PA, state_unit) + return pressure_util.convert(value, PRESSURE_PA, to_unit) -def _convert_temperature( - value: float | None, state_unit: str, _: UnitSystem -) -> float | None: +def _convert_temperature_from_c(to_unit: str, value: float | None) -> float | None: """Convert temperature in °C to to_unit.""" if value is None: return None - return temperature_util.convert(value, TEMP_CELSIUS, state_unit) + return temperature_util.convert(value, TEMP_CELSIUS, to_unit) -def _convert_volume(value: float | None, _: str, units: UnitSystem) -> float | None: - """Convert volume in m³ to ft³ or m³.""" +def _convert_volume_from_m3(to_unit: str, value: float | None) -> float | None: + """Convert volume in m³ to to_unit.""" if value is None: return None - return volume_util.convert(value, VOLUME_CUBIC_METERS, _volume_unit(units)) + return volume_util.convert(value, VOLUME_CUBIC_METERS, to_unit) -# Convert power, pressure, temperature and volume statistics from the normalized unit -# used for statistics to the unit configured by the user -STATISTIC_UNIT_TO_DISPLAY_UNIT_CONVERSIONS: dict[ - str, Callable[[float | None, str, UnitSystem], float | None] -] = { - POWER_WATT: _convert_power, - PRESSURE_PA: _convert_pressure, - TEMP_CELSIUS: _convert_temperature, - VOLUME_CUBIC_METERS: _convert_volume, +def _convert_volume_to_m3(from_unit: str, value: float) -> float: + """Convert volume in from_unit to m³.""" + return volume_util.convert(value, from_unit, VOLUME_CUBIC_METERS) + + +STATISTIC_UNIT_TO_UNIT_CLASS: dict[str | None, str] = { + ENERGY_KILO_WATT_HOUR: "energy", + POWER_WATT: "power", + PRESSURE_PA: "pressure", + TEMP_CELSIUS: "temperature", + VOLUME_CUBIC_METERS: "volume", } -# Convert volume statistics from the display unit configured by the user -# to the normalized unit used for statistics -# This is used to support adjusting statistics in the display unit -DISPLAY_UNIT_TO_STATISTIC_UNIT_CONVERSIONS: dict[ - str, Callable[[float, UnitSystem], float] + +# Convert energy power, pressure, temperature and volume statistics from the +# normalized unit used for statistics to the unit configured by the user +STATISTIC_UNIT_TO_DISPLAY_UNIT_FUNCTIONS: dict[ + str, Callable[[str, float | None], float | None] ] = { - VOLUME_CUBIC_FEET: lambda x, units: volume_util.convert( - x, _volume_unit(units), VOLUME_CUBIC_METERS - ), + ENERGY_KILO_WATT_HOUR: _convert_energy_from_kwh, + POWER_WATT: _convert_power_from_w, + PRESSURE_PA: _convert_pressure_from_pa, + TEMP_CELSIUS: _convert_temperature_from_c, + VOLUME_CUBIC_METERS: _convert_volume_from_m3, +} + +# Convert energy and volume statistics from the display unit configured by the user +# to the normalized unit used for statistics. +# This is used to support adjusting statistics in the display unit +DISPLAY_UNIT_TO_STATISTIC_UNIT_FUNCTIONS: dict[str, Callable[[str, float], float]] = { + ENERGY_KILO_WATT_HOUR: _convert_energy_to_kwh, + VOLUME_CUBIC_METERS: _convert_volume_to_m3, } _LOGGER = logging.getLogger(__name__) +def _get_statistic_to_display_unit_converter( + statistic_unit: str | None, + state_unit: str | None, + requested_units: dict[str, str] | None, +) -> Callable[[float | None], float | None]: + """Prepare a converter from the normalized statistics unit to display unit.""" + + def no_conversion(val: float | None) -> float | None: + """Return val.""" + return val + + if statistic_unit is None: + return no_conversion + + if ( + convert_fn := STATISTIC_UNIT_TO_DISPLAY_UNIT_FUNCTIONS.get(statistic_unit) + ) is None: + return no_conversion + + unit_class = STATISTIC_UNIT_TO_UNIT_CLASS[statistic_unit] + display_unit = requested_units.get(unit_class) if requested_units else state_unit + return partial(convert_fn, display_unit) + + +def _get_display_to_statistic_unit_converter( + display_unit: str | None, + statistic_unit: str | None, +) -> Callable[[float], float]: + """Prepare a converter from the display unit to the normalized statistics unit.""" + + def no_conversion(val: float) -> float: + """Return val.""" + return val + + if statistic_unit is None: + return no_conversion + + if ( + convert_fn := DISPLAY_UNIT_TO_STATISTIC_UNIT_FUNCTIONS.get(statistic_unit) + ) is None: + return no_conversion + + return partial(convert_fn, display_unit) + + @dataclasses.dataclass class PlatformCompiledStatistics: """Compiled Statistics from a platform.""" @@ -802,28 +876,6 @@ def get_metadata( ) -def _volume_unit(units: UnitSystem) -> str: - """Return the preferred volume unit according to unit system.""" - if units.is_metric: - return VOLUME_CUBIC_METERS - return VOLUME_CUBIC_FEET - - -def _configured_unit( - unit: str | None, state_unit: str | None, units: UnitSystem -) -> str | None: - """Return the pressure and temperature units configured by the user. - - Energy and volume is normalized for the energy dashboard. - For other units, display in the unit of the source. - """ - if unit == ENERGY_KILO_WATT_HOUR: - return ENERGY_KILO_WATT_HOUR - if unit == VOLUME_CUBIC_METERS: - return _volume_unit(units) - return state_unit - - def clear_statistics(instance: Recorder, statistic_ids: list[str]) -> None: """Clear statistics for a list of statistic_ids.""" with session_scope(session=instance.get_session()) as session: @@ -868,11 +920,6 @@ def list_statistic_ids( """ result = {} - def _display_unit( - hass: HomeAssistant, statistic_unit: str | None, state_unit: str | None - ) -> str | None: - return _configured_unit(statistic_unit, state_unit, hass.config.units) - # Query the database with session_scope(hass=hass) as session: metadata = get_metadata_with_session( @@ -881,12 +928,13 @@ def list_statistic_ids( result = { meta["statistic_id"]: { + "display_unit_of_measurement": meta["state_unit_of_measurement"], "has_mean": meta["has_mean"], "has_sum": meta["has_sum"], "name": meta["name"], "source": meta["source"], - "display_unit_of_measurement": _display_unit( - hass, meta["unit_of_measurement"], meta["state_unit_of_measurement"] + "unit_class": STATISTIC_UNIT_TO_UNIT_CLASS.get( + meta["unit_of_measurement"] ), "unit_of_measurement": meta["unit_of_measurement"], } @@ -909,8 +957,9 @@ def list_statistic_ids( "has_sum": meta["has_sum"], "name": meta["name"], "source": meta["source"], - "display_unit_of_measurement": _display_unit( - hass, meta["unit_of_measurement"], meta["state_unit_of_measurement"] + "display_unit_of_measurement": meta["state_unit_of_measurement"], + "unit_class": STATISTIC_UNIT_TO_UNIT_CLASS.get( + meta["unit_of_measurement"] ), "unit_of_measurement": meta["unit_of_measurement"], } @@ -925,6 +974,7 @@ def list_statistic_ids( "source": info["source"], "display_unit_of_measurement": info["display_unit_of_measurement"], "statistics_unit_of_measurement": info["unit_of_measurement"], + "unit_class": info["unit_class"], } for _id, info in result.items() ] @@ -1079,6 +1129,7 @@ def statistics_during_period( statistic_ids: list[str] | None = None, period: Literal["5minute", "day", "hour", "month"] = "hour", start_time_as_datetime: bool = False, + units: dict[str, str] | None = None, ) -> dict[str, list[dict[str, Any]]]: """Return statistics during UTC period start_time - end_time for the statistic_ids. @@ -1120,10 +1171,20 @@ def statistics_during_period( table, start_time, start_time_as_datetime, + units, ) result = _sorted_statistics_to_dict( - hass, session, stats, statistic_ids, metadata, True, table, start_time, True + hass, + session, + stats, + statistic_ids, + metadata, + True, + table, + start_time, + True, + units, ) if period == "day": @@ -1192,6 +1253,8 @@ def _get_last_statistics( convert_units, table, None, + False, + None, ) @@ -1276,6 +1339,8 @@ def get_latest_short_term_statistics( False, StatisticsShortTerm, None, + False, + None, ) @@ -1320,18 +1385,18 @@ def _sorted_statistics_to_dict( convert_units: bool, table: type[Statistics | StatisticsShortTerm], start_time: datetime | None, - start_time_as_datetime: bool = False, + start_time_as_datetime: bool, + units: dict[str, str] | None, ) -> dict[str, list[dict]]: """Convert SQL results into JSON friendly data structure.""" result: dict = defaultdict(list) - units = hass.config.units metadata = dict(_metadata.values()) need_stat_at_start_time: set[int] = set() stats_at_start_time = {} - def no_conversion(val: Any, _unit: str | None, _units: Any) -> float | None: - """Return x.""" - return val # type: ignore[no-any-return] + def no_conversion(val: float | None) -> float | None: + """Return val.""" + return val # Set all statistic IDs to empty lists in result set to maintain the order if statistic_ids is not None: @@ -1357,11 +1422,8 @@ def _sorted_statistics_to_dict( unit = metadata[meta_id]["unit_of_measurement"] state_unit = metadata[meta_id]["state_unit_of_measurement"] statistic_id = metadata[meta_id]["statistic_id"] - convert: Callable[[Any, Any, Any], float | None] if unit is not None and convert_units: - convert = STATISTIC_UNIT_TO_DISPLAY_UNIT_CONVERSIONS.get( - unit, no_conversion - ) + convert = _get_statistic_to_display_unit_converter(unit, state_unit, units) else: convert = no_conversion ent_results = result[meta_id] @@ -1373,14 +1435,14 @@ def _sorted_statistics_to_dict( "statistic_id": statistic_id, "start": start if start_time_as_datetime else start.isoformat(), "end": end.isoformat(), - "mean": convert(db_state.mean, state_unit, units), - "min": convert(db_state.min, state_unit, units), - "max": convert(db_state.max, state_unit, units), + "mean": convert(db_state.mean), + "min": convert(db_state.min), + "max": convert(db_state.max), "last_reset": process_timestamp_to_utc_isoformat( db_state.last_reset ), - "state": convert(db_state.state, state_unit, units), - "sum": convert(db_state.sum, state_unit, units), + "state": convert(db_state.state), + "sum": convert(db_state.sum), } ) @@ -1556,6 +1618,7 @@ def adjust_statistics( statistic_id: str, start_time: datetime, sum_adjustment: float, + display_unit: str, ) -> bool: """Process an add_statistics job.""" @@ -1566,11 +1629,9 @@ def adjust_statistics( if statistic_id not in metadata: return True - units = instance.hass.config.units statistic_unit = metadata[statistic_id][1]["unit_of_measurement"] - display_unit = _configured_unit(statistic_unit, None, units) - convert = DISPLAY_UNIT_TO_STATISTIC_UNIT_CONVERSIONS.get(display_unit, lambda x, units: x) # type: ignore[arg-type] - sum_adjustment = convert(sum_adjustment, units) + convert = _get_display_to_statistic_unit_converter(display_unit, statistic_unit) + sum_adjustment = convert(sum_adjustment) _adjust_sum_statistics( session, diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index cdb97d9d67c..1ab84a1ce5a 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -145,6 +145,7 @@ class AdjustStatisticsTask(RecorderTask): statistic_id: str start_time: datetime sum_adjustment: float + display_unit: str def run(self, instance: Recorder) -> None: """Run statistics task.""" @@ -153,12 +154,16 @@ class AdjustStatisticsTask(RecorderTask): self.statistic_id, self.start_time, self.sum_adjustment, + self.display_unit, ): return # Schedule a new adjust statistics task if this one didn't finish instance.queue_task( AdjustStatisticsTask( - self.statistic_id, self.start_time, self.sum_adjustment + self.statistic_id, + self.start_time, + self.sum_adjustment, + self.display_unit, ) ) diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index c625620f4c0..a61d1e8d673 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -9,10 +9,21 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.websocket_api import messages +from homeassistant.const import ( + ENERGY_KILO_WATT_HOUR, + ENERGY_MEGA_WATT_HOUR, + ENERGY_WATT_HOUR, + POWER_KILO_WATT, + POWER_WATT, + VOLUME_CUBIC_FEET, + VOLUME_CUBIC_METERS, +) from homeassistant.core import HomeAssistant, callback, valid_entity_id from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import JSON_DUMP from homeassistant.util import dt as dt_util +import homeassistant.util.pressure as pressure_util +import homeassistant.util.temperature as temperature_util from .const import MAX_QUEUE_BACKLOG from .statistics import ( @@ -47,15 +58,18 @@ def _ws_get_statistics_during_period( hass: HomeAssistant, msg_id: int, start_time: dt, - end_time: dt | None = None, - statistic_ids: list[str] | None = None, - period: Literal["5minute", "day", "hour", "month"] = "hour", + end_time: dt | None, + statistic_ids: list[str] | None, + period: Literal["5minute", "day", "hour", "month"], + units: dict[str, str], ) -> str: """Fetch statistics and convert them to json in the executor.""" return JSON_DUMP( messages.result_message( msg_id, - statistics_during_period(hass, start_time, end_time, statistic_ids, period), + statistics_during_period( + hass, start_time, end_time, statistic_ids, period, units=units + ), ) ) @@ -91,6 +105,7 @@ async def ws_handle_get_statistics_during_period( end_time, msg.get("statistic_ids"), msg.get("period"), + msg.get("units"), ) ) @@ -102,6 +117,17 @@ async def ws_handle_get_statistics_during_period( vol.Optional("end_time"): str, vol.Optional("statistic_ids"): [str], vol.Required("period"): vol.Any("5minute", "hour", "day", "month"), + vol.Optional("units"): vol.Schema( + { + vol.Optional("energy"): vol.Any( + ENERGY_WATT_HOUR, ENERGY_KILO_WATT_HOUR, ENERGY_MEGA_WATT_HOUR + ), + vol.Optional("power"): vol.Any(POWER_WATT, POWER_KILO_WATT), + vol.Optional("pressure"): vol.In(pressure_util.VALID_UNITS), + vol.Optional("temperature"): vol.In(temperature_util.VALID_UNITS), + vol.Optional("volume"): vol.Any(VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS), + } + ), } ) @websocket_api.async_response @@ -236,13 +262,18 @@ def ws_update_statistics_metadata( vol.Required("statistic_id"): str, vol.Required("start_time"): str, vol.Required("adjustment"): vol.Any(float, int), + vol.Required("display_unit"): vol.Any(str, None), } ) -@callback -def ws_adjust_sum_statistics( +@websocket_api.async_response +async def ws_adjust_sum_statistics( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: - """Adjust sum statistics.""" + """Adjust sum statistics. + + If the statistics is stored as kWh, it's allowed to make an adjustment in Wh or MWh + If the statistics is stored as m³, it's allowed to make an adjustment in ft³ + """ start_time_str = msg["start_time"] if start_time := dt_util.parse_datetime(start_time_str): @@ -251,8 +282,38 @@ def ws_adjust_sum_statistics( connection.send_error(msg["id"], "invalid_start_time", "Invalid start time") return + instance = get_instance(hass) + metadatas = await instance.async_add_executor_job( + list_statistic_ids, hass, (msg["statistic_id"],) + ) + if not metadatas: + connection.send_error(msg["id"], "unknown_statistic_id", "Unknown statistic ID") + return + metadata = metadatas[0] + + def valid_units(statistics_unit: str | None, display_unit: str | None) -> bool: + if statistics_unit == display_unit: + return True + if statistics_unit == ENERGY_KILO_WATT_HOUR and display_unit in ( + ENERGY_MEGA_WATT_HOUR, + ENERGY_WATT_HOUR, + ): + return True + if statistics_unit == VOLUME_CUBIC_METERS and display_unit == VOLUME_CUBIC_FEET: + return True + return False + + stat_unit = metadata["statistics_unit_of_measurement"] + if not valid_units(stat_unit, msg["display_unit"]): + connection.send_error( + msg["id"], + "invalid_units", + f"Can't convert {stat_unit} to {msg['display_unit']}", + ) + return + get_instance(hass).async_adjust_statistics( - msg["statistic_id"], start_time, msg["adjustment"] + msg["statistic_id"], start_time, msg["adjustment"], msg["display_unit"] ) connection.send_result(msg["id"]) @@ -286,7 +347,7 @@ def ws_adjust_sum_statistics( def ws_import_statistics( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: - """Adjust sum statistics.""" + """Import statistics.""" metadata = msg["metadata"] stats = msg["stats"] metadata["state_unit_of_measurement"] = metadata["unit_of_measurement"] diff --git a/tests/components/demo/test_init.py b/tests/components/demo/test_init.py index f7fec9629ac..79da28d8abd 100644 --- a/tests/components/demo/test_init.py +++ b/tests/components/demo/test_init.py @@ -70,6 +70,7 @@ async def test_demo_statistics(hass, recorder_mock): "source": "demo", "statistic_id": "demo:temperature_outdoor", "statistics_unit_of_measurement": "°C", + "unit_class": "temperature", } in statistic_ids assert { "display_unit_of_measurement": "kWh", @@ -79,6 +80,7 @@ async def test_demo_statistics(hass, recorder_mock): "source": "demo", "statistic_id": "demo:energy_consumption_kwh", "statistics_unit_of_measurement": "kWh", + "unit_class": "energy", } in statistic_ids diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index beb7cef2fb9..f2de32a443c 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -532,6 +532,7 @@ async def test_import_statistics( "name": "Total imported energy", "source": source, "statistics_unit_of_measurement": "kWh", + "unit_class": "energy", } ] metadata = get_metadata(hass, statistic_ids=(statistic_id,)) @@ -603,7 +604,7 @@ async def test_import_statistics( ] } - # Update the previously inserted statistics + rename and change unit + # Update the previously inserted statistics + rename and change display unit external_statistics = { "start": period1, "max": 1, @@ -620,13 +621,14 @@ async def test_import_statistics( statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ { - "display_unit_of_measurement": "kWh", + "display_unit_of_measurement": "MWh", "has_mean": False, "has_sum": True, "statistic_id": statistic_id, "name": "Total imported energy renamed", "source": source, "statistics_unit_of_measurement": "kWh", + "unit_class": "energy", } ] metadata = get_metadata(hass, statistic_ids=(statistic_id,)) @@ -651,12 +653,12 @@ async def test_import_statistics( "statistic_id": statistic_id, "start": period1.isoformat(), "end": (period1 + timedelta(hours=1)).isoformat(), - "max": approx(1.0), - "mean": approx(2.0), - "min": approx(3.0), + "max": approx(1.0 / 1000), + "mean": approx(2.0 / 1000), + "min": approx(3.0 / 1000), "last_reset": last_reset_utc_str, - "state": approx(4.0), - "sum": approx(5.0), + "state": approx(4.0 / 1000), + "sum": approx(5.0 / 1000), }, { "statistic_id": statistic_id, @@ -666,8 +668,8 @@ async def test_import_statistics( "mean": None, "min": None, "last_reset": last_reset_utc_str, - "state": approx(1.0), - "sum": approx(3.0), + "state": approx(1.0 / 1000), + "sum": approx(3.0 / 1000), }, ] } @@ -680,6 +682,7 @@ async def test_import_statistics( "statistic_id": statistic_id, "start_time": period2.isoformat(), "adjustment": 1000.0, + "display_unit": "MWh", } ) response = await client.receive_json() @@ -693,12 +696,12 @@ async def test_import_statistics( "statistic_id": statistic_id, "start": period1.isoformat(), "end": (period1 + timedelta(hours=1)).isoformat(), - "max": approx(1.0), - "mean": approx(2.0), - "min": approx(3.0), + "max": approx(1.0 / 1000), + "mean": approx(2.0 / 1000), + "min": approx(3.0 / 1000), "last_reset": last_reset_utc_str, - "state": approx(4.0), - "sum": approx(5.0), + "state": approx(4.0 / 1000), + "sum": approx(5.0 / 1000), }, { "statistic_id": statistic_id, @@ -708,8 +711,8 @@ async def test_import_statistics( "mean": None, "min": None, "last_reset": last_reset_utc_str, - "state": approx(1.0), - "sum": approx(1003.0), + "state": approx(1.0 / 1000), + "sum": approx(1000 + 3.0 / 1000), }, ] } diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 1e0633248bd..6f8b2be7d58 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -50,12 +50,12 @@ TEMPERATURE_SENSOR_F_ATTRIBUTES = { "state_class": "measurement", "unit_of_measurement": "°F", } -ENERGY_SENSOR_ATTRIBUTES = { +ENERGY_SENSOR_KWH_ATTRIBUTES = { "device_class": "energy", "state_class": "total", "unit_of_measurement": "kWh", } -GAS_SENSOR_ATTRIBUTES = { +GAS_SENSOR_M3_ATTRIBUTES = { "device_class": "gas", "state_class": "total", "unit_of_measurement": "m³", @@ -133,6 +133,241 @@ async def test_statistics_during_period( } +@pytest.mark.parametrize( + "attributes, state, value, custom_units, converted_value", + [ + (POWER_SENSOR_KW_ATTRIBUTES, 10, 10, {"power": "W"}, 10000), + (POWER_SENSOR_KW_ATTRIBUTES, 10, 10, {"power": "kW"}, 10), + (PRESSURE_SENSOR_HPA_ATTRIBUTES, 10, 10, {"pressure": "Pa"}, 1000), + (PRESSURE_SENSOR_HPA_ATTRIBUTES, 10, 10, {"pressure": "hPa"}, 10), + (PRESSURE_SENSOR_HPA_ATTRIBUTES, 10, 10, {"pressure": "psi"}, 1000 / 6894.757), + (TEMPERATURE_SENSOR_C_ATTRIBUTES, 10, 10, {"temperature": "°C"}, 10), + (TEMPERATURE_SENSOR_C_ATTRIBUTES, 10, 10, {"temperature": "°F"}, 50), + (TEMPERATURE_SENSOR_C_ATTRIBUTES, 10, 10, {"temperature": "K"}, 283.15), + ], +) +async def test_statistics_during_period_unit_conversion( + hass, + hass_ws_client, + recorder_mock, + attributes, + state, + value, + custom_units, + converted_value, +): + """Test statistics_during_period.""" + now = dt_util.utcnow() + + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.test", state, attributes=attributes) + await async_wait_recording_done(hass) + + do_adhoc_statistics(hass, start=now) + await async_wait_recording_done(hass) + + client = await hass_ws_client() + + # Query in state unit + await client.send_json( + { + "id": 1, + "type": "recorder/statistics_during_period", + "start_time": now.isoformat(), + "statistic_ids": ["sensor.test"], + "period": "5minute", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "sensor.test": [ + { + "statistic_id": "sensor.test", + "start": now.isoformat(), + "end": (now + timedelta(minutes=5)).isoformat(), + "mean": approx(value), + "min": approx(value), + "max": approx(value), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + + # Query in custom unit + await client.send_json( + { + "id": 2, + "type": "recorder/statistics_during_period", + "start_time": now.isoformat(), + "statistic_ids": ["sensor.test"], + "period": "5minute", + "units": custom_units, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "sensor.test": [ + { + "statistic_id": "sensor.test", + "start": now.isoformat(), + "end": (now + timedelta(minutes=5)).isoformat(), + "mean": approx(converted_value), + "min": approx(converted_value), + "max": approx(converted_value), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + + +@pytest.mark.parametrize( + "attributes, state, value, custom_units, converted_value", + [ + (ENERGY_SENSOR_KWH_ATTRIBUTES, 10, 10, {"energy": "kWh"}, 10), + (ENERGY_SENSOR_KWH_ATTRIBUTES, 10, 10, {"energy": "MWh"}, 0.010), + (ENERGY_SENSOR_KWH_ATTRIBUTES, 10, 10, {"energy": "Wh"}, 10000), + (GAS_SENSOR_M3_ATTRIBUTES, 10, 10, {"volume": "m³"}, 10), + (GAS_SENSOR_M3_ATTRIBUTES, 10, 10, {"volume": "ft³"}, 353.147), + ], +) +async def test_sum_statistics_during_period_unit_conversion( + hass, + hass_ws_client, + recorder_mock, + attributes, + state, + value, + custom_units, + converted_value, +): + """Test statistics_during_period.""" + now = dt_util.utcnow() + + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.test", 0, attributes=attributes) + hass.states.async_set("sensor.test", state, attributes=attributes) + await async_wait_recording_done(hass) + + do_adhoc_statistics(hass, start=now) + await async_wait_recording_done(hass) + + client = await hass_ws_client() + + # Query in state unit + await client.send_json( + { + "id": 1, + "type": "recorder/statistics_during_period", + "start_time": now.isoformat(), + "statistic_ids": ["sensor.test"], + "period": "5minute", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "sensor.test": [ + { + "statistic_id": "sensor.test", + "start": now.isoformat(), + "end": (now + timedelta(minutes=5)).isoformat(), + "mean": None, + "min": None, + "max": None, + "last_reset": None, + "state": approx(value), + "sum": approx(value), + } + ] + } + + # Query in custom unit + await client.send_json( + { + "id": 2, + "type": "recorder/statistics_during_period", + "start_time": now.isoformat(), + "statistic_ids": ["sensor.test"], + "period": "5minute", + "units": custom_units, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "sensor.test": [ + { + "statistic_id": "sensor.test", + "start": now.isoformat(), + "end": (now + timedelta(minutes=5)).isoformat(), + "mean": None, + "min": None, + "max": None, + "last_reset": None, + "state": approx(converted_value), + "sum": approx(converted_value), + } + ] + } + + +@pytest.mark.parametrize( + "custom_units", + [ + {"energy": "W"}, + {"power": "Pa"}, + {"pressure": "K"}, + {"temperature": "m³"}, + {"volume": "kWh"}, + ], +) +async def test_statistics_during_period_invalid_unit_conversion( + hass, hass_ws_client, recorder_mock, custom_units +): + """Test statistics_during_period.""" + now = dt_util.utcnow() + + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + + client = await hass_ws_client() + + # Query in state unit + await client.send_json( + { + "id": 1, + "type": "recorder/statistics_during_period", + "start_time": now.isoformat(), + "period": "5minute", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {} + + # Query in custom unit + await client.send_json( + { + "id": 2, + "type": "recorder/statistics_during_period", + "start_time": now.isoformat(), + "period": "5minute", + "units": custom_units, + } + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "invalid_format" + + @pytest.mark.parametrize( "units, attributes, state, value", [ @@ -307,16 +542,16 @@ async def test_statistics_during_period_bad_end_time( @pytest.mark.parametrize( - "units, attributes, display_unit, statistics_unit", + "units, attributes, display_unit, statistics_unit, unit_class", [ - (IMPERIAL_SYSTEM, POWER_SENSOR_KW_ATTRIBUTES, "kW", "W"), - (METRIC_SYSTEM, POWER_SENSOR_KW_ATTRIBUTES, "kW", "W"), - (IMPERIAL_SYSTEM, TEMPERATURE_SENSOR_C_ATTRIBUTES, "°C", "°C"), - (METRIC_SYSTEM, TEMPERATURE_SENSOR_C_ATTRIBUTES, "°C", "°C"), - (IMPERIAL_SYSTEM, TEMPERATURE_SENSOR_F_ATTRIBUTES, "°F", "°C"), - (METRIC_SYSTEM, TEMPERATURE_SENSOR_F_ATTRIBUTES, "°F", "°C"), - (IMPERIAL_SYSTEM, PRESSURE_SENSOR_HPA_ATTRIBUTES, "hPa", "Pa"), - (METRIC_SYSTEM, PRESSURE_SENSOR_HPA_ATTRIBUTES, "hPa", "Pa"), + (IMPERIAL_SYSTEM, POWER_SENSOR_KW_ATTRIBUTES, "kW", "W", "power"), + (METRIC_SYSTEM, POWER_SENSOR_KW_ATTRIBUTES, "kW", "W", "power"), + (IMPERIAL_SYSTEM, TEMPERATURE_SENSOR_C_ATTRIBUTES, "°C", "°C", "temperature"), + (METRIC_SYSTEM, TEMPERATURE_SENSOR_C_ATTRIBUTES, "°C", "°C", "temperature"), + (IMPERIAL_SYSTEM, TEMPERATURE_SENSOR_F_ATTRIBUTES, "°F", "°C", "temperature"), + (METRIC_SYSTEM, TEMPERATURE_SENSOR_F_ATTRIBUTES, "°F", "°C", "temperature"), + (IMPERIAL_SYSTEM, PRESSURE_SENSOR_HPA_ATTRIBUTES, "hPa", "Pa", "pressure"), + (METRIC_SYSTEM, PRESSURE_SENSOR_HPA_ATTRIBUTES, "hPa", "Pa", "pressure"), ], ) async def test_list_statistic_ids( @@ -327,6 +562,7 @@ async def test_list_statistic_ids( attributes, display_unit, statistics_unit, + unit_class, ): """Test list_statistic_ids.""" now = dt_util.utcnow() @@ -356,6 +592,7 @@ async def test_list_statistic_ids( "source": "recorder", "display_unit_of_measurement": display_unit, "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, } ] @@ -377,6 +614,7 @@ async def test_list_statistic_ids( "source": "recorder", "display_unit_of_measurement": display_unit, "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, } ] @@ -400,6 +638,7 @@ async def test_list_statistic_ids( "source": "recorder", "display_unit_of_measurement": display_unit, "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, } ] @@ -590,6 +829,7 @@ async def test_update_statistics_metadata( "name": None, "source": "recorder", "statistics_unit_of_measurement": "W", + "unit_class": "power", } ] @@ -617,6 +857,7 @@ async def test_update_statistics_metadata( "name": None, "source": "recorder", "statistics_unit_of_measurement": new_unit, + "unit_class": None, } ] @@ -802,14 +1043,14 @@ async def test_backup_end_without_start( @pytest.mark.parametrize( - "units, attributes, unit", + "units, attributes, unit, unit_class", [ - (METRIC_SYSTEM, GAS_SENSOR_ATTRIBUTES, "m³"), - (METRIC_SYSTEM, ENERGY_SENSOR_ATTRIBUTES, "kWh"), + (METRIC_SYSTEM, GAS_SENSOR_M3_ATTRIBUTES, "m³", "volume"), + (METRIC_SYSTEM, ENERGY_SENSOR_KWH_ATTRIBUTES, "kWh", "energy"), ], ) async def test_get_statistics_metadata( - hass, hass_ws_client, recorder_mock, units, attributes, unit + hass, hass_ws_client, recorder_mock, units, attributes, unit, unit_class ): """Test get_statistics_metadata.""" now = dt_util.utcnow() @@ -891,6 +1132,7 @@ async def test_get_statistics_metadata( "name": None, "source": "recorder", "statistics_unit_of_measurement": unit, + "unit_class": unit_class, } ] @@ -918,6 +1160,7 @@ async def test_get_statistics_metadata( "name": None, "source": "recorder", "statistics_unit_of_measurement": unit, + "unit_class": unit_class, } ] @@ -1014,6 +1257,7 @@ async def test_import_statistics( "name": "Total imported energy", "source": source, "statistics_unit_of_measurement": "kWh", + "unit_class": "energy", } ] metadata = get_metadata(hass, statistic_ids=(statistic_id,)) @@ -1149,6 +1393,119 @@ async def test_import_statistics( ] } + +@pytest.mark.parametrize( + "source, statistic_id", + ( + ("test", "test:total_energy_import"), + ("recorder", "sensor.total_energy_import"), + ), +) +async def test_adjust_sum_statistics_energy( + hass, hass_ws_client, recorder_mock, caplog, source, statistic_id +): + """Test adjusting statistics.""" + client = await hass_ws_client() + + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + zero = dt_util.utcnow() + period1 = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1) + period2 = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=2) + + external_statistics1 = { + "start": period1.isoformat(), + "last_reset": None, + "state": 0, + "sum": 2, + } + external_statistics2 = { + "start": period2.isoformat(), + "last_reset": None, + "state": 1, + "sum": 3, + } + + external_metadata = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": source, + "statistic_id": statistic_id, + "unit_of_measurement": "kWh", + } + + await client.send_json( + { + "id": 1, + "type": "recorder/import_statistics", + "metadata": external_metadata, + "stats": [external_statistics1, external_statistics2], + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] is None + + await async_wait_recording_done(hass) + stats = statistics_during_period(hass, zero, period="hour") + assert stats == { + statistic_id: [ + { + "statistic_id": statistic_id, + "start": period1.isoformat(), + "end": (period1 + timedelta(hours=1)).isoformat(), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(0.0), + "sum": approx(2.0), + }, + { + "statistic_id": statistic_id, + "start": period2.isoformat(), + "end": (period2 + timedelta(hours=1)).isoformat(), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(1.0), + "sum": approx(3.0), + }, + ] + } + statistic_ids = list_statistic_ids(hass) # TODO + assert statistic_ids == [ + { + "display_unit_of_measurement": "kWh", + "has_mean": False, + "has_sum": True, + "statistic_id": statistic_id, + "name": "Total imported energy", + "source": source, + "statistics_unit_of_measurement": "kWh", + "unit_class": "energy", + } + ] + metadata = get_metadata(hass, statistic_ids=(statistic_id,)) + assert metadata == { + statistic_id: ( + 1, + { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": source, + "state_unit_of_measurement": "kWh", + "statistic_id": statistic_id, + "unit_of_measurement": "kWh", + }, + ) + } + + # Adjust previously inserted statistics in kWh await client.send_json( { "id": 4, @@ -1156,6 +1513,7 @@ async def test_import_statistics( "statistic_id": statistic_id, "start_time": period2.isoformat(), "adjustment": 1000.0, + "display_unit": "kWh", } ) response = await client.receive_json() @@ -1169,12 +1527,12 @@ async def test_import_statistics( "statistic_id": statistic_id, "start": period1.isoformat(), "end": (period1 + timedelta(hours=1)).isoformat(), - "max": approx(1.0), - "mean": approx(2.0), - "min": approx(3.0), + "max": approx(None), + "mean": approx(None), + "min": approx(None), "last_reset": None, - "state": approx(4.0), - "sum": approx(5.0), + "state": approx(0.0), + "sum": approx(2.0), }, { "statistic_id": statistic_id, @@ -1189,3 +1547,432 @@ async def test_import_statistics( }, ] } + + # Adjust previously inserted statistics in MWh + await client.send_json( + { + "id": 5, + "type": "recorder/adjust_sum_statistics", + "statistic_id": statistic_id, + "start_time": period2.isoformat(), + "adjustment": 2.0, + "display_unit": "MWh", + } + ) + response = await client.receive_json() + assert response["success"] + + await async_wait_recording_done(hass) + stats = statistics_during_period(hass, zero, period="hour") + assert stats == { + statistic_id: [ + { + "statistic_id": statistic_id, + "start": period1.isoformat(), + "end": (period1 + timedelta(hours=1)).isoformat(), + "max": approx(None), + "mean": approx(None), + "min": approx(None), + "last_reset": None, + "state": approx(0.0), + "sum": approx(2.0), + }, + { + "statistic_id": statistic_id, + "start": period2.isoformat(), + "end": (period2 + timedelta(hours=1)).isoformat(), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(1.0), + "sum": approx(3003.0), + }, + ] + } + + +@pytest.mark.parametrize( + "source, statistic_id", + ( + ("test", "test:total_gas"), + ("recorder", "sensor.total_gas"), + ), +) +async def test_adjust_sum_statistics_gas( + hass, hass_ws_client, recorder_mock, caplog, source, statistic_id +): + """Test adjusting statistics.""" + client = await hass_ws_client() + + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + zero = dt_util.utcnow() + period1 = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1) + period2 = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=2) + + external_statistics1 = { + "start": period1.isoformat(), + "last_reset": None, + "state": 0, + "sum": 2, + } + external_statistics2 = { + "start": period2.isoformat(), + "last_reset": None, + "state": 1, + "sum": 3, + } + + external_metadata = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": source, + "statistic_id": statistic_id, + "unit_of_measurement": "m³", + } + + await client.send_json( + { + "id": 1, + "type": "recorder/import_statistics", + "metadata": external_metadata, + "stats": [external_statistics1, external_statistics2], + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] is None + + await async_wait_recording_done(hass) + stats = statistics_during_period(hass, zero, period="hour") + assert stats == { + statistic_id: [ + { + "statistic_id": statistic_id, + "start": period1.isoformat(), + "end": (period1 + timedelta(hours=1)).isoformat(), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(0.0), + "sum": approx(2.0), + }, + { + "statistic_id": statistic_id, + "start": period2.isoformat(), + "end": (period2 + timedelta(hours=1)).isoformat(), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(1.0), + "sum": approx(3.0), + }, + ] + } + statistic_ids = list_statistic_ids(hass) # TODO + assert statistic_ids == [ + { + "display_unit_of_measurement": "m³", + "has_mean": False, + "has_sum": True, + "statistic_id": statistic_id, + "name": "Total imported energy", + "source": source, + "statistics_unit_of_measurement": "m³", + "unit_class": "volume", + } + ] + metadata = get_metadata(hass, statistic_ids=(statistic_id,)) + assert metadata == { + statistic_id: ( + 1, + { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": source, + "state_unit_of_measurement": "m³", + "statistic_id": statistic_id, + "unit_of_measurement": "m³", + }, + ) + } + + # Adjust previously inserted statistics in m³ + await client.send_json( + { + "id": 4, + "type": "recorder/adjust_sum_statistics", + "statistic_id": statistic_id, + "start_time": period2.isoformat(), + "adjustment": 1000.0, + "display_unit": "m³", + } + ) + response = await client.receive_json() + assert response["success"] + + await async_wait_recording_done(hass) + stats = statistics_during_period(hass, zero, period="hour") + assert stats == { + statistic_id: [ + { + "statistic_id": statistic_id, + "start": period1.isoformat(), + "end": (period1 + timedelta(hours=1)).isoformat(), + "max": approx(None), + "mean": approx(None), + "min": approx(None), + "last_reset": None, + "state": approx(0.0), + "sum": approx(2.0), + }, + { + "statistic_id": statistic_id, + "start": period2.isoformat(), + "end": (period2 + timedelta(hours=1)).isoformat(), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(1.0), + "sum": approx(1003.0), + }, + ] + } + + # Adjust previously inserted statistics in ft³ + await client.send_json( + { + "id": 5, + "type": "recorder/adjust_sum_statistics", + "statistic_id": statistic_id, + "start_time": period2.isoformat(), + "adjustment": 35.3147, # ~1 m³ + "display_unit": "ft³", + } + ) + response = await client.receive_json() + assert response["success"] + + await async_wait_recording_done(hass) + stats = statistics_during_period(hass, zero, period="hour") + assert stats == { + statistic_id: [ + { + "statistic_id": statistic_id, + "start": period1.isoformat(), + "end": (period1 + timedelta(hours=1)).isoformat(), + "max": approx(None), + "mean": approx(None), + "min": approx(None), + "last_reset": None, + "state": approx(0.0), + "sum": approx(2.0), + }, + { + "statistic_id": statistic_id, + "start": period2.isoformat(), + "end": (period2 + timedelta(hours=1)).isoformat(), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(1.0), + "sum": approx(1004), + }, + ] + } + + +@pytest.mark.parametrize( + "state_unit, statistic_unit, unit_class, factor, valid_units, invalid_units", + ( + ("kWh", "kWh", "energy", 1, ("Wh", "kWh", "MWh"), ("ft³", "m³", "cats", None)), + ("MWh", "MWh", None, 1, ("MWh",), ("Wh", "kWh", "ft³", "m³", "cats", None)), + ("m³", "m³", "volume", 1, ("ft³", "m³"), ("Wh", "kWh", "MWh", "cats", None)), + ("ft³", "ft³", None, 1, ("ft³",), ("m³", "Wh", "kWh", "MWh", "cats", None)), + ("dogs", "dogs", None, 1, ("dogs",), ("cats", None)), + (None, None, None, 1, (None,), ("cats",)), + ), +) +async def test_adjust_sum_statistics_errors( + hass, + hass_ws_client, + recorder_mock, + caplog, + state_unit, + statistic_unit, + unit_class, + factor, + valid_units, + invalid_units, +): + """Test incorrectly adjusting statistics.""" + statistic_id = "sensor.total_energy_import" + source = "recorder" + client = await hass_ws_client() + + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + zero = dt_util.utcnow() + period1 = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1) + period2 = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=2) + + external_statistics1 = { + "start": period1.isoformat(), + "last_reset": None, + "state": 0, + "sum": 2, + } + external_statistics2 = { + "start": period2.isoformat(), + "last_reset": None, + "state": 1, + "sum": 3, + } + + external_metadata = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": source, + "statistic_id": statistic_id, + "unit_of_measurement": statistic_unit, + } + + await client.send_json( + { + "id": 1, + "type": "recorder/import_statistics", + "metadata": external_metadata, + "stats": [external_statistics1, external_statistics2], + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] is None + + await async_wait_recording_done(hass) + stats = statistics_during_period(hass, zero, period="hour") + assert stats == { + statistic_id: [ + { + "statistic_id": statistic_id, + "start": period1.isoformat(), + "end": (period1 + timedelta(hours=1)).isoformat(), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(0.0 * factor), + "sum": approx(2.0 * factor), + }, + { + "statistic_id": statistic_id, + "start": period2.isoformat(), + "end": (period2 + timedelta(hours=1)).isoformat(), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(1.0 * factor), + "sum": approx(3.0 * factor), + }, + ] + } + previous_stats = stats + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + { + "display_unit_of_measurement": state_unit, + "has_mean": False, + "has_sum": True, + "statistic_id": statistic_id, + "name": "Total imported energy", + "source": source, + "statistics_unit_of_measurement": statistic_unit, + "unit_class": unit_class, + } + ] + metadata = get_metadata(hass, statistic_ids=(statistic_id,)) + assert metadata == { + statistic_id: ( + 1, + { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": source, + "state_unit_of_measurement": state_unit, + "statistic_id": statistic_id, + "unit_of_measurement": statistic_unit, + }, + ) + } + + # Try to adjust statistics + msg_id = 2 + await client.send_json( + { + "id": msg_id, + "type": "recorder/adjust_sum_statistics", + "statistic_id": "sensor.does_not_exist", + "start_time": period2.isoformat(), + "adjustment": 1000.0, + "display_unit": statistic_unit, + } + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "unknown_statistic_id" + + await async_wait_recording_done(hass) + stats = statistics_during_period(hass, zero, period="hour") + assert stats == previous_stats + + for unit in invalid_units: + msg_id += 1 + await client.send_json( + { + "id": msg_id, + "type": "recorder/adjust_sum_statistics", + "statistic_id": statistic_id, + "start_time": period2.isoformat(), + "adjustment": 1000.0, + "display_unit": unit, + } + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "invalid_units" + + await async_wait_recording_done(hass) + stats = statistics_during_period(hass, zero, period="hour") + assert stats == previous_stats + + for unit in valid_units: + msg_id += 1 + await client.send_json( + { + "id": msg_id, + "type": "recorder/adjust_sum_statistics", + "statistic_id": statistic_id, + "start_time": period2.isoformat(), + "adjustment": 1000.0, + "display_unit": unit, + } + ) + response = await client.receive_json() + assert response["success"] + + await async_wait_recording_done(hass) + stats = statistics_during_period(hass, zero, period="hour") + assert stats != previous_stats + previous_stats = stats diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index b20b270ee69..f240c0f8af8 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -76,20 +76,20 @@ def set_time_zone(): @pytest.mark.parametrize( - "device_class,state_unit,display_unit,statistics_unit,mean,min,max", + "device_class, state_unit, display_unit, statistics_unit, unit_class, mean, min, max", [ - (None, "%", "%", "%", 13.050847, -10, 30), - ("battery", "%", "%", "%", 13.050847, -10, 30), - ("battery", None, None, None, 13.050847, -10, 30), - ("humidity", "%", "%", "%", 13.050847, -10, 30), - ("humidity", None, None, None, 13.050847, -10, 30), - ("pressure", "Pa", "Pa", "Pa", 13.050847, -10, 30), - ("pressure", "hPa", "hPa", "Pa", 13.050847, -10, 30), - ("pressure", "mbar", "mbar", "Pa", 13.050847, -10, 30), - ("pressure", "inHg", "inHg", "Pa", 13.050847, -10, 30), - ("pressure", "psi", "psi", "Pa", 13.050847, -10, 30), - ("temperature", "°C", "°C", "°C", 13.050847, -10, 30), - ("temperature", "°F", "°F", "°C", 13.050847, -10, 30), + (None, "%", "%", "%", None, 13.050847, -10, 30), + ("battery", "%", "%", "%", None, 13.050847, -10, 30), + ("battery", None, None, None, None, 13.050847, -10, 30), + ("humidity", "%", "%", "%", None, 13.050847, -10, 30), + ("humidity", None, None, None, None, 13.050847, -10, 30), + ("pressure", "Pa", "Pa", "Pa", "pressure", 13.050847, -10, 30), + ("pressure", "hPa", "hPa", "Pa", "pressure", 13.050847, -10, 30), + ("pressure", "mbar", "mbar", "Pa", "pressure", 13.050847, -10, 30), + ("pressure", "inHg", "inHg", "Pa", "pressure", 13.050847, -10, 30), + ("pressure", "psi", "psi", "Pa", "pressure", 13.050847, -10, 30), + ("temperature", "°C", "°C", "°C", "temperature", 13.050847, -10, 30), + ("temperature", "°F", "°F", "°C", "temperature", 13.050847, -10, 30), ], ) def test_compile_hourly_statistics( @@ -99,6 +99,7 @@ def test_compile_hourly_statistics( state_unit, display_unit, statistics_unit, + unit_class, mean, min, max, @@ -129,6 +130,7 @@ def test_compile_hourly_statistics( "name": None, "source": "recorder", "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, } ] stats = statistics_during_period(hass, zero, period="5minute") @@ -151,13 +153,19 @@ def test_compile_hourly_statistics( @pytest.mark.parametrize( - "device_class,state_unit,display_unit,statistics_unit", + "device_class, state_unit, display_unit, statistics_unit, unit_class", [ - (None, "%", "%", "%"), + (None, "%", "%", "%", None), ], ) def test_compile_hourly_statistics_purged_state_changes( - hass_recorder, caplog, device_class, state_unit, display_unit, statistics_unit + hass_recorder, + caplog, + device_class, + state_unit, + display_unit, + statistics_unit, + unit_class, ): """Test compiling hourly statistics.""" zero = dt_util.utcnow() @@ -197,6 +205,7 @@ def test_compile_hourly_statistics_purged_state_changes( "name": None, "source": "recorder", "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, } ] stats = statistics_during_period(hass, zero, period="5minute") @@ -266,6 +275,7 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes "name": None, "source": "recorder", "statistics_unit_of_measurement": "°C", + "unit_class": "temperature", }, { "statistic_id": "sensor.test6", @@ -275,6 +285,7 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes "name": None, "source": "recorder", "statistics_unit_of_measurement": "°C", + "unit_class": "temperature", }, { "statistic_id": "sensor.test7", @@ -284,6 +295,7 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes "name": None, "source": "recorder", "statistics_unit_of_measurement": "°C", + "unit_class": "temperature", }, ] stats = statistics_during_period(hass, zero, period="5minute") @@ -333,20 +345,20 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes @pytest.mark.parametrize("state_class", ["total"]) @pytest.mark.parametrize( - "units,device_class,state_unit,display_unit,statistics_unit,factor", + "units, device_class, state_unit, display_unit, statistics_unit, unit_class, factor", [ - (IMPERIAL_SYSTEM, "energy", "kWh", "kWh", "kWh", 1), - (IMPERIAL_SYSTEM, "energy", "Wh", "kWh", "kWh", 1 / 1000), - (IMPERIAL_SYSTEM, "monetary", "EUR", "EUR", "EUR", 1), - (IMPERIAL_SYSTEM, "monetary", "SEK", "SEK", "SEK", 1), - (IMPERIAL_SYSTEM, "gas", "m³", "ft³", "m³", 35.314666711), - (IMPERIAL_SYSTEM, "gas", "ft³", "ft³", "m³", 1), - (METRIC_SYSTEM, "energy", "kWh", "kWh", "kWh", 1), - (METRIC_SYSTEM, "energy", "Wh", "kWh", "kWh", 1 / 1000), - (METRIC_SYSTEM, "monetary", "EUR", "EUR", "EUR", 1), - (METRIC_SYSTEM, "monetary", "SEK", "SEK", "SEK", 1), - (METRIC_SYSTEM, "gas", "m³", "m³", "m³", 1), - (METRIC_SYSTEM, "gas", "ft³", "m³", "m³", 0.0283168466), + (IMPERIAL_SYSTEM, "energy", "kWh", "kWh", "kWh", "energy", 1), + (IMPERIAL_SYSTEM, "energy", "Wh", "Wh", "kWh", "energy", 1), + (IMPERIAL_SYSTEM, "monetary", "EUR", "EUR", "EUR", None, 1), + (IMPERIAL_SYSTEM, "monetary", "SEK", "SEK", "SEK", None, 1), + (IMPERIAL_SYSTEM, "gas", "m³", "m³", "m³", "volume", 1), + (IMPERIAL_SYSTEM, "gas", "ft³", "ft³", "m³", "volume", 1), + (METRIC_SYSTEM, "energy", "kWh", "kWh", "kWh", "energy", 1), + (METRIC_SYSTEM, "energy", "Wh", "Wh", "kWh", "energy", 1), + (METRIC_SYSTEM, "monetary", "EUR", "EUR", "EUR", None, 1), + (METRIC_SYSTEM, "monetary", "SEK", "SEK", "SEK", None, 1), + (METRIC_SYSTEM, "gas", "m³", "m³", "m³", "volume", 1), + (METRIC_SYSTEM, "gas", "ft³", "ft³", "m³", "volume", 1), ], ) async def test_compile_hourly_sum_statistics_amount( @@ -360,6 +372,7 @@ async def test_compile_hourly_sum_statistics_amount( state_unit, display_unit, statistics_unit, + unit_class, factor, ): """Test compiling hourly statistics.""" @@ -405,6 +418,7 @@ async def test_compile_hourly_sum_statistics_amount( "name": None, "source": "recorder", "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, } ] stats = statistics_during_period(hass, period0, period="5minute") @@ -478,6 +492,7 @@ async def test_compile_hourly_sum_statistics_amount( "statistic_id": "sensor.test1", "start_time": period1.isoformat(), "adjustment": 100.0, + "display_unit": display_unit, } ) response = await client.receive_json() @@ -497,6 +512,7 @@ async def test_compile_hourly_sum_statistics_amount( "statistic_id": "sensor.test1", "start_time": period2.isoformat(), "adjustment": -400.0, + "display_unit": display_unit, } ) response = await client.receive_json() @@ -511,14 +527,14 @@ async def test_compile_hourly_sum_statistics_amount( @pytest.mark.parametrize("state_class", ["total"]) @pytest.mark.parametrize( - "device_class,state_unit,display_unit,statistics_unit,factor", + "device_class, state_unit, display_unit, statistics_unit, unit_class, factor", [ - ("energy", "kWh", "kWh", "kWh", 1), - ("energy", "Wh", "kWh", "kWh", 1 / 1000), - ("monetary", "EUR", "EUR", "EUR", 1), - ("monetary", "SEK", "SEK", "SEK", 1), - ("gas", "m³", "m³", "m³", 1), - ("gas", "ft³", "m³", "m³", 0.0283168466), + ("energy", "kWh", "kWh", "kWh", "energy", 1), + ("energy", "Wh", "Wh", "kWh", "energy", 1), + ("monetary", "EUR", "EUR", "EUR", None, 1), + ("monetary", "SEK", "SEK", "SEK", None, 1), + ("gas", "m³", "m³", "m³", "volume", 1), + ("gas", "ft³", "ft³", "m³", "volume", 1), ], ) def test_compile_hourly_sum_statistics_amount_reset_every_state_change( @@ -529,6 +545,7 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change( state_unit, display_unit, statistics_unit, + unit_class, factor, ): """Test compiling hourly statistics.""" @@ -594,6 +611,7 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change( "name": None, "source": "recorder", "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, } ] stats = statistics_during_period(hass, zero, period="5minute") @@ -630,9 +648,9 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change( @pytest.mark.parametrize("state_class", ["total"]) @pytest.mark.parametrize( - "device_class,state_unit,display_unit,statistics_unit,factor", + "device_class, state_unit, display_unit, statistics_unit, unit_class, factor", [ - ("energy", "kWh", "kWh", "kWh", 1), + ("energy", "kWh", "kWh", "kWh", "energy", 1), ], ) def test_compile_hourly_sum_statistics_amount_invalid_last_reset( @@ -643,6 +661,7 @@ def test_compile_hourly_sum_statistics_amount_invalid_last_reset( state_unit, display_unit, statistics_unit, + unit_class, factor, ): """Test compiling hourly statistics.""" @@ -693,6 +712,7 @@ def test_compile_hourly_sum_statistics_amount_invalid_last_reset( "name": None, "source": "recorder", "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, } ] stats = statistics_during_period(hass, zero, period="5minute") @@ -717,9 +737,9 @@ def test_compile_hourly_sum_statistics_amount_invalid_last_reset( @pytest.mark.parametrize("state_class", ["total"]) @pytest.mark.parametrize( - "device_class,state_unit,display_unit,statistics_unit,factor", + "device_class, state_unit, display_unit, statistics_unit, unit_class, factor", [ - ("energy", "kWh", "kWh", "kWh", 1), + ("energy", "kWh", "kWh", "kWh", "energy", 1), ], ) def test_compile_hourly_sum_statistics_nan_inf_state( @@ -730,6 +750,7 @@ def test_compile_hourly_sum_statistics_nan_inf_state( state_unit, display_unit, statistics_unit, + unit_class, factor, ): """Test compiling hourly statistics with nan and inf states.""" @@ -776,6 +797,7 @@ def test_compile_hourly_sum_statistics_nan_inf_state( "name": None, "source": "recorder", "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, } ] stats = statistics_during_period(hass, zero, period="5minute") @@ -819,9 +841,9 @@ def test_compile_hourly_sum_statistics_nan_inf_state( ) @pytest.mark.parametrize("state_class", ["total_increasing"]) @pytest.mark.parametrize( - "device_class,state_unit,display_unit,statistics_unit,factor", + "device_class, state_unit, display_unit, statistics_unit, unit_class, factor", [ - ("energy", "kWh", "kWh", "kWh", 1), + ("energy", "kWh", "kWh", "kWh", "energy", 1), ], ) def test_compile_hourly_sum_statistics_negative_state( @@ -835,6 +857,7 @@ def test_compile_hourly_sum_statistics_negative_state( state_unit, display_unit, statistics_unit, + unit_class, factor, ): """Test compiling hourly statistics with negative states.""" @@ -889,6 +912,7 @@ def test_compile_hourly_sum_statistics_negative_state( "source": "recorder", "statistic_id": entity_id, "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, } in statistic_ids stats = statistics_during_period(hass, zero, period="5minute") assert stats[entity_id] == [ @@ -916,14 +940,14 @@ def test_compile_hourly_sum_statistics_negative_state( @pytest.mark.parametrize( - "device_class,state_unit,display_unit,statistics_unit,factor", + "device_class, state_unit, display_unit, statistics_unit, unit_class, factor", [ - ("energy", "kWh", "kWh", "kWh", 1), - ("energy", "Wh", "kWh", "kWh", 1 / 1000), - ("monetary", "EUR", "EUR", "EUR", 1), - ("monetary", "SEK", "SEK", "SEK", 1), - ("gas", "m³", "m³", "m³", 1), - ("gas", "ft³", "m³", "m³", 0.0283168466), + ("energy", "kWh", "kWh", "kWh", "energy", 1), + ("energy", "Wh", "Wh", "kWh", "energy", 1), + ("monetary", "EUR", "EUR", "EUR", None, 1), + ("monetary", "SEK", "SEK", "SEK", None, 1), + ("gas", "m³", "m³", "m³", "volume", 1), + ("gas", "ft³", "ft³", "m³", "volume", 1), ], ) def test_compile_hourly_sum_statistics_total_no_reset( @@ -933,6 +957,7 @@ def test_compile_hourly_sum_statistics_total_no_reset( state_unit, display_unit, statistics_unit, + unit_class, factor, ): """Test compiling hourly statistics.""" @@ -975,6 +1000,7 @@ def test_compile_hourly_sum_statistics_total_no_reset( "name": None, "source": "recorder", "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, } ] stats = statistics_during_period(hass, period0, period="5minute") @@ -1019,12 +1045,12 @@ def test_compile_hourly_sum_statistics_total_no_reset( @pytest.mark.parametrize( - "device_class,state_unit,display_unit,statistics_unit,factor", + "device_class, state_unit, display_unit, statistics_unit, unit_class, factor", [ - ("energy", "kWh", "kWh", "kWh", 1), - ("energy", "Wh", "kWh", "kWh", 1 / 1000), - ("gas", "m³", "m³", "m³", 1), - ("gas", "ft³", "m³", "m³", 0.0283168466), + ("energy", "kWh", "kWh", "kWh", "energy", 1), + ("energy", "Wh", "Wh", "kWh", "energy", 1), + ("gas", "m³", "m³", "m³", "volume", 1), + ("gas", "ft³", "ft³", "m³", "volume", 1), ], ) def test_compile_hourly_sum_statistics_total_increasing( @@ -1034,6 +1060,7 @@ def test_compile_hourly_sum_statistics_total_increasing( state_unit, display_unit, statistics_unit, + unit_class, factor, ): """Test compiling hourly statistics.""" @@ -1076,6 +1103,7 @@ def test_compile_hourly_sum_statistics_total_increasing( "name": None, "source": "recorder", "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, } ] stats = statistics_during_period(hass, period0, period="5minute") @@ -1123,8 +1151,8 @@ def test_compile_hourly_sum_statistics_total_increasing( @pytest.mark.parametrize( - "device_class,state_unit,display_unit,statistics_unit,factor", - [("energy", "kWh", "kWh", "kWh", 1)], + "device_class, state_unit, display_unit, statistics_unit, unit_class, factor", + [("energy", "kWh", "kWh", "kWh", "energy", 1)], ) def test_compile_hourly_sum_statistics_total_increasing_small_dip( hass_recorder, @@ -1133,6 +1161,7 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( state_unit, display_unit, statistics_unit, + unit_class, factor, ): """Test small dips in sensor readings do not trigger a reset.""" @@ -1188,6 +1217,7 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( "name": None, "source": "recorder", "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, } ] stats = statistics_during_period(hass, period0, period="5minute") @@ -1282,6 +1312,7 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): "name": None, "source": "recorder", "statistics_unit_of_measurement": "kWh", + "unit_class": "energy", } ] stats = statistics_during_period(hass, period0, period="5minute") @@ -1374,6 +1405,7 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "name": None, "source": "recorder", "statistics_unit_of_measurement": "kWh", + "unit_class": "energy", }, { "statistic_id": "sensor.test2", @@ -1383,15 +1415,17 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "name": None, "source": "recorder", "statistics_unit_of_measurement": "kWh", + "unit_class": "energy", }, { "statistic_id": "sensor.test3", - "display_unit_of_measurement": "kWh", + "display_unit_of_measurement": "Wh", "has_mean": False, "has_sum": True, "name": None, "source": "recorder", "statistics_unit_of_measurement": "kWh", + "unit_class": "energy", }, ] stats = statistics_during_period(hass, period0, period="5minute") @@ -1475,8 +1509,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "mean": None, "min": None, "last_reset": process_timestamp_to_utc_isoformat(period0), - "state": approx(5.0 / 1000), - "sum": approx(5.0 / 1000), + "state": approx(5.0), + "sum": approx(5.0), }, { "statistic_id": "sensor.test3", @@ -1486,8 +1520,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "mean": None, "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), - "state": approx(50.0 / 1000), - "sum": approx(60.0 / 1000), + "state": approx(50.0), + "sum": approx(60.0), }, { "statistic_id": "sensor.test3", @@ -1497,8 +1531,8 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "mean": None, "min": None, "last_reset": process_timestamp_to_utc_isoformat(four), - "state": approx(90.0 / 1000), - "sum": approx(100.0 / 1000), + "state": approx(90.0), + "sum": approx(100.0), }, ], } @@ -1666,31 +1700,31 @@ def test_compile_hourly_statistics_fails(hass_recorder, caplog): @pytest.mark.parametrize( - "state_class,device_class,state_unit,display_unit,statistics_unit,statistic_type", + "state_class, device_class, state_unit, display_unit, statistics_unit, unit_class, statistic_type", [ - ("measurement", "battery", "%", "%", "%", "mean"), - ("measurement", "battery", None, None, None, "mean"), - ("total", "energy", "Wh", "kWh", "kWh", "sum"), - ("total", "energy", "kWh", "kWh", "kWh", "sum"), - ("measurement", "energy", "Wh", "kWh", "kWh", "mean"), - ("measurement", "energy", "kWh", "kWh", "kWh", "mean"), - ("measurement", "humidity", "%", "%", "%", "mean"), - ("measurement", "humidity", None, None, None, "mean"), - ("total", "monetary", "USD", "USD", "USD", "sum"), - ("total", "monetary", "None", "None", "None", "sum"), - ("total", "gas", "m³", "m³", "m³", "sum"), - ("total", "gas", "ft³", "m³", "m³", "sum"), - ("measurement", "monetary", "USD", "USD", "USD", "mean"), - ("measurement", "monetary", "None", "None", "None", "mean"), - ("measurement", "gas", "m³", "m³", "m³", "mean"), - ("measurement", "gas", "ft³", "m³", "m³", "mean"), - ("measurement", "pressure", "Pa", "Pa", "Pa", "mean"), - ("measurement", "pressure", "hPa", "hPa", "Pa", "mean"), - ("measurement", "pressure", "mbar", "mbar", "Pa", "mean"), - ("measurement", "pressure", "inHg", "inHg", "Pa", "mean"), - ("measurement", "pressure", "psi", "psi", "Pa", "mean"), - ("measurement", "temperature", "°C", "°C", "°C", "mean"), - ("measurement", "temperature", "°F", "°F", "°C", "mean"), + ("measurement", "battery", "%", "%", "%", None, "mean"), + ("measurement", "battery", None, None, None, None, "mean"), + ("total", "energy", "Wh", "Wh", "kWh", "energy", "sum"), + ("total", "energy", "kWh", "kWh", "kWh", "energy", "sum"), + ("measurement", "energy", "Wh", "Wh", "kWh", "energy", "mean"), + ("measurement", "energy", "kWh", "kWh", "kWh", "energy", "mean"), + ("measurement", "humidity", "%", "%", "%", None, "mean"), + ("measurement", "humidity", None, None, None, None, "mean"), + ("total", "monetary", "USD", "USD", "USD", None, "sum"), + ("total", "monetary", "None", "None", "None", None, "sum"), + ("total", "gas", "m³", "m³", "m³", "volume", "sum"), + ("total", "gas", "ft³", "ft³", "m³", "volume", "sum"), + ("measurement", "monetary", "USD", "USD", "USD", None, "mean"), + ("measurement", "monetary", "None", "None", "None", None, "mean"), + ("measurement", "gas", "m³", "m³", "m³", "volume", "mean"), + ("measurement", "gas", "ft³", "ft³", "m³", "volume", "mean"), + ("measurement", "pressure", "Pa", "Pa", "Pa", "pressure", "mean"), + ("measurement", "pressure", "hPa", "hPa", "Pa", "pressure", "mean"), + ("measurement", "pressure", "mbar", "mbar", "Pa", "pressure", "mean"), + ("measurement", "pressure", "inHg", "inHg", "Pa", "pressure", "mean"), + ("measurement", "pressure", "psi", "psi", "Pa", "pressure", "mean"), + ("measurement", "temperature", "°C", "°C", "°C", "temperature", "mean"), + ("measurement", "temperature", "°F", "°F", "°C", "temperature", "mean"), ], ) def test_list_statistic_ids( @@ -1701,6 +1735,7 @@ def test_list_statistic_ids( state_unit, display_unit, statistics_unit, + unit_class, statistic_type, ): """Test listing future statistic ids.""" @@ -1724,6 +1759,7 @@ def test_list_statistic_ids( "name": None, "source": "recorder", "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, }, ] for stat_type in ["mean", "sum", "dogs"]: @@ -1738,6 +1774,7 @@ def test_list_statistic_ids( "name": None, "source": "recorder", "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, }, ] else: @@ -1772,12 +1809,12 @@ def test_list_statistic_ids_unsupported(hass_recorder, caplog, _attributes): @pytest.mark.parametrize( - "device_class,state_unit,display_unit,statistics_unit,mean,min,max", + "device_class, state_unit, display_unit, statistics_unit, unit_class, mean, min, max", [ - (None, None, None, None, 13.050847, -10, 30), - (None, "%", "%", "%", 13.050847, -10, 30), - ("battery", "%", "%", "%", 13.050847, -10, 30), - ("battery", None, None, None, 13.050847, -10, 30), + (None, None, None, None, None, 13.050847, -10, 30), + (None, "%", "%", "%", None, 13.050847, -10, 30), + ("battery", "%", "%", "%", None, 13.050847, -10, 30), + ("battery", None, None, None, None, 13.050847, -10, 30), ], ) def test_compile_hourly_statistics_changing_units_1( @@ -1787,6 +1824,7 @@ def test_compile_hourly_statistics_changing_units_1( state_unit, display_unit, statistics_unit, + unit_class, mean, min, max, @@ -1827,6 +1865,7 @@ def test_compile_hourly_statistics_changing_units_1( "name": None, "source": "recorder", "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, }, ] stats = statistics_during_period(hass, zero, period="5minute") @@ -1862,6 +1901,7 @@ def test_compile_hourly_statistics_changing_units_1( "name": None, "source": "recorder", "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, }, ] stats = statistics_during_period(hass, zero, period="5minute") @@ -1884,12 +1924,12 @@ def test_compile_hourly_statistics_changing_units_1( @pytest.mark.parametrize( - "device_class,state_unit,display_unit,statistics_unit,mean,min,max", + "device_class, state_unit, display_unit, statistics_unit, unit_class, mean, min, max", [ - (None, None, None, None, 13.050847, -10, 30), - (None, "%", "%", "%", 13.050847, -10, 30), - ("battery", "%", "%", "%", 13.050847, -10, 30), - ("battery", None, None, None, 13.050847, -10, 30), + (None, None, None, None, None, 13.050847, -10, 30), + (None, "%", "%", "%", None, 13.050847, -10, 30), + ("battery", "%", "%", "%", None, 13.050847, -10, 30), + ("battery", None, None, None, None, 13.050847, -10, 30), ], ) def test_compile_hourly_statistics_changing_units_2( @@ -1899,6 +1939,7 @@ def test_compile_hourly_statistics_changing_units_2( state_unit, display_unit, statistics_unit, + unit_class, mean, min, max, @@ -1936,6 +1977,7 @@ def test_compile_hourly_statistics_changing_units_2( "name": None, "source": "recorder", "statistics_unit_of_measurement": "cats", + "unit_class": unit_class, }, ] stats = statistics_during_period(hass, zero, period="5minute") @@ -1945,12 +1987,12 @@ def test_compile_hourly_statistics_changing_units_2( @pytest.mark.parametrize( - "device_class,state_unit,display_unit,statistics_unit,mean,min,max", + "device_class, state_unit, display_unit, statistics_unit, unit_class, mean, min, max", [ - (None, None, None, None, 13.050847, -10, 30), - (None, "%", "%", "%", 13.050847, -10, 30), - ("battery", "%", "%", "%", 13.050847, -10, 30), - ("battery", None, None, None, 13.050847, -10, 30), + (None, None, None, None, None, 13.050847, -10, 30), + (None, "%", "%", "%", None, 13.050847, -10, 30), + ("battery", "%", "%", "%", None, 13.050847, -10, 30), + ("battery", None, None, None, None, 13.050847, -10, 30), ], ) def test_compile_hourly_statistics_changing_units_3( @@ -1960,6 +2002,7 @@ def test_compile_hourly_statistics_changing_units_3( state_unit, display_unit, statistics_unit, + unit_class, mean, min, max, @@ -2000,6 +2043,7 @@ def test_compile_hourly_statistics_changing_units_3( "name": None, "source": "recorder", "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, }, ] stats = statistics_during_period(hass, zero, period="5minute") @@ -2035,6 +2079,7 @@ def test_compile_hourly_statistics_changing_units_3( "name": None, "source": "recorder", "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, }, ] stats = statistics_during_period(hass, zero, period="5minute") @@ -2057,13 +2102,21 @@ def test_compile_hourly_statistics_changing_units_3( @pytest.mark.parametrize( - "device_class,state_unit,statistic_unit,mean,min,max", + "device_class, state_unit, statistic_unit, unit_class, mean, min, max", [ - ("power", "kW", "W", 13.050847, -10, 30), + ("power", "kW", "W", None, 13.050847, -10, 30), ], ) def test_compile_hourly_statistics_changing_device_class_1( - hass_recorder, caplog, device_class, state_unit, statistic_unit, mean, min, max + hass_recorder, + caplog, + device_class, + state_unit, + statistic_unit, + unit_class, + mean, + min, + max, ): """Test compiling hourly statistics where device class changes from one hour to the next.""" zero = dt_util.utcnow() @@ -2091,6 +2144,7 @@ def test_compile_hourly_statistics_changing_device_class_1( "name": None, "source": "recorder", "statistics_unit_of_measurement": state_unit, + "unit_class": unit_class, }, ] stats = statistics_during_period(hass, zero, period="5minute") @@ -2140,6 +2194,7 @@ def test_compile_hourly_statistics_changing_device_class_1( "name": None, "source": "recorder", "statistics_unit_of_measurement": state_unit, + "unit_class": unit_class, }, ] stats = statistics_during_period(hass, zero, period="5minute") @@ -2162,9 +2217,9 @@ def test_compile_hourly_statistics_changing_device_class_1( @pytest.mark.parametrize( - "device_class,state_unit,display_unit,statistic_unit,mean,min,max", + "device_class, state_unit, display_unit, statistic_unit, unit_class, mean, min, max", [ - ("power", "kW", "kW", "W", 13.050847, -10, 30), + ("power", "kW", "kW", "W", "power", 13.050847, -10, 30), ], ) def test_compile_hourly_statistics_changing_device_class_2( @@ -2174,6 +2229,7 @@ def test_compile_hourly_statistics_changing_device_class_2( state_unit, display_unit, statistic_unit, + unit_class, mean, min, max, @@ -2205,6 +2261,7 @@ def test_compile_hourly_statistics_changing_device_class_2( "name": None, "source": "recorder", "statistics_unit_of_measurement": statistic_unit, + "unit_class": unit_class, }, ] stats = statistics_during_period(hass, zero, period="5minute") @@ -2254,6 +2311,7 @@ def test_compile_hourly_statistics_changing_device_class_2( "name": None, "source": "recorder", "statistics_unit_of_measurement": statistic_unit, + "unit_class": unit_class, }, ] stats = statistics_during_period(hass, zero, period="5minute") @@ -2276,9 +2334,9 @@ def test_compile_hourly_statistics_changing_device_class_2( @pytest.mark.parametrize( - "device_class,state_unit,display_unit,statistics_unit,mean,min,max", + "device_class, state_unit, display_unit, statistics_unit, unit_class, mean, min, max", [ - (None, None, None, None, 13.050847, -10, 30), + (None, None, None, None, None, 13.050847, -10, 30), ], ) def test_compile_hourly_statistics_changing_statistics( @@ -2288,6 +2346,7 @@ def test_compile_hourly_statistics_changing_statistics( state_unit, display_unit, statistics_unit, + unit_class, mean, min, max, @@ -2322,6 +2381,7 @@ def test_compile_hourly_statistics_changing_statistics( "name": None, "source": "recorder", "statistics_unit_of_measurement": None, + "unit_class": None, }, ] metadata = get_metadata(hass, statistic_ids=("sensor.test1",)) @@ -2358,6 +2418,7 @@ def test_compile_hourly_statistics_changing_statistics( "name": None, "source": "recorder", "statistics_unit_of_measurement": None, + "unit_class": None, }, ] metadata = get_metadata(hass, statistic_ids=("sensor.test1",)) @@ -2552,6 +2613,7 @@ def test_compile_statistics_hourly_daily_monthly_summary(hass_recorder, caplog): "name": None, "source": "recorder", "statistics_unit_of_measurement": "%", + "unit_class": None, }, { "statistic_id": "sensor.test2", @@ -2561,6 +2623,7 @@ def test_compile_statistics_hourly_daily_monthly_summary(hass_recorder, caplog): "name": None, "source": "recorder", "statistics_unit_of_measurement": "%", + "unit_class": None, }, { "statistic_id": "sensor.test3", @@ -2570,6 +2633,7 @@ def test_compile_statistics_hourly_daily_monthly_summary(hass_recorder, caplog): "name": None, "source": "recorder", "statistics_unit_of_measurement": "%", + "unit_class": None, }, { "statistic_id": "sensor.test4", @@ -2579,6 +2643,7 @@ def test_compile_statistics_hourly_daily_monthly_summary(hass_recorder, caplog): "name": None, "source": "recorder", "statistics_unit_of_measurement": "EUR", + "unit_class": None, }, ] @@ -2588,7 +2653,7 @@ def test_compile_statistics_hourly_daily_monthly_summary(hass_recorder, caplog): for i in range(13, 24): expected_sums["sensor.test4"][i] += sum_adjustment instance.async_adjust_statistics( - "sensor.test4", sum_adjustement_start, sum_adjustment + "sensor.test4", sum_adjustement_start, sum_adjustment, "EUR" ) wait_recording_done(hass)