Compare commits

...

3 Commits

Author SHA1 Message Date
Paul Bottein 6860e0f3b9 Remove comment 2026-06-25 13:02:39 +02:00
Paul Bottein d3be0cc852 Simplify live attribute handling and use event enum 2026-06-25 12:49:01 +02:00
Paul Bottein c830c05cbd Expose selected state attributes in the logbook 2026-06-25 12:15:44 +02:00
9 changed files with 150 additions and 4 deletions
@@ -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}
+7 -3
View File
@@ -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,
+27 -1
View File
@@ -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",
+43
View File
@@ -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,
+1
View File
@@ -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