Add statistics validation (#56020)

* Add statistics validation

* Remove redundant None-check

* Move validate_statistics WS API to recorder

* Apply suggestion from code review
This commit is contained in:
Erik Montnemery 2021-09-13 13:44:22 +02:00 committed by GitHub
parent d2a9f7904a
commit d899d15a1e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 351 additions and 1 deletions

View file

@ -49,7 +49,7 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
import homeassistant.util.dt as dt_util
from . import history, migration, purge, statistics
from . import history, migration, purge, statistics, websocket_api
from .const import CONF_DB_INTEGRITY_CHECK, DATA_INSTANCE, DOMAIN, SQLITE_URL_PREFIX
from .models import (
Base,
@ -264,6 +264,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
_async_register_services(hass, instance)
history.async_setup(hass)
statistics.async_setup(hass)
websocket_api.async_setup(hass)
await async_process_integration_platforms(hass, DOMAIN, _process_recorder_platform)
return await instance.async_db_ready

View file

@ -2,6 +2,7 @@
from __future__ import annotations
from collections import defaultdict
import dataclasses
from datetime import datetime, timedelta
from itertools import groupby
import logging
@ -91,6 +92,18 @@ UNIT_CONVERSIONS = {
_LOGGER = logging.getLogger(__name__)
@dataclasses.dataclass
class ValidationIssue:
"""Error or warning message."""
type: str
data: dict[str, str | None] | None = None
def as_dict(self) -> dict:
"""Return dictionary version."""
return dataclasses.asdict(self)
def async_setup(hass: HomeAssistant) -> None:
"""Set up the history hooks."""
hass.data[STATISTICS_BAKERY] = baked.bakery()
@ -471,3 +484,13 @@ def _sorted_statistics_to_dict(
# Filter out the empty lists if some states had 0 results.
return {metadata[key]["statistic_id"]: val for key, val in result.items() if val}
def validate_statistics(hass: HomeAssistant) -> dict[str, list[ValidationIssue]]:
"""Validate statistics."""
platform_validation: dict[str, list[ValidationIssue]] = {}
for platform in hass.data[DOMAIN].values():
if not hasattr(platform, "validate_statistics"):
continue
platform_validation.update(platform.validate_statistics(hass))
return platform_validation

View file

@ -0,0 +1,30 @@
"""The Energy websocket API."""
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from .statistics import validate_statistics
@callback
def async_setup(hass: HomeAssistant) -> None:
"""Set up the recorder websocket API."""
websocket_api.async_register_command(hass, ws_validate_statistics)
@websocket_api.websocket_command(
{
vol.Required("type"): "recorder/validate_statistics",
}
)
@websocket_api.async_response
async def ws_validate_statistics(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None:
"""Fetch a list of available statistic_id."""
statistic_ids = await hass.async_add_executor_job(
validate_statistics,
hass,
)
connection.send_result(msg["id"], statistic_ids)

View file

@ -1,6 +1,7 @@
"""Statistics helper for sensor."""
from __future__ import annotations
from collections import defaultdict
import datetime
import itertools
import logging
@ -543,3 +544,56 @@ def list_statistic_ids(hass: HomeAssistant, statistic_type: str | None = None) -
statistic_ids[entity_id] = statistics_unit
return statistic_ids
def validate_statistics(
hass: HomeAssistant,
) -> dict[str, list[statistics.ValidationIssue]]:
"""Validate statistics."""
validation_result = defaultdict(list)
entities = _get_entities(hass)
for (
entity_id,
_state_class,
device_class,
) in entities:
state = hass.states.get(entity_id)
assert state is not None
metadata = statistics.get_metadata(hass, entity_id)
if not metadata:
continue
state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
metadata_unit = metadata["unit_of_measurement"]
if device_class not in UNIT_CONVERSIONS:
if state_unit != metadata_unit:
validation_result[entity_id].append(
statistics.ValidationIssue(
"units_changed",
{
"statistic_id": entity_id,
"state_unit": state_unit,
"metadata_unit": metadata_unit,
},
)
)
continue
if state_unit not in UNIT_CONVERSIONS[device_class]:
validation_result[entity_id].append(
statistics.ValidationIssue(
"unsupported_unit",
{
"statistic_id": entity_id,
"device_class": device_class,
"state_unit": state_unit,
},
)
)
return validation_result

View file

@ -0,0 +1,242 @@
"""The tests for sensor recorder platform."""
# pylint: disable=protected-access,invalid-name
from datetime import timedelta
import pytest
from homeassistant.components.recorder.const import DATA_INSTANCE
from homeassistant.components.recorder.models import StatisticsMeta
from homeassistant.components.recorder.util import session_scope
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM
from tests.common import init_recorder_component
BATTERY_SENSOR_ATTRIBUTES = {
"device_class": "battery",
"state_class": "measurement",
"unit_of_measurement": "%",
}
POWER_SENSOR_ATTRIBUTES = {
"device_class": "power",
"state_class": "measurement",
"unit_of_measurement": "kW",
}
NONE_SENSOR_ATTRIBUTES = {
"state_class": "measurement",
}
PRESSURE_SENSOR_ATTRIBUTES = {
"device_class": "pressure",
"state_class": "measurement",
"unit_of_measurement": "hPa",
}
TEMPERATURE_SENSOR_ATTRIBUTES = {
"device_class": "temperature",
"state_class": "measurement",
"unit_of_measurement": "°C",
}
@pytest.mark.parametrize(
"units, attributes, unit",
[
(IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"),
(METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"),
(IMPERIAL_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°F"),
(METRIC_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°C"),
(IMPERIAL_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "psi"),
(METRIC_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "Pa"),
],
)
async def test_validate_statistics_supported_device_class(
hass, hass_ws_client, units, attributes, unit
):
"""Test list_statistic_ids."""
id = 1
def next_id():
nonlocal id
id += 1
return id
async def assert_validation_result(client, expected_result):
await client.send_json(
{"id": next_id(), "type": "recorder/validate_statistics"}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == expected_result
now = dt_util.utcnow()
hass.config.units = units
await hass.async_add_executor_job(init_recorder_component, hass)
await async_setup_component(hass, "sensor", {})
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
client = await hass_ws_client()
# No statistics, no state - empty response
await assert_validation_result(client, {})
# No statistics, valid state - empty response
hass.states.async_set(
"sensor.test", 10, attributes={**attributes, **{"unit_of_measurement": unit}}
)
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
await assert_validation_result(client, {})
# No statistics, invalid state - empty response
hass.states.async_set(
"sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": "dogs"}}
)
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
await assert_validation_result(client, {})
# Statistics has run, invalid state - expect error
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now)
hass.states.async_set(
"sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": "dogs"}}
)
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
expected = {
"sensor.test": [
{
"data": {
"device_class": attributes["device_class"],
"state_unit": "dogs",
"statistic_id": "sensor.test",
},
"type": "unsupported_unit",
}
],
}
await assert_validation_result(client, expected)
# Valid state - empty response
hass.states.async_set(
"sensor.test", 13, attributes={**attributes, **{"unit_of_measurement": unit}}
)
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
await assert_validation_result(client, {})
# Valid state, statistic runs again - empty response
hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now)
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
await assert_validation_result(client, {})
# Remove the state - empty response
hass.states.async_remove("sensor.test")
await assert_validation_result(client, {})
@pytest.mark.parametrize(
"attributes",
[BATTERY_SENSOR_ATTRIBUTES, NONE_SENSOR_ATTRIBUTES],
)
async def test_validate_statistics_unsupported_device_class(
hass, hass_ws_client, attributes
):
"""Test list_statistic_ids."""
id = 1
def next_id():
nonlocal id
id += 1
return id
async def assert_validation_result(client, expected_result):
await client.send_json(
{"id": next_id(), "type": "recorder/validate_statistics"}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == expected_result
async def assert_statistic_ids(expected_result):
with session_scope(hass=hass) as session:
db_states = list(session.query(StatisticsMeta))
assert len(db_states) == len(expected_result)
for i in range(len(db_states)):
assert db_states[i].statistic_id == expected_result[i]["statistic_id"]
assert (
db_states[i].unit_of_measurement
== expected_result[i]["unit_of_measurement"]
)
now = dt_util.utcnow()
await hass.async_add_executor_job(init_recorder_component, hass)
await async_setup_component(hass, "sensor", {})
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
client = await hass_ws_client()
rec = hass.data[DATA_INSTANCE]
# No statistics, no state - empty response
await assert_validation_result(client, {})
# No statistics, original unit - empty response
hass.states.async_set("sensor.test", 10, attributes=attributes)
await assert_validation_result(client, {})
# No statistics, changed unit - empty response
hass.states.async_set(
"sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": "dogs"}}
)
await assert_validation_result(client, {})
# Run statistics, no statistics will be generated because of conflicting units
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
rec.do_adhoc_statistics(start=now)
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
await assert_statistic_ids([])
# No statistics, changed unit - empty response
hass.states.async_set(
"sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": "dogs"}}
)
await assert_validation_result(client, {})
# Run statistics one hour later, only the "dogs" state will be considered
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
rec.do_adhoc_statistics(start=now + timedelta(hours=1))
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
await assert_statistic_ids(
[{"statistic_id": "sensor.test", "unit_of_measurement": "dogs"}]
)
await assert_validation_result(client, {})
# Change back to original unit - expect error
hass.states.async_set("sensor.test", 13, attributes=attributes)
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
expected = {
"sensor.test": [
{
"data": {
"metadata_unit": "dogs",
"state_unit": attributes.get("unit_of_measurement"),
"statistic_id": "sensor.test",
},
"type": "units_changed",
}
],
}
await assert_validation_result(client, expected)
# Changed unit - empty response
hass.states.async_set(
"sensor.test", 14, attributes={**attributes, **{"unit_of_measurement": "dogs"}}
)
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
await assert_validation_result(client, {})
# Valid state, statistic runs again - empty response
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
hass.data[DATA_INSTANCE].do_adhoc_statistics(start=now)
await hass.async_add_executor_job(hass.data[DATA_INSTANCE].block_till_done)
await assert_validation_result(client, {})
# Remove the state - empty response
hass.states.async_remove("sensor.test")
await assert_validation_result(client, {})