Ensure logbook performs well when filtering is configured (#37292)

This commit is contained in:
J. Nick Koston 2020-07-02 11:12:27 -05:00 committed by GitHub
parent 0a982f6fab
commit a87c29b5d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 92 additions and 90 deletions

View File

@ -394,16 +394,9 @@ def get_state(hass, utc_point_in_time, entity_id, run=None):
async def async_setup(hass, config): async def async_setup(hass, config):
"""Set up the history hooks.""" """Set up the history hooks."""
filters = Filters()
conf = config.get(DOMAIN, {}) conf = config.get(DOMAIN, {})
exclude = conf.get(CONF_EXCLUDE)
if exclude: filters = sqlalchemy_filter_from_include_exclude_conf(conf)
filters.excluded_entities = exclude.get(CONF_ENTITIES, [])
filters.excluded_domains = exclude.get(CONF_DOMAINS, [])
include = conf.get(CONF_INCLUDE)
if include:
filters.included_entities = include.get(CONF_ENTITIES, [])
filters.included_domains = include.get(CONF_DOMAINS, [])
use_include_order = conf.get(CONF_ORDER) use_include_order = conf.get(CONF_ORDER)
hass.http.register_view(HistoryPeriodView(filters, use_include_order)) hass.http.register_view(HistoryPeriodView(filters, use_include_order))
@ -530,6 +523,20 @@ class HistoryPeriodView(HomeAssistantView):
return self.json(result) return self.json(result)
def sqlalchemy_filter_from_include_exclude_conf(conf):
"""Build a sql filter from config."""
filters = Filters()
exclude = conf.get(CONF_EXCLUDE)
if exclude:
filters.excluded_entities = exclude.get(CONF_ENTITIES, [])
filters.excluded_domains = exclude.get(CONF_DOMAINS, [])
include = conf.get(CONF_INCLUDE)
if include:
filters.included_entities = include.get(CONF_ENTITIES, [])
filters.included_domains = include.get(CONF_DOMAINS, [])
return filters
class Filters: class Filters:
"""Container for the configured include and exclude filters.""" """Container for the configured include and exclude filters."""
@ -556,26 +563,34 @@ class Filters:
return query.filter(States.entity_id.in_(entity_ids)) return query.filter(States.entity_id.in_(entity_ids))
query = query.filter(~States.domain.in_(IGNORE_DOMAINS)) query = query.filter(~States.domain.in_(IGNORE_DOMAINS))
filter_query = None entity_filter = self.entity_filter()
if entity_filter is not None:
query = query.filter(entity_filter)
return query
def entity_filter(self):
"""Generate the entity filter query."""
entity_filter = None
# filter if only excluded domain is configured # filter if only excluded domain is configured
if self.excluded_domains and not self.included_domains: if self.excluded_domains and not self.included_domains:
filter_query = ~States.domain.in_(self.excluded_domains) entity_filter = ~States.domain.in_(self.excluded_domains)
if self.included_entities: if self.included_entities:
filter_query &= States.entity_id.in_(self.included_entities) entity_filter &= States.entity_id.in_(self.included_entities)
# filter if only included domain is configured # filter if only included domain is configured
elif not self.excluded_domains and self.included_domains: elif not self.excluded_domains and self.included_domains:
filter_query = States.domain.in_(self.included_domains) entity_filter = States.domain.in_(self.included_domains)
if self.included_entities: if self.included_entities:
filter_query |= States.entity_id.in_(self.included_entities) entity_filter |= States.entity_id.in_(self.included_entities)
# filter if included and excluded domain is configured # filter if included and excluded domain is configured
elif self.excluded_domains and self.included_domains: elif self.excluded_domains and self.included_domains:
filter_query = ~States.domain.in_(self.excluded_domains) entity_filter = ~States.domain.in_(self.excluded_domains)
if self.included_entities: if self.included_entities:
filter_query &= States.domain.in_( entity_filter &= States.domain.in_(
self.included_domains self.included_domains
) | States.entity_id.in_(self.included_entities) ) | States.entity_id.in_(self.included_entities)
else: else:
filter_query &= States.domain.in_( entity_filter &= States.domain.in_(
self.included_domains self.included_domains
) & ~States.domain.in_(self.excluded_domains) ) & ~States.domain.in_(self.excluded_domains)
# no domain filter just included entities # no domain filter just included entities
@ -584,13 +599,17 @@ class Filters:
and not self.included_domains and not self.included_domains
and self.included_entities and self.included_entities
): ):
filter_query = States.entity_id.in_(self.included_entities) entity_filter = States.entity_id.in_(self.included_entities)
if filter_query is not None:
query = query.filter(filter_query)
# finally apply excluded entities filter if configured # finally apply excluded entities filter if configured
if self.excluded_entities: if self.excluded_entities:
query = query.filter(~States.entity_id.in_(self.excluded_entities)) if entity_filter is not None:
return query entity_filter = (entity_filter) & ~States.entity_id.in_(
self.excluded_entities
)
else:
entity_filter = ~States.entity_id.in_(self.excluded_entities)
return entity_filter
class LazyState(State): class LazyState(State):

