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:
@@ -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(",")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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_)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user