Skip to content

Persist pyscript.* states between home assistant restarts #48

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Oct 21, 2020
11 changes: 11 additions & 0 deletions custom_components/pyscript/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -50,6 +51,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(
Expand All @@ -60,6 +62,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)
Expand Down
4 changes: 2 additions & 2 deletions custom_components/pyscript/eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
42 changes: 38 additions & 4 deletions custom_components/pyscript/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import logging

from homeassistant.helpers.restore_state import RestoreStateData

from .const import LOGGER_PATH
from .function import Function

Expand Down Expand Up @@ -34,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")
Expand All @@ -44,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}:
Expand Down Expand Up @@ -98,7 +105,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')")
Expand All @@ -113,8 +120,34 @@ 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)

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)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How expensive is it to call these functions every time a pyscript state variable is set? Would it be better to only do it the first time each state variable is set?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not very experienced with the Home Assistant codebase or python async code.

The async_restore_entity_added simply puts the entry on a set, so it isn't expensive at all. The get_instance method I'm not sure, I expect it to be cheap due to the singleton? https://github.com/home-assistant/core/blob/dev/homeassistant/helpers/restore_state.py#L65

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've since introduced our own set to track persisted states, so this code block will only evaluate once.

@classmethod
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)
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
def exist(cls, var_name):
"""Check if a state variable value or attribute exists in hass."""
Expand All @@ -125,7 +158,7 @@ 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:
Expand All @@ -140,7 +173,7 @@ 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')")
Expand Down Expand Up @@ -188,6 +221,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)
Expand Down
4 changes: 2 additions & 2 deletions custom_components/pyscript/trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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()
Expand Down
53 changes: 53 additions & 0 deletions docs/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,16 @@ 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
^^^^^^^^^^^^^^^^

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, default_attributes=None)``
Indicates that the entity named in `entity_id` should be persisted. Optionally, a default value and default attributes can be provided.



Service Calls
^^^^^^^^^^^^^

Expand Down Expand Up @@ -1204,3 +1214,46 @@ 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 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.


.. 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.