View File

@ -3,14 +3,13 @@ from datetime import timedelta
from itertools import groupby from itertools import groupby
import json import json
import logging import logging
import time
import sqlalchemy import sqlalchemy
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
import voluptuous as vol import voluptuous as vol
from homeassistant.components import sun from homeassistant.components import sun
from homeassistant.components.history import sqlalchemy_filter_from_include_exclude_conf
from homeassistant.components.http import HomeAssistantView from homeassistant.components.http import HomeAssistantView
from homeassistant.components.recorder.models import ( from homeassistant.components.recorder.models import (
Events, Events,
@ -18,19 +17,13 @@ from homeassistant.components.recorder.models import (
process_timestamp, process_timestamp,
process_timestamp_to_utc_isoformat, process_timestamp_to_utc_isoformat,
) )
from homeassistant.components.recorder.util import ( from homeassistant.components.recorder.util import session_scope
QUERY_RETRY_WAIT,
RETRIES,
session_scope,
)
from homeassistant.const import ( from homeassistant.const import (
ATTR_DEVICE_CLASS, ATTR_DEVICE_CLASS,
ATTR_DOMAIN, ATTR_DOMAIN,
ATTR_ENTITY_ID, ATTR_ENTITY_ID,
ATTR_FRIENDLY_NAME, ATTR_FRIENDLY_NAME,
ATTR_NAME, ATTR_NAME,
CONF_EXCLUDE,
CONF_INCLUDE,
EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_STOP,
EVENT_LOGBOOK_ENTRY, EVENT_LOGBOOK_ENTRY,
@ -123,12 +116,21 @@ async def async_setup(hass, config):
message = message.async_render() message = message.async_render()
async_log_entry(hass, name, message, domain, entity_id) async_log_entry(hass, name, message, domain, entity_id)
hass.http.register_view(LogbookView(config.get(DOMAIN, {})))
hass.components.frontend.async_register_built_in_panel( hass.components.frontend.async_register_built_in_panel(
"logbook", "logbook", "hass:format-list-bulleted-type" "logbook", "logbook", "hass:format-list-bulleted-type"
) )
conf = config.get(DOMAIN, {})
if conf:
filters = sqlalchemy_filter_from_include_exclude_conf(conf)
entities_filter = convert_include_exclude_filter(conf)
else:
filters = None
entities_filter = None
hass.http.register_view(LogbookView(conf, filters, entities_filter))
hass.services.async_register(DOMAIN, "log", log_message, schema=LOG_MESSAGE_SCHEMA) hass.services.async_register(DOMAIN, "log", log_message, schema=LOG_MESSAGE_SCHEMA)
await async_process_integration_platforms(hass, DOMAIN, _process_logbook_platform) await async_process_integration_platforms(hass, DOMAIN, _process_logbook_platform)
@ -154,9 +156,11 @@ class LogbookView(HomeAssistantView):
name = "api:logbook" name = "api:logbook"
extra_urls = ["/api/logbook/{datetime}"] extra_urls = ["/api/logbook/{datetime}"]
def __init__(self, config): def __init__(self, config, filters, entities_filter):
"""Initialize the logbook view.""" """Initialize the logbook view."""
self.config = config self.config = config
self.filters = filters
self.entities_filter = entities_filter
async def get(self, request, datetime=None): async def get(self, request, datetime=None):
"""Retrieve logbook entries.""" """Retrieve logbook entries."""
@ -191,7 +195,15 @@ class LogbookView(HomeAssistantView):
def json_events(): def json_events():
"""Fetch events and generate JSON.""" """Fetch events and generate JSON."""
return self.json( return self.json(
_get_events(hass, self.config, start_day, end_day, entity_id) _get_events(
hass,
self.config,
start_day,
end_day,
entity_id,
self.filters,
self.entities_filter,
)
) )
return await hass.async_add_job(json_events) return await hass.async_add_job(json_events)
@ -327,38 +339,9 @@ def humanify(hass, events, entity_attr_cache, prev_states=None):
} }
def _get_related_entity_ids(session, entity_filter): def _get_events(
timer_start = time.perf_counter() hass, config, start_day, end_day, entity_id=None, filters=None, entities_filter=None
):
query = session.query(States).with_entities(States.entity_id).distinct()
for tryno in range(RETRIES):
try:
result = [row.entity_id for row in query if entity_filter(row.entity_id)]
if _LOGGER.isEnabledFor(logging.DEBUG):
elapsed = time.perf_counter() - timer_start
_LOGGER.debug(
"fetching %d distinct domain/entity_id pairs took %fs",
len(result),
elapsed,
)
return result
except SQLAlchemyError as err:
_LOGGER.error("Error executing query: %s", err)
if tryno == RETRIES - 1:
raise
time.sleep(QUERY_RETRY_WAIT)
def _all_entities_filter(_):
"""Filter that accepts all entities."""
return True
def _get_events(hass, config, start_day, end_day, entity_id=None):
"""Get events for a period of time.""" """Get events for a period of time."""
entity_attr_cache = EntityAttributeCache(hass) entity_attr_cache = EntityAttributeCache(hass)
@ -373,12 +356,10 @@ def _get_events(hass, config, start_day, end_day, entity_id=None):
if entity_id is not None: if entity_id is not None:
entity_ids = [entity_id.lower()] entity_ids = [entity_id.lower()]
entities_filter = generate_filter([], entity_ids, [], []) entities_filter = generate_filter([], entity_ids, [], [])
elif config.get(CONF_EXCLUDE) or config.get(CONF_INCLUDE): apply_sql_entities_filter = False
entities_filter = convert_include_exclude_filter(config)
entity_ids = _get_related_entity_ids(session, entities_filter)
else: else:
entities_filter = _all_entities_filter
entity_ids = None entity_ids = None
apply_sql_entities_filter = True
old_state = aliased(States, name="old_state") old_state = aliased(States, name="old_state")
@ -445,6 +426,13 @@ def _get_events(hass, config, start_day, end_day, entity_id=None):
| (States.state_id.is_(None)) | (States.state_id.is_(None))
) )
if apply_sql_entities_filter and filters:
entity_filter = filters.entity_filter()
if entity_filter is not None:
query = query.filter(
entity_filter | (Events.event_type != EVENT_STATE_CHANGED)
)
# When all data is schema v8 or later, prev_states can be removed # When all data is schema v8 or later, prev_states can be removed
prev_states = {} prev_states = {}
return list(humanify(hass, yield_events(query), entity_attr_cache, prev_states)) return list(humanify(hass, yield_events(query), entity_attr_cache, prev_states))
@ -478,7 +466,7 @@ def _keep_event(hass, event, entities_filter, entity_attr_cache):
return False return False
entity_id = f"{domain}." entity_id = f"{domain}."
return entities_filter(entity_id) return entities_filter is None or entities_filter(entity_id)
def _entry_message_from_event(hass, entity_id, domain, event, entity_attr_cache): def _entry_message_from_event(hass, entity_id, domain, event, entity_attr_cache):

