Add logbook/get_events websocket endpoint (#71706)
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
parent
b9f7d1f54c
commit
04af9698d3
3 changed files with 260 additions and 34 deletions
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Reference in a new issue