mirror of
https://github.com/home-assistant/core.git
synced 2025-07-27 15:17:35 +00:00
Add live streaming logbook websocket endpoint (#72258)
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
parent
3390d62b3d
commit
9c3f949165
@ -1,63 +1,25 @@
|
|||||||
"""Event parser and human readable log generator."""
|
"""Event parser and human readable log generator."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable, Generator
|
from collections.abc import Callable
|
||||||
from contextlib import suppress
|
from typing import Any
|
||||||
from datetime import datetime as dt, timedelta
|
|
||||||
from http import HTTPStatus
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
from typing import Any, cast
|
|
||||||
|
|
||||||
from aiohttp import web
|
|
||||||
from sqlalchemy.engine.row import Row
|
|
||||||
from sqlalchemy.orm.query import Query
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components import frontend, websocket_api
|
from homeassistant.components import frontend
|
||||||
from homeassistant.components.automation import EVENT_AUTOMATION_TRIGGERED
|
|
||||||
from homeassistant.components.http import HomeAssistantView
|
|
||||||
from homeassistant.components.recorder import get_instance
|
|
||||||
from homeassistant.components.recorder.filters import (
|
from homeassistant.components.recorder.filters import (
|
||||||
Filters,
|
|
||||||
sqlalchemy_filter_from_include_exclude_conf,
|
sqlalchemy_filter_from_include_exclude_conf,
|
||||||
)
|
)
|
||||||
from homeassistant.components.recorder.models import (
|
|
||||||
process_datetime_to_timestamp,
|
|
||||||
process_timestamp_to_utc_isoformat,
|
|
||||||
)
|
|
||||||
from homeassistant.components.recorder.util import session_scope
|
|
||||||
from homeassistant.components.script import EVENT_SCRIPT_STARTED
|
|
||||||
from homeassistant.components.sensor import ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN
|
|
||||||
from homeassistant.components.websocket_api import messages
|
|
||||||
from homeassistant.components.websocket_api.const import JSON_DUMP
|
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
ATTR_DOMAIN,
|
ATTR_DOMAIN,
|
||||||
ATTR_ENTITY_ID,
|
ATTR_ENTITY_ID,
|
||||||
ATTR_FRIENDLY_NAME,
|
|
||||||
ATTR_NAME,
|
ATTR_NAME,
|
||||||
ATTR_SERVICE,
|
|
||||||
EVENT_CALL_SERVICE,
|
|
||||||
EVENT_LOGBOOK_ENTRY,
|
EVENT_LOGBOOK_ENTRY,
|
||||||
)
|
)
|
||||||
from homeassistant.core import (
|
from homeassistant.core import Context, Event, HomeAssistant, ServiceCall, callback
|
||||||
Context,
|
from homeassistant.helpers import config_validation as cv
|
||||||
Event,
|
|
||||||
HomeAssistant,
|
|
||||||
ServiceCall,
|
|
||||||
callback,
|
|
||||||
split_entity_id,
|
|
||||||
)
|
|
||||||
from homeassistant.exceptions import InvalidEntityFormatError
|
|
||||||
from homeassistant.helpers import (
|
|
||||||
config_validation as cv,
|
|
||||||
device_registry as dr,
|
|
||||||
entity_registry as er,
|
|
||||||
)
|
|
||||||
from homeassistant.helpers.entityfilter import (
|
from homeassistant.helpers.entityfilter import (
|
||||||
INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA,
|
INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA,
|
||||||
EntityFilter,
|
|
||||||
convert_include_exclude_filter,
|
convert_include_exclude_filter,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers.integration_platform import (
|
from homeassistant.helpers.integration_platform import (
|
||||||
@ -65,47 +27,25 @@ from homeassistant.helpers.integration_platform import (
|
|||||||
)
|
)
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
from homeassistant.loader import bind_hass
|
from homeassistant.loader import bind_hass
|
||||||
import homeassistant.util.dt as dt_util
|
|
||||||
|
|
||||||
from .queries import statement_for_request
|
from . import rest_api, websocket_api
|
||||||
from .queries.common import PSUEDO_EVENT_STATE_CHANGED
|
from .const import (
|
||||||
|
ATTR_MESSAGE,
|
||||||
_LOGGER = logging.getLogger(__name__)
|
DOMAIN,
|
||||||
|
LOGBOOK_ENTITIES_FILTER,
|
||||||
ENTITY_ID_JSON_EXTRACT = re.compile('"entity_id": ?"([^"]+)"')
|
LOGBOOK_ENTRY_DOMAIN,
|
||||||
DOMAIN_JSON_EXTRACT = re.compile('"domain": ?"([^"]+)"')
|
LOGBOOK_ENTRY_ENTITY_ID,
|
||||||
ATTR_MESSAGE = "message"
|
LOGBOOK_ENTRY_MESSAGE,
|
||||||
|
LOGBOOK_ENTRY_NAME,
|
||||||
DOMAIN = "logbook"
|
LOGBOOK_FILTERS,
|
||||||
|
)
|
||||||
|
from .const import LOGBOOK_ENTRY_ICON # noqa: F401
|
||||||
|
from .models import LazyEventPartialState # noqa: F401
|
||||||
|
|
||||||
CONFIG_SCHEMA = vol.Schema(
|
CONFIG_SCHEMA = vol.Schema(
|
||||||
{DOMAIN: INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA}, extra=vol.ALLOW_EXTRA
|
{DOMAIN: INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA}, extra=vol.ALLOW_EXTRA
|
||||||
)
|
)
|
||||||
|
|
||||||
CONTEXT_USER_ID = "context_user_id"
|
|
||||||
CONTEXT_ENTITY_ID = "context_entity_id"
|
|
||||||
CONTEXT_ENTITY_ID_NAME = "context_entity_id_name"
|
|
||||||
CONTEXT_EVENT_TYPE = "context_event_type"
|
|
||||||
CONTEXT_DOMAIN = "context_domain"
|
|
||||||
CONTEXT_STATE = "context_state"
|
|
||||||
CONTEXT_SERVICE = "context_service"
|
|
||||||
CONTEXT_NAME = "context_name"
|
|
||||||
CONTEXT_MESSAGE = "context_message"
|
|
||||||
|
|
||||||
LOGBOOK_ENTRY_DOMAIN = "domain"
|
|
||||||
LOGBOOK_ENTRY_ENTITY_ID = "entity_id"
|
|
||||||
LOGBOOK_ENTRY_ICON = "icon"
|
|
||||||
LOGBOOK_ENTRY_MESSAGE = "message"
|
|
||||||
LOGBOOK_ENTRY_NAME = "name"
|
|
||||||
LOGBOOK_ENTRY_STATE = "state"
|
|
||||||
LOGBOOK_ENTRY_WHEN = "when"
|
|
||||||
|
|
||||||
ALL_EVENT_TYPES_EXCEPT_STATE_CHANGED = {EVENT_LOGBOOK_ENTRY, EVENT_CALL_SERVICE}
|
|
||||||
ENTITY_EVENTS_WITHOUT_CONFIG_ENTRY = {
|
|
||||||
EVENT_LOGBOOK_ENTRY,
|
|
||||||
EVENT_AUTOMATION_TRIGGERED,
|
|
||||||
EVENT_SCRIPT_STARTED,
|
|
||||||
}
|
|
||||||
|
|
||||||
LOG_MESSAGE_SCHEMA = vol.Schema(
|
LOG_MESSAGE_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
@ -117,10 +57,6 @@ LOG_MESSAGE_SCHEMA = vol.Schema(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
LOGBOOK_FILTERS = "logbook_filters"
|
|
||||||
LOGBOOK_ENTITIES_FILTER = "entities_filter"
|
|
||||||
|
|
||||||
|
|
||||||
@bind_hass
|
@bind_hass
|
||||||
def log_entry(
|
def log_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@ -186,13 +122,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
else:
|
else:
|
||||||
filters = None
|
filters = None
|
||||||
entities_filter = None
|
entities_filter = None
|
||||||
|
|
||||||
hass.data[LOGBOOK_FILTERS] = filters
|
hass.data[LOGBOOK_FILTERS] = filters
|
||||||
hass.data[LOGBOOK_ENTITIES_FILTER] = entities_filter
|
hass.data[LOGBOOK_ENTITIES_FILTER] = entities_filter
|
||||||
|
websocket_api.async_setup(hass)
|
||||||
hass.http.register_view(LogbookView(conf, filters, entities_filter))
|
rest_api.async_setup(hass, config, filters, entities_filter)
|
||||||
websocket_api.async_register_command(hass, ws_get_events)
|
|
||||||
|
|
||||||
hass.services.async_register(DOMAIN, "log", log_message, schema=LOG_MESSAGE_SCHEMA)
|
hass.services.async_register(DOMAIN, "log", log_message, schema=LOG_MESSAGE_SCHEMA)
|
||||||
|
|
||||||
await async_process_integration_platforms(hass, DOMAIN, _process_logbook_platform)
|
await async_process_integration_platforms(hass, DOMAIN, _process_logbook_platform)
|
||||||
@ -215,628 +148,3 @@ async def _process_logbook_platform(
|
|||||||
hass.data[DOMAIN][event_name] = (domain, describe_callback)
|
hass.data[DOMAIN][event_name] = (domain, describe_callback)
|
||||||
|
|
||||||
platform.async_describe_events(hass, _async_describe_event)
|
platform.async_describe_events(hass, _async_describe_event)
|
||||||
|
|
||||||
|
|
||||||
def _async_determine_event_types(
|
|
||||||
hass: HomeAssistant, entity_ids: list[str] | None, device_ids: list[str] | None
|
|
||||||
) -> tuple[str, ...]:
|
|
||||||
"""Reduce the event types based on the entity ids and device ids."""
|
|
||||||
external_events: dict[
|
|
||||||
str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]]
|
|
||||||
] = hass.data.get(DOMAIN, {})
|
|
||||||
if not entity_ids and not device_ids:
|
|
||||||
return (*ALL_EVENT_TYPES_EXCEPT_STATE_CHANGED, *external_events)
|
|
||||||
config_entry_ids: set[str] = set()
|
|
||||||
intrested_event_types: set[str] = set()
|
|
||||||
|
|
||||||
if entity_ids:
|
|
||||||
#
|
|
||||||
# Home Assistant doesn't allow firing events from
|
|
||||||
# entities so we have a limited list to check
|
|
||||||
#
|
|
||||||
# automations and scripts can refer to entities
|
|
||||||
# but they do not have a config entry so we need
|
|
||||||
# to add them.
|
|
||||||
#
|
|
||||||
# We also allow entity_ids to be recorded via
|
|
||||||
# manual logbook entries.
|
|
||||||
#
|
|
||||||
intrested_event_types |= ENTITY_EVENTS_WITHOUT_CONFIG_ENTRY
|
|
||||||
|
|
||||||
if device_ids:
|
|
||||||
dev_reg = dr.async_get(hass)
|
|
||||||
for device_id in device_ids:
|
|
||||||
if (device := dev_reg.async_get(device_id)) and device.config_entries:
|
|
||||||
config_entry_ids |= device.config_entries
|
|
||||||
interested_domains: set[str] = set()
|
|
||||||
for entry_id in config_entry_ids:
|
|
||||||
if entry := hass.config_entries.async_get_entry(entry_id):
|
|
||||||
interested_domains.add(entry.domain)
|
|
||||||
for external_event, domain_call in external_events.items():
|
|
||||||
if domain_call[0] in interested_domains:
|
|
||||||
intrested_event_types.add(external_event)
|
|
||||||
|
|
||||||
return tuple(
|
|
||||||
event_type
|
|
||||||
for event_type in (EVENT_LOGBOOK_ENTRY, *external_events)
|
|
||||||
if event_type in intrested_event_types
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _ws_formatted_get_events(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
msg_id: int,
|
|
||||||
start_day: dt,
|
|
||||||
end_day: dt,
|
|
||||||
event_types: tuple[str, ...],
|
|
||||||
ent_reg: er.EntityRegistry,
|
|
||||||
entity_ids: list[str] | None = None,
|
|
||||||
device_ids: list[str] | None = None,
|
|
||||||
filters: Filters | None = None,
|
|
||||||
entities_filter: EntityFilter | Callable[[str], bool] | None = None,
|
|
||||||
context_id: str | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Fetch events and convert them to json in the executor."""
|
|
||||||
return JSON_DUMP(
|
|
||||||
messages.result_message(
|
|
||||||
msg_id,
|
|
||||||
_get_events(
|
|
||||||
hass,
|
|
||||||
start_day,
|
|
||||||
end_day,
|
|
||||||
event_types,
|
|
||||||
ent_reg,
|
|
||||||
entity_ids,
|
|
||||||
device_ids,
|
|
||||||
filters,
|
|
||||||
entities_filter,
|
|
||||||
context_id,
|
|
||||||
True,
|
|
||||||
False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
|
||||||
{
|
|
||||||
vol.Required("type"): "logbook/get_events",
|
|
||||||
vol.Required("start_time"): str,
|
|
||||||
vol.Optional("end_time"): str,
|
|
||||||
vol.Optional("entity_ids"): [str],
|
|
||||||
vol.Optional("device_ids"): [str],
|
|
||||||
vol.Optional("context_id"): str,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@websocket_api.async_response
|
|
||||||
async def ws_get_events(
|
|
||||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
|
||||||
) -> None:
|
|
||||||
"""Handle logbook get events websocket command."""
|
|
||||||
start_time_str = msg["start_time"]
|
|
||||||
end_time_str = msg.get("end_time")
|
|
||||||
utc_now = dt_util.utcnow()
|
|
||||||
|
|
||||||
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 not end_time_str:
|
|
||||||
end_time = utc_now
|
|
||||||
elif parsed_end_time := dt_util.parse_datetime(end_time_str):
|
|
||||||
end_time = dt_util.as_utc(parsed_end_time)
|
|
||||||
else:
|
|
||||||
connection.send_error(msg["id"], "invalid_end_time", "Invalid end_time")
|
|
||||||
return
|
|
||||||
|
|
||||||
if start_time > utc_now:
|
|
||||||
connection.send_result(msg["id"], [])
|
|
||||||
return
|
|
||||||
|
|
||||||
device_ids = msg.get("device_ids")
|
|
||||||
entity_ids = msg.get("entity_ids")
|
|
||||||
context_id = msg.get("context_id")
|
|
||||||
event_types = _async_determine_event_types(hass, entity_ids, device_ids)
|
|
||||||
ent_reg = er.async_get(hass)
|
|
||||||
|
|
||||||
connection.send_message(
|
|
||||||
await get_instance(hass).async_add_executor_job(
|
|
||||||
_ws_formatted_get_events,
|
|
||||||
hass,
|
|
||||||
msg["id"],
|
|
||||||
start_time,
|
|
||||||
end_time,
|
|
||||||
event_types,
|
|
||||||
ent_reg,
|
|
||||||
entity_ids,
|
|
||||||
device_ids,
|
|
||||||
hass.data[LOGBOOK_FILTERS],
|
|
||||||
hass.data[LOGBOOK_ENTITIES_FILTER],
|
|
||||||
context_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LogbookView(HomeAssistantView):
|
|
||||||
"""Handle logbook view requests."""
|
|
||||||
|
|
||||||
url = "/api/logbook"
|
|
||||||
name = "api:logbook"
|
|
||||||
extra_urls = ["/api/logbook/{datetime}"]
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
config: dict[str, Any],
|
|
||||||
filters: Filters | None,
|
|
||||||
entities_filter: EntityFilter | None,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the logbook view."""
|
|
||||||
self.config = config
|
|
||||||
self.filters = filters
|
|
||||||
self.entities_filter = entities_filter
|
|
||||||
|
|
||||||
async def get(
|
|
||||||
self, request: web.Request, datetime: str | None = None
|
|
||||||
) -> web.Response:
|
|
||||||
"""Retrieve logbook entries."""
|
|
||||||
if datetime:
|
|
||||||
if (datetime_dt := dt_util.parse_datetime(datetime)) is None:
|
|
||||||
return self.json_message("Invalid datetime", HTTPStatus.BAD_REQUEST)
|
|
||||||
else:
|
|
||||||
datetime_dt = dt_util.start_of_local_day()
|
|
||||||
|
|
||||||
if (period_str := request.query.get("period")) is None:
|
|
||||||
period: int = 1
|
|
||||||
else:
|
|
||||||
period = int(period_str)
|
|
||||||
|
|
||||||
if entity_ids_str := request.query.get("entity"):
|
|
||||||
try:
|
|
||||||
entity_ids = cv.entity_ids(entity_ids_str)
|
|
||||||
except vol.Invalid:
|
|
||||||
raise InvalidEntityFormatError(
|
|
||||||
f"Invalid entity id(s) encountered: {entity_ids_str}. "
|
|
||||||
"Format should be <domain>.<object_id>"
|
|
||||||
) from vol.Invalid
|
|
||||||
else:
|
|
||||||
entity_ids = None
|
|
||||||
|
|
||||||
if (end_time_str := request.query.get("end_time")) is None:
|
|
||||||
start_day = dt_util.as_utc(datetime_dt) - timedelta(days=period - 1)
|
|
||||||
end_day = start_day + timedelta(days=period)
|
|
||||||
else:
|
|
||||||
start_day = datetime_dt
|
|
||||||
if (end_day_dt := dt_util.parse_datetime(end_time_str)) is None:
|
|
||||||
return self.json_message("Invalid end_time", HTTPStatus.BAD_REQUEST)
|
|
||||||
end_day = end_day_dt
|
|
||||||
|
|
||||||
hass = request.app["hass"]
|
|
||||||
|
|
||||||
context_id = request.query.get("context_id")
|
|
||||||
|
|
||||||
if entity_ids and context_id:
|
|
||||||
return self.json_message(
|
|
||||||
"Can't combine entity with context_id", HTTPStatus.BAD_REQUEST
|
|
||||||
)
|
|
||||||
|
|
||||||
event_types = _async_determine_event_types(hass, entity_ids, None)
|
|
||||||
ent_reg = er.async_get(hass)
|
|
||||||
|
|
||||||
def json_events() -> web.Response:
|
|
||||||
"""Fetch events and generate JSON."""
|
|
||||||
return self.json(
|
|
||||||
_get_events(
|
|
||||||
hass,
|
|
||||||
start_day,
|
|
||||||
end_day,
|
|
||||||
event_types,
|
|
||||||
ent_reg,
|
|
||||||
entity_ids,
|
|
||||||
None,
|
|
||||||
self.filters,
|
|
||||||
self.entities_filter,
|
|
||||||
context_id,
|
|
||||||
False,
|
|
||||||
True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return cast(
|
|
||||||
web.Response, await get_instance(hass).async_add_executor_job(json_events)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _humanify(
|
|
||||||
rows: Generator[Row, None, None],
|
|
||||||
entities_filter: EntityFilter | Callable[[str], bool] | None,
|
|
||||||
ent_reg: er.EntityRegistry,
|
|
||||||
external_events: dict[
|
|
||||||
str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]]
|
|
||||||
],
|
|
||||||
entity_name_cache: EntityNameCache,
|
|
||||||
format_time: Callable[[Row], Any],
|
|
||||||
include_entity_name: bool = True,
|
|
||||||
) -> Generator[dict[str, Any], None, None]:
|
|
||||||
"""Generate a converted list of events into entries."""
|
|
||||||
# Continuous sensors, will be excluded from the logbook
|
|
||||||
continuous_sensors: dict[str, bool] = {}
|
|
||||||
event_data_cache: dict[str, dict[str, Any]] = {}
|
|
||||||
context_lookup: dict[str | None, Row | None] = {None: None}
|
|
||||||
event_cache = EventCache(event_data_cache)
|
|
||||||
context_augmenter = ContextAugmenter(
|
|
||||||
context_lookup,
|
|
||||||
entity_name_cache,
|
|
||||||
external_events,
|
|
||||||
event_cache,
|
|
||||||
include_entity_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _keep_row(row: Row, event_type: str) -> bool:
|
|
||||||
"""Check if the entity_filter rejects a row."""
|
|
||||||
assert entities_filter is not None
|
|
||||||
if entity_id := _row_event_data_extract(row, ENTITY_ID_JSON_EXTRACT):
|
|
||||||
return entities_filter(entity_id)
|
|
||||||
|
|
||||||
if event_type in external_events:
|
|
||||||
# If the entity_id isn't described, use the domain that describes
|
|
||||||
# the event for filtering.
|
|
||||||
domain: str | None = external_events[event_type][0]
|
|
||||||
else:
|
|
||||||
domain = _row_event_data_extract(row, DOMAIN_JSON_EXTRACT)
|
|
||||||
|
|
||||||
return domain is not None and entities_filter(f"{domain}._")
|
|
||||||
|
|
||||||
# Process rows
|
|
||||||
for row in rows:
|
|
||||||
context_id = row.context_id
|
|
||||||
context_lookup.setdefault(context_id, row)
|
|
||||||
if row.context_only:
|
|
||||||
continue
|
|
||||||
event_type = row.event_type
|
|
||||||
if event_type == EVENT_CALL_SERVICE or (
|
|
||||||
event_type is not PSUEDO_EVENT_STATE_CHANGED
|
|
||||||
and entities_filter is not None
|
|
||||||
and not _keep_row(row, event_type)
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if event_type is PSUEDO_EVENT_STATE_CHANGED:
|
|
||||||
entity_id = row.entity_id
|
|
||||||
assert entity_id is not None
|
|
||||||
# Skip continuous sensors
|
|
||||||
if (
|
|
||||||
is_continuous := continuous_sensors.get(entity_id)
|
|
||||||
) is None and split_entity_id(entity_id)[0] == SENSOR_DOMAIN:
|
|
||||||
is_continuous = _is_sensor_continuous(ent_reg, entity_id)
|
|
||||||
continuous_sensors[entity_id] = is_continuous
|
|
||||||
if is_continuous:
|
|
||||||
continue
|
|
||||||
|
|
||||||
data = {
|
|
||||||
LOGBOOK_ENTRY_WHEN: format_time(row),
|
|
||||||
LOGBOOK_ENTRY_STATE: row.state,
|
|
||||||
LOGBOOK_ENTRY_ENTITY_ID: entity_id,
|
|
||||||
}
|
|
||||||
if include_entity_name:
|
|
||||||
data[LOGBOOK_ENTRY_NAME] = entity_name_cache.get(entity_id, row)
|
|
||||||
if icon := row.icon or row.old_format_icon:
|
|
||||||
data[LOGBOOK_ENTRY_ICON] = icon
|
|
||||||
|
|
||||||
context_augmenter.augment(data, row, context_id)
|
|
||||||
yield data
|
|
||||||
|
|
||||||
elif event_type in external_events:
|
|
||||||
domain, describe_event = external_events[event_type]
|
|
||||||
data = describe_event(event_cache.get(row))
|
|
||||||
data[LOGBOOK_ENTRY_WHEN] = format_time(row)
|
|
||||||
data[LOGBOOK_ENTRY_DOMAIN] = domain
|
|
||||||
context_augmenter.augment(data, row, context_id)
|
|
||||||
yield data
|
|
||||||
|
|
||||||
elif event_type == EVENT_LOGBOOK_ENTRY:
|
|
||||||
event = event_cache.get(row)
|
|
||||||
if not (event_data := event.data):
|
|
||||||
continue
|
|
||||||
entry_domain = event_data.get(ATTR_DOMAIN)
|
|
||||||
entry_entity_id = event_data.get(ATTR_ENTITY_ID)
|
|
||||||
if entry_domain is None and entry_entity_id is not None:
|
|
||||||
with suppress(IndexError):
|
|
||||||
entry_domain = split_entity_id(str(entry_entity_id))[0]
|
|
||||||
|
|
||||||
data = {
|
|
||||||
LOGBOOK_ENTRY_WHEN: format_time(row),
|
|
||||||
LOGBOOK_ENTRY_NAME: event_data.get(ATTR_NAME),
|
|
||||||
LOGBOOK_ENTRY_MESSAGE: event_data.get(ATTR_MESSAGE),
|
|
||||||
LOGBOOK_ENTRY_DOMAIN: entry_domain,
|
|
||||||
LOGBOOK_ENTRY_ENTITY_ID: entry_entity_id,
|
|
||||||
}
|
|
||||||
context_augmenter.augment(data, row, context_id)
|
|
||||||
yield data
|
|
||||||
|
|
||||||
|
|
||||||
def _get_events(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
start_day: dt,
|
|
||||||
end_day: dt,
|
|
||||||
event_types: tuple[str, ...],
|
|
||||||
ent_reg: er.EntityRegistry,
|
|
||||||
entity_ids: list[str] | None = None,
|
|
||||||
device_ids: list[str] | None = None,
|
|
||||||
filters: Filters | None = None,
|
|
||||||
entities_filter: EntityFilter | Callable[[str], bool] | None = None,
|
|
||||||
context_id: str | None = None,
|
|
||||||
timestamp: bool = False,
|
|
||||||
include_entity_name: bool = True,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Get events for a period of time."""
|
|
||||||
assert not (
|
|
||||||
context_id and (entity_ids or device_ids)
|
|
||||||
), "can't pass in both context_id and (entity_ids or device_ids)"
|
|
||||||
external_events: dict[
|
|
||||||
str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]]
|
|
||||||
] = hass.data.get(DOMAIN, {})
|
|
||||||
format_time = _row_time_fired_timestamp if timestamp else _row_time_fired_isoformat
|
|
||||||
entity_name_cache = EntityNameCache(hass)
|
|
||||||
if entity_ids or device_ids:
|
|
||||||
entities_filter = None
|
|
||||||
|
|
||||||
def yield_rows(query: Query) -> Generator[Row, None, None]:
|
|
||||||
"""Yield rows from the database."""
|
|
||||||
# end_day - start_day intentionally checks .days and not .total_seconds()
|
|
||||||
# since we don't want to switch over to buffered if they go
|
|
||||||
# over one day by a few hours since the UI makes it so easy to do that.
|
|
||||||
if entity_ids or context_id or (end_day - start_day).days <= 1:
|
|
||||||
return query.all() # type: ignore[no-any-return]
|
|
||||||
# Only buffer rows to reduce memory pressure
|
|
||||||
# if we expect the result set is going to be very large.
|
|
||||||
# What is considered very large is going to differ
|
|
||||||
# based on the hardware Home Assistant is running on.
|
|
||||||
#
|
|
||||||
# sqlalchemy suggests that is at least 10k, but for
|
|
||||||
# even and RPi3 that number seems higher in testing
|
|
||||||
# so we don't switch over until we request > 1 day+ of data.
|
|
||||||
#
|
|
||||||
return query.yield_per(1024) # type: ignore[no-any-return]
|
|
||||||
|
|
||||||
stmt = statement_for_request(
|
|
||||||
start_day,
|
|
||||||
end_day,
|
|
||||||
event_types,
|
|
||||||
entity_ids,
|
|
||||||
device_ids,
|
|
||||||
filters,
|
|
||||||
context_id,
|
|
||||||
)
|
|
||||||
if _LOGGER.isEnabledFor(logging.DEBUG):
|
|
||||||
_LOGGER.debug(
|
|
||||||
"Literal statement: %s",
|
|
||||||
stmt.compile(compile_kwargs={"literal_binds": True}),
|
|
||||||
)
|
|
||||||
|
|
||||||
with session_scope(hass=hass) as session:
|
|
||||||
return list(
|
|
||||||
_humanify(
|
|
||||||
yield_rows(session.execute(stmt)),
|
|
||||||
entities_filter,
|
|
||||||
ent_reg,
|
|
||||||
external_events,
|
|
||||||
entity_name_cache,
|
|
||||||
format_time,
|
|
||||||
include_entity_name,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ContextAugmenter:
|
|
||||||
"""Augment data with context trace."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
context_lookup: dict[str | None, Row | None],
|
|
||||||
entity_name_cache: EntityNameCache,
|
|
||||||
external_events: dict[
|
|
||||||
str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]]
|
|
||||||
],
|
|
||||||
event_cache: EventCache,
|
|
||||||
include_entity_name: bool,
|
|
||||||
) -> None:
|
|
||||||
"""Init the augmenter."""
|
|
||||||
self.context_lookup = context_lookup
|
|
||||||
self.entity_name_cache = entity_name_cache
|
|
||||||
self.external_events = external_events
|
|
||||||
self.event_cache = event_cache
|
|
||||||
self.include_entity_name = include_entity_name
|
|
||||||
|
|
||||||
def augment(self, data: dict[str, Any], row: Row, context_id: str) -> None:
|
|
||||||
"""Augment data from the row and cache."""
|
|
||||||
if context_user_id := row.context_user_id:
|
|
||||||
data[CONTEXT_USER_ID] = context_user_id
|
|
||||||
|
|
||||||
if not (context_row := self.context_lookup.get(context_id)):
|
|
||||||
return
|
|
||||||
|
|
||||||
if _rows_match(row, context_row):
|
|
||||||
# This is the first event with the given ID. Was it directly caused by
|
|
||||||
# a parent event?
|
|
||||||
if (
|
|
||||||
not row.context_parent_id
|
|
||||||
or (context_row := self.context_lookup.get(row.context_parent_id))
|
|
||||||
is None
|
|
||||||
):
|
|
||||||
return
|
|
||||||
# Ensure the (parent) context_event exists and is not the root cause of
|
|
||||||
# this log entry.
|
|
||||||
if _rows_match(row, context_row):
|
|
||||||
return
|
|
||||||
|
|
||||||
event_type = context_row.event_type
|
|
||||||
|
|
||||||
# State change
|
|
||||||
if context_entity_id := context_row.entity_id:
|
|
||||||
data[CONTEXT_STATE] = context_row.state
|
|
||||||
data[CONTEXT_ENTITY_ID] = context_entity_id
|
|
||||||
if self.include_entity_name:
|
|
||||||
data[CONTEXT_ENTITY_ID_NAME] = self.entity_name_cache.get(
|
|
||||||
context_entity_id, context_row
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Call service
|
|
||||||
if event_type == EVENT_CALL_SERVICE:
|
|
||||||
event = self.event_cache.get(context_row)
|
|
||||||
event_data = event.data
|
|
||||||
data[CONTEXT_DOMAIN] = event_data.get(ATTR_DOMAIN)
|
|
||||||
data[CONTEXT_SERVICE] = event_data.get(ATTR_SERVICE)
|
|
||||||
data[CONTEXT_EVENT_TYPE] = event_type
|
|
||||||
return
|
|
||||||
|
|
||||||
if event_type not in self.external_events:
|
|
||||||
return
|
|
||||||
|
|
||||||
domain, describe_event = self.external_events[event_type]
|
|
||||||
data[CONTEXT_EVENT_TYPE] = event_type
|
|
||||||
data[CONTEXT_DOMAIN] = domain
|
|
||||||
event = self.event_cache.get(context_row)
|
|
||||||
described = describe_event(event)
|
|
||||||
if name := described.get(ATTR_NAME):
|
|
||||||
data[CONTEXT_NAME] = name
|
|
||||||
if message := described.get(ATTR_MESSAGE):
|
|
||||||
data[CONTEXT_MESSAGE] = message
|
|
||||||
if not (attr_entity_id := described.get(ATTR_ENTITY_ID)):
|
|
||||||
return
|
|
||||||
data[CONTEXT_ENTITY_ID] = attr_entity_id
|
|
||||||
if self.include_entity_name:
|
|
||||||
data[CONTEXT_ENTITY_ID_NAME] = self.entity_name_cache.get(
|
|
||||||
attr_entity_id, context_row
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_sensor_continuous(ent_reg: er.EntityRegistry, entity_id: str) -> bool:
|
|
||||||
"""Determine if a sensor is continuous by checking its state class.
|
|
||||||
|
|
||||||
Sensors with a unit_of_measurement are also considered continuous, but are filtered
|
|
||||||
already by the SQL query generated by _get_events
|
|
||||||
"""
|
|
||||||
if not (entry := ent_reg.async_get(entity_id)):
|
|
||||||
# Entity not registered, so can't have a state class
|
|
||||||
return False
|
|
||||||
return (
|
|
||||||
entry.capabilities is not None
|
|
||||||
and entry.capabilities.get(ATTR_STATE_CLASS) is not None
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _rows_match(row: Row, other_row: Row) -> bool:
|
|
||||||
"""Check of rows match by using the same method as Events __hash__."""
|
|
||||||
if (
|
|
||||||
(state_id := row.state_id) is not None
|
|
||||||
and state_id == other_row.state_id
|
|
||||||
or (event_id := row.event_id) is not None
|
|
||||||
and event_id == other_row.event_id
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _row_event_data_extract(row: Row, extractor: re.Pattern) -> str | None:
|
|
||||||
"""Extract from event_data row."""
|
|
||||||
result = extractor.search(row.shared_data or row.event_data or "")
|
|
||||||
return result.group(1) if result else None
|
|
||||||
|
|
||||||
|
|
||||||
def _row_time_fired_isoformat(row: Row) -> str:
|
|
||||||
"""Convert the row timed_fired to isoformat."""
|
|
||||||
return process_timestamp_to_utc_isoformat(row.time_fired or dt_util.utcnow())
|
|
||||||
|
|
||||||
|
|
||||||
def _row_time_fired_timestamp(row: Row) -> float:
|
|
||||||
"""Convert the row timed_fired to timestamp."""
|
|
||||||
return process_datetime_to_timestamp(row.time_fired or dt_util.utcnow())
|
|
||||||
|
|
||||||
|
|
||||||
class LazyEventPartialState:
|
|
||||||
"""A lazy version of core Event with limited State joined in."""
|
|
||||||
|
|
||||||
__slots__ = [
|
|
||||||
"row",
|
|
||||||
"_event_data",
|
|
||||||
"_event_data_cache",
|
|
||||||
"event_type",
|
|
||||||
"entity_id",
|
|
||||||
"state",
|
|
||||||
"context_id",
|
|
||||||
"context_user_id",
|
|
||||||
"context_parent_id",
|
|
||||||
"data",
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
row: Row,
|
|
||||||
event_data_cache: dict[str, dict[str, Any]],
|
|
||||||
) -> None:
|
|
||||||
"""Init the lazy event."""
|
|
||||||
self.row = row
|
|
||||||
self._event_data: dict[str, Any] | None = None
|
|
||||||
self._event_data_cache = event_data_cache
|
|
||||||
self.event_type: str = self.row.event_type
|
|
||||||
self.entity_id: str | None = self.row.entity_id
|
|
||||||
self.state = self.row.state
|
|
||||||
self.context_id: str | None = self.row.context_id
|
|
||||||
self.context_user_id: str | None = self.row.context_user_id
|
|
||||||
self.context_parent_id: str | None = self.row.context_parent_id
|
|
||||||
source: str = self.row.shared_data or self.row.event_data
|
|
||||||
if not source:
|
|
||||||
self.data = {}
|
|
||||||
elif event_data := self._event_data_cache.get(source):
|
|
||||||
self.data = event_data
|
|
||||||
else:
|
|
||||||
self.data = self._event_data_cache[source] = cast(
|
|
||||||
dict[str, Any], json.loads(source)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EntityNameCache:
|
|
||||||
"""A cache to lookup the name for an entity.
|
|
||||||
|
|
||||||
This class should not be used to lookup attributes
|
|
||||||
that are expected to change state.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant) -> None:
|
|
||||||
"""Init the cache."""
|
|
||||||
self._hass = hass
|
|
||||||
self._names: dict[str, str] = {}
|
|
||||||
|
|
||||||
def get(self, entity_id: str, row: Row) -> str:
|
|
||||||
"""Lookup an the friendly name."""
|
|
||||||
if entity_id in self._names:
|
|
||||||
return self._names[entity_id]
|
|
||||||
if (current_state := self._hass.states.get(entity_id)) and (
|
|
||||||
friendly_name := current_state.attributes.get(ATTR_FRIENDLY_NAME)
|
|
||||||
):
|
|
||||||
self._names[entity_id] = friendly_name
|
|
||||||
else:
|
|
||||||
return split_entity_id(entity_id)[1].replace("_", " ")
|
|
||||||
|
|
||||||
return self._names[entity_id]
|
|
||||||
|
|
||||||
|
|
||||||
class EventCache:
|
|
||||||
"""Cache LazyEventPartialState by row."""
|
|
||||||
|
|
||||||
def __init__(self, event_data_cache: dict[str, dict[str, Any]]) -> None:
|
|
||||||
"""Init the cache."""
|
|
||||||
self._event_data_cache = event_data_cache
|
|
||||||
self.event_cache: dict[Row, LazyEventPartialState] = {}
|
|
||||||
|
|
||||||
def get(self, row: Row) -> LazyEventPartialState:
|
|
||||||
"""Get the event from the row."""
|
|
||||||
if event := self.event_cache.get(row):
|
|
||||||
return event
|
|
||||||
event = self.event_cache[row] = LazyEventPartialState(
|
|
||||||
row, self._event_data_cache
|
|
||||||
)
|
|
||||||
return event
|
|
||||||
|
39
homeassistant/components/logbook/const.py
Normal file
39
homeassistant/components/logbook/const.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"""Event parser and human readable log generator."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.components.automation import EVENT_AUTOMATION_TRIGGERED
|
||||||
|
from homeassistant.components.script import EVENT_SCRIPT_STARTED
|
||||||
|
from homeassistant.const import EVENT_CALL_SERVICE, EVENT_LOGBOOK_ENTRY
|
||||||
|
|
||||||
|
ATTR_MESSAGE = "message"
|
||||||
|
|
||||||
|
DOMAIN = "logbook"
|
||||||
|
|
||||||
|
CONTEXT_USER_ID = "context_user_id"
|
||||||
|
CONTEXT_ENTITY_ID = "context_entity_id"
|
||||||
|
CONTEXT_ENTITY_ID_NAME = "context_entity_id_name"
|
||||||
|
CONTEXT_EVENT_TYPE = "context_event_type"
|
||||||
|
CONTEXT_DOMAIN = "context_domain"
|
||||||
|
CONTEXT_STATE = "context_state"
|
||||||
|
CONTEXT_SERVICE = "context_service"
|
||||||
|
CONTEXT_NAME = "context_name"
|
||||||
|
CONTEXT_MESSAGE = "context_message"
|
||||||
|
|
||||||
|
LOGBOOK_ENTRY_DOMAIN = "domain"
|
||||||
|
LOGBOOK_ENTRY_ENTITY_ID = "entity_id"
|
||||||
|
LOGBOOK_ENTRY_ICON = "icon"
|
||||||
|
LOGBOOK_ENTRY_MESSAGE = "message"
|
||||||
|
LOGBOOK_ENTRY_NAME = "name"
|
||||||
|
LOGBOOK_ENTRY_STATE = "state"
|
||||||
|
LOGBOOK_ENTRY_WHEN = "when"
|
||||||
|
|
||||||
|
ALL_EVENT_TYPES_EXCEPT_STATE_CHANGED = {EVENT_LOGBOOK_ENTRY, EVENT_CALL_SERVICE}
|
||||||
|
ENTITY_EVENTS_WITHOUT_CONFIG_ENTRY = {
|
||||||
|
EVENT_LOGBOOK_ENTRY,
|
||||||
|
EVENT_AUTOMATION_TRIGGERED,
|
||||||
|
EVENT_SCRIPT_STARTED,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
LOGBOOK_FILTERS = "logbook_filters"
|
||||||
|
LOGBOOK_ENTITIES_FILTER = "entities_filter"
|
193
homeassistant/components/logbook/helpers.py
Normal file
193
homeassistant/components/logbook/helpers.py
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
"""Event parser and human readable log generator."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.sensor import ATTR_STATE_CLASS
|
||||||
|
from homeassistant.const import (
|
||||||
|
ATTR_DEVICE_ID,
|
||||||
|
ATTR_ENTITY_ID,
|
||||||
|
ATTR_UNIT_OF_MEASUREMENT,
|
||||||
|
EVENT_LOGBOOK_ENTRY,
|
||||||
|
EVENT_STATE_CHANGED,
|
||||||
|
)
|
||||||
|
from homeassistant.core import (
|
||||||
|
CALLBACK_TYPE,
|
||||||
|
Event,
|
||||||
|
HomeAssistant,
|
||||||
|
State,
|
||||||
|
callback,
|
||||||
|
is_callback,
|
||||||
|
)
|
||||||
|
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||||
|
from homeassistant.helpers.event import async_track_state_change_event
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
ALL_EVENT_TYPES_EXCEPT_STATE_CHANGED,
|
||||||
|
DOMAIN,
|
||||||
|
ENTITY_EVENTS_WITHOUT_CONFIG_ENTRY,
|
||||||
|
)
|
||||||
|
from .models import LazyEventPartialState
|
||||||
|
|
||||||
|
|
||||||
|
def async_filter_entities(hass: HomeAssistant, entity_ids: list[str]) -> list[str]:
|
||||||
|
"""Filter out any entities that logbook will not produce results for."""
|
||||||
|
ent_reg = er.async_get(hass)
|
||||||
|
return [
|
||||||
|
entity_id
|
||||||
|
for entity_id in entity_ids
|
||||||
|
if not _is_entity_id_filtered(hass, ent_reg, entity_id)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def async_determine_event_types(
|
||||||
|
hass: HomeAssistant, entity_ids: list[str] | None, device_ids: list[str] | None
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
"""Reduce the event types based on the entity ids and device ids."""
|
||||||
|
external_events: dict[
|
||||||
|
str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]]
|
||||||
|
] = hass.data.get(DOMAIN, {})
|
||||||
|
if not entity_ids and not device_ids:
|
||||||
|
return (*ALL_EVENT_TYPES_EXCEPT_STATE_CHANGED, *external_events)
|
||||||
|
config_entry_ids: set[str] = set()
|
||||||
|
intrested_event_types: set[str] = set()
|
||||||
|
|
||||||
|
if entity_ids:
|
||||||
|
#
|
||||||
|
# Home Assistant doesn't allow firing events from
|
||||||
|
# entities so we have a limited list to check
|
||||||
|
#
|
||||||
|
# automations and scripts can refer to entities
|
||||||
|
# but they do not have a config entry so we need
|
||||||
|
# to add them.
|
||||||
|
#
|
||||||
|
# We also allow entity_ids to be recorded via
|
||||||
|
# manual logbook entries.
|
||||||
|
#
|
||||||
|
intrested_event_types |= ENTITY_EVENTS_WITHOUT_CONFIG_ENTRY
|
||||||
|
|
||||||
|
if device_ids:
|
||||||
|
dev_reg = dr.async_get(hass)
|
||||||
|
for device_id in device_ids:
|
||||||
|
if (device := dev_reg.async_get(device_id)) and device.config_entries:
|
||||||
|
config_entry_ids |= device.config_entries
|
||||||
|
interested_domains: set[str] = set()
|
||||||
|
for entry_id in config_entry_ids:
|
||||||
|
if entry := hass.config_entries.async_get_entry(entry_id):
|
||||||
|
interested_domains.add(entry.domain)
|
||||||
|
for external_event, domain_call in external_events.items():
|
||||||
|
if domain_call[0] in interested_domains:
|
||||||
|
intrested_event_types.add(external_event)
|
||||||
|
|
||||||
|
return tuple(
|
||||||
|
event_type
|
||||||
|
for event_type in (EVENT_LOGBOOK_ENTRY, *external_events)
|
||||||
|
if event_type in intrested_event_types
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_subscribe_events(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
subscriptions: list[CALLBACK_TYPE],
|
||||||
|
target: Callable[[Event], None],
|
||||||
|
event_types: tuple[str, ...],
|
||||||
|
entity_ids: list[str] | None,
|
||||||
|
device_ids: list[str] | None,
|
||||||
|
) -> None:
|
||||||
|
"""Subscribe to events for the entities and devices or all.
|
||||||
|
|
||||||
|
These are the events we need to listen for to do
|
||||||
|
the live logbook stream.
|
||||||
|
"""
|
||||||
|
ent_reg = er.async_get(hass)
|
||||||
|
assert is_callback(target), "target must be a callback"
|
||||||
|
event_forwarder = target
|
||||||
|
|
||||||
|
if entity_ids or device_ids:
|
||||||
|
entity_ids_set = set(entity_ids) if entity_ids else set()
|
||||||
|
device_ids_set = set(device_ids) if device_ids else set()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _forward_events_filtered(event: Event) -> None:
|
||||||
|
event_data = event.data
|
||||||
|
if (
|
||||||
|
entity_ids_set and event_data.get(ATTR_ENTITY_ID) in entity_ids_set
|
||||||
|
) or (device_ids_set and event_data.get(ATTR_DEVICE_ID) in device_ids_set):
|
||||||
|
target(event)
|
||||||
|
|
||||||
|
event_forwarder = _forward_events_filtered
|
||||||
|
|
||||||
|
for event_type in event_types:
|
||||||
|
subscriptions.append(
|
||||||
|
hass.bus.async_listen(event_type, event_forwarder, run_immediately=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _forward_state_events_filtered(event: Event) -> None:
|
||||||
|
if event.data.get("old_state") is None or event.data.get("new_state") is None:
|
||||||
|
return
|
||||||
|
state: State = event.data["new_state"]
|
||||||
|
if not _is_state_filtered(ent_reg, state):
|
||||||
|
target(event)
|
||||||
|
|
||||||
|
if entity_ids:
|
||||||
|
subscriptions.append(
|
||||||
|
async_track_state_change_event(
|
||||||
|
hass, entity_ids, _forward_state_events_filtered
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# We want the firehose
|
||||||
|
subscriptions.append(
|
||||||
|
hass.bus.async_listen(
|
||||||
|
EVENT_STATE_CHANGED,
|
||||||
|
_forward_state_events_filtered,
|
||||||
|
run_immediately=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_sensor_continuous(ent_reg: er.EntityRegistry, entity_id: str) -> bool:
|
||||||
|
"""Determine if a sensor is continuous by checking its state class.
|
||||||
|
|
||||||
|
Sensors with a unit_of_measurement are also considered continuous, but are filtered
|
||||||
|
already by the SQL query generated by _get_events
|
||||||
|
"""
|
||||||
|
if not (entry := ent_reg.async_get(entity_id)):
|
||||||
|
# Entity not registered, so can't have a state class
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
entry.capabilities is not None
|
||||||
|
and entry.capabilities.get(ATTR_STATE_CLASS) is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_state_filtered(ent_reg: er.EntityRegistry, state: State) -> bool:
|
||||||
|
"""Check if the logbook should filter a state.
|
||||||
|
|
||||||
|
Used when we are in live mode to ensure
|
||||||
|
we only get significant changes (state.last_changed != state.last_updated)
|
||||||
|
"""
|
||||||
|
return bool(
|
||||||
|
state.last_changed != state.last_updated
|
||||||
|
or ATTR_UNIT_OF_MEASUREMENT in state.attributes
|
||||||
|
or is_sensor_continuous(ent_reg, state.entity_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_entity_id_filtered(
|
||||||
|
hass: HomeAssistant, ent_reg: er.EntityRegistry, entity_id: str
|
||||||
|
) -> bool:
|
||||||
|
"""Check if the logbook should filter an entity.
|
||||||
|
|
||||||
|
Used to setup listeners and which entities to select
|
||||||
|
from the database when a list of entities is requested.
|
||||||
|
"""
|
||||||
|
return bool(
|
||||||
|
(state := hass.states.get(entity_id))
|
||||||
|
and (ATTR_UNIT_OF_MEASUREMENT in state.attributes)
|
||||||
|
or is_sensor_continuous(ent_reg, entity_id)
|
||||||
|
)
|
113
homeassistant/components/logbook/models.py
Normal file
113
homeassistant/components/logbook/models.py
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
"""Event parser and human readable log generator."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime as dt
|
||||||
|
import json
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from sqlalchemy.engine.row import Row
|
||||||
|
|
||||||
|
from homeassistant.const import ATTR_ICON, EVENT_STATE_CHANGED
|
||||||
|
from homeassistant.core import Context, Event, State, callback
|
||||||
|
|
||||||
|
|
||||||
|
class LazyEventPartialState:
|
||||||
|
"""A lazy version of core Event with limited State joined in."""
|
||||||
|
|
||||||
|
__slots__ = [
|
||||||
|
"row",
|
||||||
|
"_event_data",
|
||||||
|
"_event_data_cache",
|
||||||
|
"event_type",
|
||||||
|
"entity_id",
|
||||||
|
"state",
|
||||||
|
"context_id",
|
||||||
|
"context_user_id",
|
||||||
|
"context_parent_id",
|
||||||
|
"data",
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
row: Row | EventAsRow,
|
||||||
|
event_data_cache: dict[str, dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
"""Init the lazy event."""
|
||||||
|
self.row = row
|
||||||
|
self._event_data: dict[str, Any] | None = None
|
||||||
|
self._event_data_cache = event_data_cache
|
||||||
|
self.event_type: str | None = self.row.event_type
|
||||||
|
self.entity_id: str | None = self.row.entity_id
|
||||||
|
self.state = self.row.state
|
||||||
|
self.context_id: str | None = self.row.context_id
|
||||||
|
self.context_user_id: str | None = self.row.context_user_id
|
||||||
|
self.context_parent_id: str | None = self.row.context_parent_id
|
||||||
|
if data := getattr(row, "data", None):
|
||||||
|
# If its an EventAsRow we can avoid the whole
|
||||||
|
# json decode process as we already have the data
|
||||||
|
self.data = data
|
||||||
|
return
|
||||||
|
source = cast(str, self.row.shared_data or self.row.event_data)
|
||||||
|
if not source:
|
||||||
|
self.data = {}
|
||||||
|
elif event_data := self._event_data_cache.get(source):
|
||||||
|
self.data = event_data
|
||||||
|
else:
|
||||||
|
self.data = self._event_data_cache[source] = cast(
|
||||||
|
dict[str, Any], json.loads(source)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EventAsRow:
|
||||||
|
"""Convert an event to a row."""
|
||||||
|
|
||||||
|
data: dict[str, Any]
|
||||||
|
context: Context
|
||||||
|
context_id: str
|
||||||
|
time_fired: dt
|
||||||
|
state_id: int
|
||||||
|
event_data: str | None = None
|
||||||
|
old_format_icon: None = None
|
||||||
|
event_id: None = None
|
||||||
|
entity_id: str | None = None
|
||||||
|
icon: str | None = None
|
||||||
|
context_user_id: str | None = None
|
||||||
|
context_parent_id: str | None = None
|
||||||
|
event_type: str | None = None
|
||||||
|
state: str | None = None
|
||||||
|
shared_data: str | None = None
|
||||||
|
context_only: None = None
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_event_to_row(event: Event) -> EventAsRow | None:
|
||||||
|
"""Convert an event to a row."""
|
||||||
|
if event.event_type != EVENT_STATE_CHANGED:
|
||||||
|
return EventAsRow(
|
||||||
|
data=event.data,
|
||||||
|
context=event.context,
|
||||||
|
event_type=event.event_type,
|
||||||
|
context_id=event.context.id,
|
||||||
|
context_user_id=event.context.user_id,
|
||||||
|
context_parent_id=event.context.parent_id,
|
||||||
|
time_fired=event.time_fired,
|
||||||
|
state_id=hash(event),
|
||||||
|
)
|
||||||
|
# States are prefiltered so we never get states
|
||||||
|
# that are missing new_state or old_state
|
||||||
|
# since the logbook does not show these
|
||||||
|
new_state: State = event.data["new_state"]
|
||||||
|
return EventAsRow(
|
||||||
|
data=event.data,
|
||||||
|
context=event.context,
|
||||||
|
entity_id=new_state.entity_id,
|
||||||
|
state=new_state.state,
|
||||||
|
context_id=new_state.context.id,
|
||||||
|
context_user_id=new_state.context.user_id,
|
||||||
|
context_parent_id=new_state.context.parent_id,
|
||||||
|
time_fired=new_state.last_updated,
|
||||||
|
state_id=hash(event),
|
||||||
|
icon=new_state.attributes.get(ATTR_ICON),
|
||||||
|
)
|
488
homeassistant/components/logbook/processor.py
Normal file
488
homeassistant/components/logbook/processor.py
Normal file
@ -0,0 +1,488 @@
|
|||||||
|
"""Event parser and human readable log generator."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable, Generator
|
||||||
|
from contextlib import suppress
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime as dt
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.engine.row import Row
|
||||||
|
from sqlalchemy.orm.query import Query
|
||||||
|
|
||||||
|
from homeassistant.components.recorder.filters import Filters
|
||||||
|
from homeassistant.components.recorder.models import (
|
||||||
|
process_datetime_to_timestamp,
|
||||||
|
process_timestamp_to_utc_isoformat,
|
||||||
|
)
|
||||||
|
from homeassistant.components.recorder.util import session_scope
|
||||||
|
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||||
|
from homeassistant.const import (
|
||||||
|
ATTR_DOMAIN,
|
||||||
|
ATTR_ENTITY_ID,
|
||||||
|
ATTR_FRIENDLY_NAME,
|
||||||
|
ATTR_NAME,
|
||||||
|
ATTR_SERVICE,
|
||||||
|
EVENT_CALL_SERVICE,
|
||||||
|
EVENT_LOGBOOK_ENTRY,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant, split_entity_id
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
from homeassistant.helpers.entityfilter import EntityFilter
|
||||||
|
import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
ATTR_MESSAGE,
|
||||||
|
CONTEXT_DOMAIN,
|
||||||
|
CONTEXT_ENTITY_ID,
|
||||||
|
CONTEXT_ENTITY_ID_NAME,
|
||||||
|
CONTEXT_EVENT_TYPE,
|
||||||
|
CONTEXT_MESSAGE,
|
||||||
|
CONTEXT_NAME,
|
||||||
|
CONTEXT_SERVICE,
|
||||||
|
CONTEXT_STATE,
|
||||||
|
CONTEXT_USER_ID,
|
||||||
|
DOMAIN,
|
||||||
|
LOGBOOK_ENTITIES_FILTER,
|
||||||
|
LOGBOOK_ENTRY_DOMAIN,
|
||||||
|
LOGBOOK_ENTRY_ENTITY_ID,
|
||||||
|
LOGBOOK_ENTRY_ICON,
|
||||||
|
LOGBOOK_ENTRY_MESSAGE,
|
||||||
|
LOGBOOK_ENTRY_NAME,
|
||||||
|
LOGBOOK_ENTRY_STATE,
|
||||||
|
LOGBOOK_ENTRY_WHEN,
|
||||||
|
LOGBOOK_FILTERS,
|
||||||
|
)
|
||||||
|
from .helpers import is_sensor_continuous
|
||||||
|
from .models import EventAsRow, LazyEventPartialState, async_event_to_row
|
||||||
|
from .queries import statement_for_request
|
||||||
|
from .queries.common import PSUEDO_EVENT_STATE_CHANGED
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ENTITY_ID_JSON_EXTRACT = re.compile('"entity_id": ?"([^"]+)"')
|
||||||
|
DOMAIN_JSON_EXTRACT = re.compile('"domain": ?"([^"]+)"')
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LogbookRun:
|
||||||
|
"""A logbook run which may be a long running event stream or single request."""
|
||||||
|
|
||||||
|
context_lookup: ContextLookup
|
||||||
|
external_events: dict[
|
||||||
|
str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]]
|
||||||
|
]
|
||||||
|
event_cache: EventCache
|
||||||
|
entity_name_cache: EntityNameCache
|
||||||
|
include_entity_name: bool
|
||||||
|
format_time: Callable[[Row], Any]
|
||||||
|
|
||||||
|
|
||||||
|
class EventProcessor:
|
||||||
|
"""Stream into logbook format."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
event_types: tuple[str, ...],
|
||||||
|
entity_ids: list[str] | None = None,
|
||||||
|
device_ids: list[str] | None = None,
|
||||||
|
context_id: str | None = None,
|
||||||
|
timestamp: bool = False,
|
||||||
|
include_entity_name: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""Init the event stream."""
|
||||||
|
assert not (
|
||||||
|
context_id and (entity_ids or device_ids)
|
||||||
|
), "can't pass in both context_id and (entity_ids or device_ids)"
|
||||||
|
self.hass = hass
|
||||||
|
self.ent_reg = er.async_get(hass)
|
||||||
|
self.event_types = event_types
|
||||||
|
self.entity_ids = entity_ids
|
||||||
|
self.device_ids = device_ids
|
||||||
|
self.context_id = context_id
|
||||||
|
self.filters: Filters | None = hass.data[LOGBOOK_FILTERS]
|
||||||
|
if self.limited_select:
|
||||||
|
self.entities_filter: EntityFilter | Callable[[str], bool] | None = None
|
||||||
|
else:
|
||||||
|
self.entities_filter = hass.data[LOGBOOK_ENTITIES_FILTER]
|
||||||
|
format_time = (
|
||||||
|
_row_time_fired_timestamp if timestamp else _row_time_fired_isoformat
|
||||||
|
)
|
||||||
|
external_events: dict[
|
||||||
|
str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]]
|
||||||
|
] = hass.data.get(DOMAIN, {})
|
||||||
|
self.logbook_run = LogbookRun(
|
||||||
|
context_lookup=ContextLookup(hass),
|
||||||
|
external_events=external_events,
|
||||||
|
event_cache=EventCache({}),
|
||||||
|
entity_name_cache=EntityNameCache(self.hass),
|
||||||
|
include_entity_name=include_entity_name,
|
||||||
|
format_time=format_time,
|
||||||
|
)
|
||||||
|
self.context_augmenter = ContextAugmenter(self.logbook_run)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def limited_select(self) -> bool:
|
||||||
|
"""Check if the stream is limited by entities context or device ids."""
|
||||||
|
return bool(self.entity_ids or self.context_id or self.device_ids)
|
||||||
|
|
||||||
|
def switch_to_live(self) -> None:
|
||||||
|
"""Switch to live stream.
|
||||||
|
|
||||||
|
Clear caches so we can reduce memory pressure.
|
||||||
|
"""
|
||||||
|
self.logbook_run.event_cache.clear()
|
||||||
|
self.logbook_run.context_lookup.clear()
|
||||||
|
|
||||||
|
def get_events(
|
||||||
|
self,
|
||||||
|
start_day: dt,
|
||||||
|
end_day: dt,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Get events for a period of time."""
|
||||||
|
|
||||||
|
def yield_rows(query: Query) -> Generator[Row, None, None]:
|
||||||
|
"""Yield rows from the database."""
|
||||||
|
# end_day - start_day intentionally checks .days and not .total_seconds()
|
||||||
|
# since we don't want to switch over to buffered if they go
|
||||||
|
# over one day by a few hours since the UI makes it so easy to do that.
|
||||||
|
if self.limited_select or (end_day - start_day).days <= 1:
|
||||||
|
return query.all() # type: ignore[no-any-return]
|
||||||
|
# Only buffer rows to reduce memory pressure
|
||||||
|
# if we expect the result set is going to be very large.
|
||||||
|
# What is considered very large is going to differ
|
||||||
|
# based on the hardware Home Assistant is running on.
|
||||||
|
#
|
||||||
|
# sqlalchemy suggests that is at least 10k, but for
|
||||||
|
# even and RPi3 that number seems higher in testing
|
||||||
|
# so we don't switch over until we request > 1 day+ of data.
|
||||||
|
#
|
||||||
|
return query.yield_per(1024) # type: ignore[no-any-return]
|
||||||
|
|
||||||
|
stmt = statement_for_request(
|
||||||
|
start_day,
|
||||||
|
end_day,
|
||||||
|
self.event_types,
|
||||||
|
self.entity_ids,
|
||||||
|
self.device_ids,
|
||||||
|
self.filters,
|
||||||
|
self.context_id,
|
||||||
|
)
|
||||||
|
if _LOGGER.isEnabledFor(logging.DEBUG):
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Literal statement: %s",
|
||||||
|
stmt.compile(compile_kwargs={"literal_binds": True}),
|
||||||
|
)
|
||||||
|
|
||||||
|
with session_scope(hass=self.hass) as session:
|
||||||
|
return self.humanify(yield_rows(session.execute(stmt)))
|
||||||
|
|
||||||
|
def humanify(
|
||||||
|
self, row_generator: Generator[Row | EventAsRow, None, None]
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""Humanify rows."""
|
||||||
|
return list(
|
||||||
|
_humanify(
|
||||||
|
row_generator,
|
||||||
|
self.entities_filter,
|
||||||
|
self.ent_reg,
|
||||||
|
self.logbook_run,
|
||||||
|
self.context_augmenter,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _humanify(
|
||||||
|
rows: Generator[Row | EventAsRow, None, None],
|
||||||
|
entities_filter: EntityFilter | Callable[[str], bool] | None,
|
||||||
|
ent_reg: er.EntityRegistry,
|
||||||
|
logbook_run: LogbookRun,
|
||||||
|
context_augmenter: ContextAugmenter,
|
||||||
|
) -> Generator[dict[str, Any], None, None]:
|
||||||
|
"""Generate a converted list of events into entries."""
|
||||||
|
# Continuous sensors, will be excluded from the logbook
|
||||||
|
continuous_sensors: dict[str, bool] = {}
|
||||||
|
context_lookup = logbook_run.context_lookup
|
||||||
|
external_events = logbook_run.external_events
|
||||||
|
event_cache = logbook_run.event_cache
|
||||||
|
entity_name_cache = logbook_run.entity_name_cache
|
||||||
|
include_entity_name = logbook_run.include_entity_name
|
||||||
|
format_time = logbook_run.format_time
|
||||||
|
|
||||||
|
def _keep_row(row: Row | EventAsRow, event_type: str) -> bool:
|
||||||
|
"""Check if the entity_filter rejects a row."""
|
||||||
|
assert entities_filter is not None
|
||||||
|
if entity_id := _row_event_data_extract(row, ENTITY_ID_JSON_EXTRACT):
|
||||||
|
return entities_filter(entity_id)
|
||||||
|
|
||||||
|
if event_type in external_events:
|
||||||
|
# If the entity_id isn't described, use the domain that describes
|
||||||
|
# the event for filtering.
|
||||||
|
domain: str | None = external_events[event_type][0]
|
||||||
|
else:
|
||||||
|
domain = _row_event_data_extract(row, DOMAIN_JSON_EXTRACT)
|
||||||
|
|
||||||
|
return domain is not None and entities_filter(f"{domain}._")
|
||||||
|
|
||||||
|
# Process rows
|
||||||
|
for row in rows:
|
||||||
|
context_id = context_lookup.memorize(row)
|
||||||
|
if row.context_only:
|
||||||
|
continue
|
||||||
|
event_type = row.event_type
|
||||||
|
if event_type == EVENT_CALL_SERVICE or (
|
||||||
|
event_type is not PSUEDO_EVENT_STATE_CHANGED
|
||||||
|
and entities_filter is not None
|
||||||
|
and not _keep_row(row, event_type)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if event_type is PSUEDO_EVENT_STATE_CHANGED:
|
||||||
|
entity_id = row.entity_id
|
||||||
|
assert entity_id is not None
|
||||||
|
# Skip continuous sensors
|
||||||
|
if (
|
||||||
|
is_continuous := continuous_sensors.get(entity_id)
|
||||||
|
) is None and split_entity_id(entity_id)[0] == SENSOR_DOMAIN:
|
||||||
|
is_continuous = is_sensor_continuous(ent_reg, entity_id)
|
||||||
|
continuous_sensors[entity_id] = is_continuous
|
||||||
|
if is_continuous:
|
||||||
|
continue
|
||||||
|
|
||||||
|
data = {
|
||||||
|
LOGBOOK_ENTRY_WHEN: format_time(row),
|
||||||
|
LOGBOOK_ENTRY_STATE: row.state,
|
||||||
|
LOGBOOK_ENTRY_ENTITY_ID: entity_id,
|
||||||
|
}
|
||||||
|
if include_entity_name:
|
||||||
|
data[LOGBOOK_ENTRY_NAME] = entity_name_cache.get(entity_id)
|
||||||
|
if icon := row.icon or row.old_format_icon:
|
||||||
|
data[LOGBOOK_ENTRY_ICON] = icon
|
||||||
|
|
||||||
|
context_augmenter.augment(data, row, context_id)
|
||||||
|
yield data
|
||||||
|
|
||||||
|
elif event_type in external_events:
|
||||||
|
domain, describe_event = external_events[event_type]
|
||||||
|
data = describe_event(event_cache.get(row))
|
||||||
|
data[LOGBOOK_ENTRY_WHEN] = format_time(row)
|
||||||
|
data[LOGBOOK_ENTRY_DOMAIN] = domain
|
||||||
|
context_augmenter.augment(data, row, context_id)
|
||||||
|
yield data
|
||||||
|
|
||||||
|
elif event_type == EVENT_LOGBOOK_ENTRY:
|
||||||
|
event = event_cache.get(row)
|
||||||
|
if not (event_data := event.data):
|
||||||
|
continue
|
||||||
|
entry_domain = event_data.get(ATTR_DOMAIN)
|
||||||
|
entry_entity_id = event_data.get(ATTR_ENTITY_ID)
|
||||||
|
if entry_domain is None and entry_entity_id is not None:
|
||||||
|
with suppress(IndexError):
|
||||||
|
entry_domain = split_entity_id(str(entry_entity_id))[0]
|
||||||
|
data = {
|
||||||
|
LOGBOOK_ENTRY_WHEN: format_time(row),
|
||||||
|
LOGBOOK_ENTRY_NAME: event_data.get(ATTR_NAME),
|
||||||
|
LOGBOOK_ENTRY_MESSAGE: event_data.get(ATTR_MESSAGE),
|
||||||
|
LOGBOOK_ENTRY_DOMAIN: entry_domain,
|
||||||
|
LOGBOOK_ENTRY_ENTITY_ID: entry_entity_id,
|
||||||
|
}
|
||||||
|
context_augmenter.augment(data, row, context_id)
|
||||||
|
yield data
|
||||||
|
|
||||||
|
|
||||||
|
class ContextLookup:
|
||||||
|
"""A lookup class for context origins."""
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant) -> None:
|
||||||
|
"""Memorize context origin."""
|
||||||
|
self.hass = hass
|
||||||
|
self._memorize_new = True
|
||||||
|
self._lookup: dict[str | None, Row | EventAsRow | None] = {None: None}
|
||||||
|
|
||||||
|
def memorize(self, row: Row) -> str | None:
|
||||||
|
"""Memorize a context from the database."""
|
||||||
|
if self._memorize_new:
|
||||||
|
context_id: str = row.context_id
|
||||||
|
self._lookup.setdefault(context_id, row)
|
||||||
|
return context_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear the context origins and stop recording new ones."""
|
||||||
|
self._lookup.clear()
|
||||||
|
self._memorize_new = False
|
||||||
|
|
||||||
|
def get(self, context_id: str) -> Row | None:
|
||||||
|
"""Get the context origin."""
|
||||||
|
return self._lookup.get(context_id)
|
||||||
|
|
||||||
|
|
||||||
|
class ContextAugmenter:
|
||||||
|
"""Augment data with context trace."""
|
||||||
|
|
||||||
|
def __init__(self, logbook_run: LogbookRun) -> None:
|
||||||
|
"""Init the augmenter."""
|
||||||
|
self.context_lookup = logbook_run.context_lookup
|
||||||
|
self.entity_name_cache = logbook_run.entity_name_cache
|
||||||
|
self.external_events = logbook_run.external_events
|
||||||
|
self.event_cache = logbook_run.event_cache
|
||||||
|
self.include_entity_name = logbook_run.include_entity_name
|
||||||
|
|
||||||
|
def _get_context_row(
|
||||||
|
self, context_id: str | None, row: Row | EventAsRow
|
||||||
|
) -> Row | EventAsRow:
|
||||||
|
"""Get the context row from the id or row context."""
|
||||||
|
if context_id:
|
||||||
|
return self.context_lookup.get(context_id)
|
||||||
|
if (context := getattr(row, "context", None)) is not None and (
|
||||||
|
origin_event := context.origin_event
|
||||||
|
) is not None:
|
||||||
|
return async_event_to_row(origin_event)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def augment(
|
||||||
|
self, data: dict[str, Any], row: Row | EventAsRow, context_id: str | None
|
||||||
|
) -> None:
|
||||||
|
"""Augment data from the row and cache."""
|
||||||
|
if context_user_id := row.context_user_id:
|
||||||
|
data[CONTEXT_USER_ID] = context_user_id
|
||||||
|
|
||||||
|
if not (context_row := self._get_context_row(context_id, row)):
|
||||||
|
return
|
||||||
|
|
||||||
|
if _rows_match(row, context_row):
|
||||||
|
# This is the first event with the given ID. Was it directly caused by
|
||||||
|
# a parent event?
|
||||||
|
if (
|
||||||
|
not row.context_parent_id
|
||||||
|
or (
|
||||||
|
context_row := self._get_context_row(
|
||||||
|
row.context_parent_id, context_row
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
):
|
||||||
|
return
|
||||||
|
# Ensure the (parent) context_event exists and is not the root cause of
|
||||||
|
# this log entry.
|
||||||
|
if _rows_match(row, context_row):
|
||||||
|
return
|
||||||
|
event_type = context_row.event_type
|
||||||
|
# State change
|
||||||
|
if context_entity_id := context_row.entity_id:
|
||||||
|
data[CONTEXT_STATE] = context_row.state
|
||||||
|
data[CONTEXT_ENTITY_ID] = context_entity_id
|
||||||
|
if self.include_entity_name:
|
||||||
|
data[CONTEXT_ENTITY_ID_NAME] = self.entity_name_cache.get(
|
||||||
|
context_entity_id
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Call service
|
||||||
|
if event_type == EVENT_CALL_SERVICE:
|
||||||
|
event = self.event_cache.get(context_row)
|
||||||
|
event_data = event.data
|
||||||
|
data[CONTEXT_DOMAIN] = event_data.get(ATTR_DOMAIN)
|
||||||
|
data[CONTEXT_SERVICE] = event_data.get(ATTR_SERVICE)
|
||||||
|
data[CONTEXT_EVENT_TYPE] = event_type
|
||||||
|
return
|
||||||
|
|
||||||
|
if event_type not in self.external_events:
|
||||||
|
return
|
||||||
|
|
||||||
|
domain, describe_event = self.external_events[event_type]
|
||||||
|
data[CONTEXT_EVENT_TYPE] = event_type
|
||||||
|
data[CONTEXT_DOMAIN] = domain
|
||||||
|
event = self.event_cache.get(context_row)
|
||||||
|
described = describe_event(event)
|
||||||
|
if name := described.get(ATTR_NAME):
|
||||||
|
data[CONTEXT_NAME] = name
|
||||||
|
if message := described.get(ATTR_MESSAGE):
|
||||||
|
data[CONTEXT_MESSAGE] = message
|
||||||
|
if not (attr_entity_id := described.get(ATTR_ENTITY_ID)):
|
||||||
|
return
|
||||||
|
data[CONTEXT_ENTITY_ID] = attr_entity_id
|
||||||
|
if self.include_entity_name:
|
||||||
|
data[CONTEXT_ENTITY_ID_NAME] = self.entity_name_cache.get(attr_entity_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _rows_match(row: Row | EventAsRow, other_row: Row | EventAsRow) -> bool:
|
||||||
|
"""Check of rows match by using the same method as Events __hash__."""
|
||||||
|
if (
|
||||||
|
(state_id := row.state_id) is not None
|
||||||
|
and state_id == other_row.state_id
|
||||||
|
or (event_id := row.event_id) is not None
|
||||||
|
and event_id == other_row.event_id
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _row_event_data_extract(row: Row | EventAsRow, extractor: re.Pattern) -> str | None:
|
||||||
|
"""Extract from event_data row."""
|
||||||
|
result = extractor.search(row.shared_data or row.event_data or "")
|
||||||
|
return result.group(1) if result else None
|
||||||
|
|
||||||
|
|
||||||
|
def _row_time_fired_isoformat(row: Row | EventAsRow) -> str:
|
||||||
|
"""Convert the row timed_fired to isoformat."""
|
||||||
|
return process_timestamp_to_utc_isoformat(row.time_fired or dt_util.utcnow())
|
||||||
|
|
||||||
|
|
||||||
|
def _row_time_fired_timestamp(row: Row | EventAsRow) -> float:
|
||||||
|
"""Convert the row timed_fired to timestamp."""
|
||||||
|
return process_datetime_to_timestamp(row.time_fired or dt_util.utcnow())
|
||||||
|
|
||||||
|
|
||||||
|
class EntityNameCache:
|
||||||
|
"""A cache to lookup the name for an entity.
|
||||||
|
|
||||||
|
This class should not be used to lookup attributes
|
||||||
|
that are expected to change state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant) -> None:
|
||||||
|
"""Init the cache."""
|
||||||
|
self._hass = hass
|
||||||
|
self._names: dict[str, str] = {}
|
||||||
|
|
||||||
|
def get(self, entity_id: str) -> str:
|
||||||
|
"""Lookup an the friendly name."""
|
||||||
|
if entity_id in self._names:
|
||||||
|
return self._names[entity_id]
|
||||||
|
if (current_state := self._hass.states.get(entity_id)) and (
|
||||||
|
friendly_name := current_state.attributes.get(ATTR_FRIENDLY_NAME)
|
||||||
|
):
|
||||||
|
self._names[entity_id] = friendly_name
|
||||||
|
else:
|
||||||
|
return split_entity_id(entity_id)[1].replace("_", " ")
|
||||||
|
|
||||||
|
return self._names[entity_id]
|
||||||
|
|
||||||
|
|
||||||
|
class EventCache:
|
||||||
|
"""Cache LazyEventPartialState by row."""
|
||||||
|
|
||||||
|
def __init__(self, event_data_cache: dict[str, dict[str, Any]]) -> None:
|
||||||
|
"""Init the cache."""
|
||||||
|
self._event_data_cache = event_data_cache
|
||||||
|
self.event_cache: dict[Row | EventAsRow, LazyEventPartialState] = {}
|
||||||
|
|
||||||
|
def get(self, row: EventAsRow | Row) -> LazyEventPartialState:
|
||||||
|
"""Get the event from the row."""
|
||||||
|
if isinstance(row, EventAsRow):
|
||||||
|
return LazyEventPartialState(row, self._event_data_cache)
|
||||||
|
if event := self.event_cache.get(row):
|
||||||
|
return event
|
||||||
|
self.event_cache[row] = lazy_event = LazyEventPartialState(
|
||||||
|
row, self._event_data_cache
|
||||||
|
)
|
||||||
|
return lazy_event
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear the event cache."""
|
||||||
|
self._event_data_cache = {}
|
||||||
|
self.event_cache = {}
|
120
homeassistant/components/logbook/rest_api.py
Normal file
120
homeassistant/components/logbook/rest_api.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
"""Event parser and human readable log generator."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from http import HTTPStatus
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components.http import HomeAssistantView
|
||||||
|
from homeassistant.components.recorder import get_instance
|
||||||
|
from homeassistant.components.recorder.filters import Filters
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.exceptions import InvalidEntityFormatError
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
from homeassistant.helpers.entityfilter import EntityFilter
|
||||||
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
|
from .helpers import async_determine_event_types
|
||||||
|
from .processor import EventProcessor
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_setup(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
conf: ConfigType,
|
||||||
|
filters: Filters | None,
|
||||||
|
entities_filter: EntityFilter | None,
|
||||||
|
) -> None:
|
||||||
|
"""Set up the logbook rest API."""
|
||||||
|
hass.http.register_view(LogbookView(conf, filters, entities_filter))
|
||||||
|
|
||||||
|
|
||||||
|
class LogbookView(HomeAssistantView):
|
||||||
|
"""Handle logbook view requests."""
|
||||||
|
|
||||||
|
url = "/api/logbook"
|
||||||
|
name = "api:logbook"
|
||||||
|
extra_urls = ["/api/logbook/{datetime}"]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: dict[str, Any],
|
||||||
|
filters: Filters | None,
|
||||||
|
entities_filter: EntityFilter | None,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the logbook view."""
|
||||||
|
self.config = config
|
||||||
|
self.filters = filters
|
||||||
|
self.entities_filter = entities_filter
|
||||||
|
|
||||||
|
async def get(
|
||||||
|
self, request: web.Request, datetime: str | None = None
|
||||||
|
) -> web.Response:
|
||||||
|
"""Retrieve logbook entries."""
|
||||||
|
if datetime:
|
||||||
|
if (datetime_dt := dt_util.parse_datetime(datetime)) is None:
|
||||||
|
return self.json_message("Invalid datetime", HTTPStatus.BAD_REQUEST)
|
||||||
|
else:
|
||||||
|
datetime_dt = dt_util.start_of_local_day()
|
||||||
|
|
||||||
|
if (period_str := request.query.get("period")) is None:
|
||||||
|
period: int = 1
|
||||||
|
else:
|
||||||
|
period = int(period_str)
|
||||||
|
|
||||||
|
if entity_ids_str := request.query.get("entity"):
|
||||||
|
try:
|
||||||
|
entity_ids = cv.entity_ids(entity_ids_str)
|
||||||
|
except vol.Invalid:
|
||||||
|
raise InvalidEntityFormatError(
|
||||||
|
f"Invalid entity id(s) encountered: {entity_ids_str}. "
|
||||||
|
"Format should be <domain>.<object_id>"
|
||||||
|
) from vol.Invalid
|
||||||
|
else:
|
||||||
|
entity_ids = None
|
||||||
|
|
||||||
|
if (end_time_str := request.query.get("end_time")) is None:
|
||||||
|
start_day = dt_util.as_utc(datetime_dt) - timedelta(days=period - 1)
|
||||||
|
end_day = start_day + timedelta(days=period)
|
||||||
|
else:
|
||||||
|
start_day = datetime_dt
|
||||||
|
if (end_day_dt := dt_util.parse_datetime(end_time_str)) is None:
|
||||||
|
return self.json_message("Invalid end_time", HTTPStatus.BAD_REQUEST)
|
||||||
|
end_day = end_day_dt
|
||||||
|
|
||||||
|
hass = request.app["hass"]
|
||||||
|
|
||||||
|
context_id = request.query.get("context_id")
|
||||||
|
|
||||||
|
if entity_ids and context_id:
|
||||||
|
return self.json_message(
|
||||||
|
"Can't combine entity with context_id", HTTPStatus.BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
event_types = async_determine_event_types(hass, entity_ids, None)
|
||||||
|
event_processor = EventProcessor(
|
||||||
|
hass,
|
||||||
|
event_types,
|
||||||
|
entity_ids,
|
||||||
|
None,
|
||||||
|
context_id,
|
||||||
|
timestamp=False,
|
||||||
|
include_entity_name=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def json_events() -> web.Response:
|
||||||
|
"""Fetch events and generate JSON."""
|
||||||
|
return self.json(
|
||||||
|
event_processor.get_events(
|
||||||
|
start_day,
|
||||||
|
end_day,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return cast(
|
||||||
|
web.Response, await get_instance(hass).async_add_executor_job(json_events)
|
||||||
|
)
|
319
homeassistant/components/logbook/websocket_api.py
Normal file
319
homeassistant/components/logbook/websocket_api.py
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
"""Event parser and human readable log generator."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime as dt, timedelta
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components import websocket_api
|
||||||
|
from homeassistant.components.recorder import get_instance
|
||||||
|
from homeassistant.components.websocket_api import messages
|
||||||
|
from homeassistant.components.websocket_api.connection import ActiveConnection
|
||||||
|
from homeassistant.components.websocket_api.const import JSON_DUMP
|
||||||
|
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
|
||||||
|
import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
|
from .helpers import (
|
||||||
|
async_determine_event_types,
|
||||||
|
async_filter_entities,
|
||||||
|
async_subscribe_events,
|
||||||
|
)
|
||||||
|
from .models import async_event_to_row
|
||||||
|
from .processor import EventProcessor
|
||||||
|
|
||||||
|
MAX_PENDING_LOGBOOK_EVENTS = 2048
|
||||||
|
EVENT_COALESCE_TIME = 0.5
|
||||||
|
MAX_RECORDER_WAIT = 10
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_setup(hass: HomeAssistant) -> None:
|
||||||
|
"""Set up the logbook websocket API."""
|
||||||
|
websocket_api.async_register_command(hass, ws_get_events)
|
||||||
|
websocket_api.async_register_command(hass, ws_event_stream)
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_get_ws_formatted_events(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
msg_id: int,
|
||||||
|
start_time: dt,
|
||||||
|
end_time: dt,
|
||||||
|
formatter: Callable[[int, Any], dict[str, Any]],
|
||||||
|
event_processor: EventProcessor,
|
||||||
|
) -> tuple[str, dt | None]:
|
||||||
|
"""Async wrapper around _ws_formatted_get_events."""
|
||||||
|
return await get_instance(hass).async_add_executor_job(
|
||||||
|
_ws_formatted_get_events,
|
||||||
|
msg_id,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
|
formatter,
|
||||||
|
event_processor,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ws_formatted_get_events(
|
||||||
|
msg_id: int,
|
||||||
|
start_day: dt,
|
||||||
|
end_day: dt,
|
||||||
|
formatter: Callable[[int, Any], dict[str, Any]],
|
||||||
|
event_processor: EventProcessor,
|
||||||
|
) -> tuple[str, dt | None]:
|
||||||
|
"""Fetch events and convert them to json in the executor."""
|
||||||
|
events = event_processor.get_events(start_day, end_day)
|
||||||
|
last_time = None
|
||||||
|
if events:
|
||||||
|
last_time = dt_util.utc_from_timestamp(events[-1]["when"])
|
||||||
|
result = formatter(msg_id, events)
|
||||||
|
return JSON_DUMP(result), last_time
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_events_consumer(
|
||||||
|
setup_complete_future: asyncio.Future[dt],
|
||||||
|
connection: ActiveConnection,
|
||||||
|
msg_id: int,
|
||||||
|
stream_queue: asyncio.Queue[Event],
|
||||||
|
event_processor: EventProcessor,
|
||||||
|
) -> None:
|
||||||
|
"""Stream events from the queue."""
|
||||||
|
subscriptions_setup_complete_time = await setup_complete_future
|
||||||
|
event_processor.switch_to_live()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
events: list[Event] = [await stream_queue.get()]
|
||||||
|
# If the event is older than the last db
|
||||||
|
# event we already sent it so we skip it.
|
||||||
|
if events[0].time_fired <= subscriptions_setup_complete_time:
|
||||||
|
continue
|
||||||
|
# We sleep for the EVENT_COALESCE_TIME so
|
||||||
|
# we can group events together to minimize
|
||||||
|
# the number of websocket messages when the
|
||||||
|
# system is overloaded with an event storm
|
||||||
|
await asyncio.sleep(EVENT_COALESCE_TIME)
|
||||||
|
while not stream_queue.empty():
|
||||||
|
events.append(stream_queue.get_nowait())
|
||||||
|
|
||||||
|
if logbook_events := event_processor.humanify(
|
||||||
|
async_event_to_row(e) for e in events
|
||||||
|
):
|
||||||
|
connection.send_message(
|
||||||
|
JSON_DUMP(
|
||||||
|
messages.event_message(
|
||||||
|
msg_id,
|
||||||
|
logbook_events,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "logbook/event_stream",
|
||||||
|
vol.Required("start_time"): str,
|
||||||
|
vol.Optional("entity_ids"): [str],
|
||||||
|
vol.Optional("device_ids"): [str],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def ws_event_stream(
|
||||||
|
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
||||||
|
) -> None:
|
||||||
|
"""Handle logbook stream events websocket command."""
|
||||||
|
start_time_str = msg["start_time"]
|
||||||
|
utc_now = dt_util.utcnow()
|
||||||
|
|
||||||
|
if start_time := dt_util.parse_datetime(start_time_str):
|
||||||
|
start_time = dt_util.as_utc(start_time)
|
||||||
|
|
||||||
|
if not start_time or start_time > utc_now:
|
||||||
|
connection.send_error(msg["id"], "invalid_start_time", "Invalid start_time")
|
||||||
|
return
|
||||||
|
|
||||||
|
device_ids = msg.get("device_ids")
|
||||||
|
entity_ids = msg.get("entity_ids")
|
||||||
|
if entity_ids:
|
||||||
|
entity_ids = async_filter_entities(hass, entity_ids)
|
||||||
|
event_types = async_determine_event_types(hass, entity_ids, device_ids)
|
||||||
|
|
||||||
|
event_processor = EventProcessor(
|
||||||
|
hass,
|
||||||
|
event_types,
|
||||||
|
entity_ids,
|
||||||
|
device_ids,
|
||||||
|
None,
|
||||||
|
timestamp=True,
|
||||||
|
include_entity_name=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
stream_queue: asyncio.Queue[Event] = asyncio.Queue(MAX_PENDING_LOGBOOK_EVENTS)
|
||||||
|
subscriptions: list[CALLBACK_TYPE] = []
|
||||||
|
setup_complete_future: asyncio.Future[dt] = asyncio.Future()
|
||||||
|
task = asyncio.create_task(
|
||||||
|
_async_events_consumer(
|
||||||
|
setup_complete_future,
|
||||||
|
connection,
|
||||||
|
msg["id"],
|
||||||
|
stream_queue,
|
||||||
|
event_processor,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _unsub() -> None:
|
||||||
|
"""Unsubscribe from all events."""
|
||||||
|
for subscription in subscriptions:
|
||||||
|
subscription()
|
||||||
|
subscriptions.clear()
|
||||||
|
if task:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _queue_or_cancel(event: Event) -> None:
|
||||||
|
"""Queue an event to be processed or cancel."""
|
||||||
|
try:
|
||||||
|
stream_queue.put_nowait(event)
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Client exceeded max pending messages of %s",
|
||||||
|
MAX_PENDING_LOGBOOK_EVENTS,
|
||||||
|
)
|
||||||
|
_unsub()
|
||||||
|
|
||||||
|
async_subscribe_events(
|
||||||
|
hass, subscriptions, _queue_or_cancel, event_types, entity_ids, device_ids
|
||||||
|
)
|
||||||
|
subscriptions_setup_complete_time = dt_util.utcnow()
|
||||||
|
connection.subscriptions[msg["id"]] = _unsub
|
||||||
|
connection.send_result(msg["id"])
|
||||||
|
|
||||||
|
# Fetch everything from history
|
||||||
|
message, last_event_time = await _async_get_ws_formatted_events(
|
||||||
|
hass,
|
||||||
|
msg["id"],
|
||||||
|
start_time,
|
||||||
|
subscriptions_setup_complete_time,
|
||||||
|
messages.event_message,
|
||||||
|
event_processor,
|
||||||
|
)
|
||||||
|
# If there is no last_time there are no historical
|
||||||
|
# results, but we still send an empty message so
|
||||||
|
# consumers of the api know their request was
|
||||||
|
# answered but there were no results
|
||||||
|
connection.send_message(message)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
get_instance(hass).async_block_till_done(), MAX_RECORDER_WAIT
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Recorder is behind more than %s seconds, starting live stream; Some results may be missing"
|
||||||
|
)
|
||||||
|
|
||||||
|
if setup_complete_future.cancelled():
|
||||||
|
# Unsubscribe happened while waiting for recorder
|
||||||
|
return
|
||||||
|
|
||||||
|
#
|
||||||
|
# Fetch any events from the database that have
|
||||||
|
# not been committed since the original fetch
|
||||||
|
# so we can switch over to using the subscriptions
|
||||||
|
#
|
||||||
|
# We only want events that happened after the last event
|
||||||
|
# we had from the last database query or the maximum
|
||||||
|
# time we allow the recorder to be behind
|
||||||
|
#
|
||||||
|
max_recorder_behind = subscriptions_setup_complete_time - timedelta(
|
||||||
|
seconds=MAX_RECORDER_WAIT
|
||||||
|
)
|
||||||
|
second_fetch_start_time = max(
|
||||||
|
last_event_time or max_recorder_behind, max_recorder_behind
|
||||||
|
)
|
||||||
|
message, final_cutoff_time = await _async_get_ws_formatted_events(
|
||||||
|
hass,
|
||||||
|
msg["id"],
|
||||||
|
second_fetch_start_time,
|
||||||
|
subscriptions_setup_complete_time,
|
||||||
|
messages.event_message,
|
||||||
|
event_processor,
|
||||||
|
)
|
||||||
|
if final_cutoff_time: # Only sends results if we have them
|
||||||
|
connection.send_message(message)
|
||||||
|
|
||||||
|
if not setup_complete_future.cancelled():
|
||||||
|
# Unsubscribe happened while waiting for formatted events
|
||||||
|
setup_complete_future.set_result(subscriptions_setup_complete_time)
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "logbook/get_events",
|
||||||
|
vol.Required("start_time"): str,
|
||||||
|
vol.Optional("end_time"): str,
|
||||||
|
vol.Optional("entity_ids"): [str],
|
||||||
|
vol.Optional("device_ids"): [str],
|
||||||
|
vol.Optional("context_id"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def ws_get_events(
|
||||||
|
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
||||||
|
) -> None:
|
||||||
|
"""Handle logbook get events websocket command."""
|
||||||
|
start_time_str = msg["start_time"]
|
||||||
|
end_time_str = msg.get("end_time")
|
||||||
|
utc_now = dt_util.utcnow()
|
||||||
|
|
||||||
|
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 not end_time_str:
|
||||||
|
end_time = utc_now
|
||||||
|
elif parsed_end_time := dt_util.parse_datetime(end_time_str):
|
||||||
|
end_time = dt_util.as_utc(parsed_end_time)
|
||||||
|
else:
|
||||||
|
connection.send_error(msg["id"], "invalid_end_time", "Invalid end_time")
|
||||||
|
return
|
||||||
|
|
||||||
|
if start_time > utc_now:
|
||||||
|
connection.send_result(msg["id"], [])
|
||||||
|
return
|
||||||
|
|
||||||
|
device_ids = msg.get("device_ids")
|
||||||
|
entity_ids = msg.get("entity_ids")
|
||||||
|
context_id = msg.get("context_id")
|
||||||
|
if entity_ids:
|
||||||
|
entity_ids = async_filter_entities(hass, entity_ids)
|
||||||
|
if not entity_ids and not device_ids:
|
||||||
|
# Everything has been filtered away
|
||||||
|
connection.send_result(msg["id"], [])
|
||||||
|
return
|
||||||
|
|
||||||
|
event_types = async_determine_event_types(hass, entity_ids, device_ids)
|
||||||
|
|
||||||
|
event_processor = EventProcessor(
|
||||||
|
hass,
|
||||||
|
event_types,
|
||||||
|
entity_ids,
|
||||||
|
device_ids,
|
||||||
|
context_id,
|
||||||
|
timestamp=True,
|
||||||
|
include_entity_name=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
message, _ = await _async_get_ws_formatted_events(
|
||||||
|
hass,
|
||||||
|
msg["id"],
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
|
messages.result_message,
|
||||||
|
event_processor,
|
||||||
|
)
|
||||||
|
connection.send_message(message)
|
@ -75,6 +75,7 @@ from .tasks import (
|
|||||||
RecorderTask,
|
RecorderTask,
|
||||||
StatisticsTask,
|
StatisticsTask,
|
||||||
StopTask,
|
StopTask,
|
||||||
|
SynchronizeTask,
|
||||||
UpdateStatisticsMetadataTask,
|
UpdateStatisticsMetadataTask,
|
||||||
WaitTask,
|
WaitTask,
|
||||||
)
|
)
|
||||||
@ -928,6 +929,12 @@ class Recorder(threading.Thread):
|
|||||||
if self._async_event_filter(event):
|
if self._async_event_filter(event):
|
||||||
self.queue_task(EventTask(event))
|
self.queue_task(EventTask(event))
|
||||||
|
|
||||||
|
async def async_block_till_done(self) -> None:
|
||||||
|
"""Async version of block_till_done."""
|
||||||
|
event = asyncio.Event()
|
||||||
|
self.queue_task(SynchronizeTask(event))
|
||||||
|
await event.wait()
|
||||||
|
|
||||||
def block_till_done(self) -> None:
|
def block_till_done(self) -> None:
|
||||||
"""Block till all events processed.
|
"""Block till all events processed.
|
||||||
|
|
||||||
|
@ -248,3 +248,17 @@ class AddRecorderPlatformTask(RecorderTask):
|
|||||||
platforms[domain] = platform
|
platforms[domain] = platform
|
||||||
if hasattr(self.platform, "exclude_attributes"):
|
if hasattr(self.platform, "exclude_attributes"):
|
||||||
hass.data[EXCLUDE_ATTRIBUTES][domain] = platform.exclude_attributes(hass)
|
hass.data[EXCLUDE_ATTRIBUTES][domain] = platform.exclude_attributes(hass)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SynchronizeTask(RecorderTask):
|
||||||
|
"""Ensure all pending data has been committed."""
|
||||||
|
|
||||||
|
# commit_before is the default
|
||||||
|
event: asyncio.Event
|
||||||
|
|
||||||
|
def run(self, instance: Recorder) -> None:
|
||||||
|
"""Handle the task."""
|
||||||
|
# Does not use a tracked task to avoid
|
||||||
|
# blocking shutdown if the recorder is broken
|
||||||
|
instance.hass.loop.call_soon_threadsafe(self.event.set)
|
||||||
|
@ -61,7 +61,7 @@ def error_message(iden: int | None, code: str, message: str) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def event_message(iden: JSON_TYPE, event: Any) -> dict[str, Any]:
|
def event_message(iden: JSON_TYPE | int, event: Any) -> dict[str, Any]:
|
||||||
"""Return an event message."""
|
"""Return an event message."""
|
||||||
return {"id": iden, "type": "event", "event": event}
|
return {"id": iden, "type": "event", "event": event}
|
||||||
|
|
||||||
|
@ -716,13 +716,14 @@ class HomeAssistant:
|
|||||||
self._stopped.set()
|
self._stopped.set()
|
||||||
|
|
||||||
|
|
||||||
@attr.s(slots=True, frozen=True)
|
@attr.s(slots=True, frozen=False)
|
||||||
class Context:
|
class Context:
|
||||||
"""The context that triggered something."""
|
"""The context that triggered something."""
|
||||||
|
|
||||||
user_id: str | None = attr.ib(default=None)
|
user_id: str | None = attr.ib(default=None)
|
||||||
parent_id: str | None = attr.ib(default=None)
|
parent_id: str | None = attr.ib(default=None)
|
||||||
id: str = attr.ib(factory=ulid_util.ulid)
|
id: str = attr.ib(factory=ulid_util.ulid)
|
||||||
|
origin_event: Event | None = attr.ib(default=None, eq=False)
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, str | None]:
|
def as_dict(self) -> dict[str, str | None]:
|
||||||
"""Return a dictionary representation of the context."""
|
"""Return a dictionary representation of the context."""
|
||||||
@ -866,6 +867,8 @@ class EventBus:
|
|||||||
listeners = match_all_listeners + listeners
|
listeners = match_all_listeners + listeners
|
||||||
|
|
||||||
event = Event(event_type, event_data, origin, time_fired, context)
|
event = Event(event_type, event_data, origin, time_fired, context)
|
||||||
|
if not event.context.origin_event:
|
||||||
|
event.context.origin_event = event
|
||||||
|
|
||||||
_LOGGER.debug("Bus:Handling %s", event)
|
_LOGGER.debug("Bus:Handling %s", event)
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@ import json
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.components import logbook
|
from homeassistant.components import logbook
|
||||||
|
from homeassistant.components.logbook import processor
|
||||||
from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat
|
from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat
|
||||||
from homeassistant.core import Context
|
from homeassistant.core import Context
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
@ -50,16 +51,26 @@ class MockRow:
|
|||||||
|
|
||||||
def mock_humanify(hass_, rows):
|
def mock_humanify(hass_, rows):
|
||||||
"""Wrap humanify with mocked logbook objects."""
|
"""Wrap humanify with mocked logbook objects."""
|
||||||
entity_name_cache = logbook.EntityNameCache(hass_)
|
entity_name_cache = processor.EntityNameCache(hass_)
|
||||||
ent_reg = er.async_get(hass_)
|
ent_reg = er.async_get(hass_)
|
||||||
|
event_cache = processor.EventCache({})
|
||||||
|
context_lookup = processor.ContextLookup(hass_)
|
||||||
external_events = hass_.data.get(logbook.DOMAIN, {})
|
external_events = hass_.data.get(logbook.DOMAIN, {})
|
||||||
|
logbook_run = processor.LogbookRun(
|
||||||
|
context_lookup,
|
||||||
|
external_events,
|
||||||
|
event_cache,
|
||||||
|
entity_name_cache,
|
||||||
|
include_entity_name=True,
|
||||||
|
format_time=processor._row_time_fired_isoformat,
|
||||||
|
)
|
||||||
|
context_augmenter = processor.ContextAugmenter(logbook_run)
|
||||||
return list(
|
return list(
|
||||||
logbook._humanify(
|
processor._humanify(
|
||||||
rows,
|
rows,
|
||||||
None,
|
None,
|
||||||
ent_reg,
|
ent_reg,
|
||||||
external_events,
|
logbook_run,
|
||||||
entity_name_cache,
|
context_augmenter,
|
||||||
logbook._row_time_fired_isoformat,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
@ -14,6 +14,9 @@ import voluptuous as vol
|
|||||||
from homeassistant.components import logbook
|
from homeassistant.components import logbook
|
||||||
from homeassistant.components.alexa.smart_home import EVENT_ALEXA_SMART_HOME
|
from homeassistant.components.alexa.smart_home import EVENT_ALEXA_SMART_HOME
|
||||||
from homeassistant.components.automation import EVENT_AUTOMATION_TRIGGERED
|
from homeassistant.components.automation import EVENT_AUTOMATION_TRIGGERED
|
||||||
|
from homeassistant.components.logbook.models import LazyEventPartialState
|
||||||
|
from homeassistant.components.logbook.processor import EventProcessor
|
||||||
|
from homeassistant.components.logbook.queries.common import PSUEDO_EVENT_STATE_CHANGED
|
||||||
from homeassistant.components.script import EVENT_SCRIPT_STARTED
|
from homeassistant.components.script import EVENT_SCRIPT_STARTED
|
||||||
from homeassistant.components.sensor import SensorStateClass
|
from homeassistant.components.sensor import SensorStateClass
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
@ -95,15 +98,12 @@ async def test_service_call_create_logbook_entry(hass_):
|
|||||||
# Our service call will unblock when the event listeners have been
|
# Our service call will unblock when the event listeners have been
|
||||||
# scheduled. This means that they may not have been processed yet.
|
# scheduled. This means that they may not have been processed yet.
|
||||||
await async_wait_recording_done(hass_)
|
await async_wait_recording_done(hass_)
|
||||||
ent_reg = er.async_get(hass_)
|
event_processor = EventProcessor(hass_, (EVENT_LOGBOOK_ENTRY,))
|
||||||
|
|
||||||
events = list(
|
events = list(
|
||||||
logbook._get_events(
|
event_processor.get_events(
|
||||||
hass_,
|
|
||||||
dt_util.utcnow() - timedelta(hours=1),
|
dt_util.utcnow() - timedelta(hours=1),
|
||||||
dt_util.utcnow() + timedelta(hours=1),
|
dt_util.utcnow() + timedelta(hours=1),
|
||||||
(EVENT_LOGBOOK_ENTRY,),
|
|
||||||
ent_reg,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert len(events) == 2
|
assert len(events) == 2
|
||||||
@ -137,15 +137,11 @@ async def test_service_call_create_logbook_entry_invalid_entity_id(hass, recorde
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
await async_wait_recording_done(hass)
|
await async_wait_recording_done(hass)
|
||||||
ent_reg = er.async_get(hass)
|
event_processor = EventProcessor(hass, (EVENT_LOGBOOK_ENTRY,))
|
||||||
|
|
||||||
events = list(
|
events = list(
|
||||||
logbook._get_events(
|
event_processor.get_events(
|
||||||
hass,
|
|
||||||
dt_util.utcnow() - timedelta(hours=1),
|
dt_util.utcnow() - timedelta(hours=1),
|
||||||
dt_util.utcnow() + timedelta(hours=1),
|
dt_util.utcnow() + timedelta(hours=1),
|
||||||
(EVENT_LOGBOOK_ENTRY,),
|
|
||||||
ent_reg,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert len(events) == 1
|
assert len(events) == 1
|
||||||
@ -335,7 +331,7 @@ def create_state_changed_event_from_old_new(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
row.event_type = logbook.PSUEDO_EVENT_STATE_CHANGED
|
row.event_type = PSUEDO_EVENT_STATE_CHANGED
|
||||||
row.event_data = "{}"
|
row.event_data = "{}"
|
||||||
row.shared_data = "{}"
|
row.shared_data = "{}"
|
||||||
row.attributes = attributes_json
|
row.attributes = attributes_json
|
||||||
@ -353,7 +349,7 @@ def create_state_changed_event_from_old_new(
|
|||||||
row.context_parent_id = None
|
row.context_parent_id = None
|
||||||
row.old_state_id = old_state and 1
|
row.old_state_id = old_state and 1
|
||||||
row.state_id = new_state and 1
|
row.state_id = new_state and 1
|
||||||
return logbook.LazyEventPartialState(row, {})
|
return LazyEventPartialState(row, {})
|
||||||
|
|
||||||
|
|
||||||
async def test_logbook_view(hass, hass_client, recorder_mock):
|
async def test_logbook_view(hass, hass_client, recorder_mock):
|
||||||
|
1166
tests/components/logbook/test_websocket_api.py
Normal file
1166
tests/components/logbook/test_websocket_api.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,10 @@
|
|||||||
"""Common test utils for working with recorder."""
|
"""Common test utils for working with recorder."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
import time
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
@ -10,8 +13,9 @@ from sqlalchemy.orm.session import Session
|
|||||||
from homeassistant import core as ha
|
from homeassistant import core as ha
|
||||||
from homeassistant.components import recorder
|
from homeassistant.components import recorder
|
||||||
from homeassistant.components.recorder import get_instance, statistics
|
from homeassistant.components.recorder import get_instance, statistics
|
||||||
|
from homeassistant.components.recorder.core import Recorder
|
||||||
from homeassistant.components.recorder.models import RecorderRuns
|
from homeassistant.components.recorder.models import RecorderRuns
|
||||||
from homeassistant.components.recorder.tasks import StatisticsTask
|
from homeassistant.components.recorder.tasks import RecorderTask, StatisticsTask
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
@ -21,6 +25,31 @@ from tests.components.recorder import models_schema_0
|
|||||||
DEFAULT_PURGE_TASKS = 3
|
DEFAULT_PURGE_TASKS = 3
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BlockRecorderTask(RecorderTask):
|
||||||
|
"""A task to block the recorder for testing only."""
|
||||||
|
|
||||||
|
event: asyncio.Event
|
||||||
|
seconds: float
|
||||||
|
|
||||||
|
def run(self, instance: Recorder) -> None:
|
||||||
|
"""Block the recorders event loop."""
|
||||||
|
instance.hass.loop.call_soon_threadsafe(self.event.set)
|
||||||
|
time.sleep(self.seconds)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_block_recorder(hass: HomeAssistant, seconds: float) -> None:
|
||||||
|
"""Block the recorders event loop for testing.
|
||||||
|
|
||||||
|
Returns as soon as the recorder has started the block.
|
||||||
|
|
||||||
|
Does not wait for the block to finish.
|
||||||
|
"""
|
||||||
|
event = asyncio.Event()
|
||||||
|
get_instance(hass).queue_task(BlockRecorderTask(event, seconds))
|
||||||
|
await event.wait()
|
||||||
|
|
||||||
|
|
||||||
def do_adhoc_statistics(hass: HomeAssistant, **kwargs: Any) -> None:
|
def do_adhoc_statistics(hass: HomeAssistant, **kwargs: Any) -> None:
|
||||||
"""Trigger an adhoc statistics run."""
|
"""Trigger an adhoc statistics run."""
|
||||||
if not (start := kwargs.get("start")):
|
if not (start := kwargs.get("start")):
|
||||||
|
@ -55,6 +55,7 @@ from homeassistant.setup import async_setup_component, setup_component
|
|||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from .common import (
|
from .common import (
|
||||||
|
async_block_recorder,
|
||||||
async_wait_recording_done,
|
async_wait_recording_done,
|
||||||
corrupt_db_file,
|
corrupt_db_file,
|
||||||
run_information_with_session,
|
run_information_with_session,
|
||||||
@ -1537,3 +1538,25 @@ def test_deduplication_state_attributes_inside_commit_interval(hass_recorder, ca
|
|||||||
first_attributes_id = states[0].attributes_id
|
first_attributes_id = states[0].attributes_id
|
||||||
last_attributes_id = states[-1].attributes_id
|
last_attributes_id = states[-1].attributes_id
|
||||||
assert first_attributes_id == last_attributes_id
|
assert first_attributes_id == last_attributes_id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_async_block_till_done(hass, async_setup_recorder_instance):
|
||||||
|
"""Test we can block until recordering is done."""
|
||||||
|
instance = await async_setup_recorder_instance(hass)
|
||||||
|
await async_wait_recording_done(hass)
|
||||||
|
|
||||||
|
entity_id = "test.recorder"
|
||||||
|
attributes = {"test_attr": 5, "test_attr_10": "nice"}
|
||||||
|
|
||||||
|
hass.states.async_set(entity_id, "on", attributes)
|
||||||
|
hass.states.async_set(entity_id, "off", attributes)
|
||||||
|
|
||||||
|
def _fetch_states():
|
||||||
|
with session_scope(hass=hass) as session:
|
||||||
|
return list(session.query(States).filter(States.entity_id == entity_id))
|
||||||
|
|
||||||
|
await async_block_recorder(hass, 0.1)
|
||||||
|
await instance.async_block_till_done()
|
||||||
|
states = await instance.async_add_executor_job(_fetch_states)
|
||||||
|
assert len(states) == 2
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
@ -1799,3 +1799,33 @@ async def test_state_firing_event_matches_context_id_ulid_time(hass):
|
|||||||
assert _ulid_timestamp(event.context.id) == int(
|
assert _ulid_timestamp(event.context.id) == int(
|
||||||
events[0].time_fired.timestamp() * 1000
|
events[0].time_fired.timestamp() * 1000
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_event_context(hass):
|
||||||
|
"""Test we can lookup the origin of a context from an event."""
|
||||||
|
events = []
|
||||||
|
|
||||||
|
@ha.callback
|
||||||
|
def capture_events(event):
|
||||||
|
nonlocal events
|
||||||
|
events.append(event)
|
||||||
|
|
||||||
|
cancel = hass.bus.async_listen("dummy_event", capture_events)
|
||||||
|
cancel2 = hass.bus.async_listen("dummy_event_2", capture_events)
|
||||||
|
|
||||||
|
hass.bus.async_fire("dummy_event")
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
dummy_event: ha.Event = events[0]
|
||||||
|
|
||||||
|
hass.bus.async_fire("dummy_event_2", context=dummy_event.context)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
context_id = dummy_event.context.id
|
||||||
|
|
||||||
|
dummy_event2: ha.Event = events[1]
|
||||||
|
assert dummy_event2.context == dummy_event.context
|
||||||
|
assert dummy_event2.context.id == context_id
|
||||||
|
cancel()
|
||||||
|
cancel2()
|
||||||
|
|
||||||
|
assert dummy_event2.context.origin_event == dummy_event
|
||||||
|
Loading…
x
Reference in New Issue
Block a user