Compare commits

...

9 Commits

Author SHA1 Message Date
Erik
5badc3d0d5 Mark include and exclude yaml configuration as deprecated 2025-09-18 08:22:21 +02:00
Erik
4bf64d41ce Add tests 2025-09-17 18:18:49 +02:00
Erik
1b81fea2e5 Adjust 2025-09-17 16:20:38 +02:00
Erik
6d4effc7f1 Adjust WS API 2025-09-17 15:02:11 +02:00
Erik
a492f56f78 Fix tests leaving files behind 2025-09-17 14:05:53 +02:00
Erik Montnemery
428827b8ba Merge branch 'dev' into recorder_recorded_entities 2025-09-17 13:05:18 +02:00
J. Nick Koston
0bbae592f8 single entity case 2025-09-12 08:36:20 -05:00
Erik
1162a66891 Fix websocket API, add basic websocket test 2025-09-12 15:10:39 +02:00
Erik
a770293aea Add runtime-changeable exclusion of entities from recording 2025-09-12 14:41:08 +02:00
9 changed files with 1077 additions and 14 deletions

View File

@@ -8,7 +8,10 @@ from typing import Any
import voluptuous as vol
from homeassistant.const import (
CONF_DOMAINS,
CONF_ENTITIES,
CONF_EXCLUDE,
CONF_INCLUDE,
EVENT_RECORDER_5MIN_STATISTICS_GENERATED, # noqa: F401
EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, # noqa: F401
EVENT_STATE_CHANGED,
@@ -16,6 +19,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entityfilter import (
CONF_ENTITY_GLOBS,
INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA,
INCLUDE_EXCLUDE_FILTER_SCHEMA_INNER,
convert_include_exclude_filter,
@@ -34,6 +38,7 @@ from homeassistant.util.event_type import EventType
from . import (
backup, # noqa: F401
entity_registry,
recorded_entities,
websocket_api,
)
from .const import ( # noqa: F401
@@ -70,8 +75,13 @@ CONF_EVENT_TYPES = "event_types"
CONF_COMMIT_INTERVAL = "commit_interval"
EXCLUDE_SCHEMA = INCLUDE_EXCLUDE_FILTER_SCHEMA_INNER.extend(
{vol.Optional(CONF_EVENT_TYPES): vol.All(cv.ensure_list, [cv.string])}
EXCLUDE_SCHEMA = vol.All(
cv.deprecated(CONF_DOMAINS),
cv.deprecated(CONF_ENTITY_GLOBS),
cv.deprecated(CONF_ENTITIES),
INCLUDE_EXCLUDE_FILTER_SCHEMA_INNER.extend(
{vol.Optional(CONF_EVENT_TYPES): vol.All(cv.ensure_list, [cv.string])}
),
)
FILTER_SCHEMA = INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA.extend(
@@ -99,6 +109,7 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional(DOMAIN, default=dict): vol.All(
cv.deprecated(CONF_PURGE_INTERVAL),
cv.deprecated(CONF_DB_INTEGRITY_CHECK),
cv.deprecated(CONF_INCLUDE),
FILTER_SCHEMA.extend(
{
vol.Optional(CONF_AUTO_PURGE, default=True): cv.boolean,
@@ -171,6 +182,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
get_instance.cache_clear()
entity_registry.async_setup(hass)
await recorded_entities.async_setup(hass)
instance.async_initialize()
instance.async_register()
instance.start()

View File

@@ -87,6 +87,7 @@ from .models import (
UnsupportedDialect,
)
from .pool import POOL_SIZE, MutexPool, RecorderPool
from .recorded_entities import DATA_RECORDED_ENTITIES
from .table_managers.event_data import EventDataManager
from .table_managers.event_types import EventTypeManager
from .table_managers.recorder_runs import RecorderRunsManager
@@ -204,6 +205,7 @@ class Recorder(threading.Thread):
# by is_entity_recorder and the sensor recorder.
self.entity_filter = entity_filter
self.exclude_event_types = exclude_event_types
self.unrecorded_entities: set[str] = set()
self.schema_version = 0
self._commits_without_expire = 0
@@ -293,7 +295,6 @@ class Recorder(threading.Thread):
@callback
def async_initialize(self) -> None:
"""Initialize the recorder."""
entity_filter = self.entity_filter
exclude_event_types = self.exclude_event_types
queue_put = self._queue.put_nowait
@@ -303,20 +304,22 @@ class Recorder(threading.Thread):
if event.event_type in exclude_event_types:
return
if entity_filter is None or not (
unrecorded_entities = self.unrecorded_entities
if not unrecorded_entities or not (
entity_id := event.data.get(ATTR_ENTITY_ID)
):
queue_put(event)
return
if isinstance(entity_id, str):
if entity_filter(entity_id):
if entity_id not in unrecorded_entities:
queue_put(event)
return
if isinstance(entity_id, list):
for eid in entity_id:
if entity_filter(eid):
if eid not in unrecorded_entities:
queue_put(event)
return
return
@@ -449,7 +452,8 @@ class Recorder(threading.Thread):
@callback
def _async_hass_started(self, hass: HomeAssistant) -> None:
"""Notify that hass has started."""
"""Import entity filter and notify that hass has started."""
hass.data[DATA_RECORDED_ENTITIES].async_import_entity_filter(self.entity_filter)
self._hass_started.set_result(None)
@callback

View File

@@ -0,0 +1,374 @@
"""Control which entities are recorded."""
from __future__ import annotations
from collections.abc import Callable
import dataclasses
from enum import StrEnum
from itertools import chain
from typing import Any, TypedDict
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from homeassistant.util.enum import try_parse_enum
from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN
from .util import get_instance
DATA_RECORDED_ENTITIES: HassKey[RecordedEntities] = HassKey(
f"{DOMAIN}.recorded_entities"
)
STORAGE_KEY = f"{DOMAIN}.recorded_entities"
STORAGE_VERSION_MAJOR = 1
STORAGE_VERSION_MINOR = 1
SAVE_DELAY = 10
async def async_setup(hass: HomeAssistant) -> None:
"""Set up the recorded entities."""
recorded_entities = RecordedEntities(hass)
await recorded_entities.async_initialize()
hass.data[DATA_RECORDED_ENTITIES] = recorded_entities
class EntityRecordingDisabler(StrEnum):
"""What disabled recording of an entity."""
INTEGRATION = "integration"
USER = "user"
@dataclasses.dataclass(frozen=True)
class RecorderPreferences:
"""Preferences for an assistant."""
entity_filter_imported: bool
def to_json(self) -> dict[str, Any]:
"""Return a JSON serializable representation for storage."""
return {"entity_filter_imported": self.entity_filter_imported}
@dataclasses.dataclass(frozen=True)
class RecordedEntity:
"""A recorded entity without a unique_id."""
recording_disabled_by: EntityRecordingDisabler | None = None
def to_json(self) -> dict[str, Any]:
"""Return a JSON serializable representation for storage."""
return {
"recording_disabled_by": self.recording_disabled_by,
}
class SerializedRecordedEntities(TypedDict):
"""Serialized recorded entities storage collection."""
recorded_entities: dict[str, dict[str, Any]]
recorder_preferences: dict[str, Any]
class RecordedEntities:
"""Control recording of entities.
Settings for entities without a unique_id are stored in the store.
Settings for entities with a unique_id are stored in the entity registry.
"""
recorder_preferences: RecorderPreferences
entities: dict[str, RecordedEntity]
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize."""
self._hass = hass
self._store: Store[SerializedRecordedEntities] = Store(
hass,
STORAGE_VERSION_MAJOR,
STORAGE_KEY,
minor_version=STORAGE_VERSION_MINOR,
)
async def async_initialize(self) -> None:
"""Finish initializing."""
websocket_api.async_register_command(self._hass, ws_record_entity)
websocket_api.async_register_command(self._hass, ws_list_recorded_entities)
websocket_api.async_register_command(self._hass, ws_get_recorded_entity)
await self._async_load_data()
@callback
def async_import_entity_filter(
self, entity_filter: Callable[[str], bool] | None
) -> None:
"""Import an entity filter.
This will disable recording of entities which are filtered out.
"""
if self.recorder_preferences.entity_filter_imported:
return
entity_registry = er.async_get(self._hass)
# Set entity recording_disabled_by for all entities
for entity_id in entity_registry.entities:
self.async_set_entity_option(
entity_id,
recording_disabled_by=EntityRecordingDisabler.USER
if entity_filter and not entity_filter(entity_id)
else None,
)
for entity_id in self._hass.states.async_entity_ids():
if entity_id in entity_registry.entities:
continue
self.async_set_entity_option(
entity_id,
recording_disabled_by=EntityRecordingDisabler.USER
if entity_filter and not entity_filter(entity_id)
else None,
)
self.recorder_preferences = RecorderPreferences(entity_filter_imported=True)
self._async_schedule_save()
@callback
def async_set_entity_option(
self,
entity_id: str,
*,
recording_disabled_by: EntityRecordingDisabler
| None
| UndefinedType = UNDEFINED,
) -> None:
"""Set an option."""
entity_registry = er.async_get(self._hass)
if not (registry_entry := entity_registry.async_get(entity_id)):
self._async_set_legacy_entity_option(
entity_id, recording_disabled_by=recording_disabled_by
)
return
old_recorder_options = registry_entry.options.get(DOMAIN)
recorder_options = dict(old_recorder_options or {})
if recording_disabled_by is not UNDEFINED:
recorder_options["recording_disabled_by"] = recording_disabled_by
if old_recorder_options == recorder_options:
return
entity_registry.async_update_entity_options(entity_id, DOMAIN, recorder_options)
get_instance(
self._hass
).unrecorded_entities = self.async_get_unrecorded_entities()
def _async_set_legacy_entity_option(
self,
entity_id: str,
*,
recording_disabled_by: EntityRecordingDisabler
| None
| UndefinedType = UNDEFINED,
) -> None:
"""Set an option."""
old_recorded_entity = self.entities.get(entity_id)
changes = {}
if recording_disabled_by is not UNDEFINED:
changes["recording_disabled_by"] = recording_disabled_by
if old_recorded_entity:
new_recorded_entity = dataclasses.replace(old_recorded_entity, **changes)
else:
new_recorded_entity = RecordedEntity(**changes)
if old_recorded_entity == new_recorded_entity:
return
self.entities[entity_id] = new_recorded_entity
self._async_schedule_save()
@callback
def async_get_entity_options(self, entity_id: str) -> RecordedEntity:
"""Get options for an entity."""
entity_registry = er.async_get(self._hass)
if registry_entry := entity_registry.async_get(entity_id):
options: dict[str, Any] = registry_entry.options.get(DOMAIN, {})
return RecordedEntity(
recording_disabled_by=try_parse_enum(
EntityRecordingDisabler, options.get("recording_disabled_by")
)
)
if recorded_entity := self.entities.get(entity_id):
return recorded_entity
raise HomeAssistantError("Unknown entity")
@callback
def async_get_unrecorded_entities(self) -> set[str]:
"""Return a set of entities which should not be recorded."""
unrecorded_entities = {
entity_id
for entity_id, entity in self.entities.items()
if entity.recording_disabled_by is not None
}
entity_registry = er.async_get(self._hass)
for registry_entry in entity_registry.entities.values():
if DOMAIN in registry_entry.options:
if (
registry_entry.options[DOMAIN].get("recording_disabled_by")
is not None
):
unrecorded_entities.add(registry_entry.entity_id)
return unrecorded_entities
async def _async_load_data(self) -> SerializedRecordedEntities | None:
"""Load from the store."""
data = await self._store.async_load()
recorded_entities: dict[str, RecordedEntity] = {}
recorder_preferences = RecorderPreferences(
entity_filter_imported=False,
)
if data and "recorded_entities" in data:
for entity_id, preferences in data["recorded_entities"].items():
recorded_entities[entity_id] = RecordedEntity(
recording_disabled_by=try_parse_enum(
EntityRecordingDisabler, preferences["recording_disabled_by"]
)
)
if data and "recorder_preferences" in data:
recorder_preferences_data = data["recorder_preferences"]
recorder_preferences = RecorderPreferences(
entity_filter_imported=recorder_preferences_data.get(
"entity_filter_imported", False
),
)
self.entities = recorded_entities
self.recorder_preferences = recorder_preferences
return data
@callback
def _async_schedule_save(self) -> None:
"""Notify the recorder and schedule saving the preferences."""
get_instance(
self._hass
).unrecorded_entities = self.async_get_unrecorded_entities()
self._store.async_delay_save(self._data_to_save, SAVE_DELAY)
@callback
def _data_to_save(self) -> SerializedRecordedEntities:
"""Return JSON-compatible date for storing to file."""
return {
"recorded_entities": {
entity_id: entity.to_json()
for entity_id, entity in self.entities.items()
},
"recorder_preferences": self.recorder_preferences.to_json(),
}
@callback
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "recorder/recorded_entities/set_options",
vol.Required("entity_ids"): [str],
vol.Required("recording_disabled_by"): vol.Any(
None,
vol.All(
vol.Coerce(EntityRecordingDisabler),
EntityRecordingDisabler.USER.value,
),
),
}
)
def ws_record_entity(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Set recording options of entities."""
entity_ids: list[str] = msg["entity_ids"]
for entity_id in entity_ids:
async_set_entity_option(
hass, entity_id, recording_disabled_by=msg["recording_disabled_by"]
)
connection.send_result(msg["id"])
@callback
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "recorder/recorded_entities/list",
}
)
def ws_list_recorded_entities(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""List entities which have recorder settings."""
result: dict[str, Any] = {}
recorded_entities = hass.data[DATA_RECORDED_ENTITIES]
entity_registry = er.async_get(hass)
for entity_id in chain(recorded_entities.entities, entity_registry.entities):
result[entity_id] = async_get_entity_options(hass, entity_id)
connection.send_result(msg["id"], {"recorded_entities": result})
@callback
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "recorder/recorded_entities/get",
vol.Required("entity_id"): str,
}
)
def ws_get_recorded_entity(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Get recorder settings for a single entity."""
entity_id: str = msg["entity_id"]
try:
options = async_get_entity_options(hass, entity_id)
connection.send_result(msg["id"], options.to_json())
except HomeAssistantError:
connection.send_error(
msg["id"], websocket_api.ERR_NOT_FOUND, "Entity not found"
)
@callback
def async_get_entity_options(hass: HomeAssistant, entity_id: str) -> RecordedEntity:
"""Get recorder options for an entity."""
recorded_entities = hass.data[DATA_RECORDED_ENTITIES]
return recorded_entities.async_get_entity_options(entity_id)
@callback
def async_set_entity_option(
hass: HomeAssistant,
entity_id: str,
*,
recording_disabled_by: EntityRecordingDisabler | None | UndefinedType = UNDEFINED,
) -> None:
"""Set a recorder option for an entity."""
recorded_entities = hass.data[DATA_RECORDED_ENTITIES]
recorded_entities.async_set_entity_option(
entity_id, recording_disabled_by=recording_disabled_by
)

View File

@@ -0,0 +1,36 @@
# serializer version: 1
# name: test_get_entity_options
dict({
'recording_disabled_by': None,
})
# ---
# name: test_get_entity_options.1
dict({
'recording_disabled_by': <EntityRecordingDisabler.USER: 'user'>,
})
# ---
# name: test_get_entity_options.2
dict({
'recording_disabled_by': <EntityRecordingDisabler.USER: 'user'>,
})
# ---
# name: test_get_entity_options_data_from_the_future[hass_storage_data0]
dict({
'recording_disabled_by': <EntityRecordingDisabler.USER: 'user'>,
})
# ---
# name: test_get_entity_options_data_from_the_future[hass_storage_data0].1
dict({
'recording_disabled_by': None,
})
# ---
# name: test_get_entity_options_data_from_the_future[hass_storage_data0].2
dict({
'recording_disabled_by': <EntityRecordingDisabler.USER: 'user'>,
})
# ---
# name: test_get_entity_options_data_from_the_future[hass_storage_data0].3
dict({
'recording_disabled_by': None,
})
# ---

View File

@@ -622,8 +622,17 @@ async def test_setup_without_migration(
async def test_saving_state_include_domains(
hass: HomeAssistant,
async_setup_recorder_instance: RecorderInstanceGenerator,
entity_registry: er.EntityRegistry,
) -> None:
"""Test saving and restoring a state."""
# Prime entity registry
entity_registry.async_get_or_create(
"test", "mock", "1234", suggested_object_id="recorder"
)
entity_registry.async_get_or_create(
"test2", "mock", "1234", suggested_object_id="recorder"
)
await async_setup_recorder_instance(hass, {"include": {"domains": "test2"}})
states = await _add_entities(hass, ["test.recorder", "test2.recorder"])
assert len(states) == 1
@@ -633,8 +642,20 @@ async def test_saving_state_include_domains(
async def test_saving_state_include_domains_globs(
hass: HomeAssistant,
async_setup_recorder_instance: RecorderInstanceGenerator,
entity_registry: er.EntityRegistry,
) -> None:
"""Test saving and restoring a state."""
# Prime entity registry
entity_registry.async_get_or_create(
"test", "mock", "1234", suggested_object_id="recorder"
)
entity_registry.async_get_or_create(
"test2", "mock", "1234", suggested_object_id="recorder"
)
entity_registry.async_get_or_create(
"test3", "mock", "1234", suggested_object_id="included_entity"
)
await async_setup_recorder_instance(
hass, {"include": {"domains": "test2", "entity_globs": "*.included_*"}}
)
@@ -657,8 +678,17 @@ async def test_saving_state_include_domains_globs(
async def test_saving_state_incl_entities(
hass: HomeAssistant,
async_setup_recorder_instance: RecorderInstanceGenerator,
entity_registry: er.EntityRegistry,
) -> None:
"""Test saving and restoring a state."""
# Prime entity registry
entity_registry.async_get_or_create(
"test", "mock", "1234", suggested_object_id="recorder"
)
entity_registry.async_get_or_create(
"test2", "mock", "1234", suggested_object_id="recorder"
)
await async_setup_recorder_instance(
hass, {"include": {"entities": "test2.recorder"}}
)
@@ -721,8 +751,17 @@ async def test_saving_event_exclude_event_type(
async def test_saving_state_exclude_domains(
hass: HomeAssistant,
async_setup_recorder_instance: RecorderInstanceGenerator,
entity_registry: er.EntityRegistry,
) -> None:
"""Test saving and restoring a state."""
# Prime entity registry
entity_registry.async_get_or_create(
"test", "mock", "1234", suggested_object_id="recorder"
)
entity_registry.async_get_or_create(
"test2", "mock", "1234", suggested_object_id="recorder"
)
await async_setup_recorder_instance(hass, {"exclude": {"domains": "test"}})
states = await _add_entities(hass, ["test.recorder", "test2.recorder"])
assert len(states) == 1
@@ -732,8 +771,20 @@ async def test_saving_state_exclude_domains(
async def test_saving_state_exclude_domains_globs(
hass: HomeAssistant,
async_setup_recorder_instance: RecorderInstanceGenerator,
entity_registry: er.EntityRegistry,
) -> None:
"""Test saving and restoring a state."""
# Prime entity registry
entity_registry.async_get_or_create(
"test", "mock", "1234", suggested_object_id="recorder"
)
entity_registry.async_get_or_create(
"test2", "mock", "1234", suggested_object_id="recorder"
)
entity_registry.async_get_or_create(
"test2", "mock", "2345", suggested_object_id="excluded_entity"
)
await async_setup_recorder_instance(
hass, {"exclude": {"domains": "test", "entity_globs": "*.excluded_*"}}
)
@@ -747,8 +798,17 @@ async def test_saving_state_exclude_domains_globs(
async def test_saving_state_exclude_entities(
hass: HomeAssistant,
async_setup_recorder_instance: RecorderInstanceGenerator,
entity_registry: er.EntityRegistry,
) -> None:
"""Test saving and restoring a state."""
# Prime entity registry
entity_registry.async_get_or_create(
"test", "mock", "1234", suggested_object_id="recorder"
)
entity_registry.async_get_or_create(
"test2", "mock", "1234", suggested_object_id="recorder"
)
await async_setup_recorder_instance(
hass, {"exclude": {"entities": "test.recorder"}}
)
@@ -760,8 +820,17 @@ async def test_saving_state_exclude_entities(
async def test_saving_state_exclude_domain_include_entity(
hass: HomeAssistant,
async_setup_recorder_instance: RecorderInstanceGenerator,
entity_registry: er.EntityRegistry,
) -> None:
"""Test saving and restoring a state."""
# Prime entity registry
entity_registry.async_get_or_create(
"test", "mock", "1234", suggested_object_id="recorder"
)
entity_registry.async_get_or_create(
"test2", "mock", "1234", suggested_object_id="recorder"
)
await async_setup_recorder_instance(
hass,
{
@@ -776,8 +845,20 @@ async def test_saving_state_exclude_domain_include_entity(
async def test_saving_state_exclude_domain_glob_include_entity(
hass: HomeAssistant,
async_setup_recorder_instance: RecorderInstanceGenerator,
entity_registry: er.EntityRegistry,
) -> None:
"""Test saving and restoring a state."""
# Prime entity registry
entity_registry.async_get_or_create(
"test", "mock", "1234", suggested_object_id="recorder"
)
entity_registry.async_get_or_create(
"test2", "mock", "1234", suggested_object_id="recorder"
)
entity_registry.async_get_or_create(
"test", "mock", "2345", suggested_object_id="excluded_entity"
)
await async_setup_recorder_instance(
hass,
{
@@ -794,8 +875,20 @@ async def test_saving_state_exclude_domain_glob_include_entity(
async def test_saving_state_include_domain_exclude_entity(
hass: HomeAssistant,
async_setup_recorder_instance: RecorderInstanceGenerator,
entity_registry: er.EntityRegistry,
) -> None:
"""Test saving and restoring a state."""
# Prime entity registry
entity_registry.async_get_or_create(
"test", "mock", "1234", suggested_object_id="recorder"
)
entity_registry.async_get_or_create(
"test2", "mock", "1234", suggested_object_id="recorder"
)
entity_registry.async_get_or_create(
"test", "mock", "2345", suggested_object_id="ok"
)
await async_setup_recorder_instance(
hass,
{
@@ -812,8 +905,23 @@ async def test_saving_state_include_domain_exclude_entity(
async def test_saving_state_include_domain_glob_exclude_entity(
hass: HomeAssistant,
async_setup_recorder_instance: RecorderInstanceGenerator,
entity_registry: er.EntityRegistry,
) -> None:
"""Test saving and restoring a state."""
# Prime entity registry
entity_registry.async_get_or_create(
"test", "mock", "1234", suggested_object_id="recorder"
)
entity_registry.async_get_or_create(
"test2", "mock", "1234", suggested_object_id="recorder"
)
entity_registry.async_get_or_create(
"test", "mock", "2345", suggested_object_id="ok"
)
entity_registry.async_get_or_create(
"test2", "mock", "2345", suggested_object_id="included_entity"
)
await async_setup_recorder_instance(
hass,
{
@@ -1762,8 +1870,17 @@ async def test_database_corruption_while_running(
async def test_entity_id_filter(
hass: HomeAssistant,
async_setup_recorder_instance: RecorderInstanceGenerator,
entity_registry: er.EntityRegistry,
) -> None:
"""Test that entity ID filtering filters string and list."""
# Prime entity registry
entity_registry.async_get_or_create(
"hello", "mock", "1234", suggested_object_id="world"
)
entity_registry.async_get_or_create(
"hidden_domain", "mock", "1234", suggested_object_id="person"
)
await async_setup_recorder_instance(
hass,
{
@@ -1810,6 +1927,45 @@ async def test_entity_id_filter(
assert len(db_events) == idx + 1, data
@pytest.mark.parametrize(
"hass_storage_data",
[
{
"recorder.recorded_entities": {
"data": {
"recorded_entities": {},
"recorder_preferences": {
"entity_filter_imported": True,
},
},
"key": "recorder.recorded_entities",
"minor_version": 1,
"version": 1,
},
}
],
)
async def test_entity_id_filter_imported_once(
hass: HomeAssistant,
async_setup_recorder_instance: RecorderInstanceGenerator,
entity_registry: er.EntityRegistry,
) -> None:
"""Test saving and restoring a state."""
# Prime entity registry
entity_registry.async_get_or_create(
"test", "mock", "1234", suggested_object_id="recorder"
)
entity_registry.async_get_or_create(
"test2", "mock", "1234", suggested_object_id="recorder"
)
await async_setup_recorder_instance(hass, {"include": {"domains": "test2"}})
states = await _add_entities(hass, ["test.recorder", "test2.recorder"])
assert len(states) == 2
assert _state_with_context(hass, "test.recorder").as_dict() == states[0].as_dict()
assert _state_with_context(hass, "test2.recorder").as_dict() == states[1].as_dict()
@pytest.mark.skip_on_db_engine(["mysql", "postgresql"])
@pytest.mark.usefixtures("skip_by_db_engine")
@pytest.mark.parametrize("persistent_database", [True])

View File

@@ -0,0 +1,301 @@
"""Test recorder recorded entities."""
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components import recorder
from homeassistant.components.recorder.recorded_entities import (
DATA_RECORDED_ENTITIES,
EntityRecordingDisabler,
RecordedEntities,
RecordedEntity,
RecorderPreferences,
async_get_entity_options,
async_set_entity_option,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from tests.common import flush_store
from tests.typing import WebSocketGenerator
@pytest.fixture(name="entities")
def entities_fixture(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
request: pytest.FixtureRequest,
) -> dict[str, str]:
"""Set up the test environment."""
if request.param == "entities_unique_id":
return entities_unique_id(entity_registry)
if request.param == "entities_no_unique_id":
return entities_no_unique_id(hass)
raise RuntimeError("Invalid setup fixture")
def entities_unique_id(entity_registry: er.EntityRegistry) -> dict[str, str]:
"""Create some entities in the entity registry."""
entry_sensor = entity_registry.async_get_or_create("sensor", "test", "unique1")
return {
"sensor": entry_sensor.entity_id,
}
def entities_no_unique_id(hass: HomeAssistant) -> dict[str, str]:
"""Create some entities not in the entity registry."""
sensor = "sensor.test"
return {
"sensor": sensor,
}
@pytest.mark.usefixtures("recorder_mock")
async def test_load_preferences(hass: HomeAssistant) -> None:
"""Make sure that we can load/save data correctly."""
recorded_entities = hass.data[DATA_RECORDED_ENTITIES]
assert recorded_entities.entities == {}
assert recorded_entities.recorder_preferences == RecorderPreferences(
entity_filter_imported=True
)
async_set_entity_option(hass, "light.kitchen", recording_disabled_by=None)
async_set_entity_option(
hass, "light.living_room", recording_disabled_by=EntityRecordingDisabler.USER
)
assert recorded_entities.entities == {
"light.kitchen": RecordedEntity(None),
"light.living_room": RecordedEntity(EntityRecordingDisabler.USER),
}
await flush_store(recorded_entities._store)
recorded_entities2 = RecordedEntities(hass)
await recorded_entities2.async_initialize()
assert recorded_entities.entities == recorded_entities2.entities
assert (
recorded_entities.recorder_preferences
== recorded_entities2.recorder_preferences
)
@pytest.mark.usefixtures("recorder_mock")
async def test_record_entity(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test record entity."""
ws_client = await hass_ws_client(hass)
entry1 = entity_registry.async_get_or_create("test", "test", "unique1")
entry2 = entity_registry.async_get_or_create("test", "test", "unique2")
recorded_entities = hass.data[DATA_RECORDED_ENTITIES]
assert len(recorded_entities.entities) == 0
# Set options
await ws_client.send_json_auto_id(
{
"type": "recorder/recorded_entities/set_options",
"entity_ids": [entry1.entity_id],
"recording_disabled_by": None,
}
)
response = await ws_client.receive_json()
assert response["success"]
entry1 = entity_registry.async_get(entry1.entity_id)
assert entry1.options == {"recorder": {"recording_disabled_by": None}}
entry2 = entity_registry.async_get(entry2.entity_id)
assert entry2.options == {}
# Settings should be stored in the entity registry
assert len(recorded_entities.entities) == 0
# Update options
await ws_client.send_json_auto_id(
{
"type": "recorder/recorded_entities/set_options",
"entity_ids": [entry1.entity_id, entry2.entity_id],
"recording_disabled_by": "user",
}
)
response = await ws_client.receive_json()
assert response["success"]
entry1 = entity_registry.async_get(entry1.entity_id)
assert entry1.options == {"recorder": {"recording_disabled_by": "user"}}
entry2 = entity_registry.async_get(entry2.entity_id)
assert entry2.options == {"recorder": {"recording_disabled_by": "user"}}
# Settings should be stored in the entity registry
assert len(recorded_entities.entities) == 0
@pytest.mark.usefixtures("recorder_mock")
async def test_record_entity_unknown(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test behavior when disabling recording of an unknown entity."""
ws_client = await hass_ws_client(hass)
recorded_entities = hass.data[DATA_RECORDED_ENTITIES]
assert len(recorded_entities.entities) == 0
# Set options
await ws_client.send_json_auto_id(
{
"type": "recorder/recorded_entities/set_options",
"entity_ids": ["test.test1"],
"recording_disabled_by": None,
}
)
response = await ws_client.receive_json()
assert response["success"]
assert len(recorded_entities.entities) == 1
assert recorded_entities.entities == {
"test.test1": RecordedEntity(recording_disabled_by=None)
}
# Update options
await ws_client.send_json_auto_id(
{
"type": "recorder/recorded_entities/set_options",
"entity_ids": ["test.test1", "test.test2"],
"recording_disabled_by": "user",
}
)
response = await ws_client.receive_json()
assert response["success"]
assert len(recorded_entities.entities) == 2
assert recorded_entities.entities == {
"test.test1": RecordedEntity(
recording_disabled_by=EntityRecordingDisabler.USER
),
"test.test2": RecordedEntity(
recording_disabled_by=EntityRecordingDisabler.USER
),
}
@pytest.mark.parametrize(
"entities", ["entities_unique_id", "entities_no_unique_id"], indirect=True
)
@pytest.mark.usefixtures("recorder_mock")
async def test_update_recorder(
hass: HomeAssistant,
entities: dict[str, str],
) -> None:
"""Test recorder exclusion set is updated."""
unrecorded_entities = recorder.get_instance(hass).unrecorded_entities
assert unrecorded_entities == set()
entity_id = entities["sensor"]
# Settings changed - recorder exclusion set updated
async_set_entity_option(hass, entity_id, recording_disabled_by=None)
await hass.async_block_till_done()
assert recorder.get_instance(hass).unrecorded_entities is not unrecorded_entities
unrecorded_entities = recorder.get_instance(hass).unrecorded_entities
assert unrecorded_entities == set()
# Settings not changed - recorder exclusion set not updated
async_set_entity_option(hass, entity_id, recording_disabled_by=None)
await hass.async_block_till_done()
assert recorder.get_instance(hass).unrecorded_entities is unrecorded_entities
assert unrecorded_entities == set()
# Settings changed - recorder exclusion set updated
async_set_entity_option(
hass, entity_id, recording_disabled_by=EntityRecordingDisabler.USER
)
await hass.async_block_till_done()
assert recorder.get_instance(hass).unrecorded_entities is not unrecorded_entities
unrecorded_entities = recorder.get_instance(hass).unrecorded_entities
assert unrecorded_entities == {entity_id}
@pytest.mark.usefixtures("recorder_mock")
async def test_get_entity_options(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test get entity options."""
with pytest.raises(HomeAssistantError, match="Unknown entity"):
async_get_entity_options(hass, "light.not_in_registry")
entry = entity_registry.async_get_or_create("climate", "test", "unique1")
assert async_get_entity_options(hass, entry.entity_id) == snapshot
async_set_entity_option(
hass, entry.entity_id, recording_disabled_by=EntityRecordingDisabler.USER
)
async_set_entity_option(
hass,
"light.not_in_registry",
recording_disabled_by=EntityRecordingDisabler.USER,
)
assert async_get_entity_options(hass, entry.entity_id) == snapshot
assert async_get_entity_options(hass, "light.not_in_registry") == snapshot
@pytest.mark.parametrize(
"hass_storage_data",
[
{
"recorder.recorded_entities": {
"data": {
"recorded_entities": {
"light.kitchen": {
"recording_disabled_by": "user",
"future_option": "unexpected_value",
},
"light.living_room": {
"recording_disabled_by": "my_dog",
},
},
"recorder_preferences": {
"entity_filter_imported": True,
},
},
"key": "recorder.recorded_entities",
"minor_version": 1,
"version": 1,
},
}
],
)
@pytest.mark.usefixtures("recorder_mock")
async def test_get_entity_options_data_from_the_future(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test get entity options from the future."""
entry = entity_registry.async_get_or_create("climate", "test", "unique1")
entity_registry.async_update_entity_options(
entry.entity_id,
recorder.DOMAIN,
{"recording_disabled_by": "user", "unexpected_option": 42},
)
assert async_get_entity_options(hass, entry.entity_id) == snapshot
entity_registry.async_update_entity_options(
entry.entity_id,
recorder.DOMAIN,
{"recording_disabled_by": "my_dog"},
)
assert async_get_entity_options(hass, entry.entity_id) == snapshot
assert async_get_entity_options(hass, "light.kitchen") == snapshot
assert async_get_entity_options(hass, "light.living_room") == snapshot

View File

@@ -34,7 +34,7 @@ SCHEMA_MODULE = get_schema_module_path(SCHEMA_VERSION_POSTFIX)
@pytest.mark.skip_on_db_engine(["mysql", "postgresql"])
@pytest.mark.usefixtures("skip_by_db_engine")
@pytest.mark.usefixtures("hass_storage", "skip_by_db_engine")
@pytest.mark.parametrize("persistent_database", [True])
async def test_delete_duplicates(
async_test_recorder: RecorderInstanceContextManager,
@@ -222,7 +222,7 @@ async def test_delete_duplicates(
@pytest.mark.skip_on_db_engine(["mysql", "postgresql"])
@pytest.mark.usefixtures("skip_by_db_engine")
@pytest.mark.usefixtures("hass_storage", "skip_by_db_engine")
@pytest.mark.parametrize("persistent_database", [True])
async def test_delete_duplicates_many(
async_test_recorder: RecorderInstanceContextManager,
@@ -417,7 +417,7 @@ async def test_delete_duplicates_many(
@pytest.mark.freeze_time("2021-08-01 00:00:00+00:00")
@pytest.mark.skip_on_db_engine(["mysql", "postgresql"])
@pytest.mark.usefixtures("skip_by_db_engine")
@pytest.mark.usefixtures("hass_storage", "skip_by_db_engine")
@pytest.mark.parametrize("persistent_database", [True])
async def test_delete_duplicates_non_identical(
async_test_recorder: RecorderInstanceContextManager,
@@ -613,7 +613,7 @@ async def test_delete_duplicates_non_identical(
@pytest.mark.parametrize("persistent_database", [True])
@pytest.mark.skip_on_db_engine(["mysql", "postgresql"])
@pytest.mark.usefixtures("skip_by_db_engine")
@pytest.mark.usefixtures("hass_storage", "skip_by_db_engine")
async def test_delete_duplicates_short_term(
async_test_recorder: RecorderInstanceContextManager,
caplog: pytest.LogCaptureFixture,

View File

@@ -36,7 +36,7 @@ from homeassistant.components.recorder.websocket_api import UNIT_SCHEMA
from homeassistant.components.sensor import UNIT_CONVERTERS
from homeassistant.const import DEGREE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import recorder as recorder_helper
from homeassistant.helpers import entity_registry as er, recorder as recorder_helper
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM
@@ -4344,3 +4344,173 @@ async def test_import_statistics_with_last_reset(
},
]
}
async def test_recorded_entities_ws(
hass: HomeAssistant,
async_setup_recorder_instance: RecorderInstanceGenerator,
entity_registry: er.EntityRegistry,
hass_ws_client: WebSocketGenerator,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test recorded entities WS commands."""
client = await hass_ws_client()
# Prime entity registry
entity_registry.async_get_or_create(
"test", "mock", "1234", suggested_object_id="recorder"
)
entity_registry.async_get_or_create(
"test2", "mock", "1234", suggested_object_id="recorder"
)
await async_setup_recorder_instance(hass, {"include": {"domains": "test2"}})
await client.send_json_auto_id(
{
"type": "recorder/recorded_entities/list",
}
)
response = await client.receive_json()
assert response["result"] == {
"recorded_entities": {
"test.recorder": {"recording_disabled_by": "user"},
"test2.recorder": {"recording_disabled_by": None},
},
}
# Change setting of an entity
await client.send_json_auto_id(
{
"type": "recorder/recorded_entities/set_options",
"entity_ids": ["test.recorder"],
"recording_disabled_by": None,
}
)
response = await client.receive_json()
assert response["result"] is None
await client.send_json_auto_id(
{
"type": "recorder/recorded_entities/list",
}
)
response = await client.receive_json()
assert response["result"] == {
"recorded_entities": {
"test.recorder": {"recording_disabled_by": None},
"test2.recorder": {"recording_disabled_by": None},
},
}
# Change setting of an entity
await client.send_json_auto_id(
{
"type": "recorder/recorded_entities/set_options",
"entity_ids": ["test2.recorder"],
"recording_disabled_by": "user",
}
)
response = await client.receive_json()
assert response["result"] is None
await client.send_json_auto_id(
{
"type": "recorder/recorded_entities/list",
}
)
response = await client.receive_json()
assert response["result"] == {
"recorded_entities": {
"test.recorder": {"recording_disabled_by": None},
"test2.recorder": {"recording_disabled_by": "user"},
},
}
# Change setting of an entity
await client.send_json_auto_id(
{
"type": "recorder/recorded_entities/set_options",
"entity_ids": ["test2.recorder"],
"recording_disabled_by": "my dog",
}
)
response = await client.receive_json()
assert response == {
"error": {
"code": "invalid_format",
"message": "not a valid value for dictionary value @ "
"data['recording_disabled_by']. Got 'my dog'",
},
"id": ANY,
"success": False,
"type": "result",
}
await client.send_json_auto_id(
{
"type": "recorder/recorded_entities/list",
}
)
response = await client.receive_json()
assert response["result"] == {
"recorded_entities": {
"test.recorder": {"recording_disabled_by": None},
"test2.recorder": {"recording_disabled_by": "user"},
},
}
# Change setting of an unknown entity
await client.send_json_auto_id(
{
"type": "recorder/recorded_entities/set_options",
"entity_ids": ["test.test"],
"recording_disabled_by": None,
}
)
response = await client.receive_json()
assert response["result"] is None
await client.send_json_auto_id(
{
"type": "recorder/recorded_entities/list",
}
)
response = await client.receive_json()
assert response["result"] == {
"recorded_entities": {
"test.recorder": {"recording_disabled_by": None},
"test.test": {"recording_disabled_by": None},
"test2.recorder": {"recording_disabled_by": "user"},
},
}
# Test getting a single entity's settings
await client.send_json_auto_id(
{
"type": "recorder/recorded_entities/get",
"entity_id": "test.recorder",
}
)
response = await client.receive_json()
assert response["result"] == {"recording_disabled_by": None}
await client.send_json_auto_id(
{
"type": "recorder/recorded_entities/get",
"entity_id": "test2.recorder",
}
)
response = await client.receive_json()
assert response["result"] == {"recording_disabled_by": "user"}
# Test getting settings for an unknown entity
await client.send_json_auto_id(
{
"type": "recorder/recorded_entities/get",
"entity_id": "unknown.entity",
}
)
response = await client.receive_json()
assert response["error"]["code"] == "not_found"
assert response["error"]["message"] == "Entity not found"

View File

@@ -438,9 +438,19 @@ def bcrypt_cost() -> Generator[None]:
@pytest.fixture
def hass_storage() -> Generator[dict[str, Any]]:
def hass_storage_data() -> Generator[dict[str, Any]]:
"""Fixture to provide initial storage data.
Parametrize to provide custom data, e.g:
@pytest.mark.parametrize("hass_storage_data", [{"domain.store": {"key": "value"}}])
"""
return {}
@pytest.fixture
def hass_storage(hass_storage_data) -> Generator[dict[str, Any]]:
"""Fixture to mock storage."""
with mock_storage() as stored_data:
with mock_storage(hass_storage_data) as stored_data:
yield stored_data