diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index bc909448317..409641e54c9 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -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, } diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index ba19c016d19..4ffe7c72971 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -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], diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index f08f7bdcb97..6ac2207b1e0 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -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( { diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 462b25dd552..f81c3308943 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -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 "" 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 "" 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 diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index fc85f4b05a9..4ef7dbc74f0 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -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": "" + } } } diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 5cbb29afc91..bdf39c5ef4a 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -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) diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 8efbf226bc1..badf2540654 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -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: diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 4d271785114..821c10e02d9 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -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( diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index 73cd243a0c6..3b60c7f695b 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -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