From 0e275014a9eb0dd783b47e85ef894cd6c47889ff Mon Sep 17 00:00:00 2001
From: lufton <lufton@gmail.com>
Date: Sat, 18 May 2019 22:24:23 +0300
Subject: [PATCH] Added ToggleEntity save and restore state mechanism

---
 homeassistant/components/fan/__init__.py      | 24 +++++-
 homeassistant/components/fan/services.yaml    | 27 +++++++
 homeassistant/components/light/__init__.py    | 22 +++++-
 homeassistant/components/light/services.yaml  | 27 +++++++
 homeassistant/components/switch/__init__.py   | 23 +++++-
 homeassistant/components/switch/services.yaml | 27 +++++++
 homeassistant/const.py                        |  3 +
 homeassistant/helpers/entity.py               | 75 ++++++++++++++++++-
 8 files changed, 220 insertions(+), 8 deletions(-)

diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py
index 23015769f28..fef362f76d4 100644
--- a/homeassistant/components/fan/__init__.py
+++ b/homeassistant/components/fan/__init__.py
@@ -6,10 +6,13 @@ import logging
 import voluptuous as vol
 
 from homeassistant.components import group
-from homeassistant.const import (SERVICE_TURN_ON, SERVICE_TOGGLE,
-                                 SERVICE_TURN_OFF, ATTR_ENTITY_ID)
+from homeassistant.const import (
+    SERVICE_CANCEL_RESTORE_STATE, SERVICE_RESTORE_STATE, SERVICE_SAVE_STATE,
+    SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, ATTR_ENTITY_ID)
 from homeassistant.loader import bind_hass
-from homeassistant.helpers.entity import ToggleEntity
+from homeassistant.helpers.entity import (
+    ToggleEntity, ENTITY_SAVE_STATE_SCHEMA,
+    ENTITY_RESTORE_STATE_SCHEMA, ENTITY_CANCEL_RESTORE_STATE_SCHEMA)
 from homeassistant.helpers.entity_component import EntityComponent
 from homeassistant.helpers.config_validation import (  # noqa
     PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE)
@@ -123,6 +126,21 @@ async def async_setup(hass, config: dict):
         'async_set_direction'
     )
 
+    component.async_register_entity_service(
+        SERVICE_SAVE_STATE, ENTITY_SAVE_STATE_SCHEMA,
+        'async_save_state'
+    )
+
+    component.async_register_entity_service(
+        SERVICE_RESTORE_STATE, ENTITY_RESTORE_STATE_SCHEMA,
+        'async_restore_state'
+    )
+
+    component.async_register_entity_service(
+        SERVICE_CANCEL_RESTORE_STATE, ENTITY_CANCEL_RESTORE_STATE_SCHEMA,
+        'async_cancel_restore_state'
+    )
+
     return True
 
 
diff --git a/homeassistant/components/fan/services.yaml b/homeassistant/components/fan/services.yaml
index 16d3742d9ab..6d2beea8b35 100644
--- a/homeassistant/components/fan/services.yaml
+++ b/homeassistant/components/fan/services.yaml
@@ -54,6 +54,33 @@ set_direction:
       description: The direction to rotate. Either 'forward' or 'reverse'
       example: 'forward'
 
+save_state:
+  description: Saves a state of the entity.
+  fields:
+    entity_id:
+      description: Name(s) of entities to save state of.
+      example: 'fan.attic'
+    rewrite:
+      description: Should it rewrite already saved state fo the entity (default False).
+      example: True
+
+restore_state:
+  description: Restores a state of the entity.
+  fields:
+    entity_id:
+      description: Name(s) of entities to restore state of.
+      example: 'fan.attic'
+    delay:
+      description: Time period before restore.
+      example: "5, '0:05', {'minutes': 5}"
+
+cancel_restore_state:
+  description: Cancels scheduled state restore of the entity.
+  fields:
+    entity_id:
+      description: Name(s) of entities to cancel restoring state of.
+      example: 'fan.attic'
+
 xiaomi_miio_set_buzzer_on:
   description: Turn the buzzer on.
   fields:
diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py
index f9ce6eb05d4..7c98058972d 100644
--- a/homeassistant/components/light/__init__.py
+++ b/homeassistant/components/light/__init__.py
@@ -11,13 +11,16 @@ from homeassistant.auth.permissions.const import POLICY_CONTROL
 from homeassistant.components.group import \
     ENTITY_ID_FORMAT as GROUP_ENTITY_ID_FORMAT
 from homeassistant.const import (
-    ATTR_ENTITY_ID, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON,
+    ATTR_ENTITY_ID, SERVICE_CANCEL_RESTORE_STATE, SERVICE_RESTORE_STATE,
+    SERVICE_SAVE_STATE, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON,
     STATE_ON)
 from homeassistant.exceptions import UnknownUser, Unauthorized
 import homeassistant.helpers.config_validation as cv
 from homeassistant.helpers.config_validation import (  # noqa
     PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE)
-from homeassistant.helpers.entity import ToggleEntity
+from homeassistant.helpers.entity import (
+    ToggleEntity, ENTITY_SAVE_STATE_SCHEMA, ENTITY_RESTORE_STATE_SCHEMA,
+    ENTITY_CANCEL_RESTORE_STATE_SCHEMA)
 from homeassistant.helpers.entity_component import EntityComponent
 from homeassistant.helpers import intent
 from homeassistant.loader import bind_hass
@@ -320,6 +323,21 @@ async def async_setup(hass, config):
         'async_toggle'
     )
 
+    component.async_register_entity_service(
+        SERVICE_SAVE_STATE, ENTITY_SAVE_STATE_SCHEMA,
+        'async_save_state'
+    )
+
+    component.async_register_entity_service(
+        SERVICE_RESTORE_STATE, ENTITY_RESTORE_STATE_SCHEMA,
+        'async_restore_state'
+    )
+
+    component.async_register_entity_service(
+        SERVICE_CANCEL_RESTORE_STATE, ENTITY_CANCEL_RESTORE_STATE_SCHEMA,
+        'async_cancel_restore_state'
+    )
+
     hass.helpers.intent.async_register(SetIntentHandler())
 
     return True
diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml
index ef944d75efc..c471d1e48cc 100644
--- a/homeassistant/components/light/services.yaml
+++ b/homeassistant/components/light/services.yaml
@@ -71,6 +71,33 @@ toggle:
     '...':
       description: All turn_on parameters can be used.
 
+save_state:
+  description: Saves a state of the entity.
+  fields:
+    entity_id:
+      description: Name(s) of entities to save state of.
+      example: 'light.attic'
+    rewrite:
+      description: Should it rewrite already saved state fo the entity (default False).
+      example: True
+
+restore_state:
+  description: Restores a state of the entity.
+  fields:
+    entity_id:
+      description: Name(s) of entities to restore state of.
+      example: 'light.attic'
+    delay:
+      description: Time period before restore.
+      example: "5, '0:05', {'minutes': 5}"
+
+cancel_restore_state:
+  description: Cancels scheduled state restore of the entity.
+  fields:
+    entity_id:
+      description: Name(s) of entities to cancel restoring state of.
+      example: 'light.attic'
+
 lifx_set_state:
   description: Set a color/brightness and possibliy turn the light on/off.
   fields:
diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py
index e3f756abf53..6791e2052cb 100644
--- a/homeassistant/components/switch/__init__.py
+++ b/homeassistant/components/switch/__init__.py
@@ -6,12 +6,15 @@ import voluptuous as vol
 
 from homeassistant.loader import bind_hass
 from homeassistant.helpers.entity_component import EntityComponent
