Store state last seen time separately (#18806)

* Store state last seen time separately

This ensures that infrequently updated entities aren't accidentally
dropped from the restore states store

* Fix mock restore cache
This commit is contained in:
Adam Mills 2018-11-30 02:26:19 -05:00 committed by Paulus Schoutsen
parent a9dc4ba297
commit 22f27b8621
3 changed files with 80 additions and 44 deletions

View File

@ -1,7 +1,7 @@
"""Support for restoring entity states on startup.""" """Support for restoring entity states on startup."""
import asyncio import asyncio
import logging import logging
from datetime import timedelta from datetime import timedelta, datetime
from typing import Any, Dict, List, Set, Optional # noqa pylint_disable=unused-import from typing import Any, Dict, List, Set, Optional # noqa pylint_disable=unused-import
from homeassistant.core import HomeAssistant, callback, State, CoreState from homeassistant.core import HomeAssistant, callback, State, CoreState
@ -28,6 +28,32 @@ STATE_DUMP_INTERVAL = timedelta(minutes=15)
STATE_EXPIRATION = timedelta(days=7) STATE_EXPIRATION = timedelta(days=7)
class StoredState:
"""Object to represent a stored state."""
def __init__(self, state: State, last_seen: datetime) -> None:
"""Initialize a new stored state."""
self.state = state
self.last_seen = last_seen
def as_dict(self) -> Dict:
"""Return a dict representation of the stored state."""
return {
'state': self.state.as_dict(),
'last_seen': self.last_seen,
}
@classmethod
def from_dict(cls, json_dict: Dict) -> 'StoredState':
"""Initialize a stored state from a dict."""
last_seen = json_dict['last_seen']
if isinstance(last_seen, str):
last_seen = dt_util.parse_datetime(last_seen)
return cls(State.from_dict(json_dict['state']), last_seen)
class RestoreStateData(): class RestoreStateData():
"""Helper class for managing the helper saved data.""" """Helper class for managing the helper saved data."""
@ -43,18 +69,18 @@ class RestoreStateData():
data = cls(hass) data = cls(hass)
try: try:
states = await data.store.async_load() stored_states = await data.store.async_load()
except HomeAssistantError as exc: except HomeAssistantError as exc:
_LOGGER.error("Error loading last states", exc_info=exc) _LOGGER.error("Error loading last states", exc_info=exc)
states = None stored_states = None
if states is None: if stored_states is None:
_LOGGER.debug('Not creating cache - no saved states found') _LOGGER.debug('Not creating cache - no saved states found')
data.last_states = {} data.last_states = {}
else: else:
data.last_states = { data.last_states = {
state['entity_id']: State.from_dict(state) item['state']['entity_id']: StoredState.from_dict(item)
for state in states} for item in stored_states}
_LOGGER.debug( _LOGGER.debug(
'Created cache with %s', list(data.last_states)) 'Created cache with %s', list(data.last_states))
@ -74,46 +100,49 @@ class RestoreStateData():
def __init__(self, hass: HomeAssistant) -> None: def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the restore state data class.""" """Initialize the restore state data class."""
self.hass = hass # type: HomeAssistant self.hass = hass # type: HomeAssistant
self.store = Store(hass, STORAGE_VERSION, STORAGE_KEY, self.store = Store(
encoder=JSONEncoder) # type: Store hass, STORAGE_VERSION, STORAGE_KEY,
self.last_states = {} # type: Dict[str, State] encoder=JSONEncoder) # type: Store
self.last_states = {} # type: Dict[str, StoredState]
self.entity_ids = set() # type: Set[str] self.entity_ids = set() # type: Set[str]
def async_get_states(self) -> List[State]: def async_get_stored_states(self) -> List[StoredState]:
"""Get the set of states which should be stored. """Get the set of states which should be stored.
This includes the states of all registered entities, as well as the This includes the states of all registered entities, as well as the
stored states from the previous run, which have not been created as stored states from the previous run, which have not been created as
entities on this run, and have not expired. entities on this run, and have not expired.
""" """
now = dt_util.utcnow()
all_states = self.hass.states.async_all() all_states = self.hass.states.async_all()
current_entity_ids = set(state.entity_id for state in all_states) current_entity_ids = set(state.entity_id for state in all_states)
# Start with the currently registered states # Start with the currently registered states
states = [state for state in all_states stored_states = [StoredState(state, now) for state in all_states
if state.entity_id in self.entity_ids] if state.entity_id in self.entity_ids]
expiration_time = dt_util.utcnow() - STATE_EXPIRATION expiration_time = now - STATE_EXPIRATION
for entity_id, state in self.last_states.items(): for entity_id, stored_state in self.last_states.items():
# Don't save old states that have entities in the current run # Don't save old states that have entities in the current run
if entity_id in current_entity_ids: if entity_id in current_entity_ids:
continue continue
# Don't save old states that have expired # Don't save old states that have expired
if state.last_updated < expiration_time: if stored_state.last_seen < expiration_time:
continue continue
states.append(state) stored_states.append(stored_state)
return states return stored_states
async def async_dump_states(self) -> None: async def async_dump_states(self) -> None:
"""Save the current state machine to storage.""" """Save the current state machine to storage."""
_LOGGER.debug("Dumping states") _LOGGER.debug("Dumping states")
try: try:
await self.store.async_save([ await self.store.async_save([
state.as_dict() for state in self.async_get_states()]) stored_state.as_dict()
for stored_state in self.async_get_stored_states()])
except HomeAssistantError as exc: except HomeAssistantError as exc:
_LOGGER.error("Error saving current states", exc_info=exc) _LOGGER.error("Error saving current states", exc_info=exc)
@ -172,4 +201,6 @@ class RestoreEntity(Entity):
_LOGGER.warning("Cannot get last state. Entity not added to hass") _LOGGER.warning("Cannot get last state. Entity not added to hass")
return None return None
data = await RestoreStateData.async_get_instance(self.hass) data = await RestoreStateData.async_get_instance(self.hass)
return data.last_states.get(self.entity_id) if self.entity_id not in data.last_states:
return None
return data.last_states[self.entity_id].state

View File

@ -715,9 +715,11 @@ def mock_restore_cache(hass, states):
"""Mock the DATA_RESTORE_CACHE.""" """Mock the DATA_RESTORE_CACHE."""
key = restore_state.DATA_RESTORE_STATE_TASK key = restore_state.DATA_RESTORE_STATE_TASK
data = restore_state.RestoreStateData(hass) data = restore_state.RestoreStateData(hass)
now = date_util.utcnow()
data.last_states = { data.last_states = {
state.entity_id: state for state in states} state.entity_id: restore_state.StoredState(state, now)
for state in states}
_LOGGER.debug('Restore cache: %s', data.last_states) _LOGGER.debug('Restore cache: %s', data.last_states)
assert len(data.last_states) == len(states), \ assert len(data.last_states) == len(states), \
"Duplicate entity_id? {}".format(states) "Duplicate entity_id? {}".format(states)

View File

@ -6,7 +6,7 @@ from homeassistant.core import CoreState, State
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.restore_state import ( from homeassistant.helpers.restore_state import (
RestoreStateData, RestoreEntity, DATA_RESTORE_STATE_TASK) RestoreStateData, RestoreEntity, StoredState, DATA_RESTORE_STATE_TASK)
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from asynctest import patch from asynctest import patch
@ -16,14 +16,15 @@ from tests.common import mock_coro
async def test_caching_data(hass): async def test_caching_data(hass):
"""Test that we cache data.""" """Test that we cache data."""
states = [ now = dt_util.utcnow()
State('input_boolean.b0', 'on'), stored_states = [
State('input_boolean.b1', 'on'), StoredState(State('input_boolean.b0', 'on'), now),
State('input_boolean.b2', 'on'), StoredState(State('input_boolean.b1', 'on'), now),
StoredState(State('input_boolean.b2', 'on'), now),
] ]
data = await RestoreStateData.async_get_instance(hass) data = await RestoreStateData.async_get_instance(hass)
await data.store.async_save([state.as_dict() for state in states]) await data.store.async_save([state.as_dict() for state in stored_states])
# Emulate a fresh load # Emulate a fresh load
hass.data[DATA_RESTORE_STATE_TASK] = None hass.data[DATA_RESTORE_STATE_TASK] = None
@ -48,14 +49,15 @@ async def test_hass_starting(hass):
"""Test that we cache data.""" """Test that we cache data."""
hass.state = CoreState.starting hass.state = CoreState.starting
states = [ now = dt_util.utcnow()
State('input_boolean.b0', 'on'), stored_states = [
State('input_boolean.b1', 'on'), StoredState(State('input_boolean.b0', 'on'), now),
State('input_boolean.b2', 'on'), StoredState(State('input_boolean.b1', 'on'), now),
StoredState(State('input_boolean.b2', 'on'), now),
] ]
data = await RestoreStateData.async_get_instance(hass) data = await RestoreStateData.async_get_instance(hass)
await data.store.async_save([state.as_dict() for state in states]) await data.store.async_save([state.as_dict() for state in stored_states])
# Emulate a fresh load # Emulate a fresh load
hass.data[DATA_RESTORE_STATE_TASK] = None hass.data[DATA_RESTORE_STATE_TASK] = None
@ -109,14 +111,15 @@ async def test_dump_data(hass):
await entity.async_added_to_hass() await entity.async_added_to_hass()
data = await RestoreStateData.async_get_instance(hass) data = await RestoreStateData.async_get_instance(hass)
now = dt_util.utcnow()
data.last_states = { data.last_states = {
'input_boolean.b0': State('input_boolean.b0', 'off'), 'input_boolean.b0': StoredState(State('input_boolean.b0', 'off'), now),
'input_boolean.b1': State('input_boolean.b1', 'off'), 'input_boolean.b1': StoredState(State('input_boolean.b1', 'off'), now),
'input_boolean.b2': State('input_boolean.b2', 'off'), 'input_boolean.b2': StoredState(State('input_boolean.b2', 'off'), now),
'input_boolean.b3': State('input_boolean.b3', 'off'), 'input_boolean.b3': StoredState(State('input_boolean.b3', 'off'), now),
'input_boolean.b4': State( 'input_boolean.b4': StoredState(
'input_boolean.b4', 'off', last_updated=datetime( State('input_boolean.b4', 'off'),
1985, 10, 26, 1, 22, tzinfo=dt_util.UTC)), datetime(1985, 10, 26, 1, 22, tzinfo=dt_util.UTC)),
} }
with patch('homeassistant.helpers.restore_state.Store.async_save' with patch('homeassistant.helpers.restore_state.Store.async_save'
@ -134,10 +137,10 @@ async def test_dump_data(hass):
# b3 should be written, since it is still not expired # b3 should be written, since it is still not expired
# b4 should not be written, since it is now expired # b4 should not be written, since it is now expired
assert len(written_states) == 2 assert len(written_states) == 2
assert written_states[0]['entity_id'] == 'input_boolean.b1' assert written_states[0]['state']['entity_id'] == 'input_boolean.b1'
assert written_states[0]['state'] == 'on' assert written_states[0]['state']['state'] == 'on'
assert written_states[1]['entity_id'] == 'input_boolean.b3' assert written_states[1]['state']['entity_id'] == 'input_boolean.b3'
assert written_states[1]['state'] == 'off' assert written_states[1]['state']['state'] == 'off'
# Test that removed entities are not persisted # Test that removed entities are not persisted
await entity.async_will_remove_from_hass() await entity.async_will_remove_from_hass()
@ -151,8 +154,8 @@ async def test_dump_data(hass):
args = mock_write_data.mock_calls[0][1] args = mock_write_data.mock_calls[0][1]
written_states = args[0] written_states = args[0]
assert len(written_states) == 1 assert len(written_states) == 1
assert written_states[0]['entity_id'] == 'input_boolean.b3' assert written_states[0]['state']['entity_id'] == 'input_boolean.b3'
assert written_states[0]['state'] == 'off' assert written_states[0]['state']['state'] == 'off'
async def test_dump_error(hass): async def test_dump_error(hass):