From bb3f2079ce722ffaa100467df934088aee26166d Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 24 Nov 2022 23:13:18 +0100 Subject: [PATCH 1/4] Add config flow to the statistics integration --- .../components/statistics/__init__.py | 22 +- .../components/statistics/config_flow.py | 214 +++++++++++++ homeassistant/components/statistics/const.py | 11 + .../components/statistics/manifest.json | 4 +- homeassistant/components/statistics/sensor.py | 178 ++++++++++- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 12 +- tests/components/statistics/common.py | 45 +++ .../components/statistics/test_config_flow.py | 294 ++++++++++++++++++ tests/components/statistics/test_sensor.py | 2 +- 10 files changed, 768 insertions(+), 15 deletions(-) create mode 100644 homeassistant/components/statistics/config_flow.py create mode 100644 homeassistant/components/statistics/const.py create mode 100644 tests/components/statistics/common.py create mode 100644 tests/components/statistics/test_config_flow.py diff --git a/homeassistant/components/statistics/__init__.py b/homeassistant/components/statistics/__init__.py index a6419c2fb4d..ca7bb48f48e 100644 --- a/homeassistant/components/statistics/__init__.py +++ b/homeassistant/components/statistics/__init__.py @@ -1,6 +1,26 @@ """The statistics component.""" +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform +from homeassistant.core import HomeAssistant -DOMAIN = "statistics" PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Min/Max from a config entry.""" + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) + + return True + + +async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update listener, called when the config entry options are changed.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/statistics/config_flow.py b/homeassistant/components/statistics/config_flow.py new file mode 100644 index 00000000000..7a546c99416 --- /dev/null +++ b/homeassistant/components/statistics/config_flow.py @@ -0,0 +1,214 @@ +"""Config flow for Min/Max integration.""" +from __future__ import annotations + +from collections.abc import Callable, Mapping +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_ENTITY_ID +from homeassistant.core import callback +from homeassistant.helpers import selector +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowFormStep, + SchemaFlowMenuStep, +) + +from .const import ( + CONF_PRECISION, + CONF_STATE_CHARACTERISTIC, + DOMAIN, + STAT_AVERAGE_STEP_LTS, + STAT_SUM_DIFFERENCES_LTS, + STAT_VALUE_MAX_LTS, + STAT_VALUE_MIN_LTS, +) + +_CALENDAR_PERIODS = [ + selector.SelectOptionDict(value="hour", label="Hour"), + selector.SelectOptionDict(value="day", label="Day"), + selector.SelectOptionDict(value="week", label="Week"), + selector.SelectOptionDict(value="month", label="Month"), + selector.SelectOptionDict(value="year", label="Year"), +] + +_STATISTIC_CHARACTERISTICS = [ + selector.SelectOptionDict(value=STAT_VALUE_MIN_LTS, label="Minimum"), + selector.SelectOptionDict(value=STAT_VALUE_MAX_LTS, label="Maximum"), + selector.SelectOptionDict(value=STAT_AVERAGE_STEP_LTS, label="Arithmetic mean"), + selector.SelectOptionDict( + value=STAT_SUM_DIFFERENCES_LTS, label="Sum of differences (change)" + ), +] + +PERIOD_TYPES = [ + "calendar", + "fixed_period", + "rolling_window", +] + +OPTIONS_SCHEMA_STEP_1 = vol.Schema( + { + vol.Required(CONF_ENTITY_ID): selector.EntitySelector( + selector.EntitySelectorConfig(domain=SENSOR_DOMAIN), + ), + vol.Required(CONF_STATE_CHARACTERISTIC): selector.SelectSelector( + selector.SelectSelectorConfig(options=_STATISTIC_CHARACTERISTICS), + ), + vol.Required(CONF_PRECISION, default=2): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=6, mode=selector.NumberSelectorMode.BOX + ), + ), + } +) + +CONFIG_SCHEMA_STEP_1 = vol.Schema( + { + vol.Required("name"): selector.TextSelector(), + } +).extend(OPTIONS_SCHEMA_STEP_1.schema) + +CALENDAR_PERIOD_SCHEMA = vol.Schema( + { + vol.Required("calendar_period"): selector.SelectSelector( + selector.SelectSelectorConfig(options=_CALENDAR_PERIODS), + ), + vol.Required("calendar_offset", default=0): selector.NumberSelector( + selector.NumberSelectorConfig(min=0, mode=selector.NumberSelectorMode.BOX), + ), + } +) + +FIXED_PERIOD_SCHEMA = vol.Schema( + { + vol.Required("fixed_period_start_time"): selector.DateTimeSelector(), + vol.Required("fixed_period_end_time"): selector.DateTimeSelector(), + } +) + +ROLLING_WINDOW_PERIOD_SCHEMA = vol.Schema( + { + vol.Required("rolling_window_duration"): selector.DurationSelector( + selector.DurationSelectorConfig(enable_day=True) + ), + vol.Required("rolling_window_offset"): selector.DurationSelector( + selector.DurationSelectorConfig(enable_day=True) + ), + } +) + + +@callback +def choose_initial_config_step(options: dict[str, Any]) -> str | None: + """Choose the initial config step.""" + return "step_1" if not options else None + + +@callback +def set_period_suggested_values(options: dict[str, Any]) -> str: + """Add suggested values for editing the period.""" + + if calendar_period := options["period"].get("calendar"): + options["calendar_offset"] = calendar_period["offset"] + options["calendar_period"] = calendar_period["period"] + elif fixed_period := options["period"].get("fixed_period"): + options["fixed_period_start_time"] = fixed_period["start_time"] + options["fixed_period_end_time"] = fixed_period["end_time"] + else: # rolling_window + rolling_window_period = options["period"]["rolling_window"] + options["rolling_window_duration"] = rolling_window_period["duration"] + options["rolling_window_offset"] = rolling_window_period["offset"] + + return "period_type" + + +@callback +def set_period( + period_type: str, +) -> Callable[[SchemaCommonFlowHandler, dict[str, Any]], dict[str, Any]]: + """Set period.""" + + @callback + def _set_period_type( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] + ) -> dict[str, Any]: + """Add period to user input.""" + # pylint: disable-next=protected-access + handler._options.pop("calendar_offset", None) + # pylint: disable-next=protected-access + handler._options.pop("calendar_period", None) + # pylint: disable-next=protected-access + handler._options.pop("fixed_period_start_time", None) + # pylint: disable-next=protected-access + handler._options.pop("fixed_period_end_time", None) + # pylint: disable-next=protected-access + handler._options.pop("rolling_window_duration", None) + # pylint: disable-next=protected-access + handler._options.pop("rolling_window_offset", None) + + if period_type == "calendar": + period = { + "calendar": { + "offset": user_input.pop("calendar_offset"), + "period": user_input.pop("calendar_period"), + } + } + elif period_type == "fixed_period": + period = { + "fixed_period": { + "start_time": user_input.pop("fixed_period_start_time"), + "end_time": user_input.pop("fixed_period_end_time"), + } + } + else: # period_type = rolling_window + period = { + "rolling_window": { + "duration": user_input.pop("rolling_window_duration"), + "offset": user_input.pop("rolling_window_offset"), + } + } + user_input["period"] = period + return user_input + + return _set_period_type + + +CONFIG_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = { + "user": SchemaFlowFormStep(None, next_step=choose_initial_config_step), + "step_1": SchemaFlowFormStep( + CONFIG_SCHEMA_STEP_1, next_step=lambda _: "period_type" + ), + "period_type": SchemaFlowMenuStep(PERIOD_TYPES), + "calendar": SchemaFlowFormStep(CALENDAR_PERIOD_SCHEMA, set_period("calendar")), + "fixed_period": SchemaFlowFormStep(FIXED_PERIOD_SCHEMA, set_period("fixed_period")), + "rolling_window": SchemaFlowFormStep( + ROLLING_WINDOW_PERIOD_SCHEMA, set_period("rolling_window") + ), +} + +OPTIONS_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = { + "init": SchemaFlowFormStep( + OPTIONS_SCHEMA_STEP_1, next_step=set_period_suggested_values + ), + "period_type": SchemaFlowMenuStep(PERIOD_TYPES), + "calendar": SchemaFlowFormStep(CALENDAR_PERIOD_SCHEMA, set_period("calendar")), + "fixed_period": SchemaFlowFormStep(FIXED_PERIOD_SCHEMA, set_period("fixed_period")), + "rolling_window": SchemaFlowFormStep( + ROLLING_WINDOW_PERIOD_SCHEMA, set_period("rolling_window") + ), +} + + +class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle a config or options flow for Min/Max.""" + + config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return cast(str, options["name"]) if "name" in options else "" diff --git a/homeassistant/components/statistics/const.py b/homeassistant/components/statistics/const.py new file mode 100644 index 00000000000..8469cdf8e89 --- /dev/null +++ b/homeassistant/components/statistics/const.py @@ -0,0 +1,11 @@ +"""Constants for the statistics integration.""" + +DOMAIN = "statistics" + +CONF_PRECISION = "precision" +CONF_STATE_CHARACTERISTIC = "state_characteristic" + +STAT_AVERAGE_STEP_LTS = "average_step_lts" +STAT_VALUE_MAX_LTS = "value_max_lts" +STAT_VALUE_MIN_LTS = "value_min_lts" +STAT_SUM_DIFFERENCES_LTS = "sum_differences_lts" diff --git a/homeassistant/components/statistics/manifest.json b/homeassistant/components/statistics/manifest.json index 442bdf2ca6c..20753768a29 100644 --- a/homeassistant/components/statistics/manifest.json +++ b/homeassistant/components/statistics/manifest.json @@ -5,5 +5,7 @@ "after_dependencies": ["recorder"], "codeowners": ["@fabaff", "@ThomDietrich"], "quality_scale": "internal", - "iot_class": "local_polling" + "iot_class": "local_polling", + "config_flow": true, + "integration_type": "helper" } diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 64e967604c1..9766a228ea0 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -12,13 +12,24 @@ from typing import Any, Literal, cast import voluptuous as vol from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.recorder import get_instance, history +from homeassistant.components.recorder import ( + EVENT_RECORDER_5MIN_STATISTICS_GENERATED, + get_instance, + history, +) +from homeassistant.components.recorder.models import StatisticPeriod +from homeassistant.components.recorder.statistics import ( + get_metadata, + statistic_during_period, +) +from homeassistant.components.recorder.util import PERIOD_SCHEMA, resolve_period from homeassistant.components.sensor import ( PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, SensorStateClass, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, @@ -37,7 +48,7 @@ from homeassistant.core import ( callback, split_entity_id, ) -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( async_track_point_in_utc_time, @@ -48,7 +59,16 @@ from homeassistant.helpers.start import async_at_start from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from homeassistant.util import dt as dt_util -from . import DOMAIN, PLATFORMS +from . import PLATFORMS +from .const import ( + CONF_PRECISION, + CONF_STATE_CHARACTERISTIC, + DOMAIN, + STAT_AVERAGE_STEP_LTS, + STAT_SUM_DIFFERENCES_LTS, + STAT_VALUE_MAX_LTS, + STAT_VALUE_MIN_LTS, +) _LOGGER = logging.getLogger(__name__) @@ -87,6 +107,8 @@ STAT_VALUE_MAX = "value_max" STAT_VALUE_MIN = "value_min" STAT_VARIANCE = "variance" +CONF_PERIOD = "period" + # Statistics supported by a sensor source (numeric) STATS_NUMERIC_SUPPORT = { STAT_AVERAGE_LINEAR, @@ -117,6 +139,23 @@ STATS_NUMERIC_SUPPORT = { STAT_VARIANCE, } +# Statistics supported by a long term statistic source (numeric) +STATS_NUMERIC_SUPPORT_LTS = { + STAT_AVERAGE_STEP_LTS, + STAT_SUM_DIFFERENCES_LTS, + STAT_VALUE_MAX_LTS, + STAT_VALUE_MIN_LTS, +} + +STATS_LTS_TO_RECORDER_CHARACTERISTIC: dict[ + str, Literal["max", "mean", "min", "change"] +] = { + STAT_AVERAGE_STEP_LTS: "mean", + STAT_SUM_DIFFERENCES_LTS: "change", + STAT_VALUE_MAX_LTS: "max", + STAT_VALUE_MIN_LTS: "min", +} + # Statistics supported by a binary_sensor source STATS_BINARY_SUPPORT = { STAT_AVERAGE_STEP, @@ -172,13 +211,12 @@ STAT_BINARY_PERCENTAGE = { STAT_MEAN, } -CONF_STATE_CHARACTERISTIC = "state_characteristic" CONF_SAMPLES_MAX_BUFFER_SIZE = "sampling_size" CONF_MAX_AGE = "max_age" -CONF_PRECISION = "precision" CONF_PERCENTILE = "percentile" DEFAULT_NAME = "Statistical characteristic" +DEFAULT_PERCENTILE = 50 DEFAULT_PRECISION = 2 ICON = "mdi:calculator" @@ -222,7 +260,7 @@ _PLATFORM_SCHEMA_BASE = PLATFORM_SCHEMA.extend( ), vol.Optional(CONF_MAX_AGE): cv.time_period, vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): vol.Coerce(int), - vol.Optional(CONF_PERCENTILE, default=50): vol.All( + vol.Optional(CONF_PERCENTILE, default=DEFAULT_PERCENTILE): vol.All( vol.Coerce(int), vol.Range(min=1, max=99) ), } @@ -234,6 +272,36 @@ PLATFORM_SCHEMA = vol.All( ) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize min/max/mean config entry.""" + registry = er.async_get(hass) + source_entity_id = er.async_validate_entity_id( + registry, config_entry.options[CONF_ENTITY_ID] + ) + state_characteristic = config_entry.options[CONF_STATE_CHARACTERISTIC] + + entity: SensorEntity | None = None + + if state_characteristic in STATS_NUMERIC_SUPPORT_LTS: + entity = LTSStatisticsSensor( + source_entity_id=source_entity_id, + name=config_entry.title, + unique_id=config_entry.entry_id, + state_characteristic=state_characteristic, + precision=DEFAULT_PRECISION, + period=PERIOD_SCHEMA(config_entry.options["period"]), + ) + + if not entity: + return + + async_add_entities([entity], update_before_add=True) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -749,3 +817,101 @@ class StatisticsSensor(SensorEntity): if len(self.states) > 0: return 100.0 / len(self.states) * self.states.count(True) return None + + +class LTSStatisticsSensor(SensorEntity): + """Representation of a Statistics sensor sourcing data from the LTS tables.""" + + _attr_should_poll = False + + def __init__( + self, + source_entity_id: str, + name: str, + unique_id: str | None, + state_characteristic: str, + precision: int, + period: StatisticPeriod, + ) -> None: + """Initialize the Statistics sensor.""" + self._attr_icon: str = ICON + self._attr_name: str = name + self._attr_unique_id: str | None = unique_id + self._source_entity_id: str = source_entity_id + self._recorder_characteristic: Literal[ + "max", "mean", "min", "change" + ] = STATS_LTS_TO_RECORDER_CHARACTERISTIC[state_characteristic] + self._precision: int = precision + self._value: StateType = None + self._period = period + + self._update_listener: CALLBACK_TYPE | None = None + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + + @callback + def async_stats_updated_listener(_event: Event | None) -> None: + """Handle recorder LTS updated.""" + self.async_schedule_update_ha_state(True) + + async def async_stats_sensor_startup(_: HomeAssistant) -> None: + """Add listener and get recorded state.""" + _LOGGER.debug("Startup for %s", self.entity_id) + + self.async_on_remove( + self.hass.bus.async_listen( + EVENT_RECORDER_5MIN_STATISTICS_GENERATED, + async_stats_updated_listener, + ) + ) + + self.async_on_remove(async_at_start(self.hass, async_stats_sensor_startup)) + + @property + def device_class(self) -> SensorDeviceClass | None: + """Return the class of this entity.""" + _state = self.hass.states.get(self._source_entity_id) + return None if _state is None else _state.attributes.get(ATTR_DEVICE_CLASS) + + @property + def state_class(self) -> Literal[SensorStateClass.MEASUREMENT]: + """Return the state class of this entity.""" + return SensorStateClass.MEASUREMENT + + async def async_update(self) -> None: + """Get the latest data and updates the states.""" + _LOGGER.debug("%s: updating statistics", self.entity_id) + await get_instance(self.hass).async_add_executor_job( + self._update_long_term_stats_from_database + ) + + def _update_long_term_stats_from_database(self) -> None: + """Update the long term statistics from the database.""" + + result = get_metadata( + self.hass, + statistic_ids=[self._source_entity_id], + ).get(self._source_entity_id) + if result: + self._attr_native_unit_of_measurement = result[1].get("unit_of_measurement") + else: + self._attr_native_unit_of_measurement = None + + start_time, end_time = resolve_period(self._period) + stat = statistic_during_period( + self.hass, + start_time, + end_time, + self._source_entity_id, + {self._recorder_characteristic}, + None, + ) + value = stat.get(self._recorder_characteristic) + with contextlib.suppress(TypeError): + value = round(cast(float, value), self._precision) + if self._precision == 0: + value = int(value) + + self._attr_native_value = value + self._attr_available = self._attr_native_value is not None diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 4d7ed69257d..96571cd336f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -9,6 +9,7 @@ FLOWS = { "group", "integration", "min_max", + "statistics", "switch_as_x", "threshold", "tod", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ebd7d4f934e..59ef4722e6a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5099,12 +5099,6 @@ "config_flow": false, "iot_class": "cloud_polling" }, - "statistics": { - "name": "Statistics", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" - }, "statsd": { "name": "StatsD", "integration_type": "hub", @@ -6352,6 +6346,12 @@ "integration_type": "helper", "config_flow": false }, + "statistics": { + "name": "Statistics", + "integration_type": "helper", + "config_flow": true, + "iot_class": "local_polling" + }, "switch_as_x": { "integration_type": "helper", "config_flow": true, diff --git a/tests/components/statistics/common.py b/tests/components/statistics/common.py new file mode 100644 index 00000000000..80bf30cf067 --- /dev/null +++ b/tests/components/statistics/common.py @@ -0,0 +1,45 @@ +"""Test helpers for the statistics integration.""" +from datetime import datetime, timedelta + +from homeassistant.components import recorder +from homeassistant.components.recorder.db_schema import Statistics +from homeassistant.core import HomeAssistant + +from tests.components.recorder.common import async_wait_recording_done + + +async def generate_statistics( + hass: HomeAssistant, + entity_id: str, + start: datetime, + count: int, + start_value: float = 0.0, +) -> None: + """Generate LTS data.""" + + imported_stats = [ + { + "start": (start + timedelta(hours=i)), + "max": start_value + i * 2, + "mean": start_value + i, + "min": -(start_value + count * 2) + i * 2, + "sum": start_value + i, + } + for i in range(0, count) + ] + + imported_metadata = { + "has_mean": True, + "has_sum": False, + "name": None, + "source": "recorder", + "statistic_id": entity_id, + "unit_of_measurement": "°C", + } + + recorder.get_instance(hass).async_import_statistics( + imported_metadata, + imported_stats, + Statistics, + ) + await async_wait_recording_done(hass) diff --git a/tests/components/statistics/test_config_flow.py b/tests/components/statistics/test_config_flow.py new file mode 100644 index 00000000000..ecb92a4da43 --- /dev/null +++ b/tests/components/statistics/test_config_flow.py @@ -0,0 +1,294 @@ +"""Test the Statistics config flow.""" +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +from freezegun import freeze_time +import pytest + +from homeassistant import config_entries +from homeassistant.components.statistics.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +import homeassistant.util.dt as dt_util + +from .common import generate_statistics + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + "period_type, period_input, period_data", + ( + ( + "calendar", + { + "calendar_offset": 2, + "calendar_period": "day", + }, + {"offset": 2, "period": "day"}, + ), + ( + "fixed_period", + { + "fixed_period_end_time": "2022-03-24 00:00", + "fixed_period_start_time": "2022-03-24 00:00", + }, + {"end_time": "2022-03-24 00:00", "start_time": "2022-03-24 00:00"}, + ), + ( + "rolling_window", + { + "rolling_window_duration": {"days": 365}, + "rolling_window_offset": {"days": -365}, + }, + {"duration": {"days": 365}, "offset": {"days": -365}}, + ), + ), +) +async def test_config_flow( + hass: HomeAssistant, period_type, period_input, period_data +) -> None: + """Test the config flow.""" + input_sensor = "sensor.input_one" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "name": "My statistics", + "entity_id": input_sensor, + "state_characteristic": "value_max_lts", + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": period_type}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == period_type + + with patch( + "homeassistant.components.statistics.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + period_input, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "My statistics" + assert result["data"] == {} + assert result["options"] == { + "entity_id": input_sensor, + "name": "My statistics", + "period": {period_type: period_data}, + "precision": 2.0, + "state_characteristic": "value_max_lts", + } + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.data == {} + assert config_entry.options == { + "entity_id": input_sensor, + "name": "My statistics", + "period": {period_type: period_data}, + "precision": 2.0, + "state_characteristic": "value_max_lts", + } + assert config_entry.title == "My statistics" + + +@pytest.mark.parametrize( + "period_type, period_definition", + ( + ( + "calendar", + { + "period": "day", + "offset": 2, + }, + ), + ( + "fixed_period", + { + "start_time": "2022-03-24 00:00", + "end_time": "2022-03-24 00:00", + }, + ), + ( + "rolling_window", + { + "duration": {"days": 365}, + "offset": {"days": -365}, + }, + ), + ), +) +async def test_config_flow_import( + hass: HomeAssistant, period_type, period_definition +) -> None: + """Test the config flow.""" + input_sensor = "sensor.input_one" + + with patch( + "homeassistant.components.statistics.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + "entity_id": input_sensor, + "name": "My statistics", + "period": {period_type: period_definition}, + "precision": 2.0, + "state_characteristic": "value_max_lts", + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "My statistics" + assert result["data"] == {} + assert result["options"] == { + "entity_id": input_sensor, + "name": "My statistics", + "period": {period_type: period_definition}, + "precision": 2.0, + "state_characteristic": "value_max_lts", + } + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.data == {} + assert config_entry.options == { + "entity_id": input_sensor, + "name": "My statistics", + "period": {period_type: period_definition}, + "precision": 2.0, + "state_characteristic": "value_max_lts", + } + assert config_entry.title == "My statistics" + + +def get_suggested(schema, key): + """Get suggested value for key in voluptuous schema.""" + for k in schema.keys(): + if k == key: + if k.description is None or "suggested_value" not in k.description: + return None + return k.description["suggested_value"] + # Wanted key absent from schema + raise Exception + + +@freeze_time(datetime(2022, 10, 21, 7, 25, tzinfo=timezone.utc)) +async def test_options(recorder_mock, hass: HomeAssistant) -> None: + """Test reconfiguring.""" + + now = dt_util.utcnow() + + zero = now + start = zero.replace(minute=0, second=0, microsecond=0) + timedelta(days=-2) + + await generate_statistics(hass, "sensor.input_one", start, 6) + await generate_statistics(hass, "sensor.input_two", start, 6) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entity_id": "sensor.input_one", + "name": "My statistics", + "period": {"calendar": {"offset": -2, "period": "day"}}, + "precision": 2.0, + "state_characteristic": "value_max_lts", + }, + title="My statistics", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Check the state of the entity is reflecting the initial settings + state = hass.states.get("sensor.my_statistics") + assert state.state == "10.0" + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + schema = result["data_schema"].schema + assert get_suggested(schema, "entity_id") == "sensor.input_one" + assert get_suggested(schema, "precision") == 2.0 + assert get_suggested(schema, "state_characteristic") == "value_max_lts" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "entity_id": "sensor.input_two", + "precision": 1, + "state_characteristic": "value_min_lts", + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.MENU + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "rolling_window"}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "rolling_window" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + { + "rolling_window_duration": {"days": 2}, + "rolling_window_offset": {"hours": -1}, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "entity_id": "sensor.input_two", + "name": "My statistics", + "period": { + "rolling_window": {"duration": {"days": 2}, "offset": {"hours": -1}} + }, + "precision": 1.0, + "state_characteristic": "value_min_lts", + } + assert config_entry.data == {} + assert config_entry.options == { + "entity_id": "sensor.input_two", + "name": "My statistics", + "period": { + "rolling_window": {"duration": {"days": 2}, "offset": {"hours": -1}} + }, + "precision": 1.0, + "state_characteristic": "value_min_lts", + } + assert config_entry.title == "My statistics" + + # Check config entry is reloaded with new options + await hass.async_block_till_done() + + # Check the entity was updated, no new entity was created + assert len(hass.states.async_all()) == 1 + + # Check the state of the entity has changed as expected + state = hass.states.get("sensor.my_statistics") + assert state.state == "-12.0" diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index bd73216d69e..712de372105 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorStateClass, ) -from homeassistant.components.statistics import DOMAIN as STATISTICS_DOMAIN +from homeassistant.components.statistics.const import DOMAIN as STATISTICS_DOMAIN from homeassistant.components.statistics.sensor import StatisticsSensor from homeassistant.const import ( ATTR_DEVICE_CLASS, From 1c779bed2b65ac05bb44ed9e67303f3cf718f62a Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 28 Nov 2022 14:13:57 +0100 Subject: [PATCH 2/4] Improve test coverage --- homeassistant/components/statistics/sensor.py | 45 +++---- .../components/statistics/test_config_flow.py | 92 +++++++++++--- tests/components/statistics/test_sensor.py | 119 +++++++++++++++++- 3 files changed, 216 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 9766a228ea0..dab36d54fab 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -284,8 +284,6 @@ async def async_setup_entry( ) state_characteristic = config_entry.options[CONF_STATE_CHARACTERISTIC] - entity: SensorEntity | None = None - if state_characteristic in STATS_NUMERIC_SUPPORT_LTS: entity = LTSStatisticsSensor( source_entity_id=source_entity_id, @@ -295,11 +293,7 @@ async def async_setup_entry( precision=DEFAULT_PRECISION, period=PERIOD_SCHEMA(config_entry.options["period"]), ) - - if not entity: - return - - async_add_entities([entity], update_before_add=True) + async_add_entities([entity], update_before_add=True) async def async_setup_platform( @@ -329,6 +323,14 @@ async def async_setup_platform( ) +def _round_state(value: StateType | datetime, precision: int) -> StateType | datetime: + with contextlib.suppress(TypeError): + value = round(cast(float, value), precision) + if precision == 0: + value = int(value) + return value + + class StatisticsSensor(SensorEntity): """Representation of a Statistics sensor.""" @@ -626,10 +628,7 @@ class StatisticsSensor(SensorEntity): value = self._state_characteristic_fn() if self._state_characteristic not in STATS_NOT_A_NUMBER: - with contextlib.suppress(TypeError): - value = round(cast(float, value), self._precision) - if self._precision == 0: - value = int(value) + value = _round_state(value, self._precision) self._value = value # Statistics for numeric sensor @@ -886,17 +885,22 @@ class LTSStatisticsSensor(SensorEntity): self._update_long_term_stats_from_database ) - def _update_long_term_stats_from_database(self) -> None: - """Update the long term statistics from the database.""" + def _unit_of_measurement(self) -> str | None: + """Return unit_of_measurement.""" - result = get_metadata( + metadata_result = get_metadata( self.hass, statistic_ids=[self._source_entity_id], ).get(self._source_entity_id) - if result: - self._attr_native_unit_of_measurement = result[1].get("unit_of_measurement") - else: - self._attr_native_unit_of_measurement = None + if not metadata_result: + return None + + return metadata_result[1].get("unit_of_measurement") + + def _update_long_term_stats_from_database(self) -> None: + """Update the long term statistics from the database.""" + + self._attr_native_unit_of_measurement = self._unit_of_measurement() start_time, end_time = resolve_period(self._period) stat = statistic_during_period( @@ -908,10 +912,7 @@ class LTSStatisticsSensor(SensorEntity): None, ) value = stat.get(self._recorder_characteristic) - with contextlib.suppress(TypeError): - value = round(cast(float, value), self._precision) - if self._precision == 0: - value = int(value) + value = _round_state(value, self._precision) self._attr_native_value = value self._attr_available = self._attr_native_value is not None diff --git a/tests/components/statistics/test_config_flow.py b/tests/components/statistics/test_config_flow.py index ecb92a4da43..5f692901d6a 100644 --- a/tests/components/statistics/test_config_flow.py +++ b/tests/components/statistics/test_config_flow.py @@ -21,10 +21,7 @@ from tests.common import MockConfigEntry ( ( "calendar", - { - "calendar_offset": 2, - "calendar_period": "day", - }, + {"calendar_offset": 2, "calendar_period": "day"}, {"offset": 2, "period": "day"}, ), ( @@ -115,24 +112,15 @@ async def test_config_flow( ( ( "calendar", - { - "period": "day", - "offset": 2, - }, + {"period": "day", "offset": 2}, ), ( "fixed_period", - { - "start_time": "2022-03-24 00:00", - "end_time": "2022-03-24 00:00", - }, + {"start_time": "2022-03-24 00:00", "end_time": "2022-03-24 00:00"}, ), ( "rolling_window", - { - "duration": {"days": 365}, - "offset": {"days": -365}, - }, + {"duration": {"days": 365}, "offset": {"days": -365}}, ), ), ) @@ -292,3 +280,75 @@ async def test_options(recorder_mock, hass: HomeAssistant) -> None: # Check the state of the entity has changed as expected state = hass.states.get("sensor.my_statistics") assert state.state == "-12.0" + + +@freeze_time(datetime(2022, 10, 21, 7, 25, tzinfo=timezone.utc)) +@pytest.mark.parametrize( + "period_type, period, suggested_values", + ( + ( + "calendar", + {"offset": -2, "period": "day"}, + {"calendar_offset": -2, "calendar_period": "day"}, + ), + ( + "fixed_period", + {"start_time": "2022-03-24 00:00", "end_time": "2022-03-24 00:00"}, + {}, + ), + ( + "rolling_window", + {"duration": {"days": 365}, "offset": {"days": -365}}, + {}, + ), + ), +) +async def test_options_edit_period( + recorder_mock, hass: HomeAssistant, period_type, period, suggested_values +) -> None: + """Test reconfiguring period.""" + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entity_id": "sensor.input_one", + "name": "My statistics", + "period": {period_type: period}, + "precision": 2.0, + "state_characteristic": "value_max_lts", + }, + title="My statistics", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + schema = result["data_schema"].schema + assert get_suggested(schema, "entity_id") == "sensor.input_one" + assert get_suggested(schema, "precision") == 2.0 + assert get_suggested(schema, "state_characteristic") == "value_max_lts" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "entity_id": "sensor.input_two", + "precision": 1, + "state_characteristic": "value_max_lts", + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.MENU + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": period_type}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == period_type + + schema = result["data_schema"].schema + for key, configured_value in period.items(): + assert get_suggested(schema, f"{period_type}_{key}") == configured_value diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index 712de372105..2e4db1d3f46 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -2,12 +2,15 @@ from __future__ import annotations from collections.abc import Sequence -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import statistics from typing import Any from unittest.mock import patch +from freezegun import freeze_time + from homeassistant import config as hass_config +from homeassistant.components.recorder import EVENT_RECORDER_5MIN_STATISTICS_GENERATED from homeassistant.components.sensor import ( ATTR_STATE_CLASS, SensorDeviceClass, @@ -28,7 +31,9 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import async_fire_time_changed, get_fixture_path +from .common import generate_statistics + +from tests.common import MockConfigEntry, async_fire_time_changed, get_fixture_path from tests.components.recorder.common import async_wait_recording_done VALUES_BINARY = ["on", "off", "on", "off", "on", "off", "on", "off", "on"] @@ -1275,3 +1280,113 @@ async def test_reload(recorder_mock, hass: HomeAssistant): assert hass.states.get("sensor.test") is None assert hass.states.get("sensor.cputest") + + +async def test_lts_missing(recorder_mock, hass: HomeAssistant) -> None: + """Test updating when there is no lts data for the input sensor.""" + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=STATISTICS_DOMAIN, + options={ + "entity_id": "sensor.input_one", + "name": "My statistics", + "period": {"calendar": {"offset": -2, "period": "day"}}, + "precision": 2.0, + "state_characteristic": "value_max_lts", + }, + title="My statistics", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Check the initial state of the entity + state = hass.states.get("sensor.my_statistics") + assert state.state == "unavailable" + + +@freeze_time(datetime(2022, 10, 21, 7, 25, tzinfo=timezone.utc)) +async def test_lts_updated(recorder_mock, hass: HomeAssistant) -> None: + """Test updating when lts has been generated.""" + + now = dt_util.utcnow() + + zero = now + start = zero.replace(minute=0, second=0, microsecond=0) + timedelta(days=-2) + + await generate_statistics(hass, "sensor.input_one", start, 6) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=STATISTICS_DOMAIN, + options={ + "entity_id": "sensor.input_one", + "name": "My statistics", + "period": {"calendar": {"offset": -2, "period": "day"}}, + "precision": 2.0, + "state_characteristic": "value_max_lts", + }, + title="My statistics", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Check the initial state of the entity + state = hass.states.get("sensor.my_statistics") + assert state.state == "10.0" + + await generate_statistics(hass, "sensor.input_one", start + timedelta(hours=6), 12) + hass.bus.async_fire(EVENT_RECORDER_5MIN_STATISTICS_GENERATED) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get("sensor.my_statistics") + assert state.state == "22.0" + + +@freeze_time(datetime(2022, 10, 21, 7, 25, tzinfo=timezone.utc)) +async def test_lts_unit(recorder_mock, hass: HomeAssistant) -> None: + """Test state unit is preferred.""" + + now = dt_util.utcnow() + + zero = now + start = zero.replace(minute=0, second=0, microsecond=0) + timedelta(days=-2) + + await generate_statistics(hass, "sensor.input_one", start, 6) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=STATISTICS_DOMAIN, + options={ + "entity_id": "sensor.input_one", + "name": "My statistics", + "period": {"calendar": {"offset": -2, "period": "day"}}, + "precision": 2.0, + "state_characteristic": "value_max_lts", + }, + title="My statistics", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Check the initial state of the entity + state = hass.states.get("sensor.my_statistics") + assert state.state == "10.0" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "°C" + + hass.states.async_set("sensor.input_one", "blah", {ATTR_UNIT_OF_MEASUREMENT: "°F"}) + await generate_statistics(hass, "sensor.input_one", start + timedelta(hours=6), 6) + hass.bus.async_fire(EVENT_RECORDER_5MIN_STATISTICS_GENERATED) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get("sensor.my_statistics") + assert state.state == "50.0" # 10 °C = 50 °F + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "°F" From 97055dcf6c9a748e35cf57cb29af78d9407d835f Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 28 Nov 2022 17:10:56 +0100 Subject: [PATCH 3/4] Align import data with frontend statistic card --- .../components/statistics/config_flow.py | 38 ++++++++++++++----- homeassistant/components/statistics/const.py | 1 + homeassistant/components/statistics/sensor.py | 2 - .../components/statistics/test_config_flow.py | 4 +- 4 files changed, 32 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/statistics/config_flow.py b/homeassistant/components/statistics/config_flow.py index 7a546c99416..2246e2c32ff 100644 --- a/homeassistant/components/statistics/config_flow.py +++ b/homeassistant/components/statistics/config_flow.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Callable, Mapping -from typing import Any, cast +from typing import Any, Literal, cast import voluptuous as vol @@ -18,6 +18,7 @@ from homeassistant.helpers.schema_config_entry_flow import ( ) from .const import ( + CONF_PERIOD, CONF_PRECISION, CONF_STATE_CHARACTERISTIC, DOMAIN, @@ -101,25 +102,44 @@ ROLLING_WINDOW_PERIOD_SCHEMA = vol.Schema( } ) +RECORDER_CHARACTERISTIC_TO_STATS_LTS: dict[ + Literal["max", "mean", "min", "change"], str +] = { + "change": STAT_SUM_DIFFERENCES_LTS, + "max": STAT_VALUE_MAX_LTS, + "mean": STAT_AVERAGE_STEP_LTS, + "min": STAT_VALUE_MIN_LTS, +} + @callback -def choose_initial_config_step(options: dict[str, Any]) -> str | None: +def _import_or_user_config(options: dict[str, Any]) -> str | None: """Choose the initial config step.""" - return "step_1" if not options else None + if not options: + return "_user" + + # Rewrite frontend statistic card config + options[CONF_ENTITY_ID] = options.pop("entity") + options[CONF_PERIOD] = options.pop("period") + options[CONF_STATE_CHARACTERISTIC] = RECORDER_CHARACTERISTIC_TO_STATS_LTS[ + options.pop("state_type") + ] + + return None @callback def set_period_suggested_values(options: dict[str, Any]) -> str: """Add suggested values for editing the period.""" - if calendar_period := options["period"].get("calendar"): + if calendar_period := options[CONF_PERIOD].get("calendar"): options["calendar_offset"] = calendar_period["offset"] options["calendar_period"] = calendar_period["period"] - elif fixed_period := options["period"].get("fixed_period"): + elif fixed_period := options[CONF_PERIOD].get("fixed_period"): options["fixed_period_start_time"] = fixed_period["start_time"] options["fixed_period_end_time"] = fixed_period["end_time"] else: # rolling_window - rolling_window_period = options["period"]["rolling_window"] + rolling_window_period = options[CONF_PERIOD]["rolling_window"] options["rolling_window_duration"] = rolling_window_period["duration"] options["rolling_window_offset"] = rolling_window_period["offset"] @@ -171,15 +191,15 @@ def set_period( "offset": user_input.pop("rolling_window_offset"), } } - user_input["period"] = period + user_input[CONF_PERIOD] = period return user_input return _set_period_type CONFIG_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = { - "user": SchemaFlowFormStep(None, next_step=choose_initial_config_step), - "step_1": SchemaFlowFormStep( + "user": SchemaFlowFormStep(None, next_step=_import_or_user_config), + "_user": SchemaFlowFormStep( CONFIG_SCHEMA_STEP_1, next_step=lambda _: "period_type" ), "period_type": SchemaFlowMenuStep(PERIOD_TYPES), diff --git a/homeassistant/components/statistics/const.py b/homeassistant/components/statistics/const.py index 8469cdf8e89..e7ebe74f8c6 100644 --- a/homeassistant/components/statistics/const.py +++ b/homeassistant/components/statistics/const.py @@ -2,6 +2,7 @@ DOMAIN = "statistics" +CONF_PERIOD = "period" CONF_PRECISION = "precision" CONF_STATE_CHARACTERISTIC = "state_characteristic" diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index dab36d54fab..a02b0c93497 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -107,8 +107,6 @@ STAT_VALUE_MAX = "value_max" STAT_VALUE_MIN = "value_min" STAT_VARIANCE = "variance" -CONF_PERIOD = "period" - # Statistics supported by a sensor source (numeric) STATS_NUMERIC_SUPPORT = { STAT_AVERAGE_LINEAR, diff --git a/tests/components/statistics/test_config_flow.py b/tests/components/statistics/test_config_flow.py index 5f692901d6a..6e77e340715 100644 --- a/tests/components/statistics/test_config_flow.py +++ b/tests/components/statistics/test_config_flow.py @@ -138,11 +138,11 @@ async def test_config_flow_import( DOMAIN, context={"source": config_entries.SOURCE_USER}, data={ - "entity_id": input_sensor, + "entity": input_sensor, "name": "My statistics", "period": {period_type: period_definition}, "precision": 2.0, - "state_characteristic": "value_max_lts", + "state_type": "max", }, ) assert result["type"] == FlowResultType.CREATE_ENTRY From 68697703222cce170d6df9b268458fa20195eec4 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 30 Nov 2022 13:34:38 +0100 Subject: [PATCH 4/4] Update after rebase --- .../components/statistics/config_flow.py | 128 ++++++++++-------- homeassistant/components/statistics/sensor.py | 9 +- .../components/statistics/test_config_flow.py | 40 ++---- 3 files changed, 88 insertions(+), 89 deletions(-) diff --git a/homeassistant/components/statistics/config_flow.py b/homeassistant/components/statistics/config_flow.py index 2246e2c32ff..8bb076edfa2 100644 --- a/homeassistant/components/statistics/config_flow.py +++ b/homeassistant/components/statistics/config_flow.py @@ -1,7 +1,7 @@ """Config flow for Min/Max integration.""" from __future__ import annotations -from collections.abc import Callable, Mapping +from collections.abc import Callable, Coroutine, Mapping from typing import Any, Literal, cast import voluptuous as vol @@ -75,10 +75,10 @@ CONFIG_SCHEMA_STEP_1 = vol.Schema( CALENDAR_PERIOD_SCHEMA = vol.Schema( { - vol.Required("calendar_period"): selector.SelectSelector( + vol.Required("period"): selector.SelectSelector( selector.SelectSelectorConfig(options=_CALENDAR_PERIODS), ), - vol.Required("calendar_offset", default=0): selector.NumberSelector( + vol.Required("offset", default=0): selector.NumberSelector( selector.NumberSelectorConfig(min=0, mode=selector.NumberSelectorMode.BOX), ), } @@ -86,17 +86,17 @@ CALENDAR_PERIOD_SCHEMA = vol.Schema( FIXED_PERIOD_SCHEMA = vol.Schema( { - vol.Required("fixed_period_start_time"): selector.DateTimeSelector(), - vol.Required("fixed_period_end_time"): selector.DateTimeSelector(), + vol.Required("start_time"): selector.DateTimeSelector(), + vol.Required("end_time"): selector.DateTimeSelector(), } ) ROLLING_WINDOW_PERIOD_SCHEMA = vol.Schema( { - vol.Required("rolling_window_duration"): selector.DurationSelector( + vol.Required("duration"): selector.DurationSelector( selector.DurationSelectorConfig(enable_day=True) ), - vol.Required("rolling_window_offset"): selector.DurationSelector( + vol.Required("offset"): selector.DurationSelector( selector.DurationSelectorConfig(enable_day=True) ), } @@ -112,8 +112,7 @@ RECORDER_CHARACTERISTIC_TO_STATS_LTS: dict[ } -@callback -def _import_or_user_config(options: dict[str, Any]) -> str | None: +async def _import_or_user_config(options: dict[str, Any]) -> str | None: """Choose the initial config step.""" if not options: return "_user" @@ -128,80 +127,85 @@ def _import_or_user_config(options: dict[str, Any]) -> str | None: return None -@callback -def set_period_suggested_values(options: dict[str, Any]) -> str: - """Add suggested values for editing the period.""" +def period_suggested_values( + period_type: str, +) -> Callable[[SchemaCommonFlowHandler], Coroutine[Any, Any, dict[str, Any]]]: + """Set period.""" - if calendar_period := options[CONF_PERIOD].get("calendar"): - options["calendar_offset"] = calendar_period["offset"] - options["calendar_period"] = calendar_period["period"] - elif fixed_period := options[CONF_PERIOD].get("fixed_period"): - options["fixed_period_start_time"] = fixed_period["start_time"] - options["fixed_period_end_time"] = fixed_period["end_time"] - else: # rolling_window - rolling_window_period = options[CONF_PERIOD]["rolling_window"] - options["rolling_window_duration"] = rolling_window_period["duration"] - options["rolling_window_offset"] = rolling_window_period["offset"] + async def _period_suggested_values( + handler: SchemaCommonFlowHandler, + ) -> dict[str, Any]: + """Add suggested values for editing the period.""" - return "period_type" + if period_type == "calendar" and ( + calendar_period := handler.options[CONF_PERIOD].get("calendar") + ): + return { + "offset": calendar_period["offset"], + "period": calendar_period["period"], + } + if period_type == "fixed_period" and ( + fixed_period := handler.options[CONF_PERIOD].get("fixed_period") + ): + return { + "start_time": fixed_period["start_time"], + "end_time": fixed_period["end_time"], + } + if period_type == "rolling_window" and ( + rolling_window_period := handler.options[CONF_PERIOD].get("rolling_window") + ): + return { + "duration": rolling_window_period["duration"], + "offset": rolling_window_period["offset"], + } + + return {} + + return _period_suggested_values @callback def set_period( period_type: str, -) -> Callable[[SchemaCommonFlowHandler, dict[str, Any]], dict[str, Any]]: +) -> Callable[ + [SchemaCommonFlowHandler, dict[str, Any]], Coroutine[Any, Any, dict[str, Any]] +]: """Set period.""" - @callback - def _set_period_type( + async def _set_period( handler: SchemaCommonFlowHandler, user_input: dict[str, Any] ) -> dict[str, Any]: - """Add period to user input.""" - # pylint: disable-next=protected-access - handler._options.pop("calendar_offset", None) - # pylint: disable-next=protected-access - handler._options.pop("calendar_period", None) - # pylint: disable-next=protected-access - handler._options.pop("fixed_period_start_time", None) - # pylint: disable-next=protected-access - handler._options.pop("fixed_period_end_time", None) - # pylint: disable-next=protected-access - handler._options.pop("rolling_window_duration", None) - # pylint: disable-next=protected-access - handler._options.pop("rolling_window_offset", None) - + """Add period to config entry options.""" if period_type == "calendar": period = { "calendar": { - "offset": user_input.pop("calendar_offset"), - "period": user_input.pop("calendar_period"), + "offset": user_input["offset"], + "period": user_input["period"], } } elif period_type == "fixed_period": period = { "fixed_period": { - "start_time": user_input.pop("fixed_period_start_time"), - "end_time": user_input.pop("fixed_period_end_time"), + "start_time": user_input["start_time"], + "end_time": user_input["end_time"], } } else: # period_type = rolling_window period = { "rolling_window": { - "duration": user_input.pop("rolling_window_duration"), - "offset": user_input.pop("rolling_window_offset"), + "duration": user_input.pop("duration"), + "offset": user_input.pop("offset"), } } - user_input[CONF_PERIOD] = period - return user_input + handler.options[CONF_PERIOD] = period + return {} - return _set_period_type + return _set_period CONFIG_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = { "user": SchemaFlowFormStep(None, next_step=_import_or_user_config), - "_user": SchemaFlowFormStep( - CONFIG_SCHEMA_STEP_1, next_step=lambda _: "period_type" - ), + "_user": SchemaFlowFormStep(CONFIG_SCHEMA_STEP_1, next_step="period_type"), "period_type": SchemaFlowMenuStep(PERIOD_TYPES), "calendar": SchemaFlowFormStep(CALENDAR_PERIOD_SCHEMA, set_period("calendar")), "fixed_period": SchemaFlowFormStep(FIXED_PERIOD_SCHEMA, set_period("fixed_period")), @@ -211,14 +215,22 @@ CONFIG_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = { } OPTIONS_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = { - "init": SchemaFlowFormStep( - OPTIONS_SCHEMA_STEP_1, next_step=set_period_suggested_values - ), + "init": SchemaFlowFormStep(OPTIONS_SCHEMA_STEP_1, next_step="period_type"), "period_type": SchemaFlowMenuStep(PERIOD_TYPES), - "calendar": SchemaFlowFormStep(CALENDAR_PERIOD_SCHEMA, set_period("calendar")), - "fixed_period": SchemaFlowFormStep(FIXED_PERIOD_SCHEMA, set_period("fixed_period")), + "calendar": SchemaFlowFormStep( + CALENDAR_PERIOD_SCHEMA, + set_period("calendar"), + suggested_values=period_suggested_values("calendar"), + ), + "fixed_period": SchemaFlowFormStep( + FIXED_PERIOD_SCHEMA, + set_period("fixed_period"), + suggested_values=period_suggested_values("fixed_period"), + ), "rolling_window": SchemaFlowFormStep( - ROLLING_WINDOW_PERIOD_SCHEMA, set_period("rolling_window") + ROLLING_WINDOW_PERIOD_SCHEMA, + set_period("rolling_window"), + suggested_values=period_suggested_values("rolling_window"), ), } diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index a02b0c93497..b80c6f520f2 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -19,7 +19,7 @@ from homeassistant.components.recorder import ( ) from homeassistant.components.recorder.models import StatisticPeriod from homeassistant.components.recorder.statistics import ( - get_metadata, + list_statistic_ids, statistic_during_period, ) from homeassistant.components.recorder.util import PERIOD_SCHEMA, resolve_period @@ -886,14 +886,11 @@ class LTSStatisticsSensor(SensorEntity): def _unit_of_measurement(self) -> str | None: """Return unit_of_measurement.""" - metadata_result = get_metadata( - self.hass, - statistic_ids=[self._source_entity_id], - ).get(self._source_entity_id) + metadata_result = list_statistic_ids(self.hass, [self._source_entity_id]) if not metadata_result: return None - return metadata_result[1].get("unit_of_measurement") + return metadata_result[0].get("display_unit_of_measurement") def _update_long_term_stats_from_database(self) -> None: """Update the long term statistics from the database.""" diff --git a/tests/components/statistics/test_config_flow.py b/tests/components/statistics/test_config_flow.py index 6e77e340715..7b02f355c26 100644 --- a/tests/components/statistics/test_config_flow.py +++ b/tests/components/statistics/test_config_flow.py @@ -17,34 +17,23 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize( - "period_type, period_input, period_data", + "period_type, period_input", ( ( "calendar", - {"calendar_offset": 2, "calendar_period": "day"}, {"offset": 2, "period": "day"}, ), ( "fixed_period", - { - "fixed_period_end_time": "2022-03-24 00:00", - "fixed_period_start_time": "2022-03-24 00:00", - }, {"end_time": "2022-03-24 00:00", "start_time": "2022-03-24 00:00"}, ), ( "rolling_window", - { - "rolling_window_duration": {"days": 365}, - "rolling_window_offset": {"days": -365}, - }, {"duration": {"days": 365}, "offset": {"days": -365}}, ), ), ) -async def test_config_flow( - hass: HomeAssistant, period_type, period_input, period_data -) -> None: +async def test_config_flow(hass: HomeAssistant, period_type, period_input) -> None: """Test the config flow.""" input_sensor = "sensor.input_one" @@ -89,7 +78,7 @@ async def test_config_flow( assert result["options"] == { "entity_id": input_sensor, "name": "My statistics", - "period": {period_type: period_data}, + "period": {period_type: period_input}, "precision": 2.0, "state_characteristic": "value_max_lts", } @@ -100,7 +89,7 @@ async def test_config_flow( assert config_entry.options == { "entity_id": input_sensor, "name": "My statistics", - "period": {period_type: period_data}, + "period": {period_type: period_input}, "precision": 2.0, "state_characteristic": "value_max_lts", } @@ -243,8 +232,8 @@ async def test_options(recorder_mock, hass: HomeAssistant) -> None: result = await hass.config_entries.options.async_configure( result["flow_id"], { - "rolling_window_duration": {"days": 2}, - "rolling_window_offset": {"hours": -1}, + "duration": {"days": 2}, + "offset": {"hours": -1}, }, ) await hass.async_block_till_done() @@ -284,28 +273,23 @@ async def test_options(recorder_mock, hass: HomeAssistant) -> None: @freeze_time(datetime(2022, 10, 21, 7, 25, tzinfo=timezone.utc)) @pytest.mark.parametrize( - "period_type, period, suggested_values", + "period_type, period", ( ( "calendar", {"offset": -2, "period": "day"}, - {"calendar_offset": -2, "calendar_period": "day"}, ), ( "fixed_period", {"start_time": "2022-03-24 00:00", "end_time": "2022-03-24 00:00"}, - {}, ), ( "rolling_window", {"duration": {"days": 365}, "offset": {"days": -365}}, - {}, ), ), ) -async def test_options_edit_period( - recorder_mock, hass: HomeAssistant, period_type, period, suggested_values -) -> None: +async def test_options_edit_period(hass: HomeAssistant, period_type, period) -> None: """Test reconfiguring period.""" # Setup the config entry config_entry = MockConfigEntry( @@ -321,6 +305,12 @@ async def test_options_edit_period( title="My statistics", ) config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.statistics.async_setup_entry", + return_value=True, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] == FlowResultType.FORM @@ -351,4 +341,4 @@ async def test_options_edit_period( schema = result["data_schema"].schema for key, configured_value in period.items(): - assert get_suggested(schema, f"{period_type}_{key}") == configured_value + assert get_suggested(schema, key) == configured_value