-from homeassistant.helpers.entity import ToggleEntity
+from homeassistant.helpers.entity import (
+    ToggleEntity, ENTITY_SAVE_STATE_SCHEMA,
+    ENTITY_RESTORE_STATE_SCHEMA, ENTITY_CANCEL_RESTORE_STATE_SCHEMA)
 from homeassistant.helpers.config_validation import (  # noqa
     PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE)
 import homeassistant.helpers.config_validation as cv
 from homeassistant.const import (
-    STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE,
+    STATE_ON, SERVICE_CANCEL_RESTORE_STATE, SERVICE_RESTORE_STATE,
+    SERVICE_SAVE_STATE, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON,
     ATTR_ENTITY_ID)
 from homeassistant.components import group
 
@@ -81,6 +84,22 @@ async def async_setup(hass, config):
         'async_toggle'
     )
 
+    component.async_register_entity_service(
+        SERVICE_SAVE_STATE, ENTITY_SAVE_STATE_SCHEMA,
+        'async_save_state'
+    )
+
+    component.async_register_entity_service(
+        SERVICE_RESTORE_STATE, ENTITY_RESTORE_STATE_SCHEMA,
+        'async_restore_state'
+    )
+
+    component.async_register_entity_service(
+        SERVICE_CANCEL_RESTORE_STATE, ENTITY_CANCEL_RESTORE_STATE_SCHEMA,
+        'async_cancel_restore_state'
+    )
+
+
     return True
 
 
diff --git a/homeassistant/components/switch/services.yaml b/homeassistant/components/switch/services.yaml
index 46b1237f57c..5eed4d3a351 100644
--- a/homeassistant/components/switch/services.yaml
+++ b/homeassistant/components/switch/services.yaml
@@ -21,6 +21,33 @@ toggle:
       description: Name(s) of entities to toggle.
       example: 'switch.living_room'
 
+save_state:
+  description: Saves a state of the entity.
+  fields:
+    entity_id:
+      description: Name(s) of entities to save state of.
+      example: 'switch.attic'
+    rewrite:
+      description: Should it rewrite already saved state fo the entity (default False).
+      example: True
+
+restore_state:
+  description: Restores a state of the entity.
+  fields:
+    entity_id:
+      description: Name(s) of entities to restore state of.
+      example: 'switch.attic'
+    delay:
+      description: Time period before restore.
+      example: "5, '0:05', {'minutes': 5}"
+
+cancel_restore_state:
+  description: Cancels scheduled state restore of the entity.
+  fields:
+    entity_id:
+      description: Name(s) of entities to cancel restoring state of.
+      example: 'switch.attic'
+
 mysensors_send_ir_code:
   description: Set an IR code as a state attribute for a MySensors IR device switch and turn the switch on.
   fields:
diff --git a/homeassistant/const.py b/homeassistant/const.py
index 9176c1b8939..af6ad3887a3 100644
--- a/homeassistant/const.py
+++ b/homeassistant/const.py
@@ -371,6 +371,9 @@ SERVICE_HOMEASSISTANT_RESTART = 'restart'
 SERVICE_TURN_ON = 'turn_on'
 SERVICE_TURN_OFF = 'turn_off'
 SERVICE_TOGGLE = 'toggle'
+SERVICE_SAVE_STATE = 'save_state'
+SERVICE_RESTORE_STATE = 'restore_state'
+SERVICE_CANCEL_RESTORE_STATE = 'cancel_restore_state'
 SERVICE_RELOAD = 'reload'
 
 SERVICE_VOLUME_UP = 'volume_up'
diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py
index d69cdd3d997..a09c9cde0d4 100644
--- a/homeassistant/helpers/entity.py
+++ b/homeassistant/helpers/entity.py
@@ -5,21 +5,45 @@ import functools as ft
 from timeit import default_timer as timer
 from typing import Optional, List, Iterable
 
