Include all non-numeric sensor events in logbook (#71331)

This commit is contained in:
Erik Montnemery 2022-05-05 19:16:36 +02:00 committed by GitHub
parent 8a41370950
commit 203bebe668
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 92 additions and 41 deletions

View File

@ -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."""

View File

@ -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"]