267 lines
9.0 KiB
Python
267 lines
9.0 KiB
Python
"""Event parser and human readable log generator."""
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable, Mapping
|
|
from typing import Any
|
|
|
|
from homeassistant.components.sensor import ATTR_STATE_CLASS
|
|
from homeassistant.const import (
|
|
ATTR_DEVICE_ID,
|
|
ATTR_DOMAIN,
|
|
ATTR_ENTITY_ID,
|
|
ATTR_UNIT_OF_MEASUREMENT,
|
|
EVENT_LOGBOOK_ENTRY,
|
|
EVENT_STATE_CHANGED,
|
|
)
|
|
from homeassistant.core import (
|
|
CALLBACK_TYPE,
|
|
Event,
|
|
HomeAssistant,
|
|
State,
|
|
callback,
|
|
is_callback,
|
|
split_entity_id,
|
|
)
|
|
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
|
from homeassistant.helpers.event import (
|
|
EventStateChangedData,
|
|
async_track_state_change_event,
|
|
)
|
|
from homeassistant.helpers.typing import EventType
|
|
|
|
from .const import ALWAYS_CONTINUOUS_DOMAINS, AUTOMATION_EVENTS, BUILT_IN_EVENTS, DOMAIN
|
|
from .models import LogbookConfig
|
|
|
|
|
|
def async_filter_entities(hass: HomeAssistant, entity_ids: list[str]) -> list[str]:
|
|
"""Filter out any entities that logbook will not produce results for."""
|
|
ent_reg = er.async_get(hass)
|
|
return [
|
|
entity_id
|
|
for entity_id in entity_ids
|
|
if split_entity_id(entity_id)[0] not in ALWAYS_CONTINUOUS_DOMAINS
|
|
and not is_sensor_continuous(hass, ent_reg, entity_id)
|
|
]
|
|
|
|
|
|
@callback
|
|
def _async_config_entries_for_ids(
|
|
hass: HomeAssistant, entity_ids: list[str] | None, device_ids: list[str] | None
|
|
) -> set[str]:
|
|
"""Find the config entry ids for a set of entities or devices."""
|
|
config_entry_ids: set[str] = set()
|
|
if entity_ids:
|
|
eng_reg = er.async_get(hass)
|
|
for entity_id in entity_ids:
|
|
if (entry := eng_reg.async_get(entity_id)) and entry.config_entry_id:
|
|
config_entry_ids.add(entry.config_entry_id)
|
|
if device_ids:
|
|
dev_reg = dr.async_get(hass)
|
|
for device_id in device_ids:
|
|
if (device := dev_reg.async_get(device_id)) and device.config_entries:
|
|
config_entry_ids |= device.config_entries
|
|
return config_entry_ids
|
|
|
|
|
|
def async_determine_event_types(
|
|
hass: HomeAssistant, entity_ids: list[str] | None, device_ids: list[str] | None
|
|
) -> tuple[str, ...]:
|
|
"""Reduce the event types based on the entity ids and device ids."""
|
|
logbook_config: LogbookConfig = hass.data[DOMAIN]
|
|
external_events = logbook_config.external_events
|
|
if not entity_ids and not device_ids:
|
|
return (*BUILT_IN_EVENTS, *external_events)
|
|
|
|
interested_domains: set[str] = set()
|
|
for entry_id in _async_config_entries_for_ids(hass, entity_ids, device_ids):
|
|
if entry := hass.config_entries.async_get_entry(entry_id):
|
|
interested_domains.add(entry.domain)
|
|
|
|
#
|
|
# automations and scripts can refer to entities or devices
|
|
# but they do not have a config entry so we need
|
|
# to add them since we have historically included
|
|
# them when matching only on entities
|
|
#
|
|
intrested_event_types: set[str] = {
|
|
external_event
|
|
for external_event, domain_call in external_events.items()
|
|
if domain_call[0] in interested_domains
|
|
} | AUTOMATION_EVENTS
|
|
if entity_ids:
|
|
# We also allow entity_ids to be recorded via manual logbook entries.
|
|
intrested_event_types.add(EVENT_LOGBOOK_ENTRY)
|
|
|
|
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."""
|
|
if (value := source.get(attr)) is None:
|
|
return []
|
|
if isinstance(value, list):
|
|
return value
|
|
return str(value).split(",")
|
|
|
|
|
|
@callback
|
|
def event_forwarder_filtered(
|
|
target: Callable[[Event], None],
|
|
entities_filter: Callable[[str], bool] | None,
|
|
entity_ids: list[str] | None,
|
|
device_ids: list[str] | None,
|
|
) -> Callable[[Event], None]:
|
|
"""Make a callable to filter events."""
|
|
if not entities_filter and not entity_ids and not device_ids:
|
|
# No filter
|
|
# - Script Trace (context ids)
|
|
# - Automation Trace (context ids)
|
|
return target
|
|
|
|
if entities_filter:
|
|
# We have an entity filter:
|
|
# - Logbook panel
|
|
|
|
@callback
|
|
def _forward_events_filtered_by_entities_filter(event: Event) -> None:
|
|
assert entities_filter is not None
|
|
event_data = event.data
|
|
entity_ids = extract_attr(event_data, ATTR_ENTITY_ID)
|
|
if entity_ids and not any(
|
|
entities_filter(entity_id) for entity_id in entity_ids
|
|
):
|
|
return
|
|
domain = event_data.get(ATTR_DOMAIN)
|
|
if domain and not entities_filter(f"{domain}._"):
|
|
return
|
|
target(event)
|
|
|
|
return _forward_events_filtered_by_entities_filter
|
|
|
|
# We are filtering on entity_ids and/or device_ids:
|
|
# - Areas
|
|
# - Devices
|
|
# - Logbook Card
|
|
entity_ids_set = set(entity_ids) if entity_ids else set()
|
|
device_ids_set = set(device_ids) if device_ids else set()
|
|
|
|
@callback
|
|
def _forward_events_filtered_by_device_entity_ids(event: Event) -> None:
|
|
event_data = event.data
|
|
if entity_ids_set.intersection(
|
|
extract_attr(event_data, ATTR_ENTITY_ID)
|
|
) or device_ids_set.intersection(extract_attr(event_data, ATTR_DEVICE_ID)):
|
|
target(event)
|
|
|
|
return _forward_events_filtered_by_device_entity_ids
|
|
|
|
|
|
@callback
|
|
def async_subscribe_events(
|
|
hass: HomeAssistant,
|
|
subscriptions: list[CALLBACK_TYPE],
|
|
target: Callable[[Event[Any]], None],
|
|
event_types: tuple[str, ...],
|
|
entities_filter: Callable[[str], bool] | None,
|
|
entity_ids: list[str] | None,
|
|
device_ids: list[str] | None,
|
|
) -> None:
|
|
"""Subscribe to events for the entities and devices or all.
|
|
|
|
These are the events we need to listen for to do
|
|
the live logbook stream.
|
|
"""
|
|
assert is_callback(target), "target must be a callback"
|
|
event_forwarder = event_forwarder_filtered(
|
|
target, entities_filter, entity_ids, device_ids
|
|
)
|
|
for event_type in event_types:
|
|
subscriptions.append(
|
|
hass.bus.async_listen(event_type, event_forwarder, run_immediately=True)
|
|
)
|
|
|
|
if device_ids and not entity_ids:
|
|
# No entities to subscribe to but we are filtering
|
|
# on device ids so we do not want to get any state
|
|
# changed events
|
|
return
|
|
|
|
@callback
|
|
def _forward_state_events_filtered(event: EventType[EventStateChangedData]) -> None:
|
|
if (old_state := event.data["old_state"]) is None or (
|
|
new_state := event.data["new_state"]
|
|
) is None:
|
|
return
|
|
if _is_state_filtered(new_state, old_state) or (
|
|
entities_filter and not entities_filter(new_state.entity_id)
|
|
):
|
|
return
|
|
target(event)
|
|
|
|
if entity_ids:
|
|
subscriptions.append(
|
|
async_track_state_change_event(
|
|
hass, entity_ids, _forward_state_events_filtered
|
|
)
|
|
)
|
|
return
|
|
|
|
# We want the firehose
|
|
subscriptions.append(
|
|
hass.bus.async_listen(
|
|
EVENT_STATE_CHANGED,
|
|
_forward_state_events_filtered, # type: ignore[arg-type]
|
|
run_immediately=True,
|
|
)
|
|
)
|
|
|
|
|
|
def is_sensor_continuous(
|
|
hass: HomeAssistant, ent_reg: er.EntityRegistry, entity_id: str
|
|
) -> bool:
|
|
"""Determine if a sensor is continuous.
|
|
|
|
Sensors with a unit_of_measurement or state_class are considered continuous.
|
|
|
|
The unit_of_measurement check will already happen if this is
|
|
called for historical data because the SQL query generated by _get_events
|
|
will filter out any sensors with a unit_of_measurement.
|
|
|
|
If the state still exists in the state machine, this function still
|
|
checks for ATTR_UNIT_OF_MEASUREMENT since the live mode is not filtered
|
|
by the SQL query.
|
|
"""
|
|
# If it is in the state machine we can quick check if it
|
|
# has a unit_of_measurement or state_class, and filter if
|
|
# it does
|
|
if (state := hass.states.get(entity_id)) and (attributes := state.attributes):
|
|
return ATTR_UNIT_OF_MEASUREMENT in attributes or ATTR_STATE_CLASS in attributes
|
|
# If its not in the state machine, we need to check
|
|
# the entity registry to see if its a sensor
|
|
# filter with a state class. We do not check
|
|
# for unit_of_measurement since the SQL query
|
|
# will filter out any sensors with a unit_of_measurement
|
|
# and we should never get here in live mode because
|
|
# the state machine will always have the state.
|
|
return bool(
|
|
(entry := ent_reg.async_get(entity_id))
|
|
and entry.capabilities
|
|
and entry.capabilities.get(ATTR_STATE_CLASS)
|
|
)
|
|
|
|
|
|
def _is_state_filtered(new_state: State, old_state: State) -> bool:
|
|
"""Check if the logbook should filter a state.
|
|
|
|
Used when we are in live mode to ensure
|
|
we only get significant changes (state.last_changed != state.last_updated)
|
|
"""
|
|
return bool(
|
|
new_state.state == old_state.state
|
|
or new_state.last_changed != new_state.last_updated
|
|
or new_state.domain in ALWAYS_CONTINUOUS_DOMAINS
|
|
or ATTR_UNIT_OF_MEASUREMENT in new_state.attributes
|
|
or ATTR_STATE_CLASS in new_state.attributes
|
|
)
|