1598 lines
54 KiB
Python
1598 lines
54 KiB
Python
"""The tests for the Recorder component."""
|
|
from __future__ import annotations
|
|
|
|
# pylint: disable=protected-access
|
|
import asyncio
|
|
from datetime import datetime, timedelta
|
|
import sqlite3
|
|
import threading
|
|
from typing import cast
|
|
from unittest.mock import Mock, patch
|
|
|
|
import pytest
|
|
from sqlalchemy.exc import DatabaseError, OperationalError, SQLAlchemyError
|
|
|
|
from homeassistant.components import recorder
|
|
from homeassistant.components.recorder import (
|
|
CONF_AUTO_PURGE,
|
|
CONF_AUTO_REPACK,
|
|
CONF_COMMIT_INTERVAL,
|
|
CONF_DB_URL,
|
|
CONFIG_SCHEMA,
|
|
DOMAIN,
|
|
SQLITE_URL_PREFIX,
|
|
Recorder,
|
|
get_instance,
|
|
)
|
|
from homeassistant.components.recorder.const import KEEPALIVE_TIME
|
|
from homeassistant.components.recorder.db_schema import (
|
|
SCHEMA_VERSION,
|
|
EventData,
|
|
Events,
|
|
RecorderRuns,
|
|
StateAttributes,
|
|
States,
|
|
StatisticsRuns,
|
|
)
|
|
from homeassistant.components.recorder.models import process_timestamp
|
|
from homeassistant.components.recorder.services import (
|
|
SERVICE_DISABLE,
|
|
SERVICE_ENABLE,
|
|
SERVICE_PURGE,
|
|
SERVICE_PURGE_ENTITIES,
|
|
)
|
|
from homeassistant.components.recorder.util import session_scope
|
|
from homeassistant.const import (
|
|
EVENT_HOMEASSISTANT_FINAL_WRITE,
|
|
EVENT_HOMEASSISTANT_STARTED,
|
|
EVENT_HOMEASSISTANT_STOP,
|
|
MATCH_ALL,
|
|
STATE_LOCKED,
|
|
STATE_UNLOCKED,
|
|
)
|
|
from homeassistant.core import CoreState, Event, HomeAssistant, callback
|
|
from homeassistant.helpers import recorder as recorder_helper
|
|
from homeassistant.setup import async_setup_component, setup_component
|
|
from homeassistant.util import dt as dt_util
|
|
|
|
from .common import (
|
|
async_block_recorder,
|
|
async_wait_recording_done,
|
|
corrupt_db_file,
|
|
run_information_with_session,
|
|
wait_recording_done,
|
|
)
|
|
|
|
from tests.common import (
|
|
SetupRecorderInstanceT,
|
|
async_fire_time_changed,
|
|
fire_time_changed,
|
|
get_test_home_assistant,
|
|
)
|
|
|
|
|
|
def _default_recorder(hass):
|
|
"""Return a recorder with reasonable defaults."""
|
|
return Recorder(
|
|
hass,
|
|
auto_purge=True,
|
|
auto_repack=True,
|
|
keep_days=7,
|
|
commit_interval=1,
|
|
uri="sqlite://",
|
|
db_max_retries=10,
|
|
db_retry_wait=3,
|
|
entity_filter=CONFIG_SCHEMA({DOMAIN: {}}),
|
|
exclude_t=[],
|
|
exclude_attributes_by_domain={},
|
|
)
|
|
|
|
|
|
async def test_shutdown_before_startup_finishes(
|
|
hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT, tmp_path
|
|
):
|
|
"""Test shutdown before recorder starts is clean."""
|
|
|
|
# On-disk database because this test does not play nice with the
|
|
# MutexPool
|
|
config = {
|
|
recorder.CONF_DB_URL: "sqlite:///" + str(tmp_path / "pytest.db"),
|
|
recorder.CONF_COMMIT_INTERVAL: 1,
|
|
}
|
|
hass.state = CoreState.not_running
|
|
|
|
recorder_helper.async_initialize_recorder(hass)
|
|
hass.create_task(async_setup_recorder_instance(hass, config))
|
|
await recorder_helper.async_wait_recorder(hass)
|
|
instance = get_instance(hass)
|
|
|
|
session = await hass.async_add_executor_job(instance.get_session)
|
|
|
|
with patch.object(instance, "engine"):
|
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
|
|
await hass.async_block_till_done()
|
|
await hass.async_stop()
|
|
|
|
run_info = await hass.async_add_executor_job(run_information_with_session, session)
|
|
|
|
assert run_info.run_id == 1
|
|
assert run_info.start is not None
|
|
assert run_info.end is not None
|
|
|
|
|
|
async def test_canceled_before_startup_finishes(
|
|
hass: HomeAssistant,
|
|
async_setup_recorder_instance: SetupRecorderInstanceT,
|
|
caplog: pytest.LogCaptureFixture,
|
|
):
|
|
"""Test recorder shuts down when its startup future is canceled out from under it."""
|
|
hass.state = CoreState.not_running
|
|
recorder_helper.async_initialize_recorder(hass)
|
|
hass.create_task(async_setup_recorder_instance(hass))
|
|
await recorder_helper.async_wait_recorder(hass)
|
|
|
|
instance = get_instance(hass)
|
|
instance._hass_started.cancel()
|
|
with patch.object(instance, "engine"):
|
|
await hass.async_block_till_done()
|
|
await hass.async_add_executor_job(instance.join)
|
|
assert (
|
|
"Recorder startup was externally canceled before it could complete"
|
|
in caplog.text
|
|
)
|
|
|
|
|
|
async def test_shutdown_closes_connections(hass, recorder_mock):
|
|
"""Test shutdown closes connections."""
|
|
|
|
hass.state = CoreState.not_running
|
|
|
|
instance = get_instance(hass)
|
|
await instance.async_db_ready
|
|
await hass.async_block_till_done()
|
|
pool = instance.engine.pool
|
|
pool.shutdown = Mock()
|
|
|
|
def _ensure_connected():
|
|
with session_scope(hass=hass) as session:
|
|
list(session.query(States))
|
|
|
|
await instance.async_add_executor_job(_ensure_connected)
|
|
|
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
|
|
await hass.async_block_till_done()
|
|
|
|
assert len(pool.shutdown.mock_calls) == 1
|
|
with pytest.raises(RuntimeError):
|
|
assert instance.get_session()
|
|
|
|
|
|
async def test_state_gets_saved_when_set_before_start_event(
|
|
hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT
|
|
):
|
|
"""Test we can record an event when starting with not running."""
|
|
|
|
hass.state = CoreState.not_running
|
|
|
|
recorder_helper.async_initialize_recorder(hass)
|
|
hass.create_task(async_setup_recorder_instance(hass))
|
|
await recorder_helper.async_wait_recorder(hass)
|
|
|
|
entity_id = "test.recorder"
|
|
state = "restoring_from_db"
|
|
attributes = {"test_attr": 5, "test_attr_10": "nice"}
|
|
|
|
hass.states.async_set(entity_id, state, attributes)
|
|
|
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
|
|
|
await async_wait_recording_done(hass)
|
|
|
|
with session_scope(hass=hass) as session:
|
|
db_states = list(session.query(States))
|
|
assert len(db_states) == 1
|
|
assert db_states[0].event_id is None
|
|
|
|
|
|
async def test_saving_state(hass: HomeAssistant, recorder_mock):
|
|
"""Test saving and restoring a state."""
|
|
entity_id = "test.recorder"
|
|
state = "restoring_from_db"
|
|
attributes = {"test_attr": 5, "test_attr_10": "nice"}
|
|
|
|
hass.states.async_set(entity_id, state, attributes)
|
|
|
|
await async_wait_recording_done(hass)
|
|
|
|
with session_scope(hass=hass) as session:
|
|
db_states = []
|
|
for db_state, db_state_attributes in session.query(States, StateAttributes):
|
|
db_states.append(db_state)
|
|
state = db_state.to_native()
|
|
state.attributes = db_state_attributes.to_native()
|
|
assert len(db_states) == 1
|
|
assert db_states[0].event_id is None
|
|
|
|
assert state == _state_with_context(hass, entity_id)
|
|
|
|
|
|
async def test_saving_many_states(
|
|
hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT
|
|
):
|
|
"""Test we expire after many commits."""
|
|
instance = await async_setup_recorder_instance(
|
|
hass, {recorder.CONF_COMMIT_INTERVAL: 0}
|
|
)
|
|
|
|
entity_id = "test.recorder"
|
|
attributes = {"test_attr": 5, "test_attr_10": "nice"}
|
|
|
|
with patch.object(instance.event_session, "expire_all") as expire_all, patch.object(
|
|
recorder.core, "EXPIRE_AFTER_COMMITS", 2
|
|
):
|
|
for _ in range(3):
|
|
hass.states.async_set(entity_id, "on", attributes)
|
|
await async_wait_recording_done(hass)
|
|
hass.states.async_set(entity_id, "off", attributes)
|
|
await async_wait_recording_done(hass)
|
|
|
|
assert expire_all.called
|
|
|
|
with session_scope(hass=hass) as session:
|
|
db_states = list(session.query(States))
|
|
assert len(db_states) == 6
|
|
assert db_states[0].event_id is None
|
|
|
|
|
|
async def test_saving_state_with_intermixed_time_changes(
|
|
hass: HomeAssistant, recorder_mock
|
|
):
|
|
"""Test saving states with intermixed time changes."""
|
|
entity_id = "test.recorder"
|
|
state = "restoring_from_db"
|
|
attributes = {"test_attr": 5, "test_attr_10": "nice"}
|
|
attributes2 = {"test_attr": 10, "test_attr_10": "mean"}
|
|
|
|
for _ in range(KEEPALIVE_TIME + 1):
|
|
async_fire_time_changed(hass, dt_util.utcnow())
|
|
hass.states.async_set(entity_id, state, attributes)
|
|
for _ in range(KEEPALIVE_TIME + 1):
|
|
async_fire_time_changed(hass, dt_util.utcnow())
|
|
hass.states.async_set(entity_id, state, attributes2)
|
|
|
|
await async_wait_recording_done(hass)
|
|
|
|
with session_scope(hass=hass) as session:
|
|
db_states = list(session.query(States))
|
|
assert len(db_states) == 2
|
|
assert db_states[0].event_id is None
|
|
|
|
|
|
def test_saving_state_with_exception(hass, hass_recorder, caplog):
|
|
"""Test saving and restoring a state."""
|
|
hass = hass_recorder()
|
|
|
|
entity_id = "test.recorder"
|
|
state = "restoring_from_db"
|
|
attributes = {"test_attr": 5, "test_attr_10": "nice"}
|
|
|
|
def _throw_if_state_in_session(*args, **kwargs):
|
|
for obj in get_instance(hass).event_session:
|
|
if isinstance(obj, States):
|
|
raise OperationalError(
|
|
"insert the state", "fake params", "forced to fail"
|
|
)
|
|
|
|
with patch("time.sleep"), patch.object(
|
|
get_instance(hass).event_session,
|
|
"flush",
|
|
side_effect=_throw_if_state_in_session,
|
|
):
|
|
hass.states.set(entity_id, "fail", attributes)
|
|
wait_recording_done(hass)
|
|
|
|
assert "Error executing query" in caplog.text
|
|
assert "Error saving events" not in caplog.text
|
|
|
|
caplog.clear()
|
|
hass.states.set(entity_id, state, attributes)
|
|
wait_recording_done(hass)
|
|
|
|
with session_scope(hass=hass) as session:
|
|
db_states = list(session.query(States))
|
|
assert len(db_states) >= 1
|
|
|
|
assert "Error executing query" not in caplog.text
|
|
assert "Error saving events" not in caplog.text
|
|
|
|
|
|
def test_saving_state_with_sqlalchemy_exception(hass, hass_recorder, caplog):
|
|
"""Test saving state when there is an SQLAlchemyError."""
|
|
hass = hass_recorder()
|
|
|
|
entity_id = "test.recorder"
|
|
state = "restoring_from_db"
|
|
attributes = {"test_attr": 5, "test_attr_10": "nice"}
|
|
|
|
def _throw_if_state_in_session(*args, **kwargs):
|
|
for obj in get_instance(hass).event_session:
|
|
if isinstance(obj, States):
|
|
raise SQLAlchemyError(
|
|
"insert the state", "fake params", "forced to fail"
|
|
)
|
|
|
|
with patch("time.sleep"), patch.object(
|
|
get_instance(hass).event_session,
|
|
"flush",
|
|
side_effect=_throw_if_state_in_session,
|
|
):
|
|
hass.states.set(entity_id, "fail", attributes)
|
|
wait_recording_done(hass)
|
|
|
|
assert "SQLAlchemyError error processing task" in caplog.text
|
|
|
|
caplog.clear()
|
|
hass.states.set(entity_id, state, attributes)
|
|
wait_recording_done(hass)
|
|
|
|
with session_scope(hass=hass) as session:
|
|
db_states = list(session.query(States))
|
|
assert len(db_states) >= 1
|
|
|
|
assert "Error executing query" not in caplog.text
|
|
assert "Error saving events" not in caplog.text
|
|
assert "SQLAlchemyError error processing task" not in caplog.text
|
|
|
|
|
|
async def test_force_shutdown_with_queue_of_writes_that_generate_exceptions(
|
|
hass, async_setup_recorder_instance, caplog
|
|
):
|
|
"""Test forcing shutdown."""
|
|
instance = await async_setup_recorder_instance(hass)
|
|
|
|
entity_id = "test.recorder"
|
|
attributes = {"test_attr": 5, "test_attr_10": "nice"}
|
|
|
|
await async_wait_recording_done(hass)
|
|
|
|
with patch.object(instance, "db_retry_wait", 0.05), patch.object(
|
|
instance.event_session,
|
|
"flush",
|
|
side_effect=OperationalError(
|
|
"insert the state", "fake params", "forced to fail"
|
|
),
|
|
):
|
|
for _ in range(100):
|
|
hass.states.async_set(entity_id, "on", attributes)
|
|
hass.states.async_set(entity_id, "off", attributes)
|
|
|
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
|
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE)
|
|
await hass.async_block_till_done()
|
|
|
|
assert "Error executing query" in caplog.text
|
|
assert "Error saving events" not in caplog.text
|
|
|
|
|
|
def test_saving_event(hass, hass_recorder):
|
|
"""Test saving and restoring an event."""
|
|
hass = hass_recorder()
|
|
|
|
event_type = "EVENT_TEST"
|
|
event_data = {"test_attr": 5, "test_attr_10": "nice"}
|
|
|
|
events = []
|
|
|
|
@callback
|
|
def event_listener(event):
|
|
"""Record events from eventbus."""
|
|
if event.event_type == event_type:
|
|
events.append(event)
|
|
|
|
hass.bus.listen(MATCH_ALL, event_listener)
|
|
|
|
hass.bus.fire(event_type, event_data)
|
|
|
|
wait_recording_done(hass)
|
|
|
|
assert len(events) == 1
|
|
event: Event = events[0]
|
|
|
|
get_instance(hass).block_till_done()
|
|
events: list[Event] = []
|
|
|
|
with session_scope(hass=hass) as session:
|
|
for select_event, event_data in (
|
|
session.query(Events, EventData)
|
|
.filter_by(event_type=event_type)
|
|
.outerjoin(EventData, Events.data_id == EventData.data_id)
|
|
):
|
|
select_event = cast(Events, select_event)
|
|
event_data = cast(EventData, event_data)
|
|
|
|
native_event = select_event.to_native()
|
|
native_event.data = event_data.to_native()
|
|
events.append(native_event)
|
|
|
|
db_event = events[0]
|
|
|
|
assert event.event_type == db_event.event_type
|
|
assert event.data == db_event.data
|
|
assert event.origin == db_event.origin
|
|
|
|
# Recorder uses SQLite and stores datetimes as integer unix timestamps
|
|
assert event.time_fired.replace(microsecond=0) == db_event.time_fired.replace(
|
|
microsecond=0
|
|
)
|
|
|
|
|
|
def test_saving_state_with_commit_interval_zero(hass_recorder):
|
|
"""Test saving a state with a commit interval of zero."""
|
|
hass = hass_recorder({"commit_interval": 0})
|
|
get_instance(hass).commit_interval == 0
|
|
|
|
entity_id = "test.recorder"
|
|
state = "restoring_from_db"
|
|
attributes = {"test_attr": 5, "test_attr_10": "nice"}
|
|
|
|
hass.states.set(entity_id, state, attributes)
|
|
|
|
wait_recording_done(hass)
|
|
|
|
with session_scope(hass=hass) as session:
|
|
db_states = list(session.query(States))
|
|
assert len(db_states) == 1
|
|
assert db_states[0].event_id is None
|
|
|
|
|
|
def _add_entities(hass, entity_ids):
|
|
"""Add entities."""
|
|
attributes = {"test_attr": 5, "test_attr_10": "nice"}
|
|
for idx, entity_id in enumerate(entity_ids):
|
|
hass.states.set(entity_id, f"state{idx}", attributes)
|
|
wait_recording_done(hass)
|
|
|
|
with session_scope(hass=hass) as session:
|
|
states = []
|
|
for state, state_attributes in session.query(States, StateAttributes).outerjoin(
|
|
StateAttributes, States.attributes_id == StateAttributes.attributes_id
|
|
):
|
|
native_state = state.to_native()
|
|
native_state.attributes = state_attributes.to_native()
|
|
states.append(native_state)
|
|
return states
|
|
|
|
|
|
def _add_events(hass, events):
|
|
with session_scope(hass=hass) as session:
|
|
session.query(Events).delete(synchronize_session=False)
|
|
for event_type in events:
|
|
hass.bus.fire(event_type)
|
|
wait_recording_done(hass)
|
|
|
|
with session_scope(hass=hass) as session:
|
|
events = []
|
|
for event, event_data in session.query(Events, EventData).outerjoin(
|
|
EventData, Events.data_id == EventData.data_id
|
|
):
|
|
event = cast(Events, event)
|
|
event_data = cast(EventData, event_data)
|
|
|
|
native_event = event.to_native()
|
|
if event_data:
|
|
native_event.data = event_data.to_native()
|
|
events.append(native_event)
|
|
return events
|
|
|
|
|
|
def _state_with_context(hass, entity_id):
|
|
# We don't restore context unless we need it by joining the
|
|
# events table on the event_id for state_changed events
|
|
return hass.states.get(entity_id)
|
|
|
|
|
|
def test_setup_without_migration(hass_recorder):
|
|
"""Verify the schema version without a migration."""
|
|
hass = hass_recorder()
|
|
assert recorder.get_instance(hass).schema_version == SCHEMA_VERSION
|
|
|
|
|
|
# pylint: disable=redefined-outer-name,invalid-name
|
|
def test_saving_state_include_domains(hass_recorder):
|
|
"""Test saving and restoring a state."""
|
|
hass = hass_recorder({"include": {"domains": "test2"}})
|
|
states = _add_entities(hass, ["test.recorder", "test2.recorder"])
|
|
assert len(states) == 1
|
|
assert _state_with_context(hass, "test2.recorder") == states[0]
|
|
|
|
|
|
def test_saving_state_include_domains_globs(hass_recorder):
|
|
"""Test saving and restoring a state."""
|
|
hass = hass_recorder(
|
|
{"include": {"domains": "test2", "entity_globs": "*.included_*"}}
|
|
)
|
|
states = _add_entities(
|
|
hass, ["test.recorder", "test2.recorder", "test3.included_entity"]
|
|
)
|
|
assert len(states) == 2
|
|
assert _state_with_context(hass, "test2.recorder") == states[0]
|
|
assert _state_with_context(hass, "test3.included_entity") == states[1]
|
|
|
|
|
|
def test_saving_state_incl_entities(hass_recorder):
|
|
"""Test saving and restoring a state."""
|
|
hass = hass_recorder({"include": {"entities": "test2.recorder"}})
|
|
states = _add_entities(hass, ["test.recorder", "test2.recorder"])
|
|
assert len(states) == 1
|
|
assert _state_with_context(hass, "test2.recorder") == states[0]
|
|
|
|
|
|
def test_saving_event_exclude_event_type(hass_recorder):
|
|
"""Test saving and restoring an event."""
|
|
hass = hass_recorder(
|
|
{
|
|
"exclude": {
|
|
"event_types": [
|
|
"service_registered",
|
|
"homeassistant_start",
|
|
"component_loaded",
|
|
"core_config_updated",
|
|
"homeassistant_started",
|
|
"test",
|
|
]
|
|
}
|
|
}
|
|
)
|
|
events = _add_events(hass, ["test", "test2"])
|
|
assert len(events) == 1
|
|
assert events[0].event_type == "test2"
|
|
|
|
|
|
def test_saving_state_exclude_domains(hass_recorder):
|
|
"""Test saving and restoring a state."""
|
|
hass = hass_recorder({"exclude": {"domains": "test"}})
|
|
states = _add_entities(hass, ["test.recorder", "test2.recorder"])
|
|
assert len(states) == 1
|
|
assert _state_with_context(hass, "test2.recorder") == states[0]
|
|
|
|
|
|
def test_saving_state_exclude_domains_globs(hass_recorder):
|
|
"""Test saving and restoring a state."""
|
|
hass = hass_recorder(
|
|
{"exclude": {"domains": "test", "entity_globs": "*.excluded_*"}}
|
|
)
|
|
states = _add_entities(
|
|
hass, ["test.recorder", "test2.recorder", "test2.excluded_entity"]
|
|
)
|
|
assert len(states) == 1
|
|
assert _state_with_context(hass, "test2.recorder") == states[0]
|
|
|
|
|
|
def test_saving_state_exclude_entities(hass_recorder):
|
|
"""Test saving and restoring a state."""
|
|
hass = hass_recorder({"exclude": {"entities": "test.recorder"}})
|
|
states = _add_entities(hass, ["test.recorder", "test2.recorder"])
|
|
assert len(states) == 1
|
|
assert _state_with_context(hass, "test2.recorder") == states[0]
|
|
|
|
|
|
def test_saving_state_exclude_domain_include_entity(hass_recorder):
|
|
"""Test saving and restoring a state."""
|
|
hass = hass_recorder(
|
|
{"include": {"entities": "test.recorder"}, "exclude": {"domains": "test"}}
|
|
)
|
|
states = _add_entities(hass, ["test.recorder", "test2.recorder"])
|
|
assert len(states) == 2
|
|
|
|
|
|
def test_saving_state_exclude_domain_glob_include_entity(hass_recorder):
|
|
"""Test saving and restoring a state."""
|
|
hass = hass_recorder(
|
|
{
|
|
"include": {"entities": ["test.recorder", "test.excluded_entity"]},
|
|
"exclude": {"domains": "test", "entity_globs": "*._excluded_*"},
|
|
}
|
|
)
|
|
states = _add_entities(
|
|
hass, ["test.recorder", "test2.recorder", "test.excluded_entity"]
|
|
)
|
|
assert len(states) == 3
|
|
|
|
|
|
def test_saving_state_include_domain_exclude_entity(hass_recorder):
|
|
"""Test saving and restoring a state."""
|
|
hass = hass_recorder(
|
|
{"exclude": {"entities": "test.recorder"}, "include": {"domains": "test"}}
|
|
)
|
|
states = _add_entities(hass, ["test.recorder", "test2.recorder", "test.ok"])
|
|
assert len(states) == 1
|
|
assert _state_with_context(hass, "test.ok") == states[0]
|
|
assert _state_with_context(hass, "test.ok").state == "state2"
|
|
|
|
|
|
def test_saving_state_include_domain_glob_exclude_entity(hass_recorder):
|
|
"""Test saving and restoring a state."""
|
|
hass = hass_recorder(
|
|
{
|
|
"exclude": {"entities": ["test.recorder", "test2.included_entity"]},
|
|
"include": {"domains": "test", "entity_globs": "*._included_*"},
|
|
}
|
|
)
|
|
states = _add_entities(
|
|
hass, ["test.recorder", "test2.recorder", "test.ok", "test2.included_entity"]
|
|
)
|
|
assert len(states) == 1
|
|
assert _state_with_context(hass, "test.ok") == states[0]
|
|
assert _state_with_context(hass, "test.ok").state == "state2"
|
|
|
|
|
|
def test_saving_state_and_removing_entity(hass, hass_recorder):
|
|
"""Test saving the state of a removed entity."""
|
|
hass = hass_recorder()
|
|
entity_id = "lock.mine"
|
|
hass.states.set(entity_id, STATE_LOCKED)
|
|
hass.states.set(entity_id, STATE_UNLOCKED)
|
|
hass.states.remove(entity_id)
|
|
|
|
wait_recording_done(hass)
|
|
|
|
with session_scope(hass=hass) as session:
|
|
states = list(session.query(States))
|
|
assert len(states) == 3
|
|
assert states[0].entity_id == entity_id
|
|
assert states[0].state == STATE_LOCKED
|
|
assert states[1].entity_id == entity_id
|
|
assert states[1].state == STATE_UNLOCKED
|
|
assert states[2].entity_id == entity_id
|
|
assert states[2].state is None
|
|
|
|
|
|
def test_recorder_setup_failure(hass):
|
|
"""Test some exceptions."""
|
|
recorder_helper.async_initialize_recorder(hass)
|
|
with patch.object(Recorder, "_setup_connection") as setup, patch(
|
|
"homeassistant.components.recorder.core.time.sleep"
|
|
):
|
|
setup.side_effect = ImportError("driver not found")
|
|
rec = _default_recorder(hass)
|
|
rec.async_initialize()
|
|
rec.start()
|
|
rec.join()
|
|
|
|
hass.stop()
|
|
|
|
|
|
def test_recorder_setup_failure_without_event_listener(hass):
|
|
"""Test recorder setup failure when the event listener is not setup."""
|
|
recorder_helper.async_initialize_recorder(hass)
|
|
with patch.object(Recorder, "_setup_connection") as setup, patch(
|
|
"homeassistant.components.recorder.core.time.sleep"
|
|
):
|
|
setup.side_effect = ImportError("driver not found")
|
|
rec = _default_recorder(hass)
|
|
rec.start()
|
|
rec.join()
|
|
|
|
hass.stop()
|
|
|
|
|
|
async def test_defaults_set(hass):
|
|
"""Test the config defaults are set."""
|
|
recorder_config = None
|
|
|
|
async def mock_setup(hass, config):
|
|
"""Mock setup."""
|
|
nonlocal recorder_config
|
|
recorder_config = config["recorder"]
|
|
return True
|
|
|
|
with patch("homeassistant.components.recorder.async_setup", side_effect=mock_setup):
|
|
assert await async_setup_component(hass, "history", {})
|
|
|
|
assert recorder_config is not None
|
|
# pylint: disable=unsubscriptable-object
|
|
assert recorder_config["auto_purge"]
|
|
assert recorder_config["auto_repack"]
|
|
assert recorder_config["purge_keep_days"] == 10
|
|
|
|
|
|
def run_tasks_at_time(hass, test_time):
|
|
"""Advance the clock and wait for any callbacks to finish."""
|
|
fire_time_changed(hass, test_time)
|
|
hass.block_till_done()
|
|
get_instance(hass).block_till_done()
|
|
|
|
|
|
@pytest.mark.parametrize("enable_nightly_purge", [True])
|
|
def test_auto_purge(hass_recorder):
|
|
"""Test periodic purge scheduling."""
|
|
hass = hass_recorder()
|
|
|
|
original_tz = dt_util.DEFAULT_TIME_ZONE
|
|
|
|
tz = dt_util.get_time_zone("Europe/Copenhagen")
|
|
dt_util.set_default_time_zone(tz)
|
|
|
|
# Purging is scheduled to happen at 4:12am every day. Exercise this behavior by
|
|
# firing time changed events and advancing the clock around this time. Pick an
|
|
# arbitrary year in the future to avoid boundary conditions relative to the current
|
|
# date.
|
|
#
|
|
# The clock is started at 4:15am then advanced forward below
|
|
now = dt_util.utcnow()
|
|
test_time = datetime(now.year + 2, 1, 1, 4, 15, 0, tzinfo=tz)
|
|
run_tasks_at_time(hass, test_time)
|
|
|
|
with patch(
|
|
"homeassistant.components.recorder.purge.purge_old_data", return_value=True
|
|
) as purge_old_data, patch(
|
|
"homeassistant.components.recorder.tasks.periodic_db_cleanups"
|
|
) as periodic_db_cleanups:
|
|
# Advance one day, and the purge task should run
|
|
test_time = test_time + timedelta(days=1)
|
|
run_tasks_at_time(hass, test_time)
|
|
assert len(purge_old_data.mock_calls) == 1
|
|
assert len(periodic_db_cleanups.mock_calls) == 1
|
|
|
|
purge_old_data.reset_mock()
|
|
periodic_db_cleanups.reset_mock()
|
|
|
|
# Advance one day, and the purge task should run again
|
|
test_time = test_time + timedelta(days=1)
|
|
run_tasks_at_time(hass, test_time)
|
|
assert len(purge_old_data.mock_calls) == 1
|
|
assert len(periodic_db_cleanups.mock_calls) == 1
|
|
|
|
purge_old_data.reset_mock()
|
|
periodic_db_cleanups.reset_mock()
|
|
|
|
# Advance less than one full day. The alarm should not yet fire.
|
|
test_time = test_time + timedelta(hours=23)
|
|
run_tasks_at_time(hass, test_time)
|
|
assert len(purge_old_data.mock_calls) == 0
|
|
assert len(periodic_db_cleanups.mock_calls) == 0
|
|
|
|
# Advance to the next day and fire the alarm again
|
|
test_time = test_time + timedelta(hours=1)
|
|
run_tasks_at_time(hass, test_time)
|
|
assert len(purge_old_data.mock_calls) == 1
|
|
assert len(periodic_db_cleanups.mock_calls) == 1
|
|
|
|
dt_util.set_default_time_zone(original_tz)
|
|
|
|
|
|
@pytest.mark.parametrize("enable_nightly_purge", [True])
|
|
def test_auto_purge_auto_repack_on_second_sunday(hass_recorder):
|
|
"""Test periodic purge scheduling does a repack on the 2nd sunday."""
|
|
hass = hass_recorder()
|
|
|
|
original_tz = dt_util.DEFAULT_TIME_ZONE
|
|
|
|
tz = dt_util.get_time_zone("Europe/Copenhagen")
|
|
dt_util.set_default_time_zone(tz)
|
|
|
|
# Purging is scheduled to happen at 4:12am every day. Exercise this behavior by
|
|
# firing time changed events and advancing the clock around this time. Pick an
|
|
# arbitrary year in the future to avoid boundary conditions relative to the current
|
|
# date.
|
|
#
|
|
# The clock is started at 4:15am then advanced forward below
|
|
now = dt_util.utcnow()
|
|
test_time = datetime(now.year + 2, 1, 1, 4, 15, 0, tzinfo=tz)
|
|
run_tasks_at_time(hass, test_time)
|
|
|
|
with patch(
|
|
"homeassistant.components.recorder.core.is_second_sunday", return_value=True
|
|
), patch(
|
|
"homeassistant.components.recorder.purge.purge_old_data", return_value=True
|
|
) as purge_old_data, patch(
|
|
"homeassistant.components.recorder.tasks.periodic_db_cleanups"
|
|
) as periodic_db_cleanups:
|
|
# Advance one day, and the purge task should run
|
|
test_time = test_time + timedelta(days=1)
|
|
run_tasks_at_time(hass, test_time)
|
|
assert len(purge_old_data.mock_calls) == 1
|
|
args, _ = purge_old_data.call_args_list[0]
|
|
assert args[2] is True # repack
|
|
assert len(periodic_db_cleanups.mock_calls) == 1
|
|
|
|
dt_util.set_default_time_zone(original_tz)
|
|
|
|
|
|
@pytest.mark.parametrize("enable_nightly_purge", [True])
|
|
def test_auto_purge_auto_repack_disabled_on_second_sunday(hass_recorder):
|
|
"""Test periodic purge scheduling does not auto repack on the 2nd sunday if disabled."""
|
|
hass = hass_recorder({CONF_AUTO_REPACK: False})
|
|
|
|
original_tz = dt_util.DEFAULT_TIME_ZONE
|
|
|
|
tz = dt_util.get_time_zone("Europe/Copenhagen")
|
|
dt_util.set_default_time_zone(tz)
|
|
|
|
# Purging is scheduled to happen at 4:12am every day. Exercise this behavior by
|
|
# firing time changed events and advancing the clock around this time. Pick an
|
|
# arbitrary year in the future to avoid boundary conditions relative to the current
|
|
# date.
|
|
#
|
|
# The clock is started at 4:15am then advanced forward below
|
|
now = dt_util.utcnow()
|
|
test_time = datetime(now.year + 2, 1, 1, 4, 15, 0, tzinfo=tz)
|
|
run_tasks_at_time(hass, test_time)
|
|
|
|
with patch(
|
|
"homeassistant.components.recorder.core.is_second_sunday", return_value=True
|
|
), patch(
|
|
"homeassistant.components.recorder.purge.purge_old_data", return_value=True
|
|
) as purge_old_data, patch(
|
|
"homeassistant.components.recorder.tasks.periodic_db_cleanups"
|
|
) as periodic_db_cleanups:
|
|
# Advance one day, and the purge task should run
|
|
test_time = test_time + timedelta(days=1)
|
|
run_tasks_at_time(hass, test_time)
|
|
assert len(purge_old_data.mock_calls) == 1
|
|
args, _ = purge_old_data.call_args_list[0]
|
|
assert args[2] is False # repack
|
|
assert len(periodic_db_cleanups.mock_calls) == 1
|
|
|
|
dt_util.set_default_time_zone(original_tz)
|
|
|
|
|
|
@pytest.mark.parametrize("enable_nightly_purge", [True])
|
|
def test_auto_purge_no_auto_repack_on_not_second_sunday(hass_recorder):
|
|
"""Test periodic purge scheduling does not do a repack unless its the 2nd sunday."""
|
|
hass = hass_recorder()
|
|
|
|
original_tz = dt_util.DEFAULT_TIME_ZONE
|
|
|
|
tz = dt_util.get_time_zone("Europe/Copenhagen")
|
|
dt_util.set_default_time_zone(tz)
|
|
|
|
# Purging is scheduled to happen at 4:12am every day. Exercise this behavior by
|
|
# firing time changed events and advancing the clock around this time. Pick an
|
|
# arbitrary year in the future to avoid boundary conditions relative to the current
|
|
# date.
|
|
#
|
|
# The clock is started at 4:15am then advanced forward below
|
|
now = dt_util.utcnow()
|
|
test_time = datetime(now.year + 2, 1, 1, 4, 15, 0, tzinfo=tz)
|
|
run_tasks_at_time(hass, test_time)
|
|
|
|
with patch(
|
|
"homeassistant.components.recorder.core.is_second_sunday",
|
|
return_value=False,
|
|
), patch(
|
|
"homeassistant.components.recorder.purge.purge_old_data", return_value=True
|
|
) as purge_old_data, patch(
|
|
"homeassistant.components.recorder.tasks.periodic_db_cleanups"
|
|
) as periodic_db_cleanups:
|
|
# Advance one day, and the purge task should run
|
|
test_time = test_time + timedelta(days=1)
|
|
run_tasks_at_time(hass, test_time)
|
|
assert len(purge_old_data.mock_calls) == 1
|
|
args, _ = purge_old_data.call_args_list[0]
|
|
assert args[2] is False # repack
|
|
assert len(periodic_db_cleanups.mock_calls) == 1
|
|
|
|
dt_util.set_default_time_zone(original_tz)
|
|
|
|
|
|
@pytest.mark.parametrize("enable_nightly_purge", [True])
|
|
def test_auto_purge_disabled(hass_recorder):
|
|
"""Test periodic db cleanup still run when auto purge is disabled."""
|
|
hass = hass_recorder({CONF_AUTO_PURGE: False})
|
|
|
|
original_tz = dt_util.DEFAULT_TIME_ZONE
|
|
|
|
tz = dt_util.get_time_zone("Europe/Copenhagen")
|
|
dt_util.set_default_time_zone(tz)
|
|
|
|
# Purging is scheduled to happen at 4:12am every day. We want
|
|
# to verify that when auto purge is disabled periodic db cleanups
|
|
# are still scheduled
|
|
#
|
|
# The clock is started at 4:15am then advanced forward below
|
|
now = dt_util.utcnow()
|
|
test_time = datetime(now.year + 2, 1, 1, 4, 15, 0, tzinfo=tz)
|
|
run_tasks_at_time(hass, test_time)
|
|
|
|
with patch(
|
|
"homeassistant.components.recorder.purge.purge_old_data", return_value=True
|
|
) as purge_old_data, patch(
|
|
"homeassistant.components.recorder.tasks.periodic_db_cleanups"
|
|
) as periodic_db_cleanups:
|
|
# Advance one day, and the purge task should run
|
|
test_time = test_time + timedelta(days=1)
|
|
run_tasks_at_time(hass, test_time)
|
|
assert len(purge_old_data.mock_calls) == 0
|
|
assert len(periodic_db_cleanups.mock_calls) == 1
|
|
|
|
purge_old_data.reset_mock()
|
|
periodic_db_cleanups.reset_mock()
|
|
|
|
dt_util.set_default_time_zone(original_tz)
|
|
|
|
|
|
@pytest.mark.parametrize("enable_statistics", [True])
|
|
def test_auto_statistics(hass_recorder):
|
|
"""Test periodic statistics scheduling."""
|
|
hass = hass_recorder()
|
|
|
|
original_tz = dt_util.DEFAULT_TIME_ZONE
|
|
|
|
tz = dt_util.get_time_zone("Europe/Copenhagen")
|
|
dt_util.set_default_time_zone(tz)
|
|
|
|
# Statistics is scheduled to happen every 5 minutes. Exercise this behavior by
|
|
# firing time changed events and advancing the clock around this time. Pick an
|
|
# arbitrary year in the future to avoid boundary conditions relative to the current
|
|
# date.
|
|
#
|
|
# The clock is started at 4:16am then advanced forward below
|
|
now = dt_util.utcnow()
|
|
test_time = datetime(now.year + 2, 1, 1, 4, 16, 0, tzinfo=tz)
|
|
run_tasks_at_time(hass, test_time)
|
|
|
|
with patch(
|
|
"homeassistant.components.recorder.statistics.compile_statistics",
|
|
return_value=True,
|
|
) as compile_statistics:
|
|
# Advance 5 minutes, and the statistics task should run
|
|
test_time = test_time + timedelta(minutes=5)
|
|
run_tasks_at_time(hass, test_time)
|
|
assert len(compile_statistics.mock_calls) == 1
|
|
|
|
compile_statistics.reset_mock()
|
|
|
|
# Advance 5 minutes, and the statistics task should run again
|
|
test_time = test_time + timedelta(minutes=5)
|
|
run_tasks_at_time(hass, test_time)
|
|
assert len(compile_statistics.mock_calls) == 1
|
|
|
|
compile_statistics.reset_mock()
|
|
|
|
# Advance less than 5 minutes. The task should not run.
|
|
test_time = test_time + timedelta(minutes=3)
|
|
run_tasks_at_time(hass, test_time)
|
|
assert len(compile_statistics.mock_calls) == 0
|
|
|
|
# Advance 5 minutes, and the statistics task should run again
|
|
test_time = test_time + timedelta(minutes=5)
|
|
run_tasks_at_time(hass, test_time)
|
|
assert len(compile_statistics.mock_calls) == 1
|
|
|
|
dt_util.set_default_time_zone(original_tz)
|
|
|
|
|
|
def test_statistics_runs_initiated(hass_recorder):
|
|
"""Test statistics_runs is initiated when DB is created."""
|
|
now = dt_util.utcnow()
|
|
with patch(
|
|
"homeassistant.components.recorder.core.dt_util.utcnow", return_value=now
|
|
):
|
|
hass = hass_recorder()
|
|
|
|
wait_recording_done(hass)
|
|
|
|
with session_scope(hass=hass) as session:
|
|
statistics_runs = list(session.query(StatisticsRuns))
|
|
assert len(statistics_runs) == 1
|
|
last_run = process_timestamp(statistics_runs[0].start)
|
|
assert process_timestamp(last_run) == now.replace(
|
|
minute=now.minute - now.minute % 5, second=0, microsecond=0
|
|
) - timedelta(minutes=5)
|
|
|
|
|
|
def test_compile_missing_statistics(tmpdir):
|
|
"""Test missing statistics are compiled on startup."""
|
|
now = dt_util.utcnow().replace(minute=0, second=0, microsecond=0)
|
|
test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db")
|
|
dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}"
|
|
|
|
with patch(
|
|
"homeassistant.components.recorder.core.dt_util.utcnow", return_value=now
|
|
):
|
|
|
|
hass = get_test_home_assistant()
|
|
recorder_helper.async_initialize_recorder(hass)
|
|
setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}})
|
|
hass.start()
|
|
wait_recording_done(hass)
|
|
wait_recording_done(hass)
|
|
|
|
with session_scope(hass=hass) as session:
|
|
statistics_runs = list(session.query(StatisticsRuns))
|
|
assert len(statistics_runs) == 1
|
|
last_run = process_timestamp(statistics_runs[0].start)
|
|
assert last_run == now - timedelta(minutes=5)
|
|
|
|
wait_recording_done(hass)
|
|
wait_recording_done(hass)
|
|
hass.stop()
|
|
|
|
with patch(
|
|
"homeassistant.components.recorder.core.dt_util.utcnow",
|
|
return_value=now + timedelta(hours=1),
|
|
):
|
|
|
|
hass = get_test_home_assistant()
|
|
recorder_helper.async_initialize_recorder(hass)
|
|
setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}})
|
|
hass.start()
|
|
wait_recording_done(hass)
|
|
wait_recording_done(hass)
|
|
|
|
with session_scope(hass=hass) as session:
|
|
statistics_runs = list(session.query(StatisticsRuns))
|
|
assert len(statistics_runs) == 13 # 12 5-minute runs
|
|
last_run = process_timestamp(statistics_runs[1].start)
|
|
assert last_run == now
|
|
|
|
wait_recording_done(hass)
|
|
wait_recording_done(hass)
|
|
hass.stop()
|
|
|
|
|
|
def test_saving_sets_old_state(hass_recorder):
|
|
"""Test saving sets old state."""
|
|
hass = hass_recorder()
|
|
|
|
hass.states.set("test.one", "on", {})
|
|
hass.states.set("test.two", "on", {})
|
|
wait_recording_done(hass)
|
|
hass.states.set("test.one", "off", {})
|
|
hass.states.set("test.two", "off", {})
|
|
wait_recording_done(hass)
|
|
|
|
with session_scope(hass=hass) as session:
|
|
states = list(session.query(States))
|
|
assert len(states) == 4
|
|
|
|
assert states[0].entity_id == "test.one"
|
|
assert states[1].entity_id == "test.two"
|
|
assert states[2].entity_id == "test.one"
|
|
assert states[3].entity_id == "test.two"
|
|
|
|
assert states[0].old_state_id is None
|
|
assert states[1].old_state_id is None
|
|
assert states[2].old_state_id == states[0].state_id
|
|
assert states[3].old_state_id == states[1].state_id
|
|
|
|
|
|
def test_saving_state_with_serializable_data(hass_recorder, caplog):
|
|
"""Test saving data that cannot be serialized does not crash."""
|
|
hass = hass_recorder()
|
|
|
|
hass.bus.fire("bad_event", {"fail": CannotSerializeMe()})
|
|
hass.states.set("test.one", "on", {"fail": CannotSerializeMe()})
|
|
wait_recording_done(hass)
|
|
hass.states.set("test.two", "on", {})
|
|
wait_recording_done(hass)
|
|
hass.states.set("test.two", "off", {})
|
|
wait_recording_done(hass)
|
|
|
|
with session_scope(hass=hass) as session:
|
|
states = list(session.query(States))
|
|
assert len(states) == 2
|
|
|
|
assert states[0].entity_id == "test.two"
|
|
assert states[1].entity_id == "test.two"
|
|
assert states[0].old_state_id is None
|
|
assert states[1].old_state_id == states[0].state_id
|
|
|
|
assert "State is not JSON serializable" in caplog.text
|
|
|
|
|
|
def test_has_services(hass_recorder):
|
|
"""Test the services exist."""
|
|
hass = hass_recorder()
|
|
|
|
assert hass.services.has_service(DOMAIN, SERVICE_DISABLE)
|
|
assert hass.services.has_service(DOMAIN, SERVICE_ENABLE)
|
|
assert hass.services.has_service(DOMAIN, SERVICE_PURGE)
|
|
assert hass.services.has_service(DOMAIN, SERVICE_PURGE_ENTITIES)
|
|
|
|
|
|
def test_service_disable_events_not_recording(hass, hass_recorder):
|
|
"""Test that events are not recorded when recorder is disabled using service."""
|
|
hass = hass_recorder()
|
|
|
|
assert hass.services.call(
|
|
DOMAIN,
|
|
SERVICE_DISABLE,
|
|
{},
|
|
blocking=True,
|
|
)
|
|
|
|
event_type = "EVENT_TEST"
|
|
|
|
events = []
|
|
|
|
@callback
|
|
def event_listener(event):
|
|
"""Record events from eventbus."""
|
|
if event.event_type == event_type:
|
|
events.append(event)
|
|
|
|
hass.bus.listen(MATCH_ALL, event_listener)
|
|
|
|
event_data1 = {"test_attr": 5, "test_attr_10": "nice"}
|
|
hass.bus.fire(event_type, event_data1)
|
|
wait_recording_done(hass)
|
|
|
|
assert len(events) == 1
|
|
event = events[0]
|
|
|
|
with session_scope(hass=hass) as session:
|
|
db_events = list(session.query(Events).filter_by(event_type=event_type))
|
|
assert len(db_events) == 0
|
|
|
|
assert hass.services.call(
|
|
DOMAIN,
|
|
SERVICE_ENABLE,
|
|
{},
|
|
blocking=True,
|
|
)
|
|
|
|
event_data2 = {"attr_one": 5, "attr_two": "nice"}
|
|
hass.bus.fire(event_type, event_data2)
|
|
wait_recording_done(hass)
|
|
|
|
assert len(events) == 2
|
|
assert events[0] != events[1]
|
|
assert events[0].data != events[1].data
|
|
|
|
db_events = []
|
|
with session_scope(hass=hass) as session:
|
|
for select_event, event_data in (
|
|
session.query(Events, EventData)
|
|
.filter_by(event_type=event_type)
|
|
.outerjoin(EventData, Events.data_id == EventData.data_id)
|
|
):
|
|
select_event = cast(Events, select_event)
|
|
event_data = cast(EventData, event_data)
|
|
|
|
native_event = select_event.to_native()
|
|
native_event.data = event_data.to_native()
|
|
db_events.append(native_event)
|
|
|
|
assert len(db_events) == 1
|
|
db_event = db_events[0]
|
|
event = events[1]
|
|
|
|
assert event.event_type == db_event.event_type
|
|
assert event.data == db_event.data
|
|
assert event.origin == db_event.origin
|
|
assert event.time_fired.replace(microsecond=0) == db_event.time_fired.replace(
|
|
microsecond=0
|
|
)
|
|
|
|
|
|
def test_service_disable_states_not_recording(hass, hass_recorder):
|
|
"""Test that state changes are not recorded when recorder is disabled using service."""
|
|
hass = hass_recorder()
|
|
|
|
assert hass.services.call(
|
|
DOMAIN,
|
|
SERVICE_DISABLE,
|
|
{},
|
|
blocking=True,
|
|
)
|
|
|
|
hass.states.set("test.one", "on", {})
|
|
wait_recording_done(hass)
|
|
|
|
with session_scope(hass=hass) as session:
|
|
assert len(list(session.query(States))) == 0
|
|
|
|
assert hass.services.call(
|
|
DOMAIN,
|
|
SERVICE_ENABLE,
|
|
{},
|
|
blocking=True,
|
|
)
|
|
|
|
hass.states.set("test.two", "off", {})
|
|
wait_recording_done(hass)
|
|
|
|
with session_scope(hass=hass) as session:
|
|
db_states = list(session.query(States))
|
|
assert len(db_states) == 1
|
|
assert db_states[0].event_id is None
|
|
assert db_states[0].to_native() == _state_with_context(hass, "test.two")
|
|
|
|
|
|
def test_service_disable_run_information_recorded(tmpdir):
|
|
"""Test that runs are still recorded when recorder is disabled."""
|
|
test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db")
|
|
dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}"
|
|
|
|
hass = get_test_home_assistant()
|
|
recorder_helper.async_initialize_recorder(hass)
|
|
setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}})
|
|
hass.start()
|
|
wait_recording_done(hass)
|
|
|
|
with session_scope(hass=hass) as session:
|
|
db_run_info = list(session.query(RecorderRuns))
|
|
assert len(db_run_info) == 1
|
|
assert db_run_info[0].start is not None
|
|
assert db_run_info[0].end is None
|
|
|
|
assert hass.services.call(
|
|
DOMAIN,
|
|
SERVICE_DISABLE,
|
|
{},
|
|
blocking=True,
|
|
)
|
|
|
|
wait_recording_done(hass)
|
|
hass.stop()
|
|
|
|
hass = get_test_home_assistant()
|
|
recorder_helper.async_initialize_recorder(hass)
|
|
setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}})
|
|
hass.start()
|
|
wait_recording_done(hass)
|
|
|
|
with session_scope(hass=hass) as session:
|
|
db_run_info = list(session.query(RecorderRuns))
|
|
assert len(db_run_info) == 2
|
|
assert db_run_info[0].start is not None
|
|
assert db_run_info[0].end is not None
|
|
assert db_run_info[1].start is not None
|
|
assert db_run_info[1].end is None
|
|
|
|
hass.stop()
|
|
|
|
|
|
class CannotSerializeMe:
|
|
"""A class that the JSONEncoder cannot serialize."""
|
|
|
|
|
|
async def test_database_corruption_while_running(hass, tmpdir, caplog):
|
|
"""Test we can recover from sqlite3 db corruption."""
|
|
|
|
def _create_tmpdir_for_test_db():
|
|
return tmpdir.mkdir("sqlite").join("test.db")
|
|
|
|
test_db_file = await hass.async_add_executor_job(_create_tmpdir_for_test_db)
|
|
dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}"
|
|
|
|
recorder_helper.async_initialize_recorder(hass)
|
|
assert await async_setup_component(
|
|
hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl, CONF_COMMIT_INTERVAL: 0}}
|
|
)
|
|
await hass.async_block_till_done()
|
|
caplog.clear()
|
|
|
|
original_start_time = get_instance(hass).run_history.recording_start
|
|
|
|
hass.states.async_set("test.lost", "on", {})
|
|
|
|
sqlite3_exception = DatabaseError("statement", {}, [])
|
|
sqlite3_exception.__cause__ = sqlite3.DatabaseError()
|
|
|
|
with patch.object(
|
|
get_instance(hass).event_session,
|
|
"close",
|
|
side_effect=OperationalError("statement", {}, []),
|
|
):
|
|
await async_wait_recording_done(hass)
|
|
await hass.async_add_executor_job(corrupt_db_file, test_db_file)
|
|
await async_wait_recording_done(hass)
|
|
|
|
with patch.object(
|
|
get_instance(hass).event_session,
|
|
"commit",
|
|
side_effect=[sqlite3_exception, None],
|
|
):
|
|
# This state will not be recorded because
|
|
# the database corruption will be discovered
|
|
# and we will have to rollback to recover
|
|
hass.states.async_set("test.one", "off", {})
|
|
await async_wait_recording_done(hass)
|
|
|
|
assert "Unrecoverable sqlite3 database corruption detected" in caplog.text
|
|
assert "The system will rename the corrupt database file" in caplog.text
|
|
assert "Connected to recorder database" in caplog.text
|
|
|
|
# This state should go into the new database
|
|
hass.states.async_set("test.two", "on", {})
|
|
await async_wait_recording_done(hass)
|
|
|
|
def _get_last_state():
|
|
with session_scope(hass=hass) as session:
|
|
db_states = list(session.query(States))
|
|
assert len(db_states) == 1
|
|
assert db_states[0].event_id is None
|
|
return db_states[0].to_native()
|
|
|
|
state = await hass.async_add_executor_job(_get_last_state)
|
|
assert state.entity_id == "test.two"
|
|
assert state.state == "on"
|
|
|
|
new_start_time = get_instance(hass).run_history.recording_start
|
|
assert original_start_time < new_start_time
|
|
|
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
|
|
await hass.async_block_till_done()
|
|
hass.stop()
|
|
|
|
|
|
def test_entity_id_filter(hass_recorder):
|
|
"""Test that entity ID filtering filters string and list."""
|
|
hass = hass_recorder(
|
|
{"include": {"domains": "hello"}, "exclude": {"domains": "hidden_domain"}}
|
|
)
|
|
|
|
for idx, data in enumerate(
|
|
(
|
|
{},
|
|
{"entity_id": "hello.world"},
|
|
{"entity_id": ["hello.world"]},
|
|
{"entity_id": ["hello.world", "hidden_domain.person"]},
|
|
{"entity_id": {"unexpected": "data"}},
|
|
)
|
|
):
|
|
hass.bus.fire("hello", data)
|
|
wait_recording_done(hass)
|
|
|
|
with session_scope(hass=hass) as session:
|
|
db_events = list(session.query(Events).filter_by(event_type="hello"))
|
|
assert len(db_events) == idx + 1, data
|
|
|
|
for data in (
|
|
{"entity_id": "hidden_domain.person"},
|
|
{"entity_id": ["hidden_domain.person"]},
|
|
):
|
|
hass.bus.fire("hello", data)
|
|
wait_recording_done(hass)
|
|
|
|
with session_scope(hass=hass) as session:
|
|
db_events = list(session.query(Events).filter_by(event_type="hello"))
|
|
# Keep referring idx + 1, as no new events are being added
|
|
assert len(db_events) == idx + 1, data
|
|
|
|
|
|
async def test_database_lock_and_unlock(
|
|
hass: HomeAssistant,
|
|
async_setup_recorder_instance: SetupRecorderInstanceT,
|
|
tmp_path,
|
|
):
|
|
"""Test writing events during lock getting written after unlocking."""
|
|
# Use file DB, in memory DB cannot do write locks.
|
|
config = {
|
|
recorder.CONF_COMMIT_INTERVAL: 0,
|
|
recorder.CONF_DB_URL: "sqlite:///" + str(tmp_path / "pytest.db"),
|
|
}
|
|
await async_setup_recorder_instance(hass, config)
|
|
await hass.async_block_till_done()
|
|
|
|
def _get_db_events():
|
|
with session_scope(hass=hass) as session:
|
|
return list(session.query(Events).filter_by(event_type=event_type))
|
|
|
|
instance = get_instance(hass)
|
|
|
|
assert await instance.lock_database()
|
|
|
|
assert not await instance.lock_database()
|
|
|
|
event_type = "EVENT_TEST"
|
|
event_data = {"test_attr": 5, "test_attr_10": "nice"}
|
|
hass.bus.async_fire(event_type, event_data)
|
|
task = asyncio.create_task(async_wait_recording_done(hass))
|
|
|
|
# Recording can't be finished while lock is held
|
|
with pytest.raises(asyncio.TimeoutError):
|
|
await asyncio.wait_for(asyncio.shield(task), timeout=1)
|
|
db_events = await hass.async_add_executor_job(_get_db_events)
|
|
assert len(db_events) == 0
|
|
|
|
assert instance.unlock_database()
|
|
|
|
await task
|
|
db_events = await hass.async_add_executor_job(_get_db_events)
|
|
assert len(db_events) == 1
|
|
|
|
|
|
async def test_database_lock_and_overflow(
|
|
hass: HomeAssistant,
|
|
async_setup_recorder_instance: SetupRecorderInstanceT,
|
|
tmp_path,
|
|
):
|
|
"""Test writing events during lock leading to overflow the queue causes the database to unlock."""
|
|
# Use file DB, in memory DB cannot do write locks.
|
|
config = {
|
|
recorder.CONF_COMMIT_INTERVAL: 0,
|
|
recorder.CONF_DB_URL: "sqlite:///" + str(tmp_path / "pytest.db"),
|
|
}
|
|
await async_setup_recorder_instance(hass, config)
|
|
await hass.async_block_till_done()
|
|
|
|
def _get_db_events():
|
|
with session_scope(hass=hass) as session:
|
|
return list(session.query(Events).filter_by(event_type=event_type))
|
|
|
|
instance = get_instance(hass)
|
|
|
|
with patch.object(recorder.core, "MAX_QUEUE_BACKLOG", 1), patch.object(
|
|
recorder.core, "DB_LOCK_QUEUE_CHECK_TIMEOUT", 0.1
|
|
):
|
|
await instance.lock_database()
|
|
|
|
event_type = "EVENT_TEST"
|
|
event_data = {"test_attr": 5, "test_attr_10": "nice"}
|
|
hass.bus.fire(event_type, event_data)
|
|
|
|
# Check that this causes the queue to overflow and write succeeds
|
|
# even before unlocking.
|
|
await async_wait_recording_done(hass)
|
|
|
|
db_events = await hass.async_add_executor_job(_get_db_events)
|
|
assert len(db_events) == 1
|
|
|
|
assert not instance.unlock_database()
|
|
|
|
|
|
async def test_database_lock_timeout(hass, recorder_mock):
|
|
"""Test locking database timeout when recorder stopped."""
|
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
|
|
|
|
instance = get_instance(hass)
|
|
|
|
class BlockQueue(recorder.tasks.RecorderTask):
|
|
event: threading.Event = threading.Event()
|
|
|
|
def run(self, instance: Recorder) -> None:
|
|
self.event.wait()
|
|
|
|
block_task = BlockQueue()
|
|
instance.queue_task(block_task)
|
|
with patch.object(recorder.core, "DB_LOCK_TIMEOUT", 0.1):
|
|
try:
|
|
with pytest.raises(TimeoutError):
|
|
await instance.lock_database()
|
|
finally:
|
|
instance.unlock_database()
|
|
block_task.event.set()
|
|
|
|
|
|
async def test_database_lock_without_instance(hass, recorder_mock):
|
|
"""Test database lock doesn't fail if instance is not initialized."""
|
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
|
|
|
|
instance = get_instance(hass)
|
|
with patch.object(instance, "engine", None):
|
|
try:
|
|
assert await instance.lock_database()
|
|
finally:
|
|
assert instance.unlock_database()
|
|
|
|
|
|
async def test_in_memory_database(hass, caplog):
|
|
"""Test connecting to an in-memory recorder is not allowed."""
|
|
assert not await async_setup_component(
|
|
hass, recorder.DOMAIN, {recorder.DOMAIN: {recorder.CONF_DB_URL: "sqlite://"}}
|
|
)
|
|
assert "In-memory SQLite database is not supported" in caplog.text
|
|
|
|
|
|
async def test_database_connection_keep_alive(
|
|
hass: HomeAssistant,
|
|
async_setup_recorder_instance: SetupRecorderInstanceT,
|
|
caplog: pytest.LogCaptureFixture,
|
|
):
|
|
"""Test we keep alive socket based dialects."""
|
|
with patch("homeassistant.components.recorder.Recorder.dialect_name"):
|
|
instance = await async_setup_recorder_instance(hass)
|
|
# We have to mock this since we don't have a mock
|
|
# MySQL server available in tests.
|
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
|
await instance.async_recorder_ready.wait()
|
|
|
|
async_fire_time_changed(
|
|
hass, dt_util.utcnow() + timedelta(seconds=recorder.core.KEEPALIVE_TIME)
|
|
)
|
|
await async_wait_recording_done(hass)
|
|
assert "Sending keepalive" in caplog.text
|
|
|
|
|
|
async def test_database_connection_keep_alive_disabled_on_sqlite(
|
|
hass: HomeAssistant,
|
|
async_setup_recorder_instance: SetupRecorderInstanceT,
|
|
caplog: pytest.LogCaptureFixture,
|
|
):
|
|
"""Test we do not do keep alive for sqlite."""
|
|
instance = await async_setup_recorder_instance(hass)
|
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
|
await instance.async_recorder_ready.wait()
|
|
|
|
async_fire_time_changed(
|
|
hass, dt_util.utcnow() + timedelta(seconds=recorder.core.KEEPALIVE_TIME)
|
|
)
|
|
await async_wait_recording_done(hass)
|
|
assert "Sending keepalive" not in caplog.text
|
|
|
|
|
|
def test_deduplication_event_data_inside_commit_interval(hass_recorder, caplog):
|
|
"""Test deduplication of event data inside the commit interval."""
|
|
hass = hass_recorder()
|
|
|
|
for _ in range(10):
|
|
hass.bus.fire("this_event", {"de": "dupe"})
|
|
wait_recording_done(hass)
|
|
for _ in range(10):
|
|
hass.bus.fire("this_event", {"de": "dupe"})
|
|
wait_recording_done(hass)
|
|
|
|
with session_scope(hass=hass) as session:
|
|
events = list(
|
|
session.query(Events)
|
|
.filter(Events.event_type == "this_event")
|
|
.outerjoin(EventData, (Events.data_id == EventData.data_id))
|
|
)
|
|
assert len(events) == 20
|
|
first_data_id = events[0].data_id
|
|
assert all(event.data_id == first_data_id for event in events)
|
|
|
|
|
|
# Patch STATE_ATTRIBUTES_ID_CACHE_SIZE since otherwise
|
|
# the CI can fail because the test takes too long to run
|
|
@patch("homeassistant.components.recorder.core.STATE_ATTRIBUTES_ID_CACHE_SIZE", 5)
|
|
def test_deduplication_state_attributes_inside_commit_interval(hass_recorder, caplog):
|
|
"""Test deduplication of state attributes inside the commit interval."""
|
|
hass = hass_recorder()
|
|
|
|
entity_id = "test.recorder"
|
|
attributes = {"test_attr": 5, "test_attr_10": "nice"}
|
|
|
|
hass.states.set(entity_id, "on", attributes)
|
|
hass.states.set(entity_id, "off", attributes)
|
|
|
|
# Now exaust the cache to ensure we go back to the db
|
|
for attr_id in range(5):
|
|
hass.states.set(entity_id, "on", {"test_attr": attr_id})
|
|
hass.states.set(entity_id, "off", {"test_attr": attr_id})
|
|
|
|
wait_recording_done(hass)
|
|
for _ in range(5):
|
|
hass.states.set(entity_id, "on", attributes)
|
|
hass.states.set(entity_id, "off", attributes)
|
|
wait_recording_done(hass)
|
|
|
|
with session_scope(hass=hass) as session:
|
|
states = list(
|
|
session.query(States)
|
|
.filter(States.entity_id == entity_id)
|
|
.outerjoin(
|
|
StateAttributes, (States.attributes_id == StateAttributes.attributes_id)
|
|
)
|
|
)
|
|
assert len(states) == 22
|
|
first_attributes_id = states[0].attributes_id
|
|
last_attributes_id = states[-1].attributes_id
|
|
assert first_attributes_id == last_attributes_id
|
|
|
|
|
|
async def test_async_block_till_done(hass, async_setup_recorder_instance):
|
|
"""Test we can block until recordering is done."""
|
|
instance = await async_setup_recorder_instance(hass)
|
|
await async_wait_recording_done(hass)
|
|
|
|
entity_id = "test.recorder"
|
|
attributes = {"test_attr": 5, "test_attr_10": "nice"}
|
|
|
|
hass.states.async_set(entity_id, "on", attributes)
|
|
hass.states.async_set(entity_id, "off", attributes)
|
|
|
|
def _fetch_states():
|
|
with session_scope(hass=hass) as session:
|
|
return list(session.query(States).filter(States.entity_id == entity_id))
|
|
|
|
await async_block_recorder(hass, 0.1)
|
|
await instance.async_block_till_done()
|
|
states = await instance.async_add_executor_job(_fetch_states)
|
|
assert len(states) == 2
|
|
await hass.async_block_till_done()
|