diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 0e6fabb66aa..f90083c0b50 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -8,7 +8,7 @@ import time from typing import Optional, cast from aiohttp import web -from sqlalchemy import and_, bindparam, func +from sqlalchemy import and_, bindparam, func, not_, or_ from sqlalchemy.ext import baked import voluptuous as vol @@ -29,6 +29,10 @@ from homeassistant.const import ( ) from homeassistant.core import Context, State, split_entity_id import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entityfilter import ( + CONF_ENTITY_GLOBS, + INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, +) import homeassistant.util.dt as dt_util # mypy: allow-untyped-defs, no-check-untyped-defs @@ -41,26 +45,19 @@ CONF_ORDER = "use_include_order" STATE_KEY = "state" LAST_CHANGED_KEY = "last_changed" -# Not reusing from entityfilter because history does not support glob filtering -_FILTER_SCHEMA_INNER = vol.Schema( - { - vol.Optional(CONF_DOMAINS, default=[]): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, - } -) -_FILTER_SCHEMA = vol.Schema( - { - vol.Optional( - CONF_INCLUDE, default=_FILTER_SCHEMA_INNER({}) - ): _FILTER_SCHEMA_INNER, - vol.Optional( - CONF_EXCLUDE, default=_FILTER_SCHEMA_INNER({}) - ): _FILTER_SCHEMA_INNER, - vol.Optional(CONF_ORDER, default=False): cv.boolean, - } -) +GLOB_TO_SQL_CHARS = { + 42: "%", # * + 46: "_", # . +} -CONFIG_SCHEMA = vol.Schema({DOMAIN: _FILTER_SCHEMA}, extra=vol.ALLOW_EXTRA) +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA.extend( + {vol.Optional(CONF_ORDER, default=False): cv.boolean} + ) + }, + extra=vol.ALLOW_EXTRA, +) SIGNIFICANT_DOMAINS = ( "climate", @@ -563,10 +560,12 @@ def sqlalchemy_filter_from_include_exclude_conf(conf): if exclude: filters.excluded_entities = exclude.get(CONF_ENTITIES, []) filters.excluded_domains = exclude.get(CONF_DOMAINS, []) + filters.excluded_entity_globs = exclude.get(CONF_ENTITY_GLOBS, []) include = conf.get(CONF_INCLUDE) if include: filters.included_entities = include.get(CONF_ENTITIES, []) filters.included_domains = include.get(CONF_DOMAINS, []) + filters.included_entity_globs = include.get(CONF_ENTITY_GLOBS, []) return filters @@ -577,8 +576,11 @@ class Filters: """Initialise the include and exclude filters.""" self.excluded_entities = [] self.excluded_domains = [] + self.excluded_entity_globs = [] + self.included_entities = [] self.included_domains = [] + self.included_entity_globs = [] def apply(self, query, entity_ids=None): """Apply the include/exclude filter on domains and entities on query. @@ -619,52 +621,46 @@ class Filters: if ( self.excluded_entities or self.excluded_domains + or self.excluded_entity_globs or self.included_entities or self.included_domains + or self.included_entity_globs ): baked_query += lambda q: q.filter(self.entity_filter()) def entity_filter(self): """Generate the entity filter query.""" - entity_filter = None - # filter if only excluded domain is configured - if self.excluded_domains and not self.included_domains: - entity_filter = ~States.domain.in_(self.excluded_domains) - if self.included_entities: - entity_filter &= States.entity_id.in_(self.included_entities) - # filter if only included domain is configured - elif not self.excluded_domains and self.included_domains: - entity_filter = States.domain.in_(self.included_domains) - if self.included_entities: - entity_filter |= States.entity_id.in_(self.included_entities) - # filter if included and excluded domain is configured - elif self.excluded_domains and self.included_domains: - entity_filter = ~States.domain.in_(self.excluded_domains) - if self.included_entities: - entity_filter &= States.domain.in_( - self.included_domains - ) | States.entity_id.in_(self.included_entities) - else: - entity_filter &= States.domain.in_( - self.included_domains - ) & ~States.domain.in_(self.excluded_domains) - # no domain filter just included entities - elif ( - not self.excluded_domains - and not self.included_domains - and self.included_entities - ): - entity_filter = States.entity_id.in_(self.included_entities) - # finally apply excluded entities filter if configured - if self.excluded_entities: - if entity_filter is not None: - entity_filter = (entity_filter) & ~States.entity_id.in_( - self.excluded_entities - ) - else: - entity_filter = ~States.entity_id.in_(self.excluded_entities) + includes = [] + if self.included_domains: + includes.append(States.domain.in_(self.included_domains)) + if self.included_entities: + includes.append(States.entity_id.in_(self.included_entities)) + for glob in self.included_entity_globs: + includes.append(_glob_to_like(glob)) - return entity_filter + excludes = [] + if self.excluded_domains: + excludes.append(States.domain.in_(self.excluded_domains)) + if self.excluded_entities: + excludes.append(States.entity_id.in_(self.excluded_entities)) + for glob in self.excluded_entity_globs: + excludes.append(_glob_to_like(glob)) + + if not includes and not excludes: + return None + + if includes and not excludes: + return or_(*includes) + + if not excludes and includes: + return not_(or_(*excludes)) + + return or_(*includes) & not_(or_(*excludes)) + + +def _glob_to_like(glob_str): + """Translate glob to sql.""" + return States.entity_id.like(glob_str.translate(GLOB_TO_SQL_CHARS)) class LazyState(State): diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index 29068c1e261..e922c532f7d 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -591,9 +591,6 @@ def _keep_event(hass, event, entities_filter): if event.event_type in HOMEASSISTANT_EVENTS: return entities_filter is None or entities_filter(HA_DOMAIN_ENTITY_ID) - if event.event_type == EVENT_STATE_CHANGED: - return entities_filter is None or entities_filter(event.entity_id) - entity_id = event.data_entity_id if entity_id: return entities_filter is None or entities_filter(entity_id) diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index c15e4431f87..3ae947edee2 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -18,7 +18,7 @@ from tests.common import ( init_recorder_component, mock_state_change_event, ) -from tests.components.recorder.common import wait_recording_done +from tests.components.recorder.common import trigger_db_commit, wait_recording_done class TestComponentHistory(unittest.TestCase): @@ -823,3 +823,120 @@ async def test_fetch_period_api_with_include_order(hass, hass_client): params={"filter_entity_id": "non.existing,something.else"}, ) assert response.status == 200 + + +async def test_fetch_period_api_with_entity_glob_include(hass, hass_client): + """Test the fetch period view for history.""" + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component( + hass, + "history", + { + "history": { + "include": {"entity_globs": ["light.k*"]}, + } + }, + ) + await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + hass.states.async_set("light.kitchen", "on") + hass.states.async_set("light.cow", "on") + hass.states.async_set("light.nomatch", "on") + + await hass.async_block_till_done() + + await hass.async_add_executor_job(trigger_db_commit, hass) + await hass.async_block_till_done() + await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + + client = await hass_client() + response = await client.get( + f"/api/history/period/{dt_util.utcnow().isoformat()}", + ) + assert response.status == 200 + response_json = await response.json() + assert response_json[0][0]["entity_id"] == "light.kitchen" + + +async def test_fetch_period_api_with_entity_glob_exclude(hass, hass_client): + """Test the fetch period view for history.""" + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component( + hass, + "history", + { + "history": { + "exclude": { + "entity_globs": ["light.k*"], + "domains": "switch", + "entities": "media_player.test", + }, + } + }, + ) + await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + hass.states.async_set("light.kitchen", "on") + hass.states.async_set("light.cow", "on") + hass.states.async_set("light.match", "on") + hass.states.async_set("switch.match", "on") + hass.states.async_set("media_player.test", "on") + + await hass.async_block_till_done() + + await hass.async_add_executor_job(trigger_db_commit, hass) + await hass.async_block_till_done() + await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + + client = await hass_client() + response = await client.get( + f"/api/history/period/{dt_util.utcnow().isoformat()}", + ) + assert response.status == 200 + response_json = await response.json() + assert len(response_json) == 2 + assert response_json[0][0]["entity_id"] == "light.cow" + assert response_json[1][0]["entity_id"] == "light.match" + + +async def test_fetch_period_api_with_entity_glob_include_and_exclude(hass, hass_client): + """Test the fetch period view for history.""" + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component( + hass, + "history", + { + "history": { + "exclude": { + "entity_globs": ["light.many*"], + }, + "include": { + "entity_globs": ["light.m*"], + "domains": "switch", + "entities": "media_player.test", + }, + } + }, + ) + await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + hass.states.async_set("light.kitchen", "on") + hass.states.async_set("light.cow", "on") + hass.states.async_set("light.match", "on") + hass.states.async_set("light.many_state_changes", "on") + hass.states.async_set("switch.match", "on") + hass.states.async_set("media_player.test", "on") + + await hass.async_block_till_done() + + await hass.async_add_executor_job(trigger_db_commit, hass) + await hass.async_block_till_done() + await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + + client = await hass_client() + response = await client.get( + f"/api/history/period/{dt_util.utcnow().isoformat()}", + ) + assert response.status == 200 + response_json = await response.json() + assert len(response_json) == 3 + assert response_json[0][0]["entity_id"] == "light.match" + assert response_json[1][0]["entity_id"] == "media_player.test" + assert response_json[2][0]["entity_id"] == "switch.match" diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index d805eb40ec1..63ede883216 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -26,6 +26,7 @@ from homeassistant.const import ( CONF_INCLUDE, EVENT_CALL_SERVICE, EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, STATE_NOT_HOME, @@ -33,10 +34,7 @@ from homeassistant.const import ( STATE_ON, ) import homeassistant.core as ha -from homeassistant.helpers.entityfilter import ( - CONF_ENTITY_GLOBS, - convert_include_exclude_filter, -) +from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS from homeassistant.helpers.json import JSONEncoder from homeassistant.setup import async_setup_component, setup_component import homeassistant.util.dt as dt_util @@ -167,413 +165,6 @@ class TestComponentLogbook(unittest.TestCase): entries[1], pointC, "bla", domain="sensor", entity_id=entity_id ) - def test_exclude_events_entity(self): - """Test if events are filtered if entity is excluded in config.""" - entity_id = "sensor.bla" - entity_id2 = "sensor.blu" - pointA = dt_util.utcnow() - pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES) - entity_attr_cache = logbook.EntityAttributeCache(self.hass) - - eventA = self.create_state_changed_event(pointA, entity_id, 10) - eventB = self.create_state_changed_event(pointB, entity_id2, 20) - - config = logbook.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - logbook.DOMAIN: {CONF_EXCLUDE: {CONF_ENTITIES: [entity_id]}}, - } - ) - entities_filter = convert_include_exclude_filter(config[logbook.DOMAIN]) - events = [ - e - for e in ( - MockLazyEventPartialState(EVENT_HOMEASSISTANT_STOP), - eventA, - eventB, - ) - if logbook._keep_event(self.hass, e, entities_filter) - ] - entries = list(logbook.humanify(self.hass, events, entity_attr_cache, {})) - - assert len(entries) == 2 - self.assert_entry( - entries[0], name="Home Assistant", message="stopped", domain=ha.DOMAIN - ) - self.assert_entry( - entries[1], pointB, "blu", domain="sensor", entity_id=entity_id2 - ) - - def test_exclude_events_domain(self): - """Test if events are filtered if domain is excluded in config.""" - entity_id = "switch.bla" - entity_id2 = "sensor.blu" - pointA = dt_util.utcnow() - pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES) - entity_attr_cache = logbook.EntityAttributeCache(self.hass) - - eventA = self.create_state_changed_event(pointA, entity_id, 10) - eventB = self.create_state_changed_event(pointB, entity_id2, 20) - - config = logbook.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - logbook.DOMAIN: {CONF_EXCLUDE: {CONF_DOMAINS: ["switch", "alexa"]}}, - } - ) - entities_filter = convert_include_exclude_filter(config[logbook.DOMAIN]) - events = [ - e - for e in ( - MockLazyEventPartialState(EVENT_HOMEASSISTANT_START), - MockLazyEventPartialState(EVENT_ALEXA_SMART_HOME), - eventA, - eventB, - ) - if logbook._keep_event(self.hass, e, entities_filter) - ] - entries = list(logbook.humanify(self.hass, events, entity_attr_cache, {})) - - assert len(entries) == 2 - self.assert_entry( - entries[0], name="Home Assistant", message="started", domain=ha.DOMAIN - ) - self.assert_entry( - entries[1], pointB, "blu", domain="sensor", entity_id=entity_id2 - ) - - def test_exclude_events_domain_glob(self): - """Test if events are filtered if domain or glob is excluded in config.""" - entity_id = "switch.bla" - entity_id2 = "sensor.blu" - entity_id3 = "sensor.excluded" - pointA = dt_util.utcnow() - pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES) - pointC = pointB + timedelta(minutes=logbook.GROUP_BY_MINUTES) - entity_attr_cache = logbook.EntityAttributeCache(self.hass) - - eventA = self.create_state_changed_event(pointA, entity_id, 10) - eventB = self.create_state_changed_event(pointB, entity_id2, 20) - eventC = self.create_state_changed_event(pointC, entity_id3, 30) - - config = logbook.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - logbook.DOMAIN: { - CONF_EXCLUDE: { - CONF_DOMAINS: ["switch", "alexa"], - CONF_ENTITY_GLOBS: "*.excluded", - } - }, - } - ) - entities_filter = convert_include_exclude_filter(config[logbook.DOMAIN]) - events = [ - e - for e in ( - MockLazyEventPartialState(EVENT_HOMEASSISTANT_START), - MockLazyEventPartialState(EVENT_ALEXA_SMART_HOME), - eventA, - eventB, - eventC, - ) - if logbook._keep_event(self.hass, e, entities_filter) - ] - entries = list(logbook.humanify(self.hass, events, entity_attr_cache, {})) - - assert len(entries) == 2 - self.assert_entry( - entries[0], name="Home Assistant", message="started", domain=ha.DOMAIN - ) - self.assert_entry( - entries[1], pointB, "blu", domain="sensor", entity_id=entity_id2 - ) - - def test_include_events_entity(self): - """Test if events are filtered if entity is included in config.""" - entity_id = "sensor.bla" - entity_id2 = "sensor.blu" - pointA = dt_util.utcnow() - pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES) - entity_attr_cache = logbook.EntityAttributeCache(self.hass) - - eventA = self.create_state_changed_event(pointA, entity_id, 10) - eventB = self.create_state_changed_event(pointB, entity_id2, 20) - - config = logbook.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - logbook.DOMAIN: { - CONF_INCLUDE: { - CONF_DOMAINS: ["homeassistant"], - CONF_ENTITIES: [entity_id2], - } - }, - } - ) - entities_filter = convert_include_exclude_filter(config[logbook.DOMAIN]) - events = [ - e - for e in ( - MockLazyEventPartialState(EVENT_HOMEASSISTANT_STOP), - eventA, - eventB, - ) - if logbook._keep_event(self.hass, e, entities_filter) - ] - entries = list(logbook.humanify(self.hass, events, entity_attr_cache, {})) - - assert len(entries) == 2 - self.assert_entry( - entries[0], name="Home Assistant", message="stopped", domain=ha.DOMAIN - ) - self.assert_entry( - entries[1], pointB, "blu", domain="sensor", entity_id=entity_id2 - ) - - def test_include_events_domain(self): - """Test if events are filtered if domain is included in config.""" - assert setup_component(self.hass, "alexa", {}) - entity_id = "switch.bla" - entity_id2 = "sensor.blu" - pointA = dt_util.utcnow() - pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES) - entity_attr_cache = logbook.EntityAttributeCache(self.hass) - - event_alexa = MockLazyEventPartialState( - EVENT_ALEXA_SMART_HOME, - {"request": {"namespace": "Alexa.Discovery", "name": "Discover"}}, - ) - - eventA = self.create_state_changed_event(pointA, entity_id, 10) - eventB = self.create_state_changed_event(pointB, entity_id2, 20) - - config = logbook.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - logbook.DOMAIN: { - CONF_INCLUDE: {CONF_DOMAINS: ["homeassistant", "sensor", "alexa"]} - }, - } - ) - entities_filter = convert_include_exclude_filter(config[logbook.DOMAIN]) - events = [ - e - for e in ( - MockLazyEventPartialState(EVENT_HOMEASSISTANT_START), - event_alexa, - eventA, - eventB, - ) - if logbook._keep_event(self.hass, e, entities_filter) - ] - entries = list(logbook.humanify(self.hass, events, entity_attr_cache, {})) - - assert len(entries) == 3 - self.assert_entry( - entries[0], name="Home Assistant", message="started", domain=ha.DOMAIN - ) - self.assert_entry(entries[1], name="Amazon Alexa", domain="alexa") - self.assert_entry( - entries[2], pointB, "blu", domain="sensor", entity_id=entity_id2 - ) - - def test_include_events_domain_glob(self): - """Test if events are filtered if domain or glob is included in config.""" - assert setup_component(self.hass, "alexa", {}) - entity_id = "switch.bla" - entity_id2 = "sensor.blu" - entity_id3 = "switch.included" - pointA = dt_util.utcnow() - pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES) - pointC = pointB + timedelta(minutes=logbook.GROUP_BY_MINUTES) - entity_attr_cache = logbook.EntityAttributeCache(self.hass) - - event_alexa = MockLazyEventPartialState( - EVENT_ALEXA_SMART_HOME, - {"request": {"namespace": "Alexa.Discovery", "name": "Discover"}}, - ) - - eventA = self.create_state_changed_event(pointA, entity_id, 10) - eventB = self.create_state_changed_event(pointB, entity_id2, 20) - eventC = self.create_state_changed_event(pointC, entity_id3, 30) - - config = logbook.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - logbook.DOMAIN: { - CONF_INCLUDE: { - CONF_DOMAINS: ["homeassistant", "sensor", "alexa"], - CONF_ENTITY_GLOBS: ["*.included"], - } - }, - } - ) - entities_filter = convert_include_exclude_filter(config[logbook.DOMAIN]) - events = [ - e - for e in ( - MockLazyEventPartialState(EVENT_HOMEASSISTANT_START), - event_alexa, - eventA, - eventB, - eventC, - ) - if logbook._keep_event(self.hass, e, entities_filter) - ] - entries = list(logbook.humanify(self.hass, events, entity_attr_cache, {})) - - assert len(entries) == 4 - self.assert_entry( - entries[0], name="Home Assistant", message="started", domain=ha.DOMAIN - ) - self.assert_entry(entries[1], name="Amazon Alexa", domain="alexa") - self.assert_entry( - entries[2], pointB, "blu", domain="sensor", entity_id=entity_id2 - ) - self.assert_entry( - entries[3], pointC, "included", domain="switch", entity_id=entity_id3 - ) - - def test_include_exclude_events(self): - """Test if events are filtered if include and exclude is configured.""" - entity_id = "switch.bla" - entity_id2 = "sensor.blu" - entity_id3 = "sensor.bli" - pointA = dt_util.utcnow() - pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES) - entity_attr_cache = logbook.EntityAttributeCache(self.hass) - - eventA1 = self.create_state_changed_event(pointA, entity_id, 10) - eventA2 = self.create_state_changed_event(pointA, entity_id2, 10) - eventA3 = self.create_state_changed_event(pointA, entity_id3, 10) - eventB1 = self.create_state_changed_event(pointB, entity_id, 20) - eventB2 = self.create_state_changed_event(pointB, entity_id2, 20) - - config = logbook.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - logbook.DOMAIN: { - CONF_INCLUDE: { - CONF_DOMAINS: ["sensor", "homeassistant"], - CONF_ENTITIES: ["switch.bla"], - }, - CONF_EXCLUDE: { - CONF_DOMAINS: ["switch"], - CONF_ENTITIES: ["sensor.bli"], - }, - }, - } - ) - entities_filter = convert_include_exclude_filter(config[logbook.DOMAIN]) - events = [ - e - for e in ( - MockLazyEventPartialState(EVENT_HOMEASSISTANT_START), - eventA1, - eventA2, - eventA3, - eventB1, - eventB2, - ) - if logbook._keep_event(self.hass, e, entities_filter) - ] - entries = list(logbook.humanify(self.hass, events, entity_attr_cache, {})) - - assert len(entries) == 5 - self.assert_entry( - entries[0], name="Home Assistant", message="started", domain=ha.DOMAIN - ) - self.assert_entry( - entries[1], pointA, "bla", domain="switch", entity_id=entity_id - ) - self.assert_entry( - entries[2], pointA, "blu", domain="sensor", entity_id=entity_id2 - ) - self.assert_entry( - entries[3], pointB, "bla", domain="switch", entity_id=entity_id - ) - self.assert_entry( - entries[4], pointB, "blu", domain="sensor", entity_id=entity_id2 - ) - - def test_include_exclude_events_with_glob_filters(self): - """Test if events are filtered if include and exclude is configured.""" - entity_id = "switch.bla" - entity_id2 = "sensor.blu" - entity_id3 = "sensor.bli" - entity_id4 = "light.included" - entity_id5 = "switch.included" - entity_id6 = "sensor.excluded" - pointA = dt_util.utcnow() - pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES) - pointC = pointB + timedelta(minutes=logbook.GROUP_BY_MINUTES) - entity_attr_cache = logbook.EntityAttributeCache(self.hass) - - eventA1 = self.create_state_changed_event(pointA, entity_id, 10) - eventA2 = self.create_state_changed_event(pointA, entity_id2, 10) - eventA3 = self.create_state_changed_event(pointA, entity_id3, 10) - eventB1 = self.create_state_changed_event(pointB, entity_id, 20) - eventB2 = self.create_state_changed_event(pointB, entity_id2, 20) - eventC1 = self.create_state_changed_event(pointC, entity_id4, 30) - eventC2 = self.create_state_changed_event(pointC, entity_id5, 30) - eventC3 = self.create_state_changed_event(pointC, entity_id6, 30) - - config = logbook.CONFIG_SCHEMA( - { - ha.DOMAIN: {}, - logbook.DOMAIN: { - CONF_INCLUDE: { - CONF_DOMAINS: ["sensor", "homeassistant"], - CONF_ENTITIES: ["switch.bla"], - CONF_ENTITY_GLOBS: ["*.included"], - }, - CONF_EXCLUDE: { - CONF_DOMAINS: ["switch"], - CONF_ENTITY_GLOBS: ["*.excluded"], - CONF_ENTITIES: ["sensor.bli"], - }, - }, - } - ) - entities_filter = convert_include_exclude_filter(config[logbook.DOMAIN]) - events = [ - e - for e in ( - MockLazyEventPartialState(EVENT_HOMEASSISTANT_START), - eventA1, - eventA2, - eventA3, - eventB1, - eventB2, - eventC1, - eventC2, - eventC3, - ) - if logbook._keep_event(self.hass, e, entities_filter) - ] - entries = list(logbook.humanify(self.hass, events, entity_attr_cache, {})) - - assert len(entries) == 6 - self.assert_entry( - entries[0], name="Home Assistant", message="started", domain=ha.DOMAIN - ) - self.assert_entry( - entries[1], pointA, "bla", domain="switch", entity_id=entity_id - ) - self.assert_entry( - entries[2], pointA, "blu", domain="sensor", entity_id=entity_id2 - ) - self.assert_entry( - entries[3], pointB, "bla", domain="switch", entity_id=entity_id - ) - self.assert_entry( - entries[4], pointB, "blu", domain="sensor", entity_id=entity_id2 - ) - self.assert_entry( - entries[5], pointC, "included", domain="light", entity_id=entity_id4 - ) - def test_home_assistant_start_stop_grouped(self): """Test if HA start and stop events are grouped. @@ -1277,20 +868,7 @@ class TestComponentLogbook(unittest.TestCase): self, entry, when=None, name=None, message=None, domain=None, entity_id=None ): """Assert an entry is what is expected.""" - if when: - assert when.isoformat() == entry["when"] - - if name: - assert name == entry["name"] - - if message: - assert message == entry["message"] - - if domain: - assert domain == entry["domain"] - - if entity_id: - assert entity_id == entry["entity_id"] + return _assert_entry(entry, when, name, message, domain, entity_id) def create_state_changed_event( self, @@ -2287,22 +1865,10 @@ async def test_icon_and_state(hass, hass_client): ) hass.states.async_set("light.kitchen", STATE_OFF, {"icon": "mdi:chemical-weapon"}) - await hass.async_block_till_done() - - await hass.async_add_job(trigger_db_commit, hass) - await hass.async_block_till_done() - await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + await _async_commit_and_wait(hass) client = await hass_client() - - # Today time 00:00:00 - start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) - - # Test today entries without filters - response = await client.get(f"/api/logbook/{start_date.isoformat()}") - assert response.status == 200 - response_json = await response.json() + response_json = await _async_fetch_logbook(client) assert len(response_json) == 3 assert response_json[0]["domain"] == "homeassistant" @@ -2316,6 +1882,390 @@ async def test_icon_and_state(hass, hass_client): assert response_json[2]["state"] == STATE_OFF +async def test_exclude_events_domain(hass, hass_client): + """Test if events are filtered if domain is excluded in config.""" + entity_id = "switch.bla" + entity_id2 = "sensor.blu" + + config = logbook.CONFIG_SCHEMA( + { + ha.DOMAIN: {}, + logbook.DOMAIN: {CONF_EXCLUDE: {CONF_DOMAINS: ["switch", "alexa"]}}, + } + ) + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "logbook", config) + await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + hass.states.async_set(entity_id, None) + hass.states.async_set(entity_id, 10) + hass.states.async_set(entity_id2, None) + hass.states.async_set(entity_id2, 20) + + await _async_commit_and_wait(hass) + + client = await hass_client() + entries = await _async_fetch_logbook(client) + + assert len(entries) == 2 + _assert_entry( + entries[0], name="Home Assistant", message="started", domain=ha.DOMAIN + ) + _assert_entry(entries[1], name="blu", domain="sensor", entity_id=entity_id2) + + +async def test_exclude_events_domain_glob(hass, hass_client): + """Test if events are filtered if domain or glob is excluded in config.""" + entity_id = "switch.bla" + entity_id2 = "sensor.blu" + entity_id3 = "sensor.excluded" + + config = logbook.CONFIG_SCHEMA( + { + ha.DOMAIN: {}, + logbook.DOMAIN: { + CONF_EXCLUDE: { + CONF_DOMAINS: ["switch", "alexa"], + CONF_ENTITY_GLOBS: "*.excluded", + } + }, + } + ) + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "logbook", config) + await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + hass.states.async_set(entity_id, None) + hass.states.async_set(entity_id, 10) + hass.states.async_set(entity_id2, None) + hass.states.async_set(entity_id2, 20) + hass.states.async_set(entity_id3, None) + hass.states.async_set(entity_id3, 30) + + await _async_commit_and_wait(hass) + client = await hass_client() + entries = await _async_fetch_logbook(client) + + assert len(entries) == 2 + _assert_entry( + entries[0], name="Home Assistant", message="started", domain=ha.DOMAIN + ) + _assert_entry(entries[1], name="blu", domain="sensor", entity_id=entity_id2) + + +async def test_include_events_entity(hass, hass_client): + """Test if events are filtered if entity is included in config.""" + entity_id = "sensor.bla" + entity_id2 = "sensor.blu" + + config = logbook.CONFIG_SCHEMA( + { + ha.DOMAIN: {}, + logbook.DOMAIN: { + CONF_INCLUDE: { + CONF_DOMAINS: ["homeassistant"], + CONF_ENTITIES: [entity_id2], + } + }, + } + ) + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "logbook", config) + await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + hass.states.async_set(entity_id, None) + hass.states.async_set(entity_id, 10) + hass.states.async_set(entity_id2, None) + hass.states.async_set(entity_id2, 20) + + await _async_commit_and_wait(hass) + client = await hass_client() + entries = await _async_fetch_logbook(client) + + assert len(entries) == 2 + _assert_entry( + entries[0], name="Home Assistant", message="started", domain=ha.DOMAIN + ) + _assert_entry(entries[1], name="blu", domain="sensor", entity_id=entity_id2) + + +async def test_exclude_events_entity(hass, hass_client): + """Test if events are filtered if entity is excluded in config.""" + entity_id = "sensor.bla" + entity_id2 = "sensor.blu" + + config = logbook.CONFIG_SCHEMA( + { + ha.DOMAIN: {}, + logbook.DOMAIN: {CONF_EXCLUDE: {CONF_ENTITIES: [entity_id]}}, + } + ) + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "logbook", config) + await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + hass.states.async_set(entity_id, None) + hass.states.async_set(entity_id, 10) + hass.states.async_set(entity_id2, None) + hass.states.async_set(entity_id2, 20) + + await _async_commit_and_wait(hass) + client = await hass_client() + entries = await _async_fetch_logbook(client) + assert len(entries) == 2 + _assert_entry( + entries[0], name="Home Assistant", message="started", domain=ha.DOMAIN + ) + _assert_entry(entries[1], name="blu", domain="sensor", entity_id=entity_id2) + + +async def test_include_events_domain(hass, hass_client): + """Test if events are filtered if domain is included in config.""" + assert await async_setup_component(hass, "alexa", {}) + entity_id = "switch.bla" + entity_id2 = "sensor.blu" + config = logbook.CONFIG_SCHEMA( + { + ha.DOMAIN: {}, + logbook.DOMAIN: { + CONF_INCLUDE: {CONF_DOMAINS: ["homeassistant", "sensor", "alexa"]} + }, + } + ) + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "logbook", config) + await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + hass.bus.async_fire( + EVENT_ALEXA_SMART_HOME, + {"request": {"namespace": "Alexa.Discovery", "name": "Discover"}}, + ) + hass.states.async_set(entity_id, None) + hass.states.async_set(entity_id, 10) + hass.states.async_set(entity_id2, None) + hass.states.async_set(entity_id2, 20) + + await _async_commit_and_wait(hass) + client = await hass_client() + entries = await _async_fetch_logbook(client) + + assert len(entries) == 3 + _assert_entry( + entries[0], name="Home Assistant", message="started", domain=ha.DOMAIN + ) + _assert_entry(entries[1], name="Amazon Alexa", domain="alexa") + _assert_entry(entries[2], name="blu", domain="sensor", entity_id=entity_id2) + + +async def test_include_events_domain_glob(hass, hass_client): + """Test if events are filtered if domain or glob is included in config.""" + assert await async_setup_component(hass, "alexa", {}) + entity_id = "switch.bla" + entity_id2 = "sensor.blu" + entity_id3 = "switch.included" + config = logbook.CONFIG_SCHEMA( + { + ha.DOMAIN: {}, + logbook.DOMAIN: { + CONF_INCLUDE: { + CONF_DOMAINS: ["homeassistant", "sensor", "alexa"], + CONF_ENTITY_GLOBS: ["*.included"], + } + }, + } + ) + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "logbook", config) + await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + hass.bus.async_fire( + EVENT_ALEXA_SMART_HOME, + {"request": {"namespace": "Alexa.Discovery", "name": "Discover"}}, + ) + hass.states.async_set(entity_id, None) + hass.states.async_set(entity_id, 10) + hass.states.async_set(entity_id2, None) + hass.states.async_set(entity_id2, 20) + hass.states.async_set(entity_id3, None) + hass.states.async_set(entity_id3, 30) + + await _async_commit_and_wait(hass) + client = await hass_client() + entries = await _async_fetch_logbook(client) + + assert len(entries) == 4 + _assert_entry( + entries[0], name="Home Assistant", message="started", domain=ha.DOMAIN + ) + _assert_entry(entries[1], name="Amazon Alexa", domain="alexa") + _assert_entry(entries[2], name="blu", domain="sensor", entity_id=entity_id2) + _assert_entry(entries[3], name="included", domain="switch", entity_id=entity_id3) + + +async def test_include_exclude_events(hass, hass_client): + """Test if events are filtered if include and exclude is configured.""" + entity_id = "switch.bla" + entity_id2 = "sensor.blu" + entity_id3 = "sensor.bli" + entity_id4 = "sensor.keep" + + config = logbook.CONFIG_SCHEMA( + { + ha.DOMAIN: {}, + logbook.DOMAIN: { + CONF_INCLUDE: { + CONF_DOMAINS: ["sensor", "homeassistant"], + CONF_ENTITIES: ["switch.bla"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["switch"], + CONF_ENTITIES: ["sensor.bli"], + }, + }, + } + ) + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "logbook", config) + await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + hass.states.async_set(entity_id, None) + hass.states.async_set(entity_id, 10) + hass.states.async_set(entity_id2, None) + hass.states.async_set(entity_id2, 10) + hass.states.async_set(entity_id3, None) + hass.states.async_set(entity_id3, 10) + hass.states.async_set(entity_id, 20) + hass.states.async_set(entity_id2, 20) + hass.states.async_set(entity_id4, None) + hass.states.async_set(entity_id4, 10) + + await _async_commit_and_wait(hass) + client = await hass_client() + entries = await _async_fetch_logbook(client) + + assert len(entries) == 3 + _assert_entry( + entries[0], name="Home Assistant", message="started", domain=ha.DOMAIN + ) + _assert_entry(entries[1], name="blu", domain="sensor", entity_id=entity_id2) + _assert_entry(entries[2], name="keep", domain="sensor", entity_id=entity_id4) + + +async def test_include_exclude_events_with_glob_filters(hass, hass_client): + """Test if events are filtered if include and exclude is configured.""" + entity_id = "switch.bla" + entity_id2 = "sensor.blu" + entity_id3 = "sensor.bli" + entity_id4 = "light.included" + entity_id5 = "switch.included" + entity_id6 = "sensor.excluded" + config = logbook.CONFIG_SCHEMA( + { + ha.DOMAIN: {}, + logbook.DOMAIN: { + CONF_INCLUDE: { + CONF_DOMAINS: ["sensor", "homeassistant"], + CONF_ENTITIES: ["switch.bla"], + CONF_ENTITY_GLOBS: ["*.included"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["switch"], + CONF_ENTITY_GLOBS: ["*.excluded"], + CONF_ENTITIES: ["sensor.bli"], + }, + }, + } + ) + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "logbook", config) + await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + hass.states.async_set(entity_id, None) + hass.states.async_set(entity_id, 10) + hass.states.async_set(entity_id2, None) + hass.states.async_set(entity_id2, 10) + hass.states.async_set(entity_id3, None) + hass.states.async_set(entity_id3, 10) + hass.states.async_set(entity_id, 20) + hass.states.async_set(entity_id2, 20) + hass.states.async_set(entity_id4, None) + hass.states.async_set(entity_id4, 30) + hass.states.async_set(entity_id5, None) + hass.states.async_set(entity_id5, 30) + hass.states.async_set(entity_id6, None) + hass.states.async_set(entity_id6, 30) + + await _async_commit_and_wait(hass) + client = await hass_client() + entries = await _async_fetch_logbook(client) + + assert len(entries) == 3 + _assert_entry( + entries[0], name="Home Assistant", message="started", domain=ha.DOMAIN + ) + _assert_entry(entries[1], name="blu", domain="sensor", entity_id=entity_id2) + _assert_entry(entries[2], name="included", domain="light", entity_id=entity_id4) + + +async def _async_fetch_logbook(client): + + # Today time 00:00:00 + start = dt_util.utcnow().date() + start_date = datetime(start.year, start.month, start.day) - timedelta(hours=24) + + # Test today entries without filters + end_time = start + timedelta(hours=48) + response = await client.get( + f"/api/logbook/{start_date.isoformat()}?end_time={end_time}" + ) + assert response.status == 200 + return await response.json() + + +async def _async_commit_and_wait(hass): + await hass.async_block_till_done() + await hass.async_add_executor_job(trigger_db_commit, hass) + await hass.async_block_till_done() + await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + await hass.async_block_till_done() + + +def _assert_entry( + entry, when=None, name=None, message=None, domain=None, entity_id=None +): + """Assert an entry is what is expected.""" + if when: + assert when.isoformat() == entry["when"] + + if name: + assert name == entry["name"] + + if message: + assert message == entry["message"] + + if domain: + assert domain == entry["domain"] + + if entity_id: + assert entity_id == entry["entity_id"] + + class MockLazyEventPartialState(ha.Event): """Minimal mock of a Lazy event."""