diff --git a/homeassistant/components/logbook/helpers.py b/homeassistant/components/logbook/helpers.py index 238e6a0dda8..dd827690cbe 100644 --- a/homeassistant/components/logbook/helpers.py +++ b/homeassistant/components/logbook/helpers.py @@ -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(",") diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index f27a470a23d..d8e4d6f6815 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -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, diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index 1a139bb379e..b0ddf1f8798 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -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 diff --git a/homeassistant/components/logbook/queries/entities.py b/homeassistant/components/logbook/queries/entities.py index 494c2965215..cf03098e4df 100644 --- a/homeassistant/components/logbook/queries/entities.py +++ b/homeassistant/components/logbook/queries/entities.py @@ -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) diff --git a/homeassistant/components/logbook/queries/entities_and_devices.py b/homeassistant/components/logbook/queries/entities_and_devices.py index bef34f0858b..43d589d00d8 100644 --- a/homeassistant/components/logbook/queries/entities_and_devices.py +++ b/homeassistant/components/logbook/queries/entities_and_devices.py @@ -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) diff --git a/tests/components/logbook/common.py b/tests/components/logbook/common.py index b303a34e151..8b6f8d7a923 100644 --- a/tests/components/logbook/common.py +++ b/tests/components/logbook/common.py @@ -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_) diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index c62bdcaa824..020a07b4a85 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -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" diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 4c88a5874a3..5e6adb1e091 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -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)