From 1865eca441384811775ddc8f6039dc08711b453e Mon Sep 17 00:00:00 2001 From: SwazRGB <65694696+swazrgb@users.noreply.github.com> Date: Sat, 17 Oct 2020 09:23:21 +0200 Subject: [PATCH 1/8] Persist pyscript.* states between home assistant restarts With this change any states set by pyscript in the pyscript domain will be persisted when home assistant next reboots. For example when a script runs `pyscript.abc = 'foo'` then this value will be persisted. Fixes #47 --- custom_components/pyscript/__init__.py | 11 +++++++++++ custom_components/pyscript/eval.py | 2 +- custom_components/pyscript/state.py | 10 +++++++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/custom_components/pyscript/__init__.py b/custom_components/pyscript/__init__.py index 43c88d4..b7f5b19 100644 --- a/custom_components/pyscript/__init__.py +++ b/custom_components/pyscript/__init__.py @@ -17,6 +17,7 @@ ) from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.restore_state import RestoreStateData from homeassistant.loader import bind_hass from .const import CONF_ALLOW_ALL_IMPORTS, DOMAIN, FOLDER, LOGGER_PATH, SERVICE_JUPYTER_KERNEL_START @@ -39,6 +40,7 @@ async def async_setup(hass, config): """Component setup, run import config flow for each entry in config.""" + await restore_state(hass) if DOMAIN in config: hass.async_create_task( hass.config_entries.flow.async_init( @@ -49,6 +51,15 @@ async def async_setup(hass, config): return True +async def restore_state(hass): + """Restores the persisted pyscript state.""" + restore_data = await RestoreStateData.async_get_instance(hass) + for entity_id, value in restore_data.last_states.items(): + if entity_id.startswith("pyscript."): + last_state = value.state + hass.states.async_set(entity_id, last_state.state, last_state.attributes) + + async def async_setup_entry(hass, config_entry): """Initialize the pyscript config entry.""" Function.init(hass) diff --git a/custom_components/pyscript/eval.py b/custom_components/pyscript/eval.py index 8d67727..e0c4127 100644 --- a/custom_components/pyscript/eval.py +++ b/custom_components/pyscript/eval.py @@ -1168,7 +1168,7 @@ async def recurse_assign(self, lhs, val): if not isinstance(var_name, str): raise NotImplementedError(f"unknown lhs type {lhs} (got {var_name}) in assign") if var_name.find(".") >= 0: - State.set(var_name, val) + await State.set(var_name, val) return if self.curr_func and var_name in self.curr_func.global_names: self.global_sym_table[var_name] = val diff --git a/custom_components/pyscript/state.py b/custom_components/pyscript/state.py index be378bc..795a883 100644 --- a/custom_components/pyscript/state.py +++ b/custom_components/pyscript/state.py @@ -2,6 +2,8 @@ import logging +from homeassistant.helpers.restore_state import RestoreStateData + from .const import LOGGER_PATH from .function import Function @@ -98,7 +100,7 @@ def notify_var_get(cls, var_names, new_vars): return notify_vars @classmethod - def set(cls, var_name, value, new_attributes=None, **kwargs): + async def set(cls, var_name, value, new_attributes=None, **kwargs): """Set a state variable and optional attributes in hass.""" if var_name.count(".") != 1: raise NameError(f"invalid name {var_name} (should be 'domain.entity')") @@ -113,6 +115,12 @@ def set(cls, var_name, value, new_attributes=None, **kwargs): new_attributes.update(kwargs) _LOGGER.debug("setting %s = %s, attr = %s", var_name, value, new_attributes) cls.notify_var_last[var_name] = str(value) + + if var_name.startswith("pyscript."): + # have this var tracked for restore + restore_data = await RestoreStateData.async_get_instance(cls.hass) + restore_data.async_restore_entity_added(var_name) + cls.hass.states.async_set(var_name, value, new_attributes) @classmethod From fdfd29171cb22ca282df3b2c2fb3ec09210a4a0f Mon Sep 17 00:00:00 2001 From: SwazRGB <65694696+swazrgb@users.noreply.github.com> Date: Sun, 18 Oct 2020 02:39:18 +0200 Subject: [PATCH 2/8] Support persisting on read & explicit persisting Automatically persist pyscript.* state variables that are read from pyscript, either directly or using a state_trigger. Also provide a state.persist method to pyscript to explicitly register variables to be persisted. --- custom_components/pyscript/eval.py | 2 +- custom_components/pyscript/state.py | 35 ++++++++++++++++++++++----- custom_components/pyscript/trigger.py | 4 +-- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/custom_components/pyscript/eval.py b/custom_components/pyscript/eval.py index e0c4127..4906174 100644 --- a/custom_components/pyscript/eval.py +++ b/custom_components/pyscript/eval.py @@ -1307,7 +1307,7 @@ async def ast_name(self, arg): # a two-dot name for state.attr needs to exist # if num_dots == 1 or (num_dots == 2 and State.exist(arg.id)): - return State.get(arg.id) + return await State.get(arg.id) # # Couldn't find it, so return just the name wrapped in EvalName to # distinguish from a string variable value. This is to support diff --git a/custom_components/pyscript/state.py b/custom_components/pyscript/state.py index 795a883..6357197 100644 --- a/custom_components/pyscript/state.py +++ b/custom_components/pyscript/state.py @@ -36,6 +36,11 @@ class State: # pyscript_config = {} + # + # pyscript vars which have already been registered as persisted + # + persisted_vars = set() + def __init__(self): """Warn on State instantiation.""" _LOGGER.error("State class is not meant to be instantiated") @@ -46,7 +51,7 @@ def init(cls, hass): cls.hass = hass @classmethod - def notify_add(cls, var_names, queue): + async def notify_add(cls, var_names, queue): """Register to notify state variables changes to be sent to queue.""" for var_name in var_names if isinstance(var_names, set) else {var_names}: @@ -57,6 +62,7 @@ def notify_add(cls, var_names, queue): if state_var_name not in cls.notify: cls.notify[state_var_name] = {} cls.notify[state_var_name][queue] = var_names + await cls.register_persist(state_var_name) @classmethod def notify_del(cls, var_names, queue): @@ -116,12 +122,26 @@ async def set(cls, var_name, value, new_attributes=None, **kwargs): _LOGGER.debug("setting %s = %s, attr = %s", var_name, value, new_attributes) cls.notify_var_last[var_name] = str(value) - if var_name.startswith("pyscript."): - # have this var tracked for restore + await cls.register_persist(var_name) + cls.hass.states.async_set(var_name, value, new_attributes) + + @classmethod + async def register_persist(cls, var_name): + """Persists a pyscript state variable using RestoreState.""" + if var_name.startswith("pyscript.") and var_name not in cls.persisted_vars: restore_data = await RestoreStateData.async_get_instance(cls.hass) restore_data.async_restore_entity_added(var_name) + cls.persisted_vars.add(var_name) - cls.hass.states.async_set(var_name, value, new_attributes) + @classmethod + async def persist(cls, var_name, default_value=None): + """Ensures a pyscript domain state variable is persisted.""" + if var_name.count(".") != 1 or not var_name.startswith("pyscript."): + raise NameError(f"invalid name {var_name} (should be 'pyscript.entity')") + + await cls.register_persist(var_name) + if default_value is not None and not cls.exist(var_name): + await cls.set(var_name, default_value) @classmethod def exist(cls, var_name): @@ -133,12 +153,13 @@ def exist(cls, var_name): return value and (len(parts) == 2 or parts[2] in value.attributes) @classmethod - def get(cls, var_name): + async def get(cls, var_name): """Get a state variable value or attribute from hass.""" parts = var_name.split(".") if len(parts) != 2 and len(parts) != 3: raise NameError(f"invalid name '{var_name}' (should be 'domain.entity')") value = cls.hass.states.get(f"{parts[0]}.{parts[1]}") + await cls.register_persist(var_name) if not value: raise NameError(f"name '{parts[0]}.{parts[1]}' is not defined") if len(parts) == 2: @@ -148,11 +169,12 @@ def get(cls, var_name): return value.attributes.get(parts[2]) @classmethod - def get_attr(cls, var_name): + async def get_attr(cls, var_name): """Return a dict of attributes for a state variable.""" if var_name.count(".") != 1: raise NameError(f"invalid name {var_name} (should be 'domain.entity')") value = cls.hass.states.get(var_name) + await cls.register_persist(var_name) if not value: return None return value.attributes.copy() @@ -196,6 +218,7 @@ def register_functions(cls): "state.set": cls.set, "state.names": cls.names, "state.get_attr": cls.get_attr, + "state.persist": cls.persist, "pyscript.config": cls.pyscript_config, } Function.register(functions) diff --git a/custom_components/pyscript/trigger.py b/custom_components/pyscript/trigger.py index 2cb0ce3..3a74b11 100644 --- a/custom_components/pyscript/trigger.py +++ b/custom_components/pyscript/trigger.py @@ -159,7 +159,7 @@ async def wait_until( "trigger %s wait_until: watching vars %s", ast_ctx.name, state_trig_ident, ) if len(state_trig_ident) > 0: - State.notify_add(state_trig_ident, notify_q) + await State.notify_add(state_trig_ident, notify_q) if event_trigger is not None: if isinstance(event_trigger, str): event_trigger = [event_trigger] @@ -595,7 +595,7 @@ async def trigger_watch(self): self.state_trig_ident.update(self.state_trig_ident_any) _LOGGER.debug("trigger %s: watching vars %s", self.name, self.state_trig_ident) if len(self.state_trig_ident) > 0: - State.notify_add(self.state_trig_ident, self.notify_q) + await State.notify_add(self.state_trig_ident, self.notify_q) if self.active_expr: self.state_active_ident = await self.active_expr.get_names() From ed391685d1c6c073c459027a93deec3f1ff7bc0e Mon Sep 17 00:00:00 2001 From: SwazRGB <65694696+swazrgb@users.noreply.github.com> Date: Sun, 18 Oct 2020 04:49:48 +0200 Subject: [PATCH 3/8] Support persisting all state variables with prefix --- custom_components/pyscript/state.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/custom_components/pyscript/state.py b/custom_components/pyscript/state.py index 6357197..a797cf4 100644 --- a/custom_components/pyscript/state.py +++ b/custom_components/pyscript/state.py @@ -143,6 +143,16 @@ async def persist(cls, var_name, default_value=None): if default_value is not None and not cls.exist(var_name): await cls.set(var_name, default_value) + @classmethod + async def persist_prefix(cls, prefix): + """Ensures all pyscript domain state variable with prefix are persisted.""" + if prefix.count(".") != 1 or not prefix.startswith("pyscript."): + raise NameError(f"invalid prefix {prefix} (should be 'pyscript.entity')") + + for name in await cls.names("pyscript"): + if name.startswith(prefix): + await cls.register_persist(name) + @classmethod def exist(cls, var_name): """Check if a state variable value or attribute exists in hass.""" @@ -219,6 +229,7 @@ def register_functions(cls): "state.names": cls.names, "state.get_attr": cls.get_attr, "state.persist": cls.persist, + "state.persist_prefix": cls.persist_prefix, "pyscript.config": cls.pyscript_config, } Function.register(functions) From 3fbf477f4c0e9944b10a67bb8a30034fe630546c Mon Sep 17 00:00:00 2001 From: SwazRGB <65694696+swazrgb@users.noreply.github.com> Date: Sun, 18 Oct 2020 06:30:28 +0200 Subject: [PATCH 4/8] Support supplying default attributes for persisted states --- custom_components/pyscript/state.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/custom_components/pyscript/state.py b/custom_components/pyscript/state.py index a797cf4..5c34b3f 100644 --- a/custom_components/pyscript/state.py +++ b/custom_components/pyscript/state.py @@ -134,14 +134,21 @@ async def register_persist(cls, var_name): cls.persisted_vars.add(var_name) @classmethod - async def persist(cls, var_name, default_value=None): + async def persist(cls, var_name, default_value=None, default_attributes=None): """Ensures a pyscript domain state variable is persisted.""" if var_name.count(".") != 1 or not var_name.startswith("pyscript."): raise NameError(f"invalid name {var_name} (should be 'pyscript.entity')") await cls.register_persist(var_name) - if default_value is not None and not cls.exist(var_name): - await cls.set(var_name, default_value) + exists = cls.exist(var_name) + + if not exists and default_value is not None: + await cls.set(var_name, default_value, default_attributes) + elif exists and default_attributes is not None: + # Patch the attributes with new values if necessary + current = cls.hass.states.get(var_name) + new_attributes = {k: v for (k, v) in default_attributes.items() if k not in current.attributes} + await cls.set(var_name, current.state, **new_attributes) @classmethod async def persist_prefix(cls, prefix): From aa5fac1b610d5339deb76412299638f3315717e3 Mon Sep 17 00:00:00 2001 From: Daniel Lashua Date: Sun, 18 Oct 2020 16:43:57 -0500 Subject: [PATCH 5/8] add documentation --- docs/reference.rst | 102 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/docs/reference.rst b/docs/reference.rst index ccee75c..168bc8c 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -548,6 +548,17 @@ Note that in HASS, all state variable values are coerced into strings. For examp variable has a numeric value, you might want to convert it to a numeric type (eg, using ``int()`` or ``float()``). Attributes keep their native type. +Persistent State +^^^^^^^^^^^^^^^^ + +Two methods are provided to explicitly indicate that a particular entity_id should be persisted. + +``state.persist(entity_id, default_value=None)`` + Indicates that the entity named in `entity_id` should be persisted. Optionally, a default value can be provided. +``state.persist_prefix(entity_prefix)`` + Indicates that all entity_ids starting with the provided prefix should be persisted. + + Service Calls ^^^^^^^^^^^^^ @@ -1136,3 +1147,94 @@ is optional in pyscript): async with session.get(url) as resp: print(resp.status) print(resp.text()) + +Persistent State +^^^^^^^^^^^^^^^^ + +Pyscript has the ability to persist state in the `pyscript.` domain. This means that setting an entity like `pyscript.test` will cause it to be restored to its previous state when Home Assistant is restarted. + +This can be done in any of the usual ways to set the state of an `entity_id`: + +.. code:: python + + set.state('pyscript.test', 'on') + + pyscript.test = 'on' + +Attributes can be included: + +.. code:: python + + set.state('pyscript.test', 'on', friendly_name="Test", device_class="motion") + + pyscript.test = 'on' + pyscript.test.friendly_name = 'Test' + pyscript.test.device_class = 'motion' + +In order to ensure that the state of a particular entity persists, you need to be certain that your Pyscript code "accesses" it in some way _every_ time Home Assistant is restarted. The following methods are considered to be accessing the persistent state. + +1) Use it in a `@state_trigger`: + +.. code:: python + + @state_trigger('pyscript.test == "on"') + def my_function(): + log.info('pyscript.test was set to "on"') + + +2) Set a state + +.. code:: python + + pyscript.test = "on" + + state.set('pyscript.test', 'on') + + +3) Read a state + +.. code:: python + + if pyscript.test == "on": + log.info('pyscript.test is "on"') + + test_value = state.get('pyscript.test') + log.info(f'The test value is {test_value}') + +4) Use the explicit persistence methods + +.. code:: python + + state.persist('pyscript.test', default_value="on") + + state.persist_prefix('pyscript.test_') + +Be aware that there are cases where it may seem the entity will be persisted, but, in actuality it will not. Consider the following example: + +.. code:: python + + @state_trigger('binary_sensor.motion == "on"') + def turn_on_lights(): + light.turn_on('light.overhead') + pyscript.last_light_on = "light.overhead" + +In this example, while the state is being set with `pyscript.last_light_on = "light.overhead"`, there is no guarantee that this particular `@state_trigger` will fire. If it doesn't fire, then the set action is never taken. If the set action doesn't happen before Home Assistant is restarted, then this entity will no longer persist. + +In order to ensure persistence in these cases, it is advised to use the explict syntax. + +.. code:: python + + state.persist('pyscript.last_light_on') + + @state_trigger('binary_sensor.motion == "on"') + def turn_on_lights(): + light.turn_on('light.overhead') + pyscript.last_light_on = "light.overhead" + +With this in place, `state.persist()` will be called every time this script is parsed, ensuring this particular state will persist. + + + + + + \ No newline at end of file From de9379ff44d912159d40b8e9035fa85b4f79e54e Mon Sep 17 00:00:00 2001 From: Daniel Lashua Date: Wed, 21 Oct 2020 13:14:20 -0500 Subject: [PATCH 6/8] remove all persistence except state.persist --- custom_components/pyscript/state.py | 14 -------- docs/reference.rst | 55 ++--------------------------- 2 files changed, 3 insertions(+), 66 deletions(-) diff --git a/custom_components/pyscript/state.py b/custom_components/pyscript/state.py index 5c34b3f..0bc878d 100644 --- a/custom_components/pyscript/state.py +++ b/custom_components/pyscript/state.py @@ -62,7 +62,6 @@ async def notify_add(cls, var_names, queue): if state_var_name not in cls.notify: cls.notify[state_var_name] = {} cls.notify[state_var_name][queue] = var_names - await cls.register_persist(state_var_name) @classmethod def notify_del(cls, var_names, queue): @@ -122,7 +121,6 @@ async def set(cls, var_name, value, new_attributes=None, **kwargs): _LOGGER.debug("setting %s = %s, attr = %s", var_name, value, new_attributes) cls.notify_var_last[var_name] = str(value) - await cls.register_persist(var_name) cls.hass.states.async_set(var_name, value, new_attributes) @classmethod @@ -150,16 +148,6 @@ async def persist(cls, var_name, default_value=None, default_attributes=None): new_attributes = {k: v for (k, v) in default_attributes.items() if k not in current.attributes} await cls.set(var_name, current.state, **new_attributes) - @classmethod - async def persist_prefix(cls, prefix): - """Ensures all pyscript domain state variable with prefix are persisted.""" - if prefix.count(".") != 1 or not prefix.startswith("pyscript."): - raise NameError(f"invalid prefix {prefix} (should be 'pyscript.entity')") - - for name in await cls.names("pyscript"): - if name.startswith(prefix): - await cls.register_persist(name) - @classmethod def exist(cls, var_name): """Check if a state variable value or attribute exists in hass.""" @@ -176,7 +164,6 @@ async def get(cls, var_name): if len(parts) != 2 and len(parts) != 3: raise NameError(f"invalid name '{var_name}' (should be 'domain.entity')") value = cls.hass.states.get(f"{parts[0]}.{parts[1]}") - await cls.register_persist(var_name) if not value: raise NameError(f"name '{parts[0]}.{parts[1]}' is not defined") if len(parts) == 2: @@ -191,7 +178,6 @@ async def get_attr(cls, var_name): if var_name.count(".") != 1: raise NameError(f"invalid name {var_name} (should be 'domain.entity')") value = cls.hass.states.get(var_name) - await cls.register_persist(var_name) if not value: return None return value.attributes.copy() diff --git a/docs/reference.rst b/docs/reference.rst index 168bc8c..bbac9c1 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -551,12 +551,11 @@ variable has a numeric value, you might want to convert it to a numeric type (eg Persistent State ^^^^^^^^^^^^^^^^ -Two methods are provided to explicitly indicate that a particular entity_id should be persisted. +This method is provided to indicate that a particular entity_id should be persisted. This is only effective for entitys in the `pyscript` domain. ``state.persist(entity_id, default_value=None)`` Indicates that the entity named in `entity_id` should be persisted. Optionally, a default value can be provided. -``state.persist_prefix(entity_prefix)`` - Indicates that all entity_ids starting with the provided prefix should be persisted. + Service Calls @@ -1171,56 +1170,8 @@ Attributes can be included: pyscript.test.friendly_name = 'Test' pyscript.test.device_class = 'motion' -In order to ensure that the state of a particular entity persists, you need to be certain that your Pyscript code "accesses" it in some way _every_ time Home Assistant is restarted. The following methods are considered to be accessing the persistent state. - -1) Use it in a `@state_trigger`: - -.. code:: python - - @state_trigger('pyscript.test == "on"') - def my_function(): - log.info('pyscript.test was set to "on"') - - -2) Set a state - -.. code:: python - - pyscript.test = "on" - - state.set('pyscript.test', 'on') - - -3) Read a state - -.. code:: python - - if pyscript.test == "on": - log.info('pyscript.test is "on"') - - test_value = state.get('pyscript.test') - log.info(f'The test value is {test_value}') - -4) Use the explicit persistence methods - -.. code:: python - - state.persist('pyscript.test', default_value="on") - - state.persist_prefix('pyscript.test_') - -Be aware that there are cases where it may seem the entity will be persisted, but, in actuality it will not. Consider the following example: - -.. code:: python - - @state_trigger('binary_sensor.motion == "on"') - def turn_on_lights(): - light.turn_on('light.overhead') - pyscript.last_light_on = "light.overhead" - -In this example, while the state is being set with `pyscript.last_light_on = "light.overhead"`, there is no guarantee that this particular `@state_trigger` will fire. If it doesn't fire, then the set action is never taken. If the set action doesn't happen before Home Assistant is restarted, then this entity will no longer persist. +In order to ensure that the state of a particular entity persists, you need to request persistence explicitly. This must be done in a code location that will be certain to run at startup. Generally, this means outside of trigger functions. -In order to ensure persistence in these cases, it is advised to use the explict syntax. .. code:: python From 1bf67b7c1c398de2351dee73c455e3089ae8346a Mon Sep 17 00:00:00 2001 From: Daniel Lashua Date: Wed, 21 Oct 2020 13:18:16 -0500 Subject: [PATCH 7/8] remove persist_prefix from register_functions --- custom_components/pyscript/state.py | 1 - 1 file changed, 1 deletion(-) diff --git a/custom_components/pyscript/state.py b/custom_components/pyscript/state.py index 0bc878d..7dc4d49 100644 --- a/custom_components/pyscript/state.py +++ b/custom_components/pyscript/state.py @@ -222,7 +222,6 @@ def register_functions(cls): "state.names": cls.names, "state.get_attr": cls.get_attr, "state.persist": cls.persist, - "state.persist_prefix": cls.persist_prefix, "pyscript.config": cls.pyscript_config, } Function.register(functions) From 656665bbb72055e526f26d4956ce8e1d3dd74953 Mon Sep 17 00:00:00 2001 From: Daniel Lashua Date: Wed, 21 Oct 2020 15:15:47 -0500 Subject: [PATCH 8/8] docs for default_attributes on state.persist() --- docs/reference.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference.rst b/docs/reference.rst index 4d9e664..145961c 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -569,8 +569,8 @@ Persistent State This method is provided to indicate that a particular entity_id should be persisted. This is only effective for entitys in the `pyscript` domain. -``state.persist(entity_id, default_value=None)`` - Indicates that the entity named in `entity_id` should be persisted. Optionally, a default value can be provided. +``state.persist(entity_id, default_value=None, default_attributes=None)`` + Indicates that the entity named in `entity_id` should be persisted. Optionally, a default value and default attributes can be provided.