1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-02 00:20:30 +01:00

Improve logbook parent context handling

This commit is contained in:
Erik
2026-04-01 08:49:37 +02:00
parent a3f3b0bed4
commit 4cd93b0e7c
8 changed files with 517 additions and 6 deletions

View File

@@ -11,7 +11,9 @@ from homeassistant.const import (
ATTR_DEVICE_ID,
ATTR_DOMAIN,
ATTR_ENTITY_ID,
ATTR_SERVICE_DATA,
ATTR_UNIT_OF_MEASUREMENT,
EVENT_CALL_SERVICE,
EVENT_LOGBOOK_ENTRY,
EVENT_STATE_CHANGED,
)
@@ -99,15 +101,25 @@ def async_determine_event_types(
if entity_ids:
# We also allow entity_ids to be recorded via manual logbook entries.
intrested_event_types.add(EVENT_LOGBOOK_ENTRY)
# Include EVENT_CALL_SERVICE so that service call context (including
# user attribution) is available for entity-specific streams.
intrested_event_types.add(EVENT_CALL_SERVICE)
return tuple(intrested_event_types)
@callback
def extract_attr(source: Mapping[str, Any], attr: str) -> list[str]:
"""Extract an attribute as a list or string."""
"""Extract an attribute as a list or string.
For EVENT_CALL_SERVICE events, the entity_id is inside service_data,
not at the top level. Check service_data as a fallback.
"""
if (value := source.get(attr)) is None:
return []
if service_data := source.get(ATTR_SERVICE_DATA):
value = service_data.get(attr)
if value is None:
return []
if isinstance(value, list):
return value
return str(value).split(",")

View File

@@ -162,7 +162,10 @@ def async_event_to_row(event: Event) -> EventAsRow:
# that are missing new_state or old_state
# since the logbook does not show these
new_state: State = event.data["new_state"]
context = new_state.context
# Use the event's context rather than the state's context because
# State.expire() replaces the context with a copy that loses
# origin_event, which is needed for context augmentation.
context = event.context
return EventAsRow(
row_id=hash(event),
event_type=None,

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
from collections.abc import Callable, Generator, Sequence
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime as dt
import logging
import time
@@ -99,6 +99,10 @@ class LogbookRun:
include_entity_name: bool
timestamp: bool
memoize_new_contexts: bool = True
# Track context_id -> user_id for parent context user attribution.
# Persisted across batches so child context events can inherit user_id
# from a parent context whose SERVICE_CALL event arrived in an earlier batch.
context_user_ids: dict[bytes, bytes] = field(default_factory=dict)
class EventProcessor:
@@ -220,11 +224,15 @@ def _humanify(
context_id_bin: bytes
data: dict[str, Any]
context_user_ids = logbook_run.context_user_ids
# Process rows
for row in rows:
context_id_bin = row[CONTEXT_ID_BIN_POS]
if memoize_new_contexts and context_id_bin not in context_lookup:
context_lookup[context_id_bin] = row
if context_user_id_bin := row[CONTEXT_USER_ID_BIN_POS]:
context_user_ids.setdefault(context_id_bin, context_user_id_bin)
if row[CONTEXT_ONLY_POS]:
continue
event_type = row[EVENT_TYPE_POS]
@@ -307,6 +315,18 @@ def _humanify(
):
context_augmenter.augment(data, context_row)
# If user attribution is still missing, check the parent context.
# This handles child contexts (e.g., generic_thermostat creating a new
# context for the switch service call with the original as parent).
if CONTEXT_USER_ID not in data and (
context_parent_id_bin := row[CONTEXT_PARENT_ID_BIN_POS]
):
if (parent_user_id_bin := context_user_ids.get(context_parent_id_bin)) or (
(parent_row := get_context(context_parent_id_bin, row))
and (parent_user_id_bin := parent_row[CONTEXT_USER_ID_BIN_POS])
):
data[CONTEXT_USER_ID] = bytes_to_uuid_hex_or_none(parent_user_id_bin)
yield data

View File

@@ -50,6 +50,15 @@ def _select_entities_context_ids_sub_query(
(States.last_updated_ts > start_day) & (States.last_updated_ts < end_day)
)
.where(States.metadata_id.in_(states_metadata_ids)),
# Also include parent context ids so that events from parent contexts
# (e.g., the user's service call that triggered a child context) are
# fetched as context-only rows for user attribution.
apply_entities_hints(select(States.context_parent_id_bin))
.filter(
(States.last_updated_ts > start_day) & (States.last_updated_ts < end_day)
)
.where(States.metadata_id.in_(states_metadata_ids))
.where(States.context_parent_id_bin.is_not(None)),
).subquery()
return select(union.c.context_id_bin).group_by(union.c.context_id_bin)

View File

@@ -53,6 +53,15 @@ def _select_entities_device_id_context_ids_sub_query(
(States.last_updated_ts > start_day) & (States.last_updated_ts < end_day)
)
.where(States.metadata_id.in_(states_metadata_ids)),
# Also include parent context ids so that events from parent contexts
# (e.g., the user's service call that triggered a child context) are
# fetched as context-only rows for user attribution.
apply_entities_hints(select(States.context_parent_id_bin))
.filter(
(States.last_updated_ts > start_day) & (States.last_updated_ts < end_day)
)
.where(States.metadata_id.in_(states_metadata_ids))
.where(States.context_parent_id_bin.is_not(None)),
).subquery()
return select(union.c.context_id_bin).group_by(union.c.context_id_bin)

View File

@@ -13,7 +13,16 @@ from homeassistant.components.recorder.models import (
ulid_to_bytes_or_none,
uuid_hex_to_bytes_or_none,
)
from homeassistant.core import Context
from homeassistant.const import (
ATTR_DOMAIN,
ATTR_ENTITY_ID,
ATTR_FRIENDLY_NAME,
ATTR_SERVICE,
EVENT_CALL_SERVICE,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import Context, HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.json import JSONEncoder
from homeassistant.util import dt as dt_util
@@ -65,6 +74,97 @@ class MockRow:
return process_timestamp_to_utc_isoformat(self.time_fired)
def setup_thermostat_context_test_entities(hass_: HomeAssistant) -> None:
"""Set up initial states for the thermostat context chain test entities."""
hass_.states.async_set(
"climate.living_room",
"off",
{ATTR_FRIENDLY_NAME: "Living Room Thermostat"},
)
hass_.states.async_set("switch.heater", STATE_OFF)
def simulate_thermostat_context_chain(
hass_: HomeAssistant,
user_id: str = "b400facee45711eaa9308bfd3d19e474",
) -> tuple[Context, Context]:
"""Simulate the generic_thermostat context chain.
Fires events in the realistic order:
1. EVENT_CALL_SERVICE for set_hvac_mode (parent context)
2. EVENT_CALL_SERVICE for homeassistant.turn_on (child context)
3. Climate state changes off → heat (parent context)
4. Switch state changes off → on (child context)
Returns the (parent_context, child_context) tuple.
"""
parent_context = Context(
id="01GTDGKBCH00GW0X476W5TVAAA",
user_id=user_id,
)
child_context = Context(
id="01GTDGKBCH00GW0X476W5TVDDD",
parent_id=parent_context.id,
)
hass_.bus.async_fire(
EVENT_CALL_SERVICE,
{
ATTR_DOMAIN: "climate",
ATTR_SERVICE: "set_hvac_mode",
"service_data": {ATTR_ENTITY_ID: "climate.living_room"},
},
context=parent_context,
)
hass_.bus.async_fire(
EVENT_CALL_SERVICE,
{
ATTR_DOMAIN: "homeassistant",
ATTR_SERVICE: "turn_on",
"service_data": {ATTR_ENTITY_ID: "switch.heater"},
},
context=child_context,
)
hass_.states.async_set(
"climate.living_room",
"heat",
{ATTR_FRIENDLY_NAME: "Living Room Thermostat"},
context=parent_context,
)
hass_.states.async_set(
"switch.heater",
STATE_ON,
{ATTR_FRIENDLY_NAME: "Heater"},
context=child_context,
)
return parent_context, child_context
def assert_thermostat_context_chain_events(
events: list[dict[str, Any]], parent_context: Context
) -> None:
"""Assert the logbook events for a thermostat context chain.
Verifies that climate and switch state changes have correct
state, user attribution, and service call context.
"""
climate_entries = [e for e in events if e.get("entity_id") == "climate.living_room"]
assert len(climate_entries) == 1
assert climate_entries[0]["state"] == "heat"
assert climate_entries[0]["context_user_id"] == parent_context.user_id
assert climate_entries[0]["context_event_type"] == EVENT_CALL_SERVICE
assert climate_entries[0]["context_domain"] == "climate"
assert climate_entries[0]["context_service"] == "set_hvac_mode"
heater_entries = [e for e in events if e.get("entity_id") == "switch.heater"]
assert len(heater_entries) == 1
assert heater_entries[0]["state"] == "on"
assert heater_entries[0]["context_user_id"] == parent_context.user_id
assert heater_entries[0]["context_event_type"] == EVENT_CALL_SERVICE
assert heater_entries[0]["context_domain"] == "homeassistant"
assert heater_entries[0]["context_service"] == "turn_on"
def mock_humanify(hass_, rows):
"""Wrap humanify with mocked logbook objects."""
entity_name_cache = processor.EntityNameCache(hass_)

View File

@@ -47,7 +47,13 @@ from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from .common import MockRow, mock_humanify
from .common import (
MockRow,
assert_thermostat_context_chain_events,
mock_humanify,
setup_thermostat_context_test_entities,
simulate_thermostat_context_chain,
)
from tests.common import MockConfigEntry, async_capture_events, mock_platform
from tests.components.recorder.common import (
@@ -3002,3 +3008,150 @@ async def test_logbook_with_non_iterable_entity_filter(hass: HomeAssistant) -> N
},
)
await hass.async_block_till_done()
@pytest.mark.usefixtures("recorder_mock")
async def test_logbook_user_id_from_parent_context(
hass: HomeAssistant, hass_client: ClientSessionGenerator
) -> None:
"""Test user attribution is inherited through the full context chain.
Simulates the generic_thermostat pattern:
1. User calls set_hvac_mode → parent context (has user_id)
- Climate state changes off → heat (parent context)
2. Thermostat calls homeassistant.turn_on → child context (no user_id)
- SERVICE_CALL event fired (child context)
3. Switch state changes off → on (child context)
4. Climate state updates again in response (child context)
All entries should have user_id attributed, either directly (step 1)
or inherited from the parent context (steps 2-4).
"""
await asyncio.gather(
*[
async_setup_component(hass, comp, {})
for comp in ("homeassistant", "logbook")
]
)
await async_recorder_block_till_done(hass)
setup_thermostat_context_test_entities(hass)
await hass.async_block_till_done()
parent_context, _ = simulate_thermostat_context_chain(hass)
await hass.async_block_till_done()
await async_wait_recording_done(hass)
client = await hass_client()
start = dt_util.utcnow().date()
start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC)
end_time = start_date + timedelta(hours=24)
response = await client.get(
f"/api/logbook/{start_date.isoformat()}",
params={"end_time": end_time.isoformat()},
)
assert response.status == HTTPStatus.OK
json_dict = await response.json()
assert_thermostat_context_chain_events(json_dict, parent_context)
@pytest.mark.usefixtures("recorder_mock")
async def test_logbook_user_id_from_parent_context_state_changes_only(
hass: HomeAssistant, hass_client: ClientSessionGenerator
) -> None:
"""Test user attribution is inherited when only state changes are present.
Same chain as the full test but without the EVENT_CALL_SERVICE event.
This exercises the code path where context_lookup resolves the child
context to the state change row itself, and augment walks up to the
parent state change.
"""
await asyncio.gather(
*[
async_setup_component(hass, comp, {})
for comp in ("homeassistant", "logbook")
]
)
await async_recorder_block_till_done(hass)
# Set initial states so that subsequent changes are real state transitions
hass.states.async_set(
"climate.living_room",
"off",
{ATTR_FRIENDLY_NAME: "Living Room Thermostat"},
)
hass.states.async_set("switch.heater", STATE_OFF)
await hass.async_block_till_done()
# Parent context with user_id
parent_context = ha.Context(
id="01GTDGKBCH00GW0X476W5TVAAA",
user_id="b400facee45711eaa9308bfd3d19e474",
)
# Climate state change with the parent context
hass.states.async_set(
"climate.living_room",
"heat",
{ATTR_FRIENDLY_NAME: "Living Room Thermostat"},
context=parent_context,
)
await hass.async_block_till_done()
# Child context WITHOUT user_id, no service call event
child_context = ha.Context(
id="01GTDGKBCH00GW0X476W5TVDDD",
parent_id="01GTDGKBCH00GW0X476W5TVAAA",
)
# Switch state change with the child context
hass.states.async_set(
"switch.heater",
STATE_ON,
{ATTR_FRIENDLY_NAME: "Heater"},
context=child_context,
)
await hass.async_block_till_done()
# Climate updates again in response to switch state change
hass.states.async_set(
"climate.living_room",
"heat",
{ATTR_FRIENDLY_NAME: "Living Room Thermostat"},
context=child_context,
)
await hass.async_block_till_done()
await async_wait_recording_done(hass)
client = await hass_client()
start = dt_util.utcnow().date()
start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC)
end_time = start_date + timedelta(hours=24)
response = await client.get(
f"/api/logbook/{start_date.isoformat()}",
params={"end_time": end_time.isoformat()},
)
assert response.status == HTTPStatus.OK
json_dict = await response.json()
# Switch state change should be attributed to the climate entity
# and inherit user_id from the parent context
heater_entries = [
entry for entry in json_dict if entry.get("entity_id") == "switch.heater"
]
assert len(heater_entries) == 1
heater_entry = heater_entries[0]
assert heater_entry["context_entity_id"] == "climate.living_room"
assert heater_entry["context_entity_id_name"] == "Living Room Thermostat"
assert heater_entry["context_state"] == "heat"
assert heater_entry["context_user_id"] == "b400facee45711eaa9308bfd3d19e474"