View File

@ -20,6 +20,8 @@ from homeassistant.const import (
ATTR_NAME, ATTR_NAME,
CONF_DOMAINS, CONF_DOMAINS,
CONF_ENTITIES, CONF_ENTITIES,
CONF_EXCLUDE,
CONF_INCLUDE,
EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_STOP,
EVENT_STATE_CHANGED, EVENT_STATE_CHANGED,
@ -240,7 +242,7 @@ class TestComponentLogbook(unittest.TestCase):
config = logbook.CONFIG_SCHEMA( config = logbook.CONFIG_SCHEMA(
{ {
ha.DOMAIN: {}, ha.DOMAIN: {},
logbook.DOMAIN: {logbook.CONF_EXCLUDE: {CONF_ENTITIES: [entity_id]}}, logbook.DOMAIN: {CONF_EXCLUDE: {CONF_ENTITIES: [entity_id]}},
} }
) )
entities_filter = convert_include_exclude_filter(config[logbook.DOMAIN]) entities_filter = convert_include_exclude_filter(config[logbook.DOMAIN])
@ -277,9 +279,7 @@ class TestComponentLogbook(unittest.TestCase):
config = logbook.CONFIG_SCHEMA( config = logbook.CONFIG_SCHEMA(
{ {
ha.DOMAIN: {}, ha.DOMAIN: {},
logbook.DOMAIN: { logbook.DOMAIN: {CONF_EXCLUDE: {CONF_DOMAINS: ["switch", "alexa"]}},
logbook.CONF_EXCLUDE: {CONF_DOMAINS: ["switch", "alexa"]}
},
} }
) )
entities_filter = convert_include_exclude_filter(config[logbook.DOMAIN]) entities_filter = convert_include_exclude_filter(config[logbook.DOMAIN])
@ -321,7 +321,7 @@ class TestComponentLogbook(unittest.TestCase):
{ {
ha.DOMAIN: {}, ha.DOMAIN: {},
logbook.DOMAIN: { logbook.DOMAIN: {
logbook.CONF_EXCLUDE: { CONF_EXCLUDE: {
CONF_DOMAINS: ["switch", "alexa"], CONF_DOMAINS: ["switch", "alexa"],
CONF_ENTITY_GLOBS: "*.excluded", CONF_ENTITY_GLOBS: "*.excluded",
} }
@ -365,7 +365,7 @@ class TestComponentLogbook(unittest.TestCase):
{ {
ha.DOMAIN: {}, ha.DOMAIN: {},
logbook.DOMAIN: { logbook.DOMAIN: {
logbook.CONF_INCLUDE: { CONF_INCLUDE: {
CONF_DOMAINS: ["homeassistant"], CONF_DOMAINS: ["homeassistant"],
CONF_ENTITIES: [entity_id2], CONF_ENTITIES: [entity_id2],
} }
@ -413,9 +413,7 @@ class TestComponentLogbook(unittest.TestCase):
{ {
ha.DOMAIN: {}, ha.DOMAIN: {},
logbook.DOMAIN: { logbook.DOMAIN: {
logbook.CONF_INCLUDE: { CONF_INCLUDE: {CONF_DOMAINS: ["homeassistant", "sensor", "alexa"]}
CONF_DOMAINS: ["homeassistant", "sensor", "alexa"]
}
}, },
} }
) )
@ -465,7 +463,7 @@ class TestComponentLogbook(unittest.TestCase):
{ {
ha.DOMAIN: {}, ha.DOMAIN: {},
logbook.DOMAIN: { logbook.DOMAIN: {
logbook.CONF_INCLUDE: { CONF_INCLUDE: {
CONF_DOMAINS: ["homeassistant", "sensor", "alexa"], CONF_DOMAINS: ["homeassistant", "sensor", "alexa"],
CONF_ENTITY_GLOBS: ["*.included"], CONF_ENTITY_GLOBS: ["*.included"],
} }
@ -517,11 +515,11 @@ class TestComponentLogbook(unittest.TestCase):
{ {
ha.DOMAIN: {}, ha.DOMAIN: {},
logbook.DOMAIN: { logbook.DOMAIN: {
logbook.CONF_INCLUDE: { CONF_INCLUDE: {
CONF_DOMAINS: ["sensor", "homeassistant"], CONF_DOMAINS: ["sensor", "homeassistant"],
CONF_ENTITIES: ["switch.bla"], CONF_ENTITIES: ["switch.bla"],
}, },
logbook.CONF_EXCLUDE: { CONF_EXCLUDE: {
CONF_DOMAINS: ["switch"], CONF_DOMAINS: ["switch"],
CONF_ENTITIES: ["sensor.bli"], CONF_ENTITIES: ["sensor.bli"],
}, },
@ -586,12 +584,12 @@ class TestComponentLogbook(unittest.TestCase):
{ {
ha.DOMAIN: {}, ha.DOMAIN: {},
logbook.DOMAIN: { logbook.DOMAIN: {
logbook.CONF_INCLUDE: { CONF_INCLUDE: {
CONF_DOMAINS: ["sensor", "homeassistant"], CONF_DOMAINS: ["sensor", "homeassistant"],
CONF_ENTITIES: ["switch.bla"], CONF_ENTITIES: ["switch.bla"],
CONF_ENTITY_GLOBS: ["*.included"], CONF_ENTITY_GLOBS: ["*.included"],
}, },
logbook.CONF_EXCLUDE: { CONF_EXCLUDE: {
CONF_DOMAINS: ["switch"], CONF_DOMAINS: ["switch"],
CONF_ENTITY_GLOBS: ["*.excluded"], CONF_ENTITY_GLOBS: ["*.excluded"],
CONF_ENTITIES: ["sensor.bli"], CONF_ENTITIES: ["sensor.bli"],
@ -1617,10 +1615,7 @@ async def test_exclude_described_event(hass, hass_client):
logbook.DOMAIN, logbook.DOMAIN,
{ {
logbook.DOMAIN: { logbook.DOMAIN: {
logbook.CONF_EXCLUDE: { CONF_EXCLUDE: {CONF_DOMAINS: ["sensor"], CONF_ENTITIES: [entity_id]}
CONF_DOMAINS: ["sensor"],
CONF_ENTITIES: [entity_id],
}
} }
}, },
) )