mirror of
https://github.com/home-assistant/core.git
synced 2025-07-24 13:47:35 +00:00
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:
parent
161b62d8fa
commit
c023f610dd
@ -11,6 +11,9 @@
|
||||
},
|
||||
"enable": {
|
||||
"service": "mdi:database"
|
||||
},
|
||||
"get_statistics": {
|
||||
"service": "mdi:chart-bar"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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:
|
||||
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
)
|
||||
|
Loading…
x
Reference in New Issue
Block a user