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 from sqlalchemy.orm.query import Query
import voluptuous as vol 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.automation import EVENT_AUTOMATION_TRIGGERED
from homeassistant.components.history import ( from homeassistant.components.history import (
Filters, Filters,
@ -23,7 +23,10 @@ from homeassistant.components.history import (
) )
from homeassistant.components.http import HomeAssistantView from homeassistant.components.http import HomeAssistantView
from homeassistant.components.recorder import get_instance 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.recorder.util import session_scope
from homeassistant.components.script import EVENT_SCRIPT_STARTED from homeassistant.components.script import EVENT_SCRIPT_STARTED
from homeassistant.components.sensor import ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN 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 @bind_hass
def log_entry( def log_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -168,7 +175,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
filters = None filters = None
entities_filter = 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)) 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) 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) 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): class LogbookView(HomeAssistantView):
"""Handle logbook view requests.""" """Handle logbook view requests."""
@ -267,6 +333,7 @@ class LogbookView(HomeAssistantView):
self.filters, self.filters,
self.entities_filter, self.entities_filter,
context_id, context_id,
False,
) )
) )
@ -281,6 +348,7 @@ def _humanify(
entity_name_cache: EntityNameCache, entity_name_cache: EntityNameCache,
event_cache: EventCache, event_cache: EventCache,
context_augmenter: ContextAugmenter, context_augmenter: ContextAugmenter,
format_time: Callable[[Row], Any],
) -> Generator[dict[str, Any], None, None]: ) -> Generator[dict[str, Any], None, None]:
"""Generate a converted list of events into Entry objects. """Generate a converted list of events into Entry objects.
@ -307,7 +375,7 @@ def _humanify(
continue continue
data = { data = {
"when": _row_time_fired_isoformat(row), "when": format_time(row),
"name": entity_name_cache.get(entity_id, row), "name": entity_name_cache.get(entity_id, row),
"state": row.state, "state": row.state,
"entity_id": entity_id, "entity_id": entity_id,
@ -321,21 +389,21 @@ def _humanify(
elif event_type in external_events: elif event_type in external_events:
domain, describe_event = external_events[event_type] domain, describe_event = external_events[event_type]
data = describe_event(event_cache.get(row)) data = describe_event(event_cache.get(row))
data["when"] = _row_time_fired_isoformat(row) data["when"] = format_time(row)
data["domain"] = domain data["domain"] = domain
context_augmenter.augment(data, data.get(ATTR_ENTITY_ID), row) context_augmenter.augment(data, data.get(ATTR_ENTITY_ID), row)
yield data yield data
elif event_type == EVENT_HOMEASSISTANT_START: elif event_type == EVENT_HOMEASSISTANT_START:
yield { yield {
"when": _row_time_fired_isoformat(row), "when": format_time(row),
"name": "Home Assistant", "name": "Home Assistant",
"message": "started", "message": "started",
"domain": HA_DOMAIN, "domain": HA_DOMAIN,
} }
elif event_type == EVENT_HOMEASSISTANT_STOP: elif event_type == EVENT_HOMEASSISTANT_STOP:
yield { yield {
"when": _row_time_fired_isoformat(row), "when": format_time(row),
"name": "Home Assistant", "name": "Home Assistant",
"message": "stopped", "message": "stopped",
"domain": HA_DOMAIN, "domain": HA_DOMAIN,
@ -351,7 +419,7 @@ def _humanify(
domain = split_entity_id(str(entity_id))[0] domain = split_entity_id(str(entity_id))[0]
data = { data = {
"when": _row_time_fired_isoformat(row), "when": format_time(row),
"name": event_data.get(ATTR_NAME), "name": event_data.get(ATTR_NAME),
"message": event_data.get(ATTR_MESSAGE), "message": event_data.get(ATTR_MESSAGE),
"domain": domain, "domain": domain,
@ -369,6 +437,7 @@ def _get_events(
filters: Filters | None = None, filters: Filters | None = None,
entities_filter: EntityFilter | Callable[[str], bool] | None = None, entities_filter: EntityFilter | Callable[[str], bool] | None = None,
context_id: str | None = None, context_id: str | None = None,
timestamp: bool = False,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Get events for a period of time.""" """Get events for a period of time."""
assert not ( assert not (
@ -386,6 +455,7 @@ def _get_events(
context_lookup, entity_name_cache, external_events, event_cache context_lookup, entity_name_cache, external_events, event_cache
) )
event_types = (*ALL_EVENT_TYPES_EXCEPT_STATE_CHANGED, *external_events) 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]: def yield_rows(query: Query) -> Generator[Row, None, None]:
"""Yield Events that are not filtered away.""" """Yield Events that are not filtered away."""
@ -424,6 +494,7 @@ def _get_events(
entity_name_cache, entity_name_cache,
event_cache, event_cache,
context_augmenter, 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 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.""" """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: class LazyEventPartialState:

View file

@ -53,6 +53,11 @@ def mock_humanify(hass_, rows):
) )
return list( return list(
logbook._humanify( 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 datetime import datetime, timedelta
from http import HTTPStatus from http import HTTPStatus
import json import json
from typing import Any
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
import pytest import pytest
@ -13,7 +12,6 @@ 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.recorder.models import process_timestamp_to_utc_isoformat
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 (
@ -42,7 +40,7 @@ from homeassistant.helpers.json import JSONEncoder
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util 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.common import async_capture_events, mock_platform
from tests.components.recorder.common import ( from tests.components.recorder.common import (
@ -2140,27 +2138,174 @@ def _assert_entry(
assert state == entry["state"] assert state == entry["state"]
class MockRow: async def test_get_events(hass, hass_ws_client, recorder_mock):
"""Minimal row 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): hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
"""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
@property hass.states.async_set("light.kitchen", STATE_OFF)
def time_fired_minute(self): await hass.async_block_till_done()
"""Minute the event was fired.""" hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 100})
return self.time_fired.minute 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 hass.states.async_set("light.kitchen", STATE_OFF, context=context)
def time_fired_isoformat(self): await hass.async_block_till_done()
"""Time event was fired in utc isoformat."""
return process_timestamp_to_utc_isoformat(self.time_fired) 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"