Add logbook/get_events websocket endpoint (#71706)

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
J. Nick Koston 2022-05-11 22:28:06 -05:00 committed by GitHub
parent b9f7d1f54c
commit 04af9698d3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 260 additions and 34 deletions

View file

@ -15,7 +15,7 @@ from sqlalchemy.engine.row import Row
from sqlalchemy.orm.query import Query
import voluptuous as vol
from homeassistant.components import frontend
from homeassistant.components import frontend, websocket_api
from homeassistant.components.automation import EVENT_AUTOMATION_TRIGGERED
from homeassistant.components.history import (
Filters,
@ -23,7 +23,10 @@ from homeassistant.components.history import (
)
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat
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
@ -102,6 +105,10 @@ LOG_MESSAGE_SCHEMA = vol.Schema(
)
LOGBOOK_FILTERS = "logbook_filters"
LOGBOOK_ENTITIES_FILTER = "entities_filter"
@bind_hass
def log_entry(
hass: HomeAssistant,
@ -168,7 +175,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
filters = None
entities_filter = None
hass.data[LOGBOOK_FILTERS] = filters
hass.data[LOGBOOK_ENTITIES_FILTER] = entities_filter
hass.http.register_view(LogbookView(conf, filters, entities_filter))
websocket_api.async_register_command(hass, ws_get_events)
hass.services.async_register(DOMAIN, "log", log_message, schema=LOG_MESSAGE_SCHEMA)
@ -194,6 +205,61 @@ async def _process_logbook_platform(
platform.async_describe_events(hass, _async_describe_event)
@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("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
entity_ids = msg.get("entity_ids")
context_id = msg.get("context_id")
logbook_events: list[dict[str, Any]] = await get_instance(
hass
).async_add_executor_job(
_get_events,
hass,
start_time,
end_time,
entity_ids,
hass.data[LOGBOOK_FILTERS],
hass.data[LOGBOOK_ENTITIES_FILTER],
context_id,
True,
)
connection.send_result(msg["id"], logbook_events)
class LogbookView(HomeAssistantView):
"""Handle logbook view requests."""
@ -267,6 +333,7 @@ class LogbookView(HomeAssistantView):
self.filters,
self.entities_filter,
context_id,
False,
)
)
@ -281,6 +348,7 @@ def _humanify(
entity_name_cache: EntityNameCache,
event_cache: EventCache,
context_augmenter: ContextAugmenter,
format_time: Callable[[Row], Any],
) -> Generator[dict[str, Any], None, None]:
"""Generate a converted list of events into Entry objects.
@ -307,7 +375,7 @@ def _humanify(
continue
data = {
"when": _row_time_fired_isoformat(row),
"when": format_time(row),
"name": entity_name_cache.get(entity_id, row),
"state": row.state,
"entity_id": entity_id,
@ -321,21 +389,21 @@ def _humanify(
elif event_type in external_events:
domain, describe_event = external_events[event_type]
data = describe_event(event_cache.get(row))
data["when"] = _row_time_fired_isoformat(row)
data["when"] = format_time(row)
data["domain"] = domain
context_augmenter.augment(data, data.get(ATTR_ENTITY_ID), row)
yield data
elif event_type == EVENT_HOMEASSISTANT_START:
yield {
"when": _row_time_fired_isoformat(row),
"when": format_time(row),
"name": "Home Assistant",
"message": "started",
"domain": HA_DOMAIN,
}
elif event_type == EVENT_HOMEASSISTANT_STOP:
yield {
"when": _row_time_fired_isoformat(row),
"when": format_time(row),
"name": "Home Assistant",
"message": "stopped",
"domain": HA_DOMAIN,
@ -351,7 +419,7 @@ def _humanify(
domain = split_entity_id(str(entity_id))[0]
data = {
"when": _row_time_fired_isoformat(row),
"when": format_time(row),
"name": event_data.get(ATTR_NAME),
"message": event_data.get(ATTR_MESSAGE),
"domain": domain,
@ -369,6 +437,7 @@ def _get_events(
filters: Filters | None = None,
entities_filter: EntityFilter | Callable[[str], bool] | None = None,
context_id: str | None = None,
timestamp: bool = False,
) -> list[dict[str, Any]]:
"""Get events for a period of time."""
assert not (
@ -386,6 +455,7 @@ def _get_events(
context_lookup, entity_name_cache, external_events, event_cache
)
event_types = (*ALL_EVENT_TYPES_EXCEPT_STATE_CHANGED, *external_events)
format_time = _row_time_fired_timestamp if timestamp else _row_time_fired_isoformat
def yield_rows(query: Query) -> Generator[Row, None, None]:
"""Yield Events that are not filtered away."""
@ -424,6 +494,7 @@ def _get_events(
entity_name_cache,
event_cache,
context_augmenter,
format_time,
)
)
@ -575,9 +646,14 @@ def _row_attributes_extract(row: Row, extractor: re.Pattern) -> str | None:
return result.group(1) if result else None
def _row_time_fired_isoformat(row: Row) -> dt | 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()
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:

View file

@ -53,6 +53,11 @@ def mock_humanify(hass_, rows):
)
return list(
logbook._humanify(
hass_, rows, entity_name_cache, event_cache, context_augmenter
hass_,
rows,
entity_name_cache,
event_cache,
context_augmenter,
logbook._row_time_fired_isoformat,
),
)

View file

@ -4,7 +4,6 @@ import collections
from datetime import datetime, timedelta
from http import HTTPStatus
import json
from typing import Any
from unittest.mock import Mock, patch
import pytest
@ -13,7 +12,6 @@ import voluptuous as vol
from homeassistant.components import logbook
from homeassistant.components.alexa.smart_home import EVENT_ALEXA_SMART_HOME
from homeassistant.components.automation import EVENT_AUTOMATION_TRIGGERED
from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat
from homeassistant.components.script import EVENT_SCRIPT_STARTED
from homeassistant.components.sensor import SensorStateClass
from homeassistant.const import (
@ -42,7 +40,7 @@ from homeassistant.helpers.json import JSONEncoder
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
from .common import mock_humanify
from .common import MockRow, mock_humanify
from tests.common import async_capture_events, mock_platform
from tests.components.recorder.common import (
@ -2140,27 +2138,174 @@ def _assert_entry(
assert state == entry["state"]
class MockRow:
"""Minimal row mock."""
async def test_get_events(hass, hass_ws_client, recorder_mock):
"""Test logbook get_events."""
now = dt_util.utcnow()
await async_setup_component(hass, "logbook", {})
await async_recorder_block_till_done(hass)
def __init__(self, event_type: str, data: dict[str, Any] = None):
"""Init the fake row."""
self.event_type = event_type
self.shared_data = json.dumps(data, cls=JSONEncoder)
self.data = data
self.time_fired = dt_util.utcnow()
self.context_parent_id = None
self.context_user_id = None
self.context_id = None
self.state = None
self.entity_id = None
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
@property
def time_fired_minute(self):
"""Minute the event was fired."""
return self.time_fired.minute
hass.states.async_set("light.kitchen", STATE_OFF)
await hass.async_block_till_done()
hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 100})
await hass.async_block_till_done()
hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 200})
await hass.async_block_till_done()
hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 300})
await hass.async_block_till_done()
hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 400})
await hass.async_block_till_done()
context = ha.Context(
id="ac5bd62de45711eaaeb351041eec8dd9",
user_id="b400facee45711eaa9308bfd3d19e474",
)
@property
def time_fired_isoformat(self):
"""Time event was fired in utc isoformat."""
return process_timestamp_to_utc_isoformat(self.time_fired)
hass.states.async_set("light.kitchen", STATE_OFF, context=context)
await hass.async_block_till_done()
await async_wait_recording_done(hass)
client = await hass_ws_client()
await client.send_json(
{
"id": 1,
"type": "logbook/get_events",
"start_time": now.isoformat(),
"end_time": now.isoformat(),
"entity_ids": ["light.kitchen"],
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == []
await client.send_json(
{
"id": 2,
"type": "logbook/get_events",
"start_time": now.isoformat(),
"entity_ids": ["sensor.test"],
}
)
response = await client.receive_json()
assert response["success"]
assert response["id"] == 2
assert response["result"] == []
await client.send_json(
{
"id": 3,
"type": "logbook/get_events",
"start_time": now.isoformat(),
"entity_ids": ["light.kitchen"],
}
)
response = await client.receive_json()
assert response["success"]
assert response["id"] == 3
results = response["result"]
assert results[0]["entity_id"] == "light.kitchen"
assert results[0]["state"] == "on"
assert results[1]["entity_id"] == "light.kitchen"
assert results[1]["state"] == "off"
await client.send_json(
{
"id": 4,
"type": "logbook/get_events",
"start_time": now.isoformat(),
}
)
response = await client.receive_json()
assert response["success"]
assert response["id"] == 4
results = response["result"]
assert len(results) == 3
assert results[0]["message"] == "started"
assert results[1]["entity_id"] == "light.kitchen"
assert results[1]["state"] == "on"
assert isinstance(results[1]["when"], float)
assert results[2]["entity_id"] == "light.kitchen"
assert results[2]["state"] == "off"
assert isinstance(results[2]["when"], float)
await client.send_json(
{
"id": 5,
"type": "logbook/get_events",
"start_time": now.isoformat(),
"context_id": "ac5bd62de45711eaaeb351041eec8dd9",
}
)
response = await client.receive_json()
assert response["success"]
assert response["id"] == 5
results = response["result"]
assert len(results) == 1
assert results[0]["entity_id"] == "light.kitchen"
assert results[0]["state"] == "off"
assert isinstance(results[0]["when"], float)
async def test_get_events_future_start_time(hass, hass_ws_client, recorder_mock):
"""Test get_events with a future start time."""
await async_setup_component(hass, "logbook", {})
await async_recorder_block_till_done(hass)
future = dt_util.utcnow() + timedelta(hours=10)
client = await hass_ws_client()
await client.send_json(
{
"id": 1,
"type": "logbook/get_events",
"start_time": future.isoformat(),
}
)
response = await client.receive_json()
assert response["success"]
assert response["id"] == 1
results = response["result"]
assert len(results) == 0
async def test_get_events_bad_start_time(hass, hass_ws_client, recorder_mock):
"""Test get_events bad start time."""
await async_setup_component(hass, "logbook", {})
await async_recorder_block_till_done(hass)
client = await hass_ws_client()
await client.send_json(
{
"id": 1,
"type": "logbook/get_events",
"start_time": "cats",
}
)
response = await client.receive_json()
assert not response["success"]
assert response["error"]["code"] == "invalid_start_time"
async def test_get_events_bad_end_time(hass, hass_ws_client, recorder_mock):
"""Test get_events bad end time."""
now = dt_util.utcnow()
await async_setup_component(hass, "logbook", {})
await async_recorder_block_till_done(hass)
client = await hass_ws_client()
await client.send_json(
{
"id": 1,
"type": "logbook/get_events",
"start_time": now.isoformat(),
"end_time": "dogs",
}
)
response = await client.receive_json()
assert not response["success"]
assert response["error"]["code"] == "invalid_end_time"