Mark counter domain as continuous to exclude it from logbook (#73101)

This commit is contained in:
J. Nick Koston 2022-06-05 21:25:26 -10:00 committed by GitHub
parent 457c7a4ddc
commit 0b62944148
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 66 additions and 20 deletions

View File

@ -2,9 +2,18 @@
from __future__ import annotations from __future__ import annotations
from homeassistant.components.automation import EVENT_AUTOMATION_TRIGGERED from homeassistant.components.automation import EVENT_AUTOMATION_TRIGGERED
from homeassistant.components.counter import DOMAIN as COUNTER_DOMAIN
from homeassistant.components.proximity import DOMAIN as PROXIMITY_DOMAIN
from homeassistant.components.script import EVENT_SCRIPT_STARTED from homeassistant.components.script import EVENT_SCRIPT_STARTED
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import EVENT_CALL_SERVICE, EVENT_LOGBOOK_ENTRY from homeassistant.const import EVENT_CALL_SERVICE, EVENT_LOGBOOK_ENTRY
# Domains that are always continuous
ALWAYS_CONTINUOUS_DOMAINS = {COUNTER_DOMAIN, PROXIMITY_DOMAIN}
# Domains that are continuous if there is a UOM set on the entity
CONDITIONALLY_CONTINUOUS_DOMAINS = {SENSOR_DOMAIN}
ATTR_MESSAGE = "message" ATTR_MESSAGE = "message"
DOMAIN = "logbook" DOMAIN = "logbook"

View File

@ -20,12 +20,13 @@ from homeassistant.core import (
State, State,
callback, callback,
is_callback, is_callback,
split_entity_id,
) )
from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.helpers.entityfilter import EntityFilter
from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.event import async_track_state_change_event
from .const import AUTOMATION_EVENTS, BUILT_IN_EVENTS, DOMAIN from .const import ALWAYS_CONTINUOUS_DOMAINS, AUTOMATION_EVENTS, BUILT_IN_EVENTS, DOMAIN
from .models import LazyEventPartialState from .models import LazyEventPartialState
@ -235,7 +236,8 @@ def _is_state_filtered(ent_reg: er.EntityRegistry, state: State) -> bool:
we only get significant changes (state.last_changed != state.last_updated) we only get significant changes (state.last_changed != state.last_updated)
""" """
return bool( return bool(
state.last_changed != state.last_updated split_entity_id(state.entity_id)[0] in ALWAYS_CONTINUOUS_DOMAINS
or state.last_changed != state.last_updated
or ATTR_UNIT_OF_MEASUREMENT in state.attributes or ATTR_UNIT_OF_MEASUREMENT in state.attributes
or is_sensor_continuous(ent_reg, state.entity_id) or is_sensor_continuous(ent_reg, state.entity_id)
) )
@ -250,7 +252,8 @@ def _is_entity_id_filtered(
from the database when a list of entities is requested. from the database when a list of entities is requested.
""" """
return bool( return bool(
(state := hass.states.get(entity_id)) split_entity_id(entity_id)[0] in ALWAYS_CONTINUOUS_DOMAINS
or (state := hass.states.get(entity_id))
and (ATTR_UNIT_OF_MEASUREMENT in state.attributes) and (ATTR_UNIT_OF_MEASUREMENT in state.attributes)
or is_sensor_continuous(ent_reg, entity_id) or is_sensor_continuous(ent_reg, entity_id)
) )

View File

@ -10,7 +10,7 @@ from sqlalchemy.sql.elements import ClauseList
from sqlalchemy.sql.expression import literal from sqlalchemy.sql.expression import literal
from sqlalchemy.sql.selectable import Select from sqlalchemy.sql.selectable import Select
from homeassistant.components.proximity import DOMAIN as PROXIMITY_DOMAIN from homeassistant.components.recorder.filters import like_domain_matchers
from homeassistant.components.recorder.models import ( from homeassistant.components.recorder.models import (
EVENTS_CONTEXT_ID_INDEX, EVENTS_CONTEXT_ID_INDEX,
OLD_FORMAT_ATTRS_JSON, OLD_FORMAT_ATTRS_JSON,
@ -22,15 +22,19 @@ from homeassistant.components.recorder.models import (
StateAttributes, StateAttributes,
States, States,
) )
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
CONTINUOUS_DOMAINS = {PROXIMITY_DOMAIN, SENSOR_DOMAIN} from ..const import ALWAYS_CONTINUOUS_DOMAINS, CONDITIONALLY_CONTINUOUS_DOMAINS
CONTINUOUS_ENTITY_ID_LIKE = [f"{domain}.%" for domain in CONTINUOUS_DOMAINS]
# Domains that are continuous if there is a UOM set on the entity
CONDITIONALLY_CONTINUOUS_ENTITY_ID_LIKE = like_domain_matchers(
CONDITIONALLY_CONTINUOUS_DOMAINS
)
# Domains that are always continuous
ALWAYS_CONTINUOUS_ENTITY_ID_LIKE = like_domain_matchers(ALWAYS_CONTINUOUS_DOMAINS)
UNIT_OF_MEASUREMENT_JSON = '"unit_of_measurement":' UNIT_OF_MEASUREMENT_JSON = '"unit_of_measurement":'
UNIT_OF_MEASUREMENT_JSON_LIKE = f"%{UNIT_OF_MEASUREMENT_JSON}%" UNIT_OF_MEASUREMENT_JSON_LIKE = f"%{UNIT_OF_MEASUREMENT_JSON}%"
PSUEDO_EVENT_STATE_CHANGED = None PSUEDO_EVENT_STATE_CHANGED = None
# Since we don't store event_types and None # Since we don't store event_types and None
# and we don't store state_changed in events # and we don't store state_changed in events
@ -220,29 +224,44 @@ def _missing_state_matcher() -> sqlalchemy.and_:
def _not_continuous_entity_matcher() -> sqlalchemy.or_: def _not_continuous_entity_matcher() -> sqlalchemy.or_:
"""Match non continuous entities.""" """Match non continuous entities."""
return sqlalchemy.or_( return sqlalchemy.or_(
_not_continuous_domain_matcher(), # First exclude domains that may be continuous
_not_possible_continuous_domain_matcher(),
# But let in the entities in the possible continuous domains
# that are not actually continuous sensors because they lack a UOM
sqlalchemy.and_( sqlalchemy.and_(
_continuous_domain_matcher, _not_uom_attributes_matcher() _conditionally_continuous_domain_matcher, _not_uom_attributes_matcher()
).self_group(), ).self_group(),
) )
def _not_continuous_domain_matcher() -> sqlalchemy.and_: def _not_possible_continuous_domain_matcher() -> sqlalchemy.and_:
"""Match not continuous domains.""" """Match not continuous domains.
This matches domain that are always considered continuous
and domains that are conditionally (if they have a UOM)
continuous domains.
"""
return sqlalchemy.and_( return sqlalchemy.and_(
*[ *[
~States.entity_id.like(entity_domain) ~States.entity_id.like(entity_domain)
for entity_domain in CONTINUOUS_ENTITY_ID_LIKE for entity_domain in (
*ALWAYS_CONTINUOUS_ENTITY_ID_LIKE,
*CONDITIONALLY_CONTINUOUS_ENTITY_ID_LIKE,
)
], ],
).self_group() ).self_group()
def _continuous_domain_matcher() -> sqlalchemy.or_: def _conditionally_continuous_domain_matcher() -> sqlalchemy.or_:
"""Match continuous domains.""" """Match conditionally continuous domains.
This matches domain that are only considered
continuous if a UOM is set.
"""
return sqlalchemy.or_( return sqlalchemy.or_(
*[ *[
States.entity_id.like(entity_domain) States.entity_id.like(entity_domain)
for entity_domain in CONTINUOUS_ENTITY_ID_LIKE for entity_domain in CONDITIONALLY_CONTINUOUS_ENTITY_ID_LIKE
], ],
).self_group() ).self_group()

View File

@ -248,8 +248,13 @@ def _domain_matcher(
domains: Iterable[str], columns: Iterable[Column], encoder: Callable[[Any], Any] domains: Iterable[str], columns: Iterable[Column], encoder: Callable[[Any], Any]
) -> ClauseList: ) -> ClauseList:
matchers = [ matchers = [
(column.is_not(None) & cast(column, Text()).like(encoder(f"{domain}.%"))) (column.is_not(None) & cast(column, Text()).like(encoder(domain_matcher)))
for domain in domains for domain_matcher in like_domain_matchers(domains)
for column in columns for column in columns
] ]
return or_(*matchers) if matchers else or_(False) return or_(*matchers) if matchers else or_(False)
def like_domain_matchers(domains: Iterable[str]) -> list[str]:
"""Convert a list of domains to sql LIKE matchers."""
return [f"{domain}.%" for domain in domains]

View File

@ -745,6 +745,12 @@ async def test_filter_continuous_sensor_values(
entity_id_third = "light.bla" entity_id_third = "light.bla"
hass.states.async_set(entity_id_third, STATE_OFF, {"unit_of_measurement": "foo"}) hass.states.async_set(entity_id_third, STATE_OFF, {"unit_of_measurement": "foo"})
hass.states.async_set(entity_id_third, STATE_ON, {"unit_of_measurement": "foo"}) hass.states.async_set(entity_id_third, STATE_ON, {"unit_of_measurement": "foo"})
entity_id_proximity = "proximity.bla"
hass.states.async_set(entity_id_proximity, STATE_OFF)
hass.states.async_set(entity_id_proximity, STATE_ON)
entity_id_counter = "counter.bla"
hass.states.async_set(entity_id_counter, STATE_OFF)
hass.states.async_set(entity_id_counter, STATE_ON)
await async_wait_recording_done(hass) await async_wait_recording_done(hass)

View File

@ -2209,7 +2209,9 @@ async def test_recorder_is_far_behind(hass, recorder_mock, hass_ws_client, caplo
@patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0)
async def test_subscribe_all_entities_have_uom(hass, recorder_mock, hass_ws_client): async def test_subscribe_all_entities_are_continuous(
hass, recorder_mock, hass_ws_client
):
"""Test subscribe/unsubscribe logbook stream with entities that are always filtered.""" """Test subscribe/unsubscribe logbook stream with entities that are always filtered."""
now = dt_util.utcnow() now = dt_util.utcnow()
await asyncio.gather( await asyncio.gather(
@ -2227,6 +2229,8 @@ async def test_subscribe_all_entities_have_uom(hass, recorder_mock, hass_ws_clie
hass.states.async_set( hass.states.async_set(
entity_id, state, {ATTR_UNIT_OF_MEASUREMENT: "any"} entity_id, state, {ATTR_UNIT_OF_MEASUREMENT: "any"}
) )
hass.states.async_set("counter.any", state)
hass.states.async_set("proximity.any", state)
init_count = sum(hass.bus.async_listeners().values()) init_count = sum(hass.bus.async_listeners().values())
_cycle_entities() _cycle_entities()
@ -2238,7 +2242,7 @@ async def test_subscribe_all_entities_have_uom(hass, recorder_mock, hass_ws_clie
"id": 7, "id": 7,
"type": "logbook/event_stream", "type": "logbook/event_stream",
"start_time": now.isoformat(), "start_time": now.isoformat(),
"entity_ids": ["sensor.uom"], "entity_ids": ["sensor.uom", "counter.any", "proximity.any"],
} }
) )