View File

@@ -41,6 +41,12 @@ from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from .common import (
assert_thermostat_context_chain_events,
setup_thermostat_context_test_entities,
simulate_thermostat_context_chain,
)
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.components.recorder.common import (
async_block_recorder,
@@ -3202,3 +3208,202 @@ async def test_consistent_stream_and_recorder_filtering(
results = response["result"]
assert len(results) == result_count
@pytest.mark.usefixtures("recorder_mock")
async def test_logbook_stream_user_id_from_parent_context(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test user attribution from parent context in live event stream.
Simulates the generic_thermostat pattern where a child context
(no user_id) is created for the heater service call, while the
parent context (from the user's set_hvac_mode call) has the user_id.
The live stream uses memoize_new_contexts=False, so context_lookup
is empty. User_id must be resolved via the context_user_ids map.
"""
await asyncio.gather(
*[
async_setup_component(hass, comp, {})
for comp in ("homeassistant", "logbook")
]
)
await hass.async_block_till_done()
setup_thermostat_context_test_entities(hass)
await hass.async_block_till_done()
await async_wait_recording_done(hass)
now = dt_util.utcnow()
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"]
# Receive historical events (partial) and sync message
msg = await asyncio.wait_for(websocket_client.receive_json(), 2)
assert msg["event"]["partial"] is True
msg = await asyncio.wait_for(websocket_client.receive_json(), 2)
assert msg["event"]["events"] == []
# Simulate the full generic_thermostat chain as live events
parent_context, _ = simulate_thermostat_context_chain(hass)
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_thermostat_context_chain_events(msg["event"]["events"], parent_context)
@pytest.mark.usefixtures("recorder_mock")
async def test_logbook_stream_user_id_from_parent_context_filtered(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test user attribution from parent context in filtered live event stream.
Same scenario as test_logbook_stream_user_id_from_parent_context but
with entity_ids in the subscription, matching what the frontend does.
This exercises the filtered event subscription path where
EVENT_CALL_SERVICE must be explicitly included and matched via
service_data.
"""
await asyncio.gather(
*[
async_setup_component(hass, comp, {})
for comp in ("homeassistant", "logbook")
]
)
await hass.async_block_till_done()
setup_thermostat_context_test_entities(hass)
await hass.async_block_till_done()
await async_wait_recording_done(hass)
now = dt_util.utcnow()
websocket_client = await hass_ws_client()
# Subscribe with entity_ids, matching what the frontend logbook card does
end_time = now + timedelta(hours=3)
await websocket_client.send_json(
{
"id": 7,
"type": "logbook/event_stream",
"start_time": now.isoformat(),
"end_time": end_time.isoformat(),
"entity_ids": ["climate.living_room", "switch.heater"],
}
)
msg = await asyncio.wait_for(websocket_client.receive_json(), 2)
assert msg["id"] == 7
assert msg["type"] == TYPE_RESULT
assert msg["success"]
# Receive historical events (partial) and sync message
msg = await asyncio.wait_for(websocket_client.receive_json(), 2)
assert msg["event"]["partial"] is True
msg = await asyncio.wait_for(websocket_client.receive_json(), 2)
assert msg["event"]["events"] == []
# Simulate the full chain as live events
parent_context, _ = simulate_thermostat_context_chain(hass)
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_thermostat_context_chain_events(msg["event"]["events"], parent_context)
@pytest.mark.usefixtures("recorder_mock")
async def test_logbook_get_events_user_id_from_parent_context(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test user attribution from parent context in unfiltered historical logbook.
Uses logbook/get_events without entity_ids, which triggers the
unfiltered SQL query path.
"""
await asyncio.gather(
*[
async_setup_component(hass, comp, {})
for comp in ("homeassistant", "logbook")
]
)
await hass.async_block_till_done()
setup_thermostat_context_test_entities(hass)
await hass.async_block_till_done()
now = dt_util.utcnow()
parent_context, _ = simulate_thermostat_context_chain(hass)
await hass.async_block_till_done()
await async_wait_recording_done(hass)
websocket_client = await hass_ws_client()
await websocket_client.send_json(
{
"id": 1,
"type": "logbook/get_events",
"start_time": now.isoformat(),
}
)
response = await websocket_client.receive_json()
assert response["success"]
assert_thermostat_context_chain_events(response["result"], parent_context)
@pytest.mark.usefixtures("recorder_mock")
async def test_logbook_get_events_user_id_from_parent_context_filtered(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test user attribution from parent context in historical logbook with entity filter.
Uses logbook/get_events with entity_ids, which triggers the filtered
SQL query path. The query must also fetch parent context rows so that
user_id can be inherited from the parent context.
"""
await asyncio.gather(
*[
async_setup_component(hass, comp, {})
for comp in ("homeassistant", "logbook")
]
)
await hass.async_block_till_done()
setup_thermostat_context_test_entities(hass)
await hass.async_block_till_done()
now = dt_util.utcnow()
parent_context, _ = simulate_thermostat_context_chain(hass)
await hass.async_block_till_done()
await async_wait_recording_done(hass)
websocket_client = await hass_ws_client()
await websocket_client.send_json(
{
"id": 1,
"type": "logbook/get_events",
"start_time": now.isoformat(),
"entity_ids": ["climate.living_room", "switch.heater"],
}
)
response = await websocket_client.receive_json()
assert response["success"]
assert_thermostat_context_chain_events(response["result"], parent_context)