+import voluptuous as vol
+
 from homeassistant.const import (
     ATTR_ASSUMED_STATE, ATTR_FRIENDLY_NAME, ATTR_HIDDEN, ATTR_ICON,
     ATTR_UNIT_OF_MEASUREMENT, DEVICE_DEFAULT_NAME, STATE_OFF, STATE_ON,
     STATE_UNAVAILABLE, STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT,
-    ATTR_ENTITY_PICTURE, ATTR_SUPPORTED_FEATURES, ATTR_DEVICE_CLASS)
+    ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, ATTR_SUPPORTED_FEATURES,
+    ATTR_DEVICE_CLASS)
 from homeassistant.core import HomeAssistant, callback
 from homeassistant.config import DATA_CUSTOMIZE
 from homeassistant.exceptions import NoEntitySpecifiedError
 from homeassistant.util import ensure_unique_string, slugify
 from homeassistant.util.async_ import run_callback_threadsafe
 from homeassistant.util import dt as dt_util
+import homeassistant.helpers.config_validation as cv
+import homeassistant.helpers.event as evt
 
 _LOGGER = logging.getLogger(__name__)
 SLOW_UPDATE_WARNING = 10
 
+ATTR_REWRITE = "rewrite"
+ATTR_DELAY = "delay"
+
+ENTITY_SAVE_STATE_SCHEMA = vol.Schema({
+    ATTR_ENTITY_ID: cv.comp_entity_ids,
+    ATTR_REWRITE: cv.boolean,
+})
+
+ENTITY_RESTORE_STATE_SCHEMA = vol.Schema({
+    ATTR_ENTITY_ID: cv.comp_entity_ids,
+    ATTR_DELAY: vol.All(cv.time_period, cv.positive_timedelta),
+})
+
+ENTITY_CANCEL_RESTORE_STATE_SCHEMA = vol.Schema({
+    ATTR_ENTITY_ID: cv.comp_entity_ids,
+})
+
+SAVED_STATE_ID_FORMAT = "saved_{}"
+
 
 def generate_entity_id(entity_id_format: str, name: Optional[str],
                        current_ids: Optional[List[str]] = None,
@@ -448,6 +472,8 @@ class Entity:
 class ToggleEntity(Entity):
     """An abstract class for entities that can be turned on and off."""
 
+    _restore_state_listener = None
+
     @property
     def state(self) -> str:
         """Return the state."""
@@ -458,6 +484,11 @@ class ToggleEntity(Entity):
         """Return True if entity is on."""
         raise NotImplementedError()
 
+    @property
+    def saved_state_id(self):
+        """Return the id of state to save"""
+        return SAVED_STATE_ID_FORMAT.format(self.entity_id)
+
     def turn_on(self, **kwargs) -> None:
         """Turn the entity on."""
         raise NotImplementedError()
@@ -497,3 +528,45 @@ class ToggleEntity(Entity):
         if self.is_on:
             return self.async_turn_off(**kwargs)
         return self.async_turn_on(**kwargs)
+
+    async def async_cancel_restore_state(self):
+        """Cancel scheduled entity state restore."""
+        if self._restore_state_listener:
+            self._restore_state_listener()
+        self._restore_state_listener = None
+
+    async def async_save_state(self, rewrite=False):
+        """Save entity state."""
+        if rewrite:
+            await self.async_cancel_restore_state()
+
+        if rewrite or not self.hass.states.get(self.saved_state_id):
+            self.hass.states.async_set(
+                self.saved_state_id,
+                self.state,
+                self.hass.states.get(self.entity_id).attributes
+            )
+
+    @callback
+    async def async_restore_state_listener(self, *args):
+        """Restore entity state after a delay."""
+        await self.async_cancel_restore_state()
+        saved_state = self.hass.states.get(self.saved_state_id)
+        if saved_state:
+            if saved_state.state == STATE_ON:
+                await self.async_turn_on(**saved_state.attributes)
+            else:
+                await self.async_turn_off()
+        self.hass.states.async_remove(self.saved_state_id)
+
+    async def async_restore_state(self, delay=None):
+        """Restore previously saved entity state."""
+        if delay:
+            await self.async_cancel_restore_state()
+            self._restore_state_listener = evt.async_call_later(
+                self.hass,
+                delay.total_seconds(),
+                self.async_restore_state_listener()
+            )
+        else:
+            await self.async_restore_state_listener()