Compare commits

...
Sign in to create a new pull request.

4 commits

Author SHA1 Message Date
Erik
6869770322 Update after rebase 2022-11-30 13:34:38 +01:00
Erik
97055dcf6c Align import data with frontend statistic card 2022-11-30 12:29:20 +01:00
Erik
1c779bed2b Improve test coverage 2022-11-30 12:29:20 +01:00
Erik
bb3f2079ce Add config flow to the statistics integration 2022-11-30 12:29:20 +01:00
10 changed files with 968 additions and 21 deletions

View file

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

View file

@ -0,0 +1,246 @@
"""Config flow for Min/Max integration."""
from __future__ import annotations
from collections.abc import Callable, Coroutine, Mapping
from typing import Any, Literal, 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_PERIOD,
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("period"): selector.SelectSelector(
selector.SelectSelectorConfig(options=_CALENDAR_PERIODS),
),
vol.Required("offset", default=0): selector.NumberSelector(
selector.NumberSelectorConfig(min=0, mode=selector.NumberSelectorMode.BOX),
),
}
)
FIXED_PERIOD_SCHEMA = vol.Schema(
{
vol.Required("start_time"): selector.DateTimeSelector(),
vol.Required("end_time"): selector.DateTimeSelector(),
}
)
ROLLING_WINDOW_PERIOD_SCHEMA = vol.Schema(
{
vol.Required("duration"): selector.DurationSelector(
selector.DurationSelectorConfig(enable_day=True)
),
vol.Required("offset"): selector.DurationSelector(
selector.DurationSelectorConfig(enable_day=True)
),
}
)
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,
}
async def _import_or_user_config(options: dict[str, Any]) -> str | None:
"""Choose the initial config step."""
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
def period_suggested_values(
period_type: str,
) -> Callable[[SchemaCommonFlowHandler], Coroutine[Any, Any, dict[str, Any]]]:
"""Set period."""
async def _period_suggested_values(
handler: SchemaCommonFlowHandler,
) -> dict[str, Any]:
"""Add suggested values for editing the period."""
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]], Coroutine[Any, Any, dict[str, Any]]
]:
"""Set period."""
async def _set_period(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Add period to config entry options."""
if period_type == "calendar":
period = {
"calendar": {
"offset": user_input["offset"],
"period": user_input["period"],
}
}
elif period_type == "fixed_period":
period = {
"fixed_period": {
"start_time": user_input["start_time"],
"end_time": user_input["end_time"],
}
}
else: # period_type = rolling_window
period = {
"rolling_window": {
"duration": user_input.pop("duration"),
"offset": user_input.pop("offset"),
}
}
handler.options[CONF_PERIOD] = period
return {}
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="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="period_type"),
"period_type": SchemaFlowMenuStep(PERIOD_TYPES),
"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"),
suggested_values=period_suggested_values("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 ""

View file

@ -0,0 +1,12 @@
"""Constants for the statistics integration."""
DOMAIN = "statistics"
CONF_PERIOD = "period"
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"

View file

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

View file

@ -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 (
list_statistic_ids,
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__)
@ -117,6 +137,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 +209,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 +258,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 +270,30 @@ 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]
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"]),
)
async_add_entities([entity], update_before_add=True)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
@ -261,6 +321,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."""
@ -558,10 +626,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
@ -749,3 +814,100 @@ 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 _unit_of_measurement(self) -> str | None:
"""Return unit_of_measurement."""
metadata_result = list_statistic_ids(self.hass, [self._source_entity_id])
if not metadata_result:
return None
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."""
self._attr_native_unit_of_measurement = self._unit_of_measurement()
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)
value = _round_state(value, self._precision)
self._attr_native_value = value
self._attr_available = self._attr_native_value is not None

View file

@ -9,6 +9,7 @@ FLOWS = {
"group",
"integration",
"min_max",
"statistics",
"switch_as_x",
"threshold",
"tod",

View file

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

View file

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

View file

@ -0,0 +1,344 @@
"""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",
(
(
"calendar",
{"offset": 2, "period": "day"},
),
(
"fixed_period",
{"end_time": "2022-03-24 00:00", "start_time": "2022-03-24 00:00"},
),
(
"rolling_window",
{"duration": {"days": 365}, "offset": {"days": -365}},
),
),
)
async def test_config_flow(hass: HomeAssistant, period_type, period_input) -> 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_input},
"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_input},
"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": input_sensor,
"name": "My statistics",
"period": {period_type: period_definition},
"precision": 2.0,
"state_type": "max",
},
)
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"],
{
"duration": {"days": 2},
"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"
@freeze_time(datetime(2022, 10, 21, 7, 25, tzinfo=timezone.utc))
@pytest.mark.parametrize(
"period_type, period",
(
(
"calendar",
{"offset": -2, "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(hass: HomeAssistant, period_type, period) -> 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)
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
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, key) == configured_value

View file

@ -2,18 +2,21 @@
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,
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,
@ -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"