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

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections import defaultdict from collections import defaultdict
import dataclasses
from datetime import datetime, timedelta from datetime import datetime, timedelta
from itertools import groupby from itertools import groupby
import logging import logging
@ -91,6 +92,18 @@ UNIT_CONVERSIONS = {
_LOGGER = logging.getLogger(__name__) _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: def async_setup(hass: HomeAssistant) -> None:
"""Set up the history hooks.""" """Set up the history hooks."""
hass.data[STATISTICS_BAKERY] = baked.bakery() 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. # 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} 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.""" """Statistics helper for sensor."""
from __future__ import annotations from __future__ import annotations
from collections import defaultdict
import datetime import datetime
import itertools import itertools
import logging import logging
@ -543,3 +544,56 @@ def list_statistic_ids(hass: HomeAssistant, statistic_type: str | None = None) -
statistic_ids[entity_id] = statistics_unit statistic_ids[entity_id] = statistics_unit
return statistic_ids 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, {})