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:
parent
d2a9f7904a
commit
d899d15a1e
5 changed files with 351 additions and 1 deletions
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
30
homeassistant/components/recorder/websocket_api.py
Normal file
30
homeassistant/components/recorder/websocket_api.py
Normal 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)
|
|
@ -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
|
||||||
|
|
242
tests/components/recorder/test_websocket_api.py
Normal file
242
tests/components/recorder/test_websocket_api.py
Normal 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, {})
|
Loading…
Add table
Add a link
Reference in a new issue