mirror of
https://github.com/home-assistant/core.git
synced 2026-06-27 00:55:26 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6860e0f3b9 | |||
| d3be0cc852 | |||
| c830c05cbd |
@@ -52,7 +52,9 @@ __all__ = [
|
||||
"DoorbellEventType",
|
||||
"EventDeviceClass",
|
||||
"EventEntity",
|
||||
"EventEntityCapabilityAttribute",
|
||||
"EventEntityDescription",
|
||||
"EventEntityStateAttribute",
|
||||
]
|
||||
|
||||
# mypy: disallow-any-generics
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Event parser and human readable log generator."""
|
||||
|
||||
from homeassistant.components.automation import EVENT_AUTOMATION_TRIGGERED
|
||||
from homeassistant.components.event import EventEntityStateAttribute
|
||||
from homeassistant.components.script import EVENT_SCRIPT_STARTED
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.const import EVENT_CALL_SERVICE, EVENT_LOGBOOK_ENTRY
|
||||
@@ -39,8 +40,12 @@ LOGBOOK_ENTRY_SOURCE = "source"
|
||||
LOGBOOK_ENTRY_MESSAGE = "message"
|
||||
LOGBOOK_ENTRY_NAME = "name"
|
||||
LOGBOOK_ENTRY_STATE = "state"
|
||||
LOGBOOK_ENTRY_ATTRIBUTES = "attributes"
|
||||
LOGBOOK_ENTRY_WHEN = "when"
|
||||
|
||||
# State attributes surfaced in logbook entries; extend as needed.
|
||||
EXPOSED_STATE_ATTRIBUTES = {EventEntityStateAttribute.EVENT_TYPE}
|
||||
|
||||
# Automation events that can affect an entity_id or device_id
|
||||
AUTOMATION_EVENTS = {EVENT_AUTOMATION_TRIGGERED, EVENT_SCRIPT_STARTED}
|
||||
|
||||
|
||||
@@ -106,10 +106,11 @@ CONTEXT_PARENT_ID_BIN_POS: Final = 6
|
||||
STATE_POS: Final = 7
|
||||
ENTITY_ID_POS: Final = 8
|
||||
ICON_POS: Final = 9
|
||||
CONTEXT_ONLY_POS: Final = 10
|
||||
ATTRIBUTES_POS: Final = 10
|
||||
CONTEXT_ONLY_POS: Final = 11
|
||||
# - For EventAsRow, additional fields are:
|
||||
DATA_POS: Final = 11
|
||||
CONTEXT_POS: Final = 12
|
||||
DATA_POS: Final = 12
|
||||
CONTEXT_POS: Final = 13
|
||||
|
||||
|
||||
@final # Final to allow direct checking of the type instead of using isinstance
|
||||
@@ -129,6 +130,7 @@ class EventAsRow(NamedTuple):
|
||||
state: str | None
|
||||
entity_id: str | None
|
||||
icon: str | None
|
||||
attributes: Mapping[str, Any] | None
|
||||
context_only: bool | None
|
||||
|
||||
# Additional fields for EventAsRow
|
||||
@@ -152,6 +154,7 @@ def async_event_to_row(event: Event) -> EventAsRow:
|
||||
state=None,
|
||||
entity_id=None,
|
||||
icon=None,
|
||||
attributes=None,
|
||||
context_only=None,
|
||||
data=event.data,
|
||||
context=context,
|
||||
@@ -175,6 +178,7 @@ def async_event_to_row(event: Event) -> EventAsRow:
|
||||
state=new_state.state,
|
||||
entity_id=new_state.entity_id,
|
||||
icon=new_state.attributes.get(ATTR_ICON),
|
||||
attributes=new_state.attributes,
|
||||
context_only=None,
|
||||
data=event.data,
|
||||
context=context,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Event parser and human readable log generator."""
|
||||
|
||||
from collections.abc import Callable, Collection, Generator, Sequence
|
||||
from collections.abc import Callable, Collection, Generator, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime as dt
|
||||
import logging
|
||||
@@ -16,6 +16,7 @@ from homeassistant.components.recorder import get_instance
|
||||
from homeassistant.components.recorder.filters import Filters
|
||||
from homeassistant.components.recorder.models import (
|
||||
bytes_to_uuid_hex_or_none,
|
||||
decode_attributes_from_source,
|
||||
extract_event_type_ids,
|
||||
extract_metadata_ids,
|
||||
process_timestamp_to_utc_isoformat,
|
||||
@@ -53,6 +54,8 @@ from .const import (
|
||||
CONTEXT_STATE,
|
||||
CONTEXT_USER_ID,
|
||||
DOMAIN,
|
||||
EXPOSED_STATE_ATTRIBUTES,
|
||||
LOGBOOK_ENTRY_ATTRIBUTES,
|
||||
LOGBOOK_ENTRY_DOMAIN,
|
||||
LOGBOOK_ENTRY_ENTITY_ID,
|
||||
LOGBOOK_ENTRY_ICON,
|
||||
@@ -64,6 +67,7 @@ from .const import (
|
||||
)
|
||||
from .helpers import is_sensor_continuous
|
||||
from .models import (
|
||||
ATTRIBUTES_POS,
|
||||
CONTEXT_ID_BIN_POS,
|
||||
CONTEXT_ONLY_POS,
|
||||
CONTEXT_PARENT_ID_BIN_POS,
|
||||
@@ -273,6 +277,24 @@ class EventProcessor:
|
||||
)
|
||||
|
||||
|
||||
def _exposed_state_attributes(
|
||||
row: Row | EventAsRow, attr_cache: dict[str, dict[str, Any]]
|
||||
) -> dict[str, Any]:
|
||||
"""Return the allowlisted state attributes for a state change row."""
|
||||
attributes: Mapping[str, Any] | None
|
||||
if type(row) is EventAsRow:
|
||||
attributes = row[ATTRIBUTES_POS]
|
||||
else:
|
||||
attributes = decode_attributes_from_source(row[ATTRIBUTES_POS], attr_cache)
|
||||
if not attributes:
|
||||
return {}
|
||||
return {
|
||||
name: attributes[name]
|
||||
for name in EXPOSED_STATE_ATTRIBUTES
|
||||
if name in attributes
|
||||
}
|
||||
|
||||
|
||||
def _humanify(
|
||||
hass: HomeAssistant,
|
||||
rows: Generator[EventAsRow] | Sequence[Row] | Result,
|
||||
@@ -294,6 +316,8 @@ def _humanify(
|
||||
get_context = context_augmenter.get_context
|
||||
context_id_bin: bytes
|
||||
data: dict[str, Any]
|
||||
# Decode each shared attribute set only once per run.
|
||||
attr_cache: dict[str, dict[str, Any]] = {}
|
||||
|
||||
context_user_ids = logbook_run.context_user_ids
|
||||
# Skip the LRU write on one-shot runs — the LogbookRun is discarded.
|
||||
@@ -337,6 +361,8 @@ def _humanify(
|
||||
data[LOGBOOK_ENTRY_NAME] = entity_name_cache_get(entity_id)
|
||||
if icon := row[ICON_POS]:
|
||||
data[LOGBOOK_ENTRY_ICON] = icon
|
||||
if exposed := _exposed_state_attributes(row, attr_cache):
|
||||
data[LOGBOOK_ENTRY_ATTRIBUTES] = exposed
|
||||
|
||||
elif event_type in external_events:
|
||||
domain, describe_event = external_events[event_type]
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.components.recorder.db_schema import (
|
||||
EVENTS_CONTEXT_ID_BIN_INDEX,
|
||||
OLD_FORMAT_ATTRS_JSON,
|
||||
OLD_STATE,
|
||||
SHARED_ATTR_OR_LEGACY_ATTRIBUTES,
|
||||
SHARED_ATTRS_JSON,
|
||||
SHARED_DATA_OR_LEGACY_EVENT_DATA,
|
||||
STATES_CONTEXT_ID_BIN_INDEX,
|
||||
@@ -65,12 +66,14 @@ STATE_COLUMNS = (
|
||||
States.state.label("state"),
|
||||
StatesMeta.entity_id.label("entity_id"),
|
||||
ICON_OR_OLD_FORMAT_ICON_JSON,
|
||||
SHARED_ATTR_OR_LEGACY_ATTRIBUTES,
|
||||
)
|
||||
|
||||
STATE_CONTEXT_ONLY_COLUMNS = (
|
||||
States.state.label("state"),
|
||||
StatesMeta.entity_id.label("entity_id"),
|
||||
literal(value=None, type_=sqlalchemy.String).label("icon"),
|
||||
literal(value=None, type_=sqlalchemy.String).label("attributes"),
|
||||
)
|
||||
|
||||
EVENT_COLUMNS_FOR_STATE_SELECT = (
|
||||
@@ -93,6 +96,7 @@ EMPTY_STATE_COLUMNS = (
|
||||
literal(value=None, type_=sqlalchemy.String).label("state"),
|
||||
literal(value=None, type_=sqlalchemy.String).label("entity_id"),
|
||||
literal(value=None, type_=sqlalchemy.String).label("icon"),
|
||||
literal(value=None, type_=sqlalchemy.String).label("attributes"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from .context import (
|
||||
from .database import DatabaseEngine, DatabaseOptimizer, UnsupportedDialect
|
||||
from .event import extract_event_type_ids
|
||||
from .state import LazyState, extract_metadata_ids, row_to_compressed_state
|
||||
from .state_attributes import decode_attributes_from_source
|
||||
from .statistics import (
|
||||
CalendarStatisticPeriod,
|
||||
FixedStatisticPeriod,
|
||||
@@ -44,6 +45,7 @@ __all__ = [
|
||||
"bytes_to_ulid_or_none",
|
||||
"bytes_to_uuid_hex_or_none",
|
||||
"datetime_to_timestamp_or_none",
|
||||
"decode_attributes_from_source",
|
||||
"extract_event_type_ids",
|
||||
"extract_metadata_ids",
|
||||
"process_timestamp",
|
||||
|
||||
@@ -42,6 +42,7 @@ from homeassistant.const import (
|
||||
EVENT_LOGBOOK_ENTRY,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import Context, Event, HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
@@ -345,6 +346,7 @@ def create_state_changed_event_from_old_new(
|
||||
state=new_state and new_state.get("state"),
|
||||
entity_id=entity_id,
|
||||
icon=None,
|
||||
attributes=None,
|
||||
context_only=False,
|
||||
data=None,
|
||||
context=None,
|
||||
@@ -1857,6 +1859,46 @@ async def test_icon_and_state(
|
||||
assert response_json[2]["state"] == STATE_OFF
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("recorder_mock")
|
||||
async def test_state_attributes_in_logbook(
|
||||
hass: HomeAssistant, hass_client: ClientSessionGenerator
|
||||
) -> None:
|
||||
"""Test state attributes are exposed in the logbook like the history.
|
||||
|
||||
The recorded subset is surfaced, so attributes the recorder excludes
|
||||
(such as supported_features) must not appear.
|
||||
"""
|
||||
await asyncio.gather(
|
||||
*[
|
||||
async_setup_component(hass, domain, {})
|
||||
for domain in ("homeassistant", "logbook")
|
||||
]
|
||||
)
|
||||
await async_recorder_block_till_done(hass)
|
||||
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
|
||||
|
||||
hass.states.async_set("event.doorbell", STATE_UNKNOWN, {"event_type": None})
|
||||
hass.states.async_set(
|
||||
"event.doorbell",
|
||||
"2024-01-01T00:00:00.000+00:00",
|
||||
{"event_type": "ring", "supported_features": 1},
|
||||
)
|
||||
hass.states.async_set(
|
||||
"event.doorbell", "2024-01-01T00:01:00.000+00:00", {"event_type": "motion"}
|
||||
)
|
||||
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
client = await hass_client()
|
||||
response_json = await _async_fetch_logbook(client)
|
||||
|
||||
entries = [e for e in response_json if e.get("entity_id") == "event.doorbell"]
|
||||
assert len(entries) == 2
|
||||
assert entries[0]["attributes"] == {"event_type": "ring"}
|
||||
assert entries[1]["attributes"] == {"event_type": "motion"}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("recorder_mock")
|
||||
async def test_fire_logbook_entries(
|
||||
hass: HomeAssistant, hass_client: ClientSessionGenerator
|
||||
@@ -3335,6 +3377,7 @@ async def test_parent_user_attribution_does_not_use_origin_event_fallback(
|
||||
state=STATE_ON,
|
||||
entity_id="switch.heater",
|
||||
icon=None,
|
||||
attributes=None,
|
||||
context_only=False,
|
||||
data={},
|
||||
context=child_context,
|
||||
|
||||
@@ -19,6 +19,7 @@ def test_lazy_event_partial_state_context() -> None:
|
||||
state="state",
|
||||
entity_id="entity_id",
|
||||
icon="icon",
|
||||
attributes=None,
|
||||
context_only=False,
|
||||
data={},
|
||||
context=Mock(),
|
||||
|
||||
@@ -35,6 +35,7 @@ from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_START,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, State, callback
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
@@ -1479,6 +1480,64 @@ async def test_subscribe_unsubscribe_logbook_stream(
|
||||
) == listeners_without_writes(init_listeners)
|
||||
|
||||
|
||||
@patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0)
|
||||
async def test_subscribe_logbook_stream_state_attributes(
|
||||
recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""Test the live logbook stream exposes allowlisted state attributes."""
|
||||
now = dt_util.utcnow()
|
||||
await asyncio.gather(
|
||||
*[
|
||||
async_setup_component(hass, domain, {})
|
||||
for domain in ("homeassistant", "logbook")
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
hass.states.async_set("binary_sensor.is_light", STATE_ON)
|
||||
hass.states.async_set("binary_sensor.is_light", STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
websocket_client = await hass_ws_client()
|
||||
await websocket_client.send_json(
|
||||
{"id": 7, "type": "logbook/event_stream", "start_time": now.isoformat()}
|
||||
)
|
||||
|
||||
msg = await asyncio.wait_for(websocket_client.receive_json(), 2)
|
||||
assert msg["id"] == 7
|
||||
assert msg["type"] == TYPE_RESULT
|
||||
assert msg["success"]
|
||||
|
||||
msg = await asyncio.wait_for(websocket_client.receive_json(), 2)
|
||||
assert msg["event"]["partial"] is True
|
||||
|
||||
await hass.async_block_till_done()
|
||||
msg = await asyncio.wait_for(websocket_client.receive_json(), 2)
|
||||
assert "partial" not in msg["event"]
|
||||
assert msg["event"]["events"] == []
|
||||
|
||||
hass.states.async_set("event.doorbell", STATE_UNKNOWN, {"event_type": None})
|
||||
hass.states.async_set(
|
||||
"event.doorbell",
|
||||
"2024-01-01T00:00:00.000+00:00",
|
||||
{"event_type": "ring", "supported_features": 1},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
msg = await asyncio.wait_for(websocket_client.receive_json(), 2)
|
||||
assert msg["id"] == 7
|
||||
assert msg["type"] == "event"
|
||||
assert msg["event"]["events"] == [
|
||||
{
|
||||
"entity_id": "event.doorbell",
|
||||
"state": "2024-01-01T00:00:00.000+00:00",
|
||||
"attributes": {"event_type": "ring"},
|
||||
"when": ANY,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0)
|
||||
async def test_subscribe_unsubscribe_logbook_stream_entities(
|
||||
recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
|
||||
Reference in New Issue
Block a user