diff --git a/custom_components/pyscript/__init__.py b/custom_components/pyscript/__init__.py index 62db1ed..32c4667 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 ( @@ -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( @@ -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) diff --git a/custom_components/pyscript/eval.py b/custom_components/pyscript/eval.py index 8d67727..4906174 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 @@ -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 be378bc..7dc4d49 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 @@ -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") @@ -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}: @@ -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')") @@ -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) + + @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.""" @@ -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: @@ -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')") @@ -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) 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() diff --git a/docs/reference.rst b/docs/reference.rst index 4486586..145961c 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -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 ^^^^^^^^^^^^^ @@ -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. + + + + + + \ No newline at end of file