548 lines
18 KiB
Python
548 lines
18 KiB
Python
"""The Recorder websocket API."""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime as dt
|
|
import logging
|
|
from typing import Any, Literal, cast
|
|
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.components import websocket_api
|
|
from homeassistant.components.websocket_api import messages
|
|
from homeassistant.core import HomeAssistant, callback, valid_entity_id
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
from homeassistant.helpers import config_validation as cv
|
|
from homeassistant.helpers.json import JSON_DUMP
|
|
from homeassistant.util import dt as dt_util
|
|
from homeassistant.util.unit_conversion import (
|
|
DataRateConverter,
|
|
DistanceConverter,
|
|
ElectricCurrentConverter,
|
|
ElectricPotentialConverter,
|
|
EnergyConverter,
|
|
InformationConverter,
|
|
MassConverter,
|
|
PowerConverter,
|
|
PressureConverter,
|
|
SpeedConverter,
|
|
TemperatureConverter,
|
|
UnitlessRatioConverter,
|
|
VolumeConverter,
|
|
)
|
|
|
|
from .models import StatisticPeriod
|
|
from .statistics import (
|
|
STATISTIC_UNIT_TO_UNIT_CONVERTER,
|
|
async_add_external_statistics,
|
|
async_change_statistics_unit,
|
|
async_import_statistics,
|
|
async_list_statistic_ids,
|
|
list_statistic_ids,
|
|
statistic_during_period,
|
|
statistics_during_period,
|
|
validate_statistics,
|
|
)
|
|
from .util import (
|
|
PERIOD_SCHEMA,
|
|
async_migration_in_progress,
|
|
async_migration_is_live,
|
|
get_instance,
|
|
resolve_period,
|
|
)
|
|
|
|
_LOGGER: logging.Logger = logging.getLogger(__package__)
|
|
|
|
UNIT_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Optional("data_rate"): vol.In(DataRateConverter.VALID_UNITS),
|
|
vol.Optional("distance"): vol.In(DistanceConverter.VALID_UNITS),
|
|
vol.Optional("electric_current"): vol.In(ElectricCurrentConverter.VALID_UNITS),
|
|
vol.Optional("voltage"): vol.In(ElectricPotentialConverter.VALID_UNITS),
|
|
vol.Optional("energy"): vol.In(EnergyConverter.VALID_UNITS),
|
|
vol.Optional("information"): vol.In(InformationConverter.VALID_UNITS),
|
|
vol.Optional("mass"): vol.In(MassConverter.VALID_UNITS),
|
|
vol.Optional("power"): vol.In(PowerConverter.VALID_UNITS),
|
|
vol.Optional("pressure"): vol.In(PressureConverter.VALID_UNITS),
|
|
vol.Optional("speed"): vol.In(SpeedConverter.VALID_UNITS),
|
|
vol.Optional("temperature"): vol.In(TemperatureConverter.VALID_UNITS),
|
|
vol.Optional("unitless"): vol.In(UnitlessRatioConverter.VALID_UNITS),
|
|
vol.Optional("volume"): vol.In(VolumeConverter.VALID_UNITS),
|
|
}
|
|
)
|
|
|
|
|
|
@callback
|
|
def async_setup(hass: HomeAssistant) -> None:
|
|
"""Set up the recorder websocket API."""
|
|
websocket_api.async_register_command(hass, ws_adjust_sum_statistics)
|
|
websocket_api.async_register_command(hass, ws_backup_end)
|
|
websocket_api.async_register_command(hass, ws_backup_start)
|
|
websocket_api.async_register_command(hass, ws_change_statistics_unit)
|
|
websocket_api.async_register_command(hass, ws_clear_statistics)
|
|
websocket_api.async_register_command(hass, ws_get_statistic_during_period)
|
|
websocket_api.async_register_command(hass, ws_get_statistics_during_period)
|
|
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_info)
|
|
websocket_api.async_register_command(hass, ws_update_statistics_metadata)
|
|
websocket_api.async_register_command(hass, ws_validate_statistics)
|
|
|
|
|
|
def _ws_get_statistic_during_period(
|
|
hass: HomeAssistant,
|
|
msg_id: int,
|
|
start_time: dt | None,
|
|
end_time: dt | None,
|
|
statistic_id: str,
|
|
types: set[Literal["max", "mean", "min", "change"]] | None,
|
|
units: dict[str, str],
|
|
) -> str:
|
|
"""Fetch statistics and convert them to json in the executor."""
|
|
return JSON_DUMP(
|
|
messages.result_message(
|
|
msg_id,
|
|
statistic_during_period(
|
|
hass, start_time, end_time, statistic_id, types, units=units
|
|
),
|
|
)
|
|
)
|
|
|
|
|
|
@websocket_api.websocket_command(
|
|
{
|
|
vol.Required("type"): "recorder/statistic_during_period",
|
|
vol.Required("statistic_id"): str,
|
|
vol.Optional("types"): vol.All(
|
|
[vol.Any("max", "mean", "min", "change")], vol.Coerce(set)
|
|
),
|
|
vol.Optional("units"): UNIT_SCHEMA,
|
|
**PERIOD_SCHEMA.schema,
|
|
}
|
|
)
|
|
@websocket_api.async_response
|
|
async def ws_get_statistic_during_period(
|
|
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
|
) -> None:
|
|
"""Handle statistics websocket command."""
|
|
if ("start_time" in msg or "end_time" in msg) and "duration" in msg:
|
|
raise HomeAssistantError
|
|
if "offset" in msg and "duration" not in msg:
|
|
raise HomeAssistantError
|
|
|
|
start_time, end_time = resolve_period(cast(StatisticPeriod, msg))
|
|
|
|
connection.send_message(
|
|
await get_instance(hass).async_add_executor_job(
|
|
_ws_get_statistic_during_period,
|
|
hass,
|
|
msg["id"],
|
|
start_time,
|
|
end_time,
|
|
msg["statistic_id"],
|
|
msg.get("types"),
|
|
msg.get("units"),
|
|
)
|
|
)
|
|
|
|
|
|
def _ws_get_statistics_during_period(
|
|
hass: HomeAssistant,
|
|
msg_id: int,
|
|
start_time: dt,
|
|
end_time: dt | None,
|
|
statistic_ids: set[str] | None,
|
|
period: Literal["5minute", "day", "hour", "week", "month"],
|
|
units: dict[str, str],
|
|
types: set[Literal["change", "last_reset", "max", "mean", "min", "state", "sum"]],
|
|
) -> str:
|
|
"""Fetch statistics and convert them to json in the executor."""
|
|
result = statistics_during_period(
|
|
hass,
|
|
start_time,
|
|
end_time,
|
|
statistic_ids,
|
|
period,
|
|
units,
|
|
types,
|
|
)
|
|
for statistic_id in result:
|
|
for item in result[statistic_id]:
|
|
if (start := item.get("start")) is not None:
|
|
item["start"] = int(start * 1000)
|
|
if (end := item.get("end")) is not None:
|
|
item["end"] = int(end * 1000)
|
|
if (last_reset := item.get("last_reset")) is not None:
|
|
item["last_reset"] = int(last_reset * 1000)
|
|
return JSON_DUMP(messages.result_message(msg_id, result))
|
|
|
|
|
|
async def ws_handle_get_statistics_during_period(
|
|
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
|
) -> None:
|
|
"""Handle statistics websocket command."""
|
|
start_time_str = msg["start_time"]
|
|
end_time_str = msg.get("end_time")
|
|
|
|
if start_time := dt_util.parse_datetime(start_time_str):
|
|
start_time = dt_util.as_utc(start_time)
|
|
else:
|
|
connection.send_error(msg["id"], "invalid_start_time", "Invalid start_time")
|
|
return
|
|
|
|
if end_time_str:
|
|
if end_time := dt_util.parse_datetime(end_time_str):
|
|
end_time = dt_util.as_utc(end_time)
|
|
else:
|
|
connection.send_error(msg["id"], "invalid_end_time", "Invalid end_time")
|
|
return
|
|
else:
|
|
end_time = None
|
|
|
|
if (types := msg.get("types")) is None:
|
|
types = {"change", "last_reset", "max", "mean", "min", "state", "sum"}
|
|
connection.send_message(
|
|
await get_instance(hass).async_add_executor_job(
|
|
_ws_get_statistics_during_period,
|
|
hass,
|
|
msg["id"],
|
|
start_time,
|
|
end_time,
|
|
set(msg["statistic_ids"]),
|
|
msg.get("period"),
|
|
msg.get("units"),
|
|
types,
|
|
)
|
|
)
|
|
|
|
|
|
@websocket_api.websocket_command(
|
|
{
|
|
vol.Required("type"): "recorder/statistics_during_period",
|
|
vol.Required("start_time"): str,
|
|
vol.Optional("end_time"): str,
|
|
vol.Required("statistic_ids"): vol.All([str], vol.Length(min=1)),
|
|
vol.Required("period"): vol.Any("5minute", "hour", "day", "week", "month"),
|
|
vol.Optional("units"): UNIT_SCHEMA,
|
|
vol.Optional("types"): vol.All(
|
|
[vol.Any("change", "last_reset", "max", "mean", "min", "state", "sum")],
|
|
vol.Coerce(set),
|
|
),
|
|
}
|
|
)
|
|
@websocket_api.async_response
|
|
async def ws_get_statistics_during_period(
|
|
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
|
) -> None:
|
|
"""Handle statistics websocket command."""
|
|
await ws_handle_get_statistics_during_period(hass, connection, msg)
|
|
|
|
|
|
def _ws_get_list_statistic_ids(
|
|
hass: HomeAssistant,
|
|
msg_id: int,
|
|
statistic_type: Literal["mean"] | Literal["sum"] | None = None,
|
|
) -> str:
|
|
"""Fetch a list of available statistic_id and convert them to JSON.
|
|
|
|
Runs in the executor.
|
|
"""
|
|
return JSON_DUMP(
|
|
messages.result_message(msg_id, list_statistic_ids(hass, None, statistic_type))
|
|
)
|
|
|
|
|
|
async def ws_handle_list_statistic_ids(
|
|
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
|
) -> None:
|
|
"""Fetch a list of available statistic_id."""
|
|
connection.send_message(
|
|
await get_instance(hass).async_add_executor_job(
|
|
_ws_get_list_statistic_ids,
|
|
hass,
|
|
msg["id"],
|
|
msg.get("statistic_type"),
|
|
)
|
|
)
|
|
|
|
|
|
@websocket_api.websocket_command(
|
|
{
|
|
vol.Required("type"): "recorder/list_statistic_ids",
|
|
vol.Optional("statistic_type"): vol.Any("sum", "mean"),
|
|
}
|
|
)
|
|
@websocket_api.async_response
|
|
async def ws_list_statistic_ids(
|
|
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
|
) -> None:
|
|
"""Fetch a list of available statistic_id."""
|
|
await ws_handle_list_statistic_ids(hass, connection, msg)
|
|
|
|
|
|
@websocket_api.websocket_command(
|
|
{
|
|
vol.Required("type"): "recorder/validate_statistics",
|
|
}
|
|
)
|
|
@websocket_api.async_response
|
|
async def ws_validate_statistics(
|
|
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
|
) -> None:
|
|
"""Fetch a list of available statistic_id."""
|
|
instance = get_instance(hass)
|
|
statistic_ids = await instance.async_add_executor_job(
|
|
validate_statistics,
|
|
hass,
|
|
)
|
|
connection.send_result(msg["id"], statistic_ids)
|
|
|
|
|
|
@websocket_api.require_admin
|
|
@websocket_api.websocket_command(
|
|
{
|
|
vol.Required("type"): "recorder/clear_statistics",
|
|
vol.Required("statistic_ids"): [str],
|
|
}
|
|
)
|
|
@callback
|
|
def ws_clear_statistics(
|
|
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
|
) -> None:
|
|
"""Clear statistics for a list of statistic_ids.
|
|
|
|
Note: The WS call posts a job to the recorder's queue and then returns, it doesn't
|
|
wait until the job is completed.
|
|
"""
|
|
get_instance(hass).async_clear_statistics(msg["statistic_ids"])
|
|
connection.send_result(msg["id"])
|
|
|
|
|
|
@websocket_api.websocket_command(
|
|
{
|
|
vol.Required("type"): "recorder/get_statistics_metadata",
|
|
vol.Optional("statistic_ids"): [str],
|
|
}
|
|
)
|
|
@websocket_api.async_response
|
|
async def ws_get_statistics_metadata(
|
|
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
|
) -> None:
|
|
"""Get metadata for a list of statistic_ids."""
|
|
statistic_ids = msg.get("statistic_ids")
|
|
statistic_ids_set_or_none = set(statistic_ids) if statistic_ids else None
|
|
metadata = await async_list_statistic_ids(hass, statistic_ids_set_or_none)
|
|
connection.send_result(msg["id"], metadata)
|
|
|
|
|
|
@websocket_api.require_admin
|
|
@websocket_api.websocket_command(
|
|
{
|
|
vol.Required("type"): "recorder/update_statistics_metadata",
|
|
vol.Required("statistic_id"): str,
|
|
vol.Required("unit_of_measurement"): vol.Any(str, None),
|
|
}
|
|
)
|
|
@callback
|
|
def ws_update_statistics_metadata(
|
|
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
|
) -> None:
|
|
"""Update statistics metadata for a statistic_id.
|
|
|
|
Only the normalized unit of measurement can be updated.
|
|
"""
|
|
get_instance(hass).async_update_statistics_metadata(
|
|
msg["statistic_id"], new_unit_of_measurement=msg["unit_of_measurement"]
|
|
)
|
|
connection.send_result(msg["id"])
|
|
|
|
|
|
@websocket_api.require_admin
|
|
@websocket_api.websocket_command(
|
|
{
|
|
vol.Required("type"): "recorder/change_statistics_unit",
|
|
vol.Required("statistic_id"): str,
|
|
vol.Required("new_unit_of_measurement"): vol.Any(str, None),
|
|
vol.Required("old_unit_of_measurement"): vol.Any(str, None),
|
|
}
|
|
)
|
|
@callback
|
|
def ws_change_statistics_unit(
|
|
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
|
) -> None:
|
|
"""Change the unit_of_measurement for a statistic_id.
|
|
|
|
All existing statistics will be converted to the new unit.
|
|
"""
|
|
async_change_statistics_unit(
|
|
hass,
|
|
msg["statistic_id"],
|
|
new_unit_of_measurement=msg["new_unit_of_measurement"],
|
|
old_unit_of_measurement=msg["old_unit_of_measurement"],
|
|
)
|
|
connection.send_result(msg["id"])
|
|
|
|
|
|
@websocket_api.require_admin
|
|
@websocket_api.websocket_command(
|
|
{
|
|
vol.Required("type"): "recorder/adjust_sum_statistics",
|
|
vol.Required("statistic_id"): str,
|
|
vol.Required("start_time"): str,
|
|
vol.Required("adjustment"): vol.Any(float, int),
|
|
vol.Required("adjustment_unit_of_measurement"): vol.Any(str, None),
|
|
}
|
|
)
|
|
@websocket_api.async_response
|
|
async def ws_adjust_sum_statistics(
|
|
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
|
) -> None:
|
|
"""Adjust sum statistics.
|
|
|
|
If the statistics is stored as NORMALIZED_UNIT,
|
|
it's allowed to make an adjustment in VALID_UNIT
|
|
"""
|
|
start_time_str = msg["start_time"]
|
|
|
|
if start_time := dt_util.parse_datetime(start_time_str):
|
|
start_time = dt_util.as_utc(start_time)
|
|
else:
|
|
connection.send_error(msg["id"], "invalid_start_time", "Invalid start time")
|
|
return
|
|
|
|
instance = get_instance(hass)
|
|
metadatas = await instance.async_add_executor_job(
|
|
list_statistic_ids, hass, {msg["statistic_id"]}
|
|
)
|
|
if not metadatas:
|
|
connection.send_error(msg["id"], "unknown_statistic_id", "Unknown statistic ID")
|
|
return
|
|
metadata = metadatas[0]
|
|
|
|
def valid_units(statistics_unit: str | None, adjustment_unit: str | None) -> bool:
|
|
if statistics_unit == adjustment_unit:
|
|
return True
|
|
converter = STATISTIC_UNIT_TO_UNIT_CONVERTER.get(statistics_unit)
|
|
if converter is not None and adjustment_unit in converter.VALID_UNITS:
|
|
return True
|
|
return False
|
|
|
|
stat_unit = metadata["statistics_unit_of_measurement"]
|
|
adjustment_unit = msg["adjustment_unit_of_measurement"]
|
|
if not valid_units(stat_unit, adjustment_unit):
|
|
connection.send_error(
|
|
msg["id"],
|
|
"invalid_units",
|
|
f"Can't convert {stat_unit} to {adjustment_unit}",
|
|
)
|
|
return
|
|
|
|
get_instance(hass).async_adjust_statistics(
|
|
msg["statistic_id"], start_time, msg["adjustment"], adjustment_unit
|
|
)
|
|
connection.send_result(msg["id"])
|
|
|
|
|
|
@websocket_api.require_admin
|
|
@websocket_api.websocket_command(
|
|
{
|
|
vol.Required("type"): "recorder/import_statistics",
|
|
vol.Required("metadata"): {
|
|
vol.Required("has_mean"): bool,
|
|
vol.Required("has_sum"): bool,
|
|
vol.Required("name"): vol.Any(str, None),
|
|
vol.Required("source"): str,
|
|
vol.Required("statistic_id"): str,
|
|
vol.Required("unit_of_measurement"): vol.Any(str, None),
|
|
},
|
|
vol.Required("stats"): [
|
|
{
|
|
vol.Required("start"): cv.datetime,
|
|
vol.Optional("mean"): vol.Any(float, int),
|
|
vol.Optional("min"): vol.Any(float, int),
|
|
vol.Optional("max"): vol.Any(float, int),
|
|
vol.Optional("last_reset"): vol.Any(cv.datetime, None),
|
|
vol.Optional("state"): vol.Any(float, int),
|
|
vol.Optional("sum"): vol.Any(float, int),
|
|
}
|
|
],
|
|
}
|
|
)
|
|
@callback
|
|
def ws_import_statistics(
|
|
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
|
) -> None:
|
|
"""Import statistics."""
|
|
metadata = msg["metadata"]
|
|
stats = msg["stats"]
|
|
|
|
if valid_entity_id(metadata["statistic_id"]):
|
|
async_import_statistics(hass, metadata, stats)
|
|
else:
|
|
async_add_external_statistics(hass, metadata, stats)
|
|
connection.send_result(msg["id"])
|
|
|
|
|
|
@websocket_api.websocket_command(
|
|
{
|
|
vol.Required("type"): "recorder/info",
|
|
}
|
|
)
|
|
@callback
|
|
def ws_info(
|
|
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
|
) -> None:
|
|
"""Return status of the recorder."""
|
|
instance = get_instance(hass)
|
|
|
|
backlog = instance.backlog if instance else None
|
|
migration_in_progress = async_migration_in_progress(hass)
|
|
migration_is_live = async_migration_is_live(hass)
|
|
recording = instance.recording if instance else False
|
|
thread_alive = instance.is_alive() if instance else False
|
|
|
|
recorder_info = {
|
|
"backlog": backlog,
|
|
"max_backlog": instance.max_backlog,
|
|
"migration_in_progress": migration_in_progress,
|
|
"migration_is_live": migration_is_live,
|
|
"recording": recording,
|
|
"thread_running": thread_alive,
|
|
}
|
|
connection.send_result(msg["id"], recorder_info)
|
|
|
|
|
|
@websocket_api.ws_require_user(only_supervisor=True)
|
|
@websocket_api.websocket_command({vol.Required("type"): "backup/start"})
|
|
@websocket_api.async_response
|
|
async def ws_backup_start(
|
|
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
|
) -> None:
|
|
"""Backup start notification."""
|
|
|
|
_LOGGER.info("Backup start notification, locking database for writes")
|
|
instance = get_instance(hass)
|
|
try:
|
|
await instance.lock_database()
|
|
except TimeoutError as err:
|
|
connection.send_error(msg["id"], "timeout_error", str(err))
|
|
return
|
|
connection.send_result(msg["id"])
|
|
|
|
|
|
@websocket_api.ws_require_user(only_supervisor=True)
|
|
@websocket_api.websocket_command({vol.Required("type"): "backup/end"})
|
|
@websocket_api.async_response
|
|
async def ws_backup_end(
|
|
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
|
) -> None:
|
|
"""Backup end notification."""
|
|
|
|
instance = get_instance(hass)
|
|
_LOGGER.info("Backup end notification, releasing write lock")
|
|
if not instance.unlock_database():
|
|
connection.send_error(
|
|
msg["id"], "database_unlock_failed", "Failed to unlock database."
|
|
)
|
|
connection.send_result(msg["id"])
|