From 04af9698d3783c84ba8f6684badb40b96d63b13d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 May 2022 22:28:06 -0500 Subject: [PATCH] Add logbook/get_events websocket endpoint (#71706) Co-authored-by: Paulus Schoutsen --- homeassistant/components/logbook/__init__.py | 94 ++++++++- tests/components/logbook/common.py | 7 +- tests/components/logbook/test_init.py | 193 ++++++++++++++++--- 3 files changed, 260 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index 9cf3ad99e57..df8143d39fb 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -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: diff --git a/tests/components/logbook/common.py b/tests/components/logbook/common.py index 896add3104e..32273f04c05 100644 --- a/tests/components/logbook/common.py +++ b/tests/components/logbook/common.py @@ -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, ), ) diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index cc10346fc07..d382506a3db 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -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"