Home Assistant Unofficial Reference 2024.12.1
restore_state.py
Go to the documentation of this file.
1 """Support for restoring entity states on startup."""
2 
3 from __future__ import annotations
4 
5 from abc import ABC, abstractmethod
6 from datetime import datetime, timedelta
7 import logging
8 from typing import Any, Self, cast
9 
10 from homeassistant.const import ATTR_RESTORED, EVENT_HOMEASSISTANT_STOP
11 from homeassistant.core import HomeAssistant, State, callback, valid_entity_id
12 from homeassistant.exceptions import HomeAssistantError
13 import homeassistant.util.dt as dt_util
14 from homeassistant.util.hass_dict import HassKey
15 from homeassistant.util.json import json_loads
16 
17 from . import start
18 from .entity import Entity
19 from .event import async_track_time_interval
20 from .json import JSONEncoder
21 from .singleton import singleton
22 from .storage import Store
23 
24 DATA_RESTORE_STATE: HassKey[RestoreStateData] = HassKey("restore_state")
25 
26 _LOGGER = logging.getLogger(__name__)
27 
28 STORAGE_KEY = "core.restore_state"
29 STORAGE_VERSION = 1
30 
31 # How long between periodically saving the current states to disk
32 STATE_DUMP_INTERVAL = timedelta(minutes=15)
33 
34 # How long should a saved state be preserved if the entity no longer exists
35 STATE_EXPIRATION = timedelta(days=7)
36 
37 
38 class ExtraStoredData(ABC):
39  """Object to hold extra stored data."""
40 
41  @abstractmethod
42  def as_dict(self) -> dict[str, Any]:
43  """Return a dict representation of the extra data.
44 
45  Must be serializable by Home Assistant's JSONEncoder.
46  """
47 
48 
49 class RestoredExtraData(ExtraStoredData):
50  """Object to hold extra stored data loaded from storage."""
51 
52  def __init__(self, json_dict: dict[str, Any]) -> None:
53  """Object to hold extra stored data."""
54  self.json_dictjson_dict = json_dict
55 
56  def as_dict(self) -> dict[str, Any]:
57  """Return a dict representation of the extra data."""
58  return self.json_dictjson_dict
59 
60 
62  """Object to represent a stored state."""
63 
64  def __init__(
65  self,
66  state: State,
67  extra_data: ExtraStoredData | None,
68  last_seen: datetime,
69  ) -> None:
70  """Initialize a new stored state."""
71  self.extra_dataextra_data = extra_data
72  self.last_seenlast_seen = last_seen
73  self.statestate = state
74 
75  def as_dict(self) -> dict[str, Any]:
76  """Return a dict representation of the stored state to be JSON serialized."""
77  return {
78  "state": self.statestate.json_fragment,
79  "extra_data": self.extra_dataextra_data.as_dict() if self.extra_dataextra_data else None,
80  "last_seen": self.last_seenlast_seen,
81  }
82 
83  @classmethod
84  def from_dict(cls, json_dict: dict) -> Self:
85  """Initialize a stored state from a dict."""
86  extra_data_dict = json_dict.get("extra_data")
87  extra_data = RestoredExtraData(extra_data_dict) if extra_data_dict else None
88  last_seen = json_dict["last_seen"]
89 
90  if isinstance(last_seen, str):
91  last_seen = dt_util.parse_datetime(last_seen)
92 
93  return cls(
94  cast(State, State.from_dict(json_dict["state"])), extra_data, last_seen
95  )
96 
97 
98 async def async_load(hass: HomeAssistant) -> None:
99  """Load the restore state task."""
100  await async_get(hass).async_setup()
101 
102 
103 @callback
104 @singleton(DATA_RESTORE_STATE)
105 def async_get(hass: HomeAssistant) -> RestoreStateData:
106  """Get the restore state data helper."""
107  return RestoreStateData(hass)
108 
109 
111  """Helper class for managing the helper saved data."""
112 
113  @classmethod
114  async def async_save_persistent_states(cls, hass: HomeAssistant) -> None:
115  """Dump states now."""
116  await async_get(hass).async_dump_states()
117 
118  def __init__(self, hass: HomeAssistant) -> None:
119  """Initialize the restore state data class."""
120  self.hass: HomeAssistant = hass
121  self.storestore = Store[list[dict[str, Any]]](
122  hass, STORAGE_VERSION, STORAGE_KEY, encoder=JSONEncoder
123  )
124  self.last_stateslast_states: dict[str, StoredState] = {}
125  self.entities: dict[str, RestoreEntity] = {}
126 
127  async def async_setup(self) -> None:
128  """Set up up the instance of this data helper."""
129  await self.async_loadasync_load()
130 
131  @callback
132  def hass_start(hass: HomeAssistant) -> None:
133  """Start the restore state task."""
134  self.async_setup_dumpasync_setup_dump()
135 
136  start.async_at_start(self.hass, hass_start)
137 
138  async def async_load(self) -> None:
139  """Load the instance of this data helper."""
140  try:
141  stored_states = await self.storestore.async_load()
142  except HomeAssistantError as exc:
143  _LOGGER.error("Error loading last states", exc_info=exc)
144  stored_states = None
145 
146  if stored_states is None:
147  _LOGGER.debug("Not creating cache - no saved states found")
148  self.last_stateslast_states = {}
149  else:
150  self.last_stateslast_states = {
151  item["state"]["entity_id"]: StoredState.from_dict(item)
152  for item in stored_states
153  if valid_entity_id(item["state"]["entity_id"])
154  }
155  _LOGGER.debug("Created cache with %s", list(self.last_stateslast_states))
156 
157  @callback
158  def async_get_stored_states(self) -> list[StoredState]:
159  """Get the set of states which should be stored.
160 
161  This includes the states of all registered entities, as well as the
162  stored states from the previous run, which have not been created as
163  entities on this run, and have not expired.
164  """
165  now = dt_util.utcnow()
166  all_states = self.hass.states.async_all()
167  # Entities currently backed by an entity object
168  current_states_by_entity_id = {
169  state.entity_id: state
170  for state in all_states
171  if not state.attributes.get(ATTR_RESTORED)
172  }
173 
174  # Start with the currently registered states
175  stored_states = [
176  StoredState(
177  current_states_by_entity_id[entity_id],
178  entity.extra_restore_state_data,
179  now,
180  )
181  for entity_id, entity in self.entities.items()
182  if entity_id in current_states_by_entity_id
183  ]
184  expiration_time = now - STATE_EXPIRATION
185 
186  for entity_id, stored_state in self.last_stateslast_states.items():
187  # Don't save old states that have entities in the current run
188  # They are either registered and already part of stored_states,
189  # or no longer care about restoring.
190  if entity_id in current_states_by_entity_id:
191  continue
192 
193  # Don't save old states that have expired
194  if stored_state.last_seen < expiration_time:
195  continue
196 
197  stored_states.append(stored_state)
198 
199  return stored_states
200 
201  async def async_dump_states(self) -> None:
202  """Save the current state machine to storage."""
203  _LOGGER.debug("Dumping states")
204  try:
205  await self.storestore.async_save(
206  [
207  stored_state.as_dict()
208  for stored_state in self.async_get_stored_statesasync_get_stored_states()
209  ]
210  )
211  except HomeAssistantError as exc:
212  _LOGGER.error("Error saving current states", exc_info=exc)
213 
214  @callback
215  def async_setup_dump(self, *args: Any) -> None:
216  """Set up the restore state listeners."""
217 
218  async def _async_dump_states(*_: Any) -> None:
219  await self.async_dump_statesasync_dump_states()
220 
221  # Dump the initial states now. This helps minimize the risk of having
222  # old states loaded by overwriting the last states once Home Assistant
223  # has started and the old states have been read.
224  self.hass.async_create_task_internal(
225  _async_dump_states(), "RestoreStateData dump"
226  )
227 
228  # Dump states periodically
229  cancel_interval = async_track_time_interval(
230  self.hass,
231  _async_dump_states,
232  STATE_DUMP_INTERVAL,
233  name="RestoreStateData dump states",
234  )
235 
236  async def _async_dump_states_at_stop(*_: Any) -> None:
237  cancel_interval()
238  await self.async_dump_statesasync_dump_states()
239 
240  # Dump states when stopping hass
241  self.hass.bus.async_listen_once(
242  EVENT_HOMEASSISTANT_STOP, _async_dump_states_at_stop
243  )
244 
245  @callback
246  def async_restore_entity_added(self, entity: RestoreEntity) -> None:
247  """Store this entity's state when hass is shutdown."""
248  self.entities[entity.entity_id] = entity
249 
250  @callback
252  self, entity_id: str, extra_data: ExtraStoredData | None
253  ) -> None:
254  """Unregister this entity from saving state."""
255  # When an entity is being removed from hass, store its last state. This
256  # allows us to support state restoration if the entity is removed, then
257  # re-added while hass is still running.
258  state = self.hass.states.get(entity_id)
259  # To fully mimic all the attribute data types when loaded from storage,
260  # we're going to serialize it to JSON and then re-load it.
261  if state is not None:
262  state = State.from_dict(json_loads(state.as_dict_json)) # type: ignore[arg-type]
263  if state is not None:
264  self.last_stateslast_states[entity_id] = StoredState(
265  state, extra_data, dt_util.utcnow()
266  )
267 
268  del self.entities[entity_id]
269 
270 
272  """Mixin class for restoring previous entity state."""
273 
274  async def async_internal_added_to_hass(self) -> None:
275  """Register this entity as a restorable entity."""
276  await super().async_internal_added_to_hass()
277  async_get(self.hasshass).async_restore_entity_added(self)
278 
279  async def async_internal_will_remove_from_hass(self) -> None:
280  """Run when entity will be removed from hass."""
281  async_get(self.hasshass).async_restore_entity_removed(
282  self.entity_identity_id, self.extra_restore_state_dataextra_restore_state_data
283  )
285 
286  @callback
287  def _async_get_restored_data(self) -> StoredState | None:
288  """Get data stored for an entity, if any."""
289  if self.hasshass is None or self.entity_identity_id is None:
290  # Return None if this entity isn't added to hass yet
291  _LOGGER.warning( # type: ignore[unreachable]
292  "Cannot get last state. Entity not added to hass"
293  )
294  return None
295  return async_get(self.hasshass).last_states.get(self.entity_identity_id)
296 
297  async def async_get_last_state(self) -> State | None:
298  """Get the entity state from the previous run."""
299  if (stored_state := self._async_get_restored_data_async_get_restored_data()) is None:
300  return None
301  return stored_state.state
302 
303  async def async_get_last_extra_data(self) -> ExtraStoredData | None:
304  """Get the entity specific state data from the previous run."""
305  if (stored_state := self._async_get_restored_data_async_get_restored_data()) is None:
306  return None
307  return stored_state.extra_data
308 
309  @property
310  def extra_restore_state_data(self) -> ExtraStoredData | None:
311  """Return entity specific state data to be restored.
312 
313  Implemented by platform classes.
314  """
315  return None
ExtraStoredData|None extra_restore_state_data(self)
ExtraStoredData|None async_get_last_extra_data(self)
None async_restore_entity_added(self, RestoreEntity entity)
None async_restore_entity_removed(self, str entity_id, ExtraStoredData|None extra_data)
None async_save_persistent_states(cls, HomeAssistant hass)
None __init__(self, dict[str, Any] json_dict)
None __init__(self, State state, ExtraStoredData|None extra_data, datetime last_seen)
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:86
bool valid_entity_id(str entity_id)
Definition: core.py:235
CALLBACK_TYPE async_track_time_interval(HomeAssistant hass, Callable[[datetime], Coroutine[Any, Any, None]|None] action, timedelta interval, *str|None name=None, bool|None cancel_on_shutdown=None)
Definition: event.py:1679
RestoreStateData async_get(HomeAssistant hass)
None async_load(HomeAssistant hass)
None async_save(self, _T data)
Definition: storage.py:424