From 203bebe668cd2cf0b4130c225527691977c9f932 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 5 May 2022 19:16:36 +0200 Subject: [PATCH] Include all non-numeric sensor events in logbook (#71331) --- homeassistant/components/logbook/__init__.py | 48 ++++++++--- tests/components/logbook/test_init.py | 85 +++++++++++++------- 2 files changed, 92 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index 7bacbd4cdd7..1c930cf3004 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -26,6 +26,7 @@ from homeassistant.components.history import ( sqlalchemy_filter_from_include_exclude_conf, ) from homeassistant.components.http import HomeAssistantView +from homeassistant.components.proximity import DOMAIN as PROXIMITY_DOMAIN from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.models import ( EventData, @@ -36,6 +37,7 @@ from homeassistant.components.recorder.models import ( ) from homeassistant.components.recorder.util import session_scope from homeassistant.components.script import EVENT_SCRIPT_STARTED +from homeassistant.components.sensor import ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN from homeassistant.const import ( ATTR_DOMAIN, ATTR_ENTITY_ID, @@ -58,7 +60,7 @@ from homeassistant.core import ( split_entity_id, ) from homeassistant.exceptions import InvalidEntityFormatError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, EntityFilter, @@ -79,7 +81,7 @@ DOMAIN_JSON_EXTRACT = re.compile('"domain": ?"([^"]+)"') ICON_JSON_EXTRACT = re.compile('"icon": ?"([^"]+)"') ATTR_MESSAGE = "message" -CONTINUOUS_DOMAINS = {"proximity", "sensor"} +CONTINUOUS_DOMAINS = {PROXIMITY_DOMAIN, SENSOR_DOMAIN} CONTINUOUS_ENTITY_ID_LIKE = [f"{domain}.%" for domain in CONTINUOUS_DOMAINS] DOMAIN = "logbook" @@ -331,7 +333,6 @@ def humanify( """Generate a converted list of events into Entry objects. Will try to group events if possible: - - if 2+ sensor updates in GROUP_BY_MINUTES, show last - if Home Assistant stop and start happen in same minute call it restarted """ external_events = hass.data.get(DOMAIN, {}) @@ -343,8 +344,8 @@ def humanify( events_batch = list(g_events) - # Keep track of last sensor states - last_sensor_event = {} + # Continuous sensors, will be excluded from the logbook + continuous_sensors = {} # Group HA start/stop events # Maps minute of event to 1: stop, 2: stop + start @@ -353,8 +354,13 @@ def humanify( # Process events for event in events_batch: if event.event_type == EVENT_STATE_CHANGED: - if event.domain in CONTINUOUS_DOMAINS: - last_sensor_event[event.entity_id] = event + if event.domain != SENSOR_DOMAIN: + continue + entity_id = event.entity_id + if entity_id in continuous_sensors: + continue + assert entity_id is not None + continuous_sensors[entity_id] = _is_sensor_continuous(hass, entity_id) elif event.event_type == EVENT_HOMEASSISTANT_STOP: if event.time_fired_minute in start_stop_events: @@ -373,15 +379,12 @@ def humanify( if event.event_type == EVENT_STATE_CHANGED: entity_id = event.entity_id domain = event.domain + assert entity_id is not None - if ( - domain in CONTINUOUS_DOMAINS - and event != last_sensor_event[entity_id] - ): - # Skip all but the last sensor state + if domain == SENSOR_DOMAIN and continuous_sensors[entity_id]: + # Skip continuous sensors continue - assert entity_id is not None data = { "when": event.time_fired_isoformat, "name": _entity_name_from_event( @@ -812,6 +815,25 @@ def _entity_name_from_event( ) or split_entity_id(entity_id)[1].replace("_", " ") +def _is_sensor_continuous( + hass: HomeAssistant, + entity_id: str, +) -> bool: + """Determine if a sensor is continuous by checking its state class. + + Sensors with a unit_of_measurement are also considered continuous, but are filtered + already by the SQL query generated by _get_events + """ + registry = er.async_get(hass) + if not (entry := registry.async_get(entity_id)): + # Entity not registered, so can't have a state class + return False + return ( + entry.capabilities is not None + and entry.capabilities.get(ATTR_STATE_CLASS) is not None + ) + + class LazyEventPartialState: """A lazy version of core Event with limited State joined in.""" diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index 68b9169224e..2612765584f 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -14,12 +14,14 @@ from homeassistant.components.alexa.smart_home import EVENT_ALEXA_SMART_HOME from homeassistant.components.automation import EVENT_AUTOMATION_TRIGGERED from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat from homeassistant.components.script import EVENT_SCRIPT_STARTED +from homeassistant.components.sensor import SensorStateClass from homeassistant.const import ( ATTR_DOMAIN, ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_NAME, ATTR_SERVICE, + ATTR_UNIT_OF_MEASUREMENT, CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, @@ -33,6 +35,7 @@ from homeassistant.const import ( STATE_ON, ) import homeassistant.core as ha +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS from homeassistant.helpers.json import JSONEncoder from homeassistant.setup import async_setup_component @@ -157,27 +160,51 @@ async def test_service_call_create_log_book_entry_no_message(hass_): assert len(calls) == 0 -def test_humanify_filter_sensor(hass_): - """Test humanify filter too frequent sensor values.""" - entity_id = "sensor.bla" +async def test_filter_sensor(hass_: ha.HomeAssistant, hass_client): + """Test numeric sensors are filtered.""" - pointA = dt_util.utcnow().replace(minute=2) - pointB = pointA.replace(minute=5) - pointC = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES) - entity_attr_cache = logbook.EntityAttributeCache(hass_) + registry = er.async_get(hass_) - eventA = create_state_changed_event(pointA, entity_id, 10) - eventB = create_state_changed_event(pointB, entity_id, 20) - eventC = create_state_changed_event(pointC, entity_id, 30) + # Unregistered sensor without a unit of measurement - should be in logbook + entity_id1 = "sensor.bla" + attributes_1 = None + # Unregistered sensor with a unit of measurement - should be excluded from logbook + entity_id2 = "sensor.blu" + attributes_2 = {ATTR_UNIT_OF_MEASUREMENT: "cats"} + # Registered sensor with state class - should be excluded from logbook + entity_id3 = registry.async_get_or_create( + "sensor", + "test", + "unique_3", + suggested_object_id="bli", + capabilities={"state_class": SensorStateClass.MEASUREMENT}, + ).entity_id + attributes_3 = None + # Registered sensor without state class or unit - should be in logbook + entity_id4 = registry.async_get_or_create( + "sensor", "test", "unique_4", suggested_object_id="ble" + ).entity_id + attributes_4 = None - entries = list( - logbook.humanify(hass_, (eventA, eventB, eventC), entity_attr_cache, {}) - ) + hass_.states.async_set(entity_id1, None, attributes_1) # Excluded + hass_.states.async_set(entity_id1, 10, attributes_1) # Included + hass_.states.async_set(entity_id2, None, attributes_2) # Excluded + hass_.states.async_set(entity_id2, 10, attributes_2) # Excluded + hass_.states.async_set(entity_id3, None, attributes_3) # Excluded + hass_.states.async_set(entity_id3, 10, attributes_3) # Excluded + hass_.states.async_set(entity_id1, 20, attributes_1) # Included + hass_.states.async_set(entity_id2, 20, attributes_2) # Excluded + hass_.states.async_set(entity_id4, None, attributes_4) # Excluded + hass_.states.async_set(entity_id4, 10, attributes_4) # Included - assert len(entries) == 2 - assert_entry(entries[0], pointB, "bla", entity_id=entity_id) + await async_wait_recording_done(hass_) + client = await hass_client() + entries = await _async_fetch_logbook(client) - assert_entry(entries[1], pointC, "bla", entity_id=entity_id) + assert len(entries) == 3 + _assert_entry(entries[0], name="bla", entity_id=entity_id1, state="10") + _assert_entry(entries[1], name="bla", entity_id=entity_id1, state="20") + _assert_entry(entries[2], name="ble", entity_id=entity_id4, state="10") def test_home_assistant_start_stop_grouped(hass_): @@ -1790,12 +1817,13 @@ async def test_include_exclude_events(hass, hass_client, recorder_mock): client = await hass_client() entries = await _async_fetch_logbook(client) - assert len(entries) == 3 + assert len(entries) == 4 _assert_entry( entries[0], name="Home Assistant", message="started", domain=ha.DOMAIN ) - _assert_entry(entries[1], name="blu", entity_id=entity_id2) - _assert_entry(entries[2], name="keep", entity_id=entity_id4) + _assert_entry(entries[1], name="blu", entity_id=entity_id2, state="10") + _assert_entry(entries[2], name="blu", entity_id=entity_id2, state="20") + _assert_entry(entries[3], name="keep", entity_id=entity_id4, state="10") async def test_include_exclude_events_with_glob_filters( @@ -1849,12 +1877,13 @@ async def test_include_exclude_events_with_glob_filters( client = await hass_client() entries = await _async_fetch_logbook(client) - assert len(entries) == 3 + assert len(entries) == 4 _assert_entry( entries[0], name="Home Assistant", message="started", domain=ha.DOMAIN ) - _assert_entry(entries[1], name="blu", entity_id=entity_id2) - _assert_entry(entries[2], name="included", entity_id=entity_id4) + _assert_entry(entries[1], name="blu", entity_id=entity_id2, state="10") + _assert_entry(entries[2], name="blu", entity_id=entity_id2, state="20") + _assert_entry(entries[3], name="included", entity_id=entity_id4, state="30") async def test_empty_config(hass, hass_client, recorder_mock): @@ -1939,22 +1968,22 @@ def _assert_entry( entry, when=None, name=None, message=None, domain=None, entity_id=None, state=None ): """Assert an entry is what is expected.""" - if when: + if when is not None: assert when.isoformat() == entry["when"] - if name: + if name is not None: assert name == entry["name"] - if message: + if message is not None: assert message == entry["message"] - if domain: + if domain is not None: assert domain == entry["domain"] - if entity_id: + if entity_id is not None: assert entity_id == entry["entity_id"] - if state: + if state is not None: assert state == entry["state"]