Make statistics validation create issue registry issues (#122595)

* Make statistics validation create issue registry issues

* Disable creating issue about outdated MariaDB version in tests

* Use call_soon_threadsafe instead of run_callback_threadsafe

* Update tests

* Fix flapping test

* Disable creating issue about outdated SQLite version in tests

* Implement agreed changes

* Add translation strings for issue titles

* Update test
This commit is contained in:
Erik Montnemery 2024-09-25 11:11:11 +02:00 committed by GitHub
parent d6e34e0984
commit 771575cfc5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 395 additions and 109 deletions

View File

@ -62,13 +62,15 @@ LAST_REPORTED_SCHEMA_VERSION = 43
LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28
INTEGRATION_PLATFORM_COMPILE_STATISTICS = "compile_statistics"
INTEGRATION_PLATFORM_VALIDATE_STATISTICS = "validate_statistics"
INTEGRATION_PLATFORM_LIST_STATISTIC_IDS = "list_statistic_ids"
INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES = "update_statistics_issues"
INTEGRATION_PLATFORM_VALIDATE_STATISTICS = "validate_statistics"
INTEGRATION_PLATFORM_METHODS = {
INTEGRATION_PLATFORM_COMPILE_STATISTICS,
INTEGRATION_PLATFORM_VALIDATE_STATISTICS,
INTEGRATION_PLATFORM_LIST_STATISTIC_IDS,
INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES,
INTEGRATION_PLATFORM_VALIDATE_STATISTICS,
}

View File

@ -52,6 +52,7 @@ from .const import (
EVENT_RECORDER_HOURLY_STATISTICS_GENERATED,
INTEGRATION_PLATFORM_COMPILE_STATISTICS,
INTEGRATION_PLATFORM_LIST_STATISTIC_IDS,
INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES,
INTEGRATION_PLATFORM_VALIDATE_STATISTICS,
SupportedDialect,
)
@ -586,6 +587,17 @@ def _compile_statistics(
):
new_short_term_stats.append(new_stat)
if start.minute == 50:
# Once every hour, update issues
for platform in instance.hass.data[DOMAIN].recorder_platforms.values():
if not (
platform_update_issues := getattr(
platform, INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES, None
)
):
continue
platform_update_issues(instance.hass, session)
if start.minute == 55:
# A full hour is ready, summarize it
_compile_hourly_statistics(session, start)
@ -2212,6 +2224,16 @@ def validate_statistics(hass: HomeAssistant) -> dict[str, list[ValidationIssue]]
return platform_validation
def update_statistics_issues(hass: HomeAssistant) -> None:
"""Update statistics issues."""
with session_scope(hass=hass, read_only=True) as session:
for platform in hass.data[DOMAIN].recorder_platforms.values():
if platform_update_statistics_issues := getattr(
platform, INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES, None
):
platform_update_statistics_issues(hass, session)
def _statistics_exists(
session: Session,
table: type[StatisticsBase],

View File

@ -43,6 +43,7 @@ from .statistics import (
list_statistic_ids,
statistic_during_period,
statistics_during_period,
update_statistics_issues,
validate_statistics,
)
from .util import PERIOD_SCHEMA, get_instance, resolve_period
@ -80,6 +81,7 @@ def async_setup(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_get_statistics_metadata)
websocket_api.async_register_command(hass, ws_list_statistic_ids)
websocket_api.async_register_command(hass, ws_import_statistics)
websocket_api.async_register_command(hass, ws_update_statistics_issues)
websocket_api.async_register_command(hass, ws_update_statistics_metadata)
websocket_api.async_register_command(hass, ws_validate_statistics)
@ -292,6 +294,24 @@ async def ws_validate_statistics(
connection.send_result(msg["id"], statistic_ids)
@websocket_api.websocket_command(
{
vol.Required("type"): "recorder/update_statistics_issues",
}
)
@websocket_api.async_response
async def ws_update_statistics_issues(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Update statistics issues."""
instance = get_instance(hass)
await instance.async_add_executor_job(
update_statistics_issues,
hass,
)
connection.send_result(msg["id"])
@websocket_api.require_admin
@websocket_api.websocket_command(
{

View File

@ -5,6 +5,7 @@ from __future__ import annotations
from collections import defaultdict
from collections.abc import Callable, Iterable
import datetime
from functools import partial
import itertools
import logging
import math
@ -30,8 +31,9 @@ from homeassistant.const import (
UnitOfSoundPressure,
UnitOfVolume,
)
from homeassistant.core import HomeAssistant, State, split_entity_id
from homeassistant.core import HomeAssistant, State, callback, split_entity_id
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.entity import entity_sources
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from homeassistant.loader import async_suggest_report_issue
@ -672,6 +674,113 @@ def list_statistic_ids(
return result
@callback
def _update_issues(
report_issue: Callable[[str, str, dict[str, Any]], None],
clear_issue: Callable[[str, str], None],
sensor_states: list[State],
metadatas: dict[str, tuple[int, StatisticMetaData]],
) -> None:
"""Update repair issues."""
for state in sensor_states:
entity_id = state.entity_id
state_class = try_parse_enum(
SensorStateClass, state.attributes.get(ATTR_STATE_CLASS)
)
state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
if metadata := metadatas.get(entity_id):
if state_class is None:
# Sensor no longer has a valid state class
report_issue(
"unsupported_state_class",
entity_id,
{
"statistic_id": entity_id,
"state_class": state_class,
},
)
else:
clear_issue("unsupported_state_class", entity_id)
metadata_unit = metadata[1]["unit_of_measurement"]
converter = statistics.STATISTIC_UNIT_TO_UNIT_CONVERTER.get(metadata_unit)
if not converter:
if not _equivalent_units({state_unit, metadata_unit}):
# The unit has changed, and it's not possible to convert
report_issue(
"units_changed",
entity_id,
{
"statistic_id": entity_id,
"state_unit": state_unit,
"metadata_unit": metadata_unit,
"supported_unit": metadata_unit,
},
)
else:
clear_issue("units_changed", entity_id)
elif state_unit not in converter.VALID_UNITS:
# The state unit can't be converted to the unit in metadata
valid_units = (unit or "<None>" for unit in converter.VALID_UNITS)
valid_units_str = ", ".join(sorted(valid_units))
report_issue(
"units_changed",
entity_id,
{
"statistic_id": entity_id,
"state_unit": state_unit,
"metadata_unit": metadata_unit,
"supported_unit": valid_units_str,
},
)
else:
clear_issue("units_changed", entity_id)
def update_statistics_issues(
hass: HomeAssistant,
session: Session,
) -> None:
"""Validate statistics."""
instance = get_instance(hass)
sensor_states = hass.states.all(DOMAIN)
metadatas = statistics.get_metadata_with_session(
instance, session, statistic_source=RECORDER_DOMAIN
)
def create_issue_registry_issue(
issue_type: str, statistic_id: str, data: dict[str, Any]
) -> None:
"""Create an issue registry issue."""
hass.loop.call_soon_threadsafe(
partial(
ir.async_create_issue,
hass,
DOMAIN,
f"{issue_type}_{statistic_id}",
data=data | {"issue_type": issue_type},
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key=issue_type,
translation_placeholders=data,
)
)
def delete_issue_registry_issue(issue_type: str, statistic_id: str) -> None:
"""Delete an issue registry issue."""
hass.loop.call_soon_threadsafe(
ir.async_delete_issue, hass, DOMAIN, f"{issue_type}_{statistic_id}"
)
_update_issues(
create_issue_registry_issue,
delete_issue_registry_issue,
sensor_states,
metadatas,
)
def validate_statistics(
hass: HomeAssistant,
) -> dict[str, list[statistics.ValidationIssue]]:
@ -685,14 +794,28 @@ def validate_statistics(
instance = get_instance(hass)
entity_filter = instance.entity_filter
def create_statistic_validation_issue(
issue_type: str, statistic_id: str, data: dict[str, Any]
) -> None:
"""Create a statistic validation issue."""
validation_result[statistic_id].append(
statistics.ValidationIssue(issue_type, data)
)
_update_issues(
create_statistic_validation_issue,
lambda issue_type, statistic_id: None,
sensor_states,
metadatas,
)
for state in sensor_states:
entity_id = state.entity_id
state_class = try_parse_enum(
SensorStateClass, state.attributes.get(ATTR_STATE_CLASS)
)
state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
if metadata := metadatas.get(entity_id):
if entity_id in metadatas:
if entity_filter and not entity_filter(state.entity_id):
# Sensor was previously recorded, but no longer is
validation_result[entity_id].append(
@ -701,47 +824,6 @@ def validate_statistics(
{"statistic_id": entity_id},
)
)
if state_class is None:
# Sensor no longer has a valid state class
validation_result[entity_id].append(
statistics.ValidationIssue(
"unsupported_state_class",
{"statistic_id": entity_id, "state_class": state_class},
)
)
metadata_unit = metadata[1]["unit_of_measurement"]
converter = statistics.STATISTIC_UNIT_TO_UNIT_CONVERTER.get(metadata_unit)
if not converter:
if not _equivalent_units({state_unit, metadata_unit}):
# The unit has changed, and it's not possible to convert
validation_result[entity_id].append(
statistics.ValidationIssue(
"units_changed",
{
"statistic_id": entity_id,
"state_unit": state_unit,
"metadata_unit": metadata_unit,
"supported_unit": metadata_unit,
},
)
)
elif state_unit not in converter.VALID_UNITS:
# The state unit can't be converted to the unit in metadata
valid_units = (unit or "<None>" for unit in converter.VALID_UNITS)
valid_units_str = ", ".join(sorted(valid_units))
validation_result[entity_id].append(
statistics.ValidationIssue(
"units_changed",
{
"statistic_id": entity_id,
"state_unit": state_unit,
"metadata_unit": metadata_unit,
"supported_unit": valid_units_str,
},
)
)
elif state_class is not None:
if entity_filter and not entity_filter(state.entity_id):
# Sensor is not recorded

View File

@ -287,5 +287,15 @@
"wind_speed": {
"name": "Wind speed"
}
},
"issues": {
"units_changed": {
"title": "The unit of {statistic_id} has changed",
"description": ""
},
"unsupported_state_class": {
"title": "The state class of {statistic_id} is not supported",
"description": ""
}
}
}

View File

@ -2512,6 +2512,7 @@ async def test_recorder_platform_with_statistics(
recorder_platform = Mock(
compile_statistics=Mock(wraps=_mock_compile_statistics),
list_statistic_ids=Mock(wraps=_mock_list_statistic_ids),
update_statistics_issues=Mock(),
validate_statistics=Mock(wraps=_mock_validate_statistics),
)
@ -2523,16 +2524,20 @@ async def test_recorder_platform_with_statistics(
recorder_platform.compile_statistics.assert_not_called()
recorder_platform.list_statistic_ids.assert_not_called()
recorder_platform.update_statistics_issues.assert_not_called()
recorder_platform.validate_statistics.assert_not_called()
# Test compile statistics
zero = get_start_time(dt_util.utcnow())
# Test compile statistics + update statistics issues
# Issues are updated hourly when minutes = 50, trigger one hour later to make
# sure statistics is not suppressed by an existing row in StatisticsRuns
zero = get_start_time(dt_util.utcnow()).replace(minute=50) + timedelta(hours=1)
do_adhoc_statistics(hass, start=zero)
await async_wait_recording_done(hass)
recorder_platform.compile_statistics.assert_called_once_with(
hass, ANY, zero, zero + timedelta(minutes=5)
)
recorder_platform.update_statistics_issues.assert_called_once_with(hass, ANY)
recorder_platform.list_statistic_ids.assert_not_called()
recorder_platform.validate_statistics.assert_not_called()
@ -2542,6 +2547,7 @@ async def test_recorder_platform_with_statistics(
recorder_platform.list_statistic_ids.assert_called_once_with(
hass, statistic_ids=None, statistic_type=None
)
recorder_platform.update_statistics_issues.assert_called_once()
recorder_platform.validate_statistics.assert_not_called()
# Test validate statistics
@ -2551,6 +2557,7 @@ async def test_recorder_platform_with_statistics(
)
recorder_platform.compile_statistics.assert_called_once()
recorder_platform.list_statistic_ids.assert_called_once()
recorder_platform.update_statistics_issues.assert_called_once()
recorder_platform.validate_statistics.assert_called_once_with(hass)
@ -2575,6 +2582,7 @@ async def test_recorder_platform_without_statistics(
[
("compile_statistics",),
("list_statistic_ids",),
("update_statistics_issues",),
("validate_statistics",),
],
)
@ -2601,6 +2609,7 @@ async def test_recorder_platform_with_partial_statistics_support(
mock_impl = {
"compile_statistics": _mock_compile_statistics,
"list_statistic_ids": _mock_list_statistic_ids,
"update_statistics_issues": None,
"validate_statistics": _mock_validate_statistics,
}
@ -2620,8 +2629,10 @@ async def test_recorder_platform_with_partial_statistics_support(
for meth in supported_methods:
getattr(recorder_platform, meth).assert_not_called()
# Test compile statistics
zero = get_start_time(dt_util.utcnow())
# Test compile statistics + update statistics issues
# Issues are updated hourly when minutes = 50, trigger one hour later to make
# sure statistics is not suppressed by an existing row in StatisticsRuns
zero = get_start_time(dt_util.utcnow()).replace(minute=50) + timedelta(hours=1)
do_adhoc_statistics(hass, start=zero)
await async_wait_recording_done(hass)

View File

@ -1984,6 +1984,18 @@ async def test_validate_statistics(
await assert_validation_result(client, {})
async def test_update_statistics_issues(
recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test update_statistics_issues can be called."""
client = await hass_ws_client()
await client.send_json_auto_id({"type": "recorder/update_statistics_issues"})
response = await client.receive_json()
assert response["success"]
assert response["result"] is None
async def test_clear_statistics(
recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:

View File

@ -1,10 +1,11 @@
"""The tests for sensor recorder platform."""
from collections.abc import Iterable
from datetime import datetime, timedelta
import math
from statistics import mean
from typing import Any, Literal
from unittest.mock import patch
from unittest.mock import ANY, patch
from freezegun import freeze_time
from freezegun.api import FrozenDateTimeFactory
@ -37,6 +38,7 @@ from homeassistant.components.recorder.util import get_instance, session_scope
from homeassistant.components.sensor import ATTR_OPTIONS, DOMAIN, SensorDeviceClass
from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM
@ -110,6 +112,24 @@ def setup_recorder(recorder_mock: Recorder) -> Recorder:
"""Set up recorder."""
@pytest.fixture(autouse=True)
def disable_mariadb_issue() -> None:
"""Disable creating issue about outdated MariaDB version."""
with patch(
"homeassistant.components.recorder.util._async_create_mariadb_range_index_regression_issue"
):
yield
@pytest.fixture(autouse=True)
def disable_sqlite_issue() -> None:
"""Disable creating issue about outdated SQLite version."""
with patch(
"homeassistant.components.recorder.util._async_create_issue_deprecated_version"
):
yield
async def async_list_statistic_ids(
hass: HomeAssistant,
statistic_ids: set[str] | None = None,
@ -137,15 +157,61 @@ async def assert_statistic_ids(
)
def assert_issues(
hass: HomeAssistant,
expected_issues: dict[str, dict[str, Any]],
) -> None:
"""Assert statistics issues."""
issue_registry = ir.async_get(hass)
assert len(issue_registry.issues) == len(expected_issues)
for issue_id, expected_issue_data in expected_issues.items():
expected_translation_placeholders = dict(expected_issue_data)
expected_translation_placeholders.pop("issue_type")
expected_issue = ir.IssueEntry(
active=True,
breaks_in_ha_version=None,
created=ANY,
data=expected_issue_data,
dismissed_version=None,
domain=DOMAIN,
is_fixable=False,
is_persistent=False,
issue_domain=None,
issue_id=issue_id,
learn_more_url=None,
severity=ir.IssueSeverity.WARNING,
translation_key=expected_issue_data["issue_type"],
translation_placeholders=expected_translation_placeholders,
)
assert (DOMAIN, issue_id) in issue_registry.issues
assert issue_registry.issues[(DOMAIN, issue_id)] == expected_issue
async def assert_validation_result(
hass: HomeAssistant,
client: MockHAClientWebSocket,
expected_result: dict[str, list[dict[str, Any]]],
expected_validation_result: dict[str, list[dict[str, Any]]],
expected_issues: Iterable[str],
) -> None:
"""Assert statistics validation result."""
await client.send_json_auto_id({"type": "recorder/validate_statistics"})
response = await client.receive_json()
assert response["success"]
assert response["result"] == expected_result
assert response["result"] == expected_validation_result
await hass.async_block_till_done()
# Check we get corresponding issues
await client.send_json_auto_id({"type": "recorder/update_statistics_issues"})
response = await client.receive_json()
assert response["success"]
expected_issue_registry_issues = {
f"{issue['type']}_{statistic_id}": issue["data"] | {"issue_type": issue["type"]}
for statistic_id, issues in expected_validation_result.items()
for issue in issues
if issue["type"] in expected_issues
}
assert_issues(hass, expected_issue_registry_issues)
@pytest.mark.parametrize(
@ -4219,7 +4285,7 @@ async def test_validate_unit_change_convertible(
client = await hass_ws_client()
# No statistics, no state - empty response
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# No statistics, unit in state matching device class - empty response
hass.states.async_set(
@ -4229,7 +4295,7 @@ async def test_validate_unit_change_convertible(
timestamp=now.timestamp(),
)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# No statistics, unit in state not matching device class - empty response
hass.states.async_set(
@ -4239,7 +4305,7 @@ async def test_validate_unit_change_convertible(
timestamp=now.timestamp(),
)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# Statistics has run, incompatible unit - expect error
await async_recorder_block_till_done(hass)
@ -4264,7 +4330,7 @@ async def test_validate_unit_change_convertible(
}
],
}
await assert_validation_result(client, expected)
await assert_validation_result(hass, client, expected, {"units_changed"})
# Valid state - empty response
hass.states.async_set(
@ -4274,12 +4340,12 @@ async def test_validate_unit_change_convertible(
timestamp=now.timestamp(),
)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# Valid state, statistic runs again - empty response
do_adhoc_statistics(hass, start=now + timedelta(hours=1))
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# Valid state in compatible unit - empty response
hass.states.async_set(
@ -4289,12 +4355,12 @@ async def test_validate_unit_change_convertible(
timestamp=now.timestamp(),
)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# Valid state, statistic runs again - empty response
do_adhoc_statistics(hass, start=now + timedelta(hours=2))
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# Remove the state - expect error about missing state
hass.states.async_remove("sensor.test")
@ -4306,7 +4372,7 @@ async def test_validate_unit_change_convertible(
}
],
}
await assert_validation_result(client, expected)
await assert_validation_result(hass, client, expected, {})
@pytest.mark.parametrize(
@ -4333,7 +4399,7 @@ async def test_validate_statistics_unit_ignore_device_class(
client = await hass_ws_client()
# No statistics, no state - empty response
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# No statistics, no device class - empty response
initial_attributes = {"state_class": "measurement", "unit_of_measurement": "dogs"}
@ -4341,7 +4407,7 @@ async def test_validate_statistics_unit_ignore_device_class(
"sensor.test", 10, attributes=initial_attributes, timestamp=now.timestamp()
)
await hass.async_block_till_done()
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# Statistics has run, device class set not matching unit - empty response
do_adhoc_statistics(hass, start=now)
@ -4353,7 +4419,7 @@ async def test_validate_statistics_unit_ignore_device_class(
timestamp=now.timestamp(),
)
await hass.async_block_till_done()
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
@pytest.mark.parametrize(
@ -4418,7 +4484,7 @@ async def test_validate_statistics_unit_change_no_device_class(
client = await hass_ws_client()
# No statistics, no state - empty response
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# No statistics, sensor state set - empty response
hass.states.async_set(
@ -4428,7 +4494,7 @@ async def test_validate_statistics_unit_change_no_device_class(
timestamp=now.timestamp(),
)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# No statistics, sensor state set to an incompatible unit - empty response
hass.states.async_set(
@ -4438,7 +4504,7 @@ async def test_validate_statistics_unit_change_no_device_class(
timestamp=now.timestamp(),
)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# Statistics has run, incompatible unit - expect error
await async_recorder_block_till_done(hass)
@ -4463,7 +4529,7 @@ async def test_validate_statistics_unit_change_no_device_class(
}
],
}
await assert_validation_result(client, expected)
await assert_validation_result(hass, client, expected, {"units_changed"})
# Valid state - empty response
hass.states.async_set(
@ -4473,12 +4539,12 @@ async def test_validate_statistics_unit_change_no_device_class(
timestamp=now.timestamp(),
)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# Valid state, statistic runs again - empty response
do_adhoc_statistics(hass, start=now + timedelta(hours=1))
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# Valid state in compatible unit - empty response
hass.states.async_set(
@ -4488,12 +4554,12 @@ async def test_validate_statistics_unit_change_no_device_class(
timestamp=now.timestamp(),
)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# Valid state, statistic runs again - empty response
do_adhoc_statistics(hass, start=now + timedelta(hours=2))
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# Remove the state - expect error about missing state
hass.states.async_remove("sensor.test")
@ -4505,7 +4571,7 @@ async def test_validate_statistics_unit_change_no_device_class(
}
],
}
await assert_validation_result(client, expected)
await assert_validation_result(hass, client, expected, {})
@pytest.mark.parametrize(
@ -4530,19 +4596,19 @@ async def test_validate_statistics_unsupported_state_class(
client = await hass_ws_client()
# No statistics, no state - empty response
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# No statistics, valid state - empty response
hass.states.async_set(
"sensor.test", 10, attributes=attributes, timestamp=now.timestamp()
)
await hass.async_block_till_done()
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# Statistics has run, empty response
do_adhoc_statistics(hass, start=now)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# State update with invalid state class, expect error
_attributes = dict(attributes)
@ -4562,7 +4628,7 @@ async def test_validate_statistics_unsupported_state_class(
}
],
}
await assert_validation_result(client, expected)
await assert_validation_result(hass, client, expected, {"unsupported_state_class"})
@pytest.mark.parametrize(
@ -4587,19 +4653,19 @@ async def test_validate_statistics_sensor_no_longer_recorded(
client = await hass_ws_client()
# No statistics, no state - empty response
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# No statistics, valid state - empty response
hass.states.async_set(
"sensor.test", 10, attributes=attributes, timestamp=now.timestamp()
)
await hass.async_block_till_done()
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# Statistics has run, empty response
do_adhoc_statistics(hass, start=now)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# Sensor no longer recorded, expect error
expected = {
@ -4616,7 +4682,7 @@ async def test_validate_statistics_sensor_no_longer_recorded(
"entity_filter",
return_value=False,
):
await assert_validation_result(client, expected)
await assert_validation_result(hass, client, expected, {})
@pytest.mark.parametrize(
@ -4641,7 +4707,7 @@ async def test_validate_statistics_sensor_not_recorded(
client = await hass_ws_client()
# No statistics, no state - empty response
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# Sensor not recorded, expect error
expected = {
@ -4662,12 +4728,12 @@ async def test_validate_statistics_sensor_not_recorded(
"sensor.test", 10, attributes=attributes, timestamp=now.timestamp()
)
await hass.async_block_till_done()
await assert_validation_result(client, expected)
await assert_validation_result(hass, client, expected, {})
# Statistics has run, expect same error
do_adhoc_statistics(hass, start=now)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, expected)
await assert_validation_result(hass, client, expected, {})
@pytest.mark.parametrize(
@ -4692,19 +4758,19 @@ async def test_validate_statistics_sensor_removed(
client = await hass_ws_client()
# No statistics, no state - empty response
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# No statistics, valid state - empty response
hass.states.async_set(
"sensor.test", 10, attributes=attributes, timestamp=now.timestamp()
)
await hass.async_block_till_done()
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# Statistics has run, empty response
do_adhoc_statistics(hass, start=now)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# Sensor removed, expect error
hass.states.async_remove("sensor.test")
@ -4716,7 +4782,7 @@ async def test_validate_statistics_sensor_removed(
}
],
}
await assert_validation_result(client, expected)
await assert_validation_result(hass, client, expected, {})
@pytest.mark.parametrize(
@ -4741,7 +4807,7 @@ async def test_validate_statistics_unit_change_no_conversion(
client = await hass_ws_client()
# No statistics, no state - empty response
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# No statistics, original unit - empty response
hass.states.async_set(
@ -4750,7 +4816,7 @@ async def test_validate_statistics_unit_change_no_conversion(
attributes={**attributes, "unit_of_measurement": unit1},
timestamp=now.timestamp(),
)
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# No statistics, changed unit - empty response
hass.states.async_set(
@ -4759,7 +4825,7 @@ async def test_validate_statistics_unit_change_no_conversion(
attributes={**attributes, "unit_of_measurement": unit2},
timestamp=now.timestamp(),
)
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# Run statistics, no statistics will be generated because of conflicting units
await async_recorder_block_till_done(hass)
@ -4774,7 +4840,7 @@ async def test_validate_statistics_unit_change_no_conversion(
attributes={**attributes, "unit_of_measurement": unit1},
timestamp=now.timestamp(),
)
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# Run statistics one hour later, only the state with unit1 will be considered
await async_recorder_block_till_done(hass)
@ -4783,7 +4849,7 @@ async def test_validate_statistics_unit_change_no_conversion(
await assert_statistic_ids(
hass, [{"statistic_id": "sensor.test", "unit_of_measurement": unit1}]
)
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# Change unit - expect error
hass.states.async_set(
@ -4806,7 +4872,7 @@ async def test_validate_statistics_unit_change_no_conversion(
}
],
}
await assert_validation_result(client, expected)
await assert_validation_result(hass, client, expected, {"units_changed"})
# Original unit - empty response
hass.states.async_set(
@ -4816,13 +4882,13 @@ async def test_validate_statistics_unit_change_no_conversion(
timestamp=now.timestamp(),
)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# Valid state, statistic runs again - empty response
await async_recorder_block_till_done(hass)
do_adhoc_statistics(hass, start=now + timedelta(hours=2))
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# Remove the state - expect error
hass.states.async_remove("sensor.test")
@ -4834,7 +4900,7 @@ async def test_validate_statistics_unit_change_no_conversion(
}
],
}
await assert_validation_result(client, expected)
await assert_validation_result(hass, client, expected, {})
@pytest.mark.parametrize(
@ -4864,7 +4930,7 @@ async def test_validate_statistics_unit_change_equivalent_units(
client = await hass_ws_client()
# No statistics, no state - empty response
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# No statistics, original unit - empty response
hass.states.async_set(
@ -4873,7 +4939,7 @@ async def test_validate_statistics_unit_change_equivalent_units(
attributes={**attributes, "unit_of_measurement": unit1},
timestamp=now.timestamp(),
)
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# Run statistics
await async_recorder_block_till_done(hass)
@ -4890,7 +4956,7 @@ async def test_validate_statistics_unit_change_equivalent_units(
attributes={**attributes, "unit_of_measurement": unit2},
timestamp=now.timestamp() + 1,
)
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# Run statistics one hour later, metadata will be updated
await async_recorder_block_till_done(hass)
@ -4899,7 +4965,7 @@ async def test_validate_statistics_unit_change_equivalent_units(
await assert_statistic_ids(
hass, [{"statistic_id": "sensor.test", "unit_of_measurement": unit2}]
)
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
@pytest.mark.parametrize(
@ -4928,7 +4994,7 @@ async def test_validate_statistics_unit_change_equivalent_units_2(
client = await hass_ws_client()
# No statistics, no state - empty response
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# No statistics, original unit - empty response
hass.states.async_set(
@ -4937,7 +5003,7 @@ async def test_validate_statistics_unit_change_equivalent_units_2(
attributes={**attributes, "unit_of_measurement": unit1},
timestamp=now.timestamp(),
)
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
# Run statistics
await async_recorder_block_till_done(hass)
@ -4967,7 +5033,7 @@ async def test_validate_statistics_unit_change_equivalent_units_2(
}
],
}
await assert_validation_result(client, expected)
await assert_validation_result(hass, client, expected, {"units_changed"})
# Run statistics one hour later, metadata will not be updated
await async_recorder_block_till_done(hass)
@ -4976,7 +5042,7 @@ async def test_validate_statistics_unit_change_equivalent_units_2(
await assert_statistic_ids(
hass, [{"statistic_id": "sensor.test", "unit_of_measurement": unit1}]
)
await assert_validation_result(client, expected)
await assert_validation_result(hass, client, expected, {"units_changed"})
async def test_validate_statistics_other_domain(
@ -5009,7 +5075,68 @@ async def test_validate_statistics_other_domain(
await async_recorder_block_till_done(hass)
# We should not get complains about the missing number entity
await assert_validation_result(client, {})
await assert_validation_result(hass, client, {}, {})
@pytest.mark.parametrize(
("units", "attributes", "unit"),
[
(US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"),
],
)
async def test_update_statistics_issues(
hass: HomeAssistant,
units,
attributes,
unit,
) -> None:
"""Test update_statistics_issues."""
async def one_hour_stats(start: datetime) -> datetime:
"""Generate 5-minute statistics for one hour."""
for _ in range(12):
do_adhoc_statistics(hass, start=start)
await async_wait_recording_done(hass)
start += timedelta(minutes=5)
return start
now = get_start_time(dt_util.utcnow())
hass.config.units = units
await async_setup_component(hass, "sensor", {})
await async_recorder_block_till_done(hass)
# No statistics, no state - no issues
now = await one_hour_stats(now)
assert_issues(hass, {})
# Statistics, valid state - no issues
hass.states.async_set(
"sensor.test", 10, attributes=attributes, timestamp=now.timestamp()
)
await hass.async_block_till_done()
now = await one_hour_stats(now)
assert_issues(hass, {})
# State update with invalid state class, statistics did not run again
_attributes = dict(attributes)
_attributes.pop("state_class")
hass.states.async_set(
"sensor.test", 12, attributes=_attributes, timestamp=now.timestamp()
)
await hass.async_block_till_done()
assert_issues(hass, {})
# Let statistics run for one hour, expect issue
now = await one_hour_stats(now)
expected = {
"unsupported_state_class_sensor.test": {
"issue_type": "unsupported_state_class",
"state_class": None,
"statistic_id": "sensor.test",
}
}
assert_issues(hass, expected)
async def async_record_meter_states(

View File

@ -425,10 +425,10 @@ async def test_caching(hass: HomeAssistant) -> None:
side_effect=translation.build_resources,
) as mock_build_resources:
load1 = await translation.async_get_translations(hass, "en", "entity_component")
assert len(mock_build_resources.mock_calls) == 6
assert len(mock_build_resources.mock_calls) == 7
load2 = await translation.async_get_translations(hass, "en", "entity_component")
assert len(mock_build_resources.mock_calls) == 6
assert len(mock_build_resources.mock_calls) == 7
assert load1 == load2