Compare commits
4 commits
dev
...
statistics
Author | SHA1 | Date | |
---|---|---|---|
|
6869770322 | ||
|
97055dcf6c | ||
|
1c779bed2b | ||
|
bb3f2079ce |
10 changed files with 968 additions and 21 deletions
|
@ -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)
|
||||
|
|
246
homeassistant/components/statistics/config_flow.py
Normal file
246
homeassistant/components/statistics/config_flow.py
Normal 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 ""
|
12
homeassistant/components/statistics/const.py
Normal file
12
homeassistant/components/statistics/const.py
Normal 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"
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -9,6 +9,7 @@ FLOWS = {
|
|||
"group",
|
||||
"integration",
|
||||
"min_max",
|
||||
"statistics",
|
||||
"switch_as_x",
|
||||
"threshold",
|
||||
"tod",
|
||||
|
|
|
@ -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,
|
||||
|
|
45
tests/components/statistics/common.py
Normal file
45
tests/components/statistics/common.py
Normal 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)
|
344
tests/components/statistics/test_config_flow.py
Normal file
344
tests/components/statistics/test_config_flow.py
Normal 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
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue