Introduce recorder.get_statistics service (#142602)

Co-authored-by: abmantis <amfcalt@gmail.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
This commit is contained in:
Sören Beye 2025-05-14 12:28:32 +02:00 committed by GitHub
parent 161b62d8fa
commit c023f610dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 512 additions and 3 deletions

View File

@ -11,6 +11,9 @@
},
"enable": {
"service": "mdi:database"
},
"get_statistics": {
"service": "mdi:chart-bar"
}
}
}

View File

@ -8,7 +8,13 @@ from typing import cast
import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.core import (
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
callback,
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entityfilter import generate_filter
from homeassistant.helpers.service import (
@ -16,15 +22,18 @@ from homeassistant.helpers.service import (
async_register_admin_service,
)
from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonArrayType, JsonObjectType
from .const import ATTR_APPLY_FILTER, ATTR_KEEP_DAYS, ATTR_REPACK, DOMAIN
from .core import Recorder
from .statistics import statistics_during_period
from .tasks import PurgeEntitiesTask, PurgeTask
SERVICE_PURGE = "purge"
SERVICE_PURGE_ENTITIES = "purge_entities"
SERVICE_ENABLE = "enable"
SERVICE_DISABLE = "disable"
SERVICE_GET_STATISTICS = "get_statistics"
SERVICE_PURGE_SCHEMA = vol.Schema(
{
@ -63,6 +72,20 @@ SERVICE_PURGE_ENTITIES_SCHEMA = vol.All(
SERVICE_ENABLE_SCHEMA = vol.Schema({})
SERVICE_DISABLE_SCHEMA = vol.Schema({})
SERVICE_GET_STATISTICS_SCHEMA = vol.Schema(
{
vol.Required("start_time"): cv.datetime,
vol.Optional("end_time"): cv.datetime,
vol.Required("statistic_ids"): vol.All(cv.ensure_list, [cv.string]),
vol.Required("period"): vol.In(["5minute", "hour", "day", "week", "month"]),
vol.Required("types"): vol.All(
cv.ensure_list,
[vol.In(["change", "last_reset", "max", "mean", "min", "state", "sum"])],
),
vol.Optional("units"): vol.Schema({cv.string: cv.string}),
}
)
@callback
def _async_register_purge_service(hass: HomeAssistant, instance: Recorder) -> None:
@ -135,6 +158,79 @@ def _async_register_disable_service(hass: HomeAssistant, instance: Recorder) ->
)
@callback
def _async_register_get_statistics_service(
hass: HomeAssistant, instance: Recorder
) -> None:
async def async_handle_get_statistics_service(
service: ServiceCall,
) -> ServiceResponse:
"""Handle calls to the get_statistics service."""
start_time = dt_util.as_utc(service.data["start_time"])
end_time = (
dt_util.as_utc(service.data["end_time"])
if "end_time" in service.data
else None
)
statistic_ids = service.data["statistic_ids"]
types = service.data["types"]
period = service.data["period"]
units = service.data.get("units")
result = await instance.async_add_executor_job(
statistics_during_period,
hass,
start_time,
end_time,
statistic_ids,
period,
units,
types,
)
formatted_result: JsonObjectType = {}
for statistic_id, statistic_rows in result.items():
formatted_statistic_rows: JsonArrayType = []
for row in statistic_rows:
formatted_row: JsonObjectType = {
"start": dt_util.utc_from_timestamp(row["start"]).isoformat(),
"end": dt_util.utc_from_timestamp(row["end"]).isoformat(),
}
if (last_reset := row.get("last_reset")) is not None:
formatted_row["last_reset"] = dt_util.utc_from_timestamp(
last_reset
).isoformat()
if (state := row.get("state")) is not None:
formatted_row["state"] = state
if (sum_value := row.get("sum")) is not None:
formatted_row["sum"] = sum_value
if (min_value := row.get("min")) is not None:
formatted_row["min"] = min_value
if (max_value := row.get("max")) is not None:
formatted_row["max"] = max_value
if (mean := row.get("mean")) is not None:
formatted_row["mean"] = mean
if (change := row.get("change")) is not None:
formatted_row["change"] = change
formatted_statistic_rows.append(formatted_row)
formatted_result[statistic_id] = formatted_statistic_rows
return {"statistics": formatted_result}
async_register_admin_service(
hass,
DOMAIN,
SERVICE_GET_STATISTICS,
async_handle_get_statistics_service,
schema=SERVICE_GET_STATISTICS_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
@callback
def async_register_services(hass: HomeAssistant, instance: Recorder) -> None:
"""Register recorder services."""
@ -142,3 +238,4 @@ def async_register_services(hass: HomeAssistant, instance: Recorder) -> None:
_async_register_purge_entities_service(hass, instance)
_async_register_enable_service(hass, instance)
_async_register_disable_service(hass, instance)
_async_register_get_statistics_service(hass, instance)

View File

@ -48,3 +48,63 @@ purge_entities:
disable:
enable:
get_statistics:
fields:
start_time:
required: true
example: "2025-01-01 00:00:00"
selector:
datetime:
end_time:
required: false
example: "2025-01-02 00:00:00"
selector:
datetime:
statistic_ids:
required: true
example:
- sensor.energy_consumption
- sensor.temperature
selector:
entity:
multiple: true
period:
required: true
example: "hour"
selector:
select:
options:
- "5minute"
- "hour"
- "day"
- "week"
- "month"
types:
required: true
example:
- "mean"
- "sum"
selector:
select:
options:
- "change"
- "last_reset"
- "max"
- "mean"
- "min"
- "state"
- "sum"
multiple: true
units:
required: false
example:
energy: "kWh"
temperature: "°C"
selector:
object:

View File

@ -66,6 +66,36 @@
"enable": {
"name": "[%key:common::action::enable%]",
"description": "Starts the recording of events and state changes."
},
"get_statistics": {
"name": "Get statistics",
"description": "Retrieves statistics data for entities within a specific time period.",
"fields": {
"end_time": {
"name": "End time",
"description": "The end time for the statistics query. If omitted, returns all statistics from start time onward."
},
"period": {
"name": "Period",
"description": "The time period to group statistics by."
},
"start_time": {
"name": "Start time",
"description": "The start time for the statistics query."
},
"statistic_ids": {
"name": "Statistic IDs",
"description": "The entity IDs or statistic IDs to return statistics for."
},
"types": {
"name": "Types",
"description": "The types of statistics values to return."
},
"units": {
"name": "Units",
"description": "Optional unit conversion mapping."
}
}
}
}
}

View File

@ -2,12 +2,15 @@
from collections.abc import Generator
from datetime import timedelta
import re
from typing import Any
from unittest.mock import ANY, Mock, patch
import pytest
from sqlalchemy import select
import voluptuous as vol
from homeassistant import exceptions
from homeassistant.components import recorder
from homeassistant.components.recorder import Recorder, history, statistics
from homeassistant.components.recorder.db_schema import StatisticsShortTerm
@ -40,7 +43,7 @@ from homeassistant.components.recorder.table_managers.statistics_meta import (
)
from homeassistant.components.recorder.util import session_scope
from homeassistant.components.sensor import UNIT_CONVERTERS
from homeassistant.core import HomeAssistant
from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
@ -56,7 +59,7 @@ from .common import (
statistics_during_period,
)
from tests.common import MockPlatform, mock_platform
from tests.common import MockPlatform, MockUser, mock_platform
from tests.typing import RecorderInstanceContextManager, WebSocketGenerator
@ -3421,3 +3424,319 @@ async def test_recorder_platform_with_partial_statistics_support(
for meth in supported_methods:
getattr(recorder_platform, meth).assert_called_once()
@pytest.mark.parametrize(
("service_args", "expected_result"),
[
(
{
"start_time": "2023-05-08 07:00:00Z",
"period": "hour",
"statistic_ids": ["sensor.i_dont_exist"],
"types": ["change", "last_reset", "max", "mean", "min", "state", "sum"],
},
{"statistics": {}},
),
(
{
"start_time": "2023-05-08 07:00:00Z",
"period": "hour",
"statistic_ids": [
"sensor.total_energy_import1",
"sensor.total_energy_import2",
],
"types": ["change", "last_reset", "max", "mean", "min", "state", "sum"],
},
{
"statistics": {
"sensor.total_energy_import1": [
{
"last_reset": "2021-12-31T22:00:00+00:00",
"change": 2.0,
"end": "2023-05-08T08:00:00+00:00",
"start": "2023-05-08T07:00:00+00:00",
"state": 0.0,
"sum": 2.0,
"min": 0.0,
"max": 10.0,
"mean": 1.0,
},
{
"change": 1.0,
"end": "2023-05-08T09:00:00+00:00",
"start": "2023-05-08T08:00:00+00:00",
"state": 1.0,
"sum": 3.0,
"min": 1.0,
"max": 11.0,
"mean": 1.0,
},
{
"change": 2.0,
"end": "2023-05-08T10:00:00+00:00",
"start": "2023-05-08T09:00:00+00:00",
"state": 2.0,
"sum": 5.0,
"min": 2.0,
"max": 12.0,
"mean": 1.0,
},
{
"change": 3.0,
"end": "2023-05-08T11:00:00+00:00",
"start": "2023-05-08T10:00:00+00:00",
"state": 3.0,
"sum": 8.0,
"min": 3.0,
"max": 13.0,
"mean": 1.0,
},
],
"sensor.total_energy_import2": [
{
"last_reset": "2021-12-31T22:00:00+00:00",
"change": 2.0,
"end": "2023-05-08T08:00:00+00:00",
"start": "2023-05-08T07:00:00+00:00",
"state": 0.0,
"sum": 2.0,
"min": 0.0,
"max": 10.0,
"mean": 1.0,
},
{
"change": 1.0,
"end": "2023-05-08T09:00:00+00:00",
"start": "2023-05-08T08:00:00+00:00",
"state": 1.0,
"sum": 3.0,
"min": 1.0,
"max": 11.0,
"mean": 1.0,
},
{
"change": 2.0,
"end": "2023-05-08T10:00:00+00:00",
"start": "2023-05-08T09:00:00+00:00",
"state": 2.0,
"sum": 5.0,
"min": 2.0,
"max": 12.0,
"mean": 1.0,
},
{
"change": 3.0,
"end": "2023-05-08T11:00:00+00:00",
"start": "2023-05-08T10:00:00+00:00",
"state": 3.0,
"sum": 8.0,
"min": 3.0,
"max": 13.0,
"mean": 1.0,
},
],
}
},
),
(
{
"start_time": "2023-05-08 07:00:00Z",
"period": "day",
"statistic_ids": [
"sensor.total_energy_import1",
"sensor.total_energy_import2",
],
"types": ["sum"],
},
{
"statistics": {
"sensor.total_energy_import1": [
{
"start": "2023-05-08T07:00:00+00:00",
"end": "2023-05-09T07:00:00+00:00",
"sum": 8.0,
}
],
"sensor.total_energy_import2": [
{
"start": "2023-05-08T07:00:00+00:00",
"end": "2023-05-09T07:00:00+00:00",
"sum": 8.0,
}
],
}
},
),
(
{
"start_time": "2023-05-08 07:00:00Z",
"end_time": "2023-05-08 08:00:00Z",
"period": "hour",
"types": ["change", "sum"],
"statistic_ids": ["sensor.total_energy_import1"],
"units": {"energy": "Wh"},
},
{
"statistics": {
"sensor.total_energy_import1": [
{
"start": "2023-05-08T07:00:00+00:00",
"end": "2023-05-08T08:00:00+00:00",
"change": 2000.0,
"sum": 2000.0,
},
],
}
},
),
],
)
@pytest.mark.usefixtures("recorder_mock")
async def test_get_statistics_service(
hass: HomeAssistant,
hass_read_only_user: MockUser,
service_args: dict[str, Any],
expected_result: dict[str, Any],
) -> None:
"""Test the get_statistics service."""
period1 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00"))
period2 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00"))
period3 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00"))
period4 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00"))
last_reset = dt_util.parse_datetime("2022-01-01T00:00:00+02:00")
external_statistics = (
{
"start": period1,
"state": 0,
"sum": 2,
"min": 0,
"max": 10,
"mean": 1,
"last_reset": last_reset,
},
{
"start": period2,
"state": 1,
"sum": 3,
"min": 1,
"max": 11,
"mean": 1,
"last_reset": None,
},
{
"start": period3,
"state": 2,
"sum": 5,
"min": 2,
"max": 12,
"mean": 1,
"last_reset": None,
},
{
"start": period4,
"state": 3,
"sum": 8,
"min": 3,
"max": 13,
"mean": 1,
"last_reset": None,
},
)
external_metadata1 = {
"has_mean": True,
"has_sum": True,
"name": "Total imported energy",
"source": "recorder",
"statistic_id": "sensor.total_energy_import1",
"unit_of_measurement": "kWh",
}
external_metadata2 = {
"has_mean": True,
"has_sum": True,
"name": "Total imported energy",
"source": "recorder",
"statistic_id": "sensor.total_energy_import2",
"unit_of_measurement": "kWh",
}
async_import_statistics(hass, external_metadata1, external_statistics)
async_import_statistics(hass, external_metadata2, external_statistics)
await async_setup_component(hass, "sensor", {})
await async_recorder_block_till_done(hass)
result = await hass.services.async_call(
"recorder", "get_statistics", service_args, return_response=True, blocking=True
)
assert result == expected_result
with pytest.raises(exceptions.Unauthorized):
result = await hass.services.async_call(
"recorder",
"get_statistics",
service_args,
return_response=True,
blocking=True,
context=Context(user_id=hass_read_only_user.id),
)
@pytest.mark.parametrize(
("service_args", "missing_key"),
[
(
{
"period": "hour",
"statistic_ids": ["sensor.sensor"],
"types": ["change", "last_reset", "max", "mean", "min", "state", "sum"],
},
"start_time",
),
(
{
"start_time": "2023-05-08 07:00:00Z",
"period": "hour",
"types": ["change", "last_reset", "max", "mean", "min", "state", "sum"],
},
"statistic_ids",
),
(
{
"start_time": "2023-05-08 07:00:00Z",
"statistic_ids": ["sensor.sensor"],
"types": ["change", "last_reset", "max", "mean", "min", "state", "sum"],
},
"period",
),
(
{
"start_time": "2023-05-08 07:00:00Z",
"period": "hour",
"statistic_ids": ["sensor.sensor"],
},
"types",
),
],
)
@pytest.mark.usefixtures("recorder_mock")
async def test_get_statistics_service_missing_mandatory_keys(
hass: HomeAssistant,
service_args: dict[str, Any],
missing_key: str,
) -> None:
"""Test the get_statistics service with missing mandatory keys."""
await async_recorder_block_till_done(hass)
with pytest.raises(
vol.error.MultipleInvalid,
match=re.escape(f"required key not provided @ data['{missing_key}']"),
):
await hass.services.async_call(
"recorder",
"get_statistics",
service_args,
return_response=True,
blocking=True,
)