Skip to content

Commit e59345f

Browse files
committed
adds @time_trigger("shutdown") to run on shutdown or reload; see #103
tests and docs still need updating
1 parent a7314cb commit e59345f

File tree

4 files changed

+126
-96
lines changed

4 files changed

+126
-96
lines changed

custom_components/pyscript/__init__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ async def unload_scripts(global_ctx_only=None, unload_all=False):
255255
ctx_delete[global_ctx_name] = global_ctx
256256
for global_ctx_name, global_ctx in ctx_delete.items():
257257
GlobalContextMgr.delete(global_ctx_name)
258+
await Function.reaper_sync()
258259

259260

260261
@bind_hass
@@ -307,7 +308,6 @@ def glob_read_files(load_paths, apps_config):
307308
continue
308309
mod_name = rel_path[0:-3]
309310
if mod_name.endswith("/__init__"):
310-
# mod_name = mod_name[0 : -len("/__init__")]
311311
rel_import_path = mod_name
312312
mod_name = mod_name.replace("/", ".")
313313
if path == "":
@@ -497,6 +497,7 @@ def import_recurse(ctx_name, visited, ctx2imports):
497497
global_ctx.stop()
498498
_LOGGER.debug("reload: deleting global_ctx=%s", global_ctx_name)
499499
GlobalContextMgr.delete(global_ctx_name)
500+
await Function.reaper_sync()
500501

501502
#
502503
# now load the requested files, and files that depend on loaded files

custom_components/pyscript/function.py

+49-24
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,10 @@ class Function:
4848
ast_functions = {}
4949

5050
#
51-
# task id of the task that cancel and waits for other tasks
51+
# task id of the task that cancels and waits for other tasks,
52+
# and also awaits on coros
5253
#
53-
task_cancel_repeaer = None
54+
task_repeaer = None
5455

5556
def __init__(self):
5657
"""Warn on Function instantiation."""
@@ -84,35 +85,64 @@ def init(cls, hass):
8485
# start a task which is a reaper for canceled tasks, since some # functions
8586
# like TrigInfo.stop() can't be async (it's called from a __del__ method)
8687
#
87-
async def task_cancel_reaper(reaper_q):
88+
async def task_reaper(reaper_q):
8889
while True:
8990
try:
90-
try:
91-
task = await reaper_q.get()
92-
if task is None:
93-
return
94-
task.cancel()
95-
await task
96-
except asyncio.CancelledError:
97-
pass
91+
cmd = await reaper_q.get()
92+
if cmd[0] == "exit":
93+
return
94+
if cmd[0] == "cancel":
95+
try:
96+
cmd[1].cancel()
97+
await cmd[1]
98+
except asyncio.CancelledError:
99+
pass
100+
elif cmd[0] == "await":
101+
await cmd[1]
102+
elif cmd[0] == "sync":
103+
await cmd[1].put(0)
104+
else:
105+
_LOGGER.error("task_reaper: unknown command %s", cmd[0])
98106
except asyncio.CancelledError:
99107
raise
100108
except Exception:
101-
_LOGGER.error("task_cancel_reaper: got exception %s", traceback.format_exc(-1))
109+
_LOGGER.error("task_reaper: got exception %s", traceback.format_exc(-1))
102110

103-
if not cls.task_cancel_repeaer:
111+
if not cls.task_repeaer:
104112
cls.task_reaper_q = asyncio.Queue(0)
105-
cls.task_cancel_repeaer = Function.create_task(task_cancel_reaper(cls.task_reaper_q))
113+
cls.task_repeaer = Function.create_task(task_reaper(cls.task_reaper_q))
106114

107115
@classmethod
108116
async def reaper_stop(cls):
109117
"""Tell the reaper task to exit by sending a special task None."""
110-
if cls.task_cancel_repeaer:
111-
cls.task_cancel(None)
112-
await cls.task_cancel_repeaer
113-
cls.task_cancel_repeaer = None
118+
if cls.task_repeaer:
119+
cls.task_reaper_q.put_nowait(["exit"])
120+
await cls.task_repeaer
121+
cls.task_repeaer = None
114122
cls.task_reaper_q = None
115123

124+
@classmethod
125+
def reaper_cancel(cls, task):
126+
"""Send a task to be canceled by the reaper."""
127+
cls.task_reaper_q.put_nowait(["cancel", task])
128+
129+
@classmethod
130+
def reaper_await(cls, coro):
131+
"""Send a coro to be awaited by the reaper."""
132+
cls.task_reaper_q.put_nowait(["await", coro])
133+
134+
@classmethod
135+
async def reaper_sync(cls):
136+
"""Wait until the reaper queue is empty."""
137+
sync_q = asyncio.Queue(0)
138+
sync_q.put_nowait(["sync", sync_q])
139+
await sync_q.get()
140+
141+
@classmethod
142+
def reaper_exit(cls):
143+
"""Send an exit request to the reaper."""
144+
cls.task_reaper_q.put_nowait(["exit"])
145+
116146
@classmethod
117147
async def async_sleep(cls, duration):
118148
"""Implement task.sleep()."""
@@ -152,7 +182,7 @@ async def task_unique(name, kill_me=False):
152182
# it seems we can't cancel ourselves, so we
153183
# tell the repeaer task to cancel us
154184
#
155-
Function.task_cancel(curr_task)
185+
Function.reaper_cancel(curr_task)
156186
# wait to be canceled
157187
await asyncio.sleep(100000)
158188
elif task != curr_task and task in cls.our_tasks:
@@ -313,8 +343,3 @@ async def run_coro(cls, coro):
313343
def create_task(cls, coro):
314344
"""Create a new task that runs a coroutine."""
315345
return cls.hass.loop.create_task(cls.run_coro(coro))
316-
317-
@classmethod
318-
def task_cancel(cls, task):
319-
"""Send a task to be canceled by the reaper."""
320-
cls.task_reaper_q.put_nowait(task)

custom_components/pyscript/trigger.py

+74-70
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,7 @@ def __init__(
690690
self.have_trigger = False
691691
self.setup_ok = False
692692
self.run_on_startup = False
693+
self.run_on_shutdown = False
693694

694695
if self.state_active is not None:
695696
self.active_expr = AstEval(
@@ -702,14 +703,17 @@ def __init__(
702703
self.active_expr.get_logger().error(exc)
703704
return
704705

706+
if "time_trigger" in trig_cfg and self.time_trigger is None:
707+
self.run_on_startup = True
705708
if self.time_trigger is not None:
706709
while "startup" in self.time_trigger:
707710
self.run_on_startup = True
708711
self.time_trigger.remove("startup")
712+
while "shutdown" in self.time_trigger:
713+
self.run_on_shutdown = True
714+
self.time_trigger.remove("shutdown")
709715
if len(self.time_trigger) == 0:
710716
self.time_trigger = None
711-
if "time_trigger" in trig_cfg and self.time_trigger is None:
712-
self.run_on_startup = True
713717

714718
if self.state_trigger is not None:
715719
state_trig = []
@@ -783,7 +787,12 @@ def stop(self):
783787
if self.mqtt_trigger is not None:
784788
Mqtt.notify_del(self.mqtt_trigger[0], self.notify_q)
785789
if self.task:
786-
Function.task_cancel(self.task)
790+
Function.reaper_cancel(self.task)
791+
if self.run_on_shutdown:
792+
notify_type = "shutdown"
793+
notify_info = {"trigger_type": "shutdown", "trigger_time": None}
794+
action_future = self.call_action(notify_type, notify_info, run_task=False)
795+
Function.reaper_await(action_future)
787796

788797
def start(self):
789798
"""Start this trigger task."""
@@ -1000,30 +1009,6 @@ async def trigger_watch(self):
10001009
)
10011010
continue
10021011

1003-
action_ast_ctx = AstEval(
1004-
f"{self.action.global_ctx_name}.{self.action.name}", self.action.global_ctx
1005-
)
1006-
Function.install_ast_funcs(action_ast_ctx)
1007-
task_unique_func = None
1008-
if self.task_unique is not None:
1009-
task_unique_func = Function.task_unique_factory(action_ast_ctx)
1010-
1011-
#
1012-
# check for @task_unique with kill_me=True
1013-
#
1014-
if (
1015-
self.task_unique is not None
1016-
and self.task_unique_kwargs
1017-
and self.task_unique_kwargs["kill_me"]
1018-
and Function.unique_name_used(action_ast_ctx, self.task_unique)
1019-
):
1020-
_LOGGER.debug(
1021-
"trigger %s got %s trigger, @task_unique kill_me=True prevented new action",
1022-
notify_type,
1023-
self.name,
1024-
)
1025-
continue
1026-
10271012
if (
10281013
self.time_active_hold_off is not None
10291014
and last_trig_time is not None
@@ -1037,49 +1022,8 @@ async def trigger_watch(self):
10371022
)
10381023
continue
10391024

1040-
# Create new HASS Context with incoming as parent
1041-
if "context" in func_args and isinstance(func_args["context"], Context):
1042-
hass_context = Context(parent_id=func_args["context"].id)
1043-
else:
1044-
hass_context = Context()
1045-
1046-
# Fire an event indicating that pyscript is running
1047-
# Note: the event must have an entity_id for logbook to work correctly.
1048-
ev_name = self.name.replace(".", "_")
1049-
ev_entity_id = f"pyscript.{ev_name}"
1050-
1051-
event_data = dict(name=ev_name, entity_id=ev_entity_id, func_args=func_args)
1052-
Function.hass.bus.async_fire("pyscript_running", event_data, context=hass_context)
1053-
1054-
_LOGGER.debug(
1055-
"trigger %s got %s trigger, running action (kwargs = %s)",
1056-
self.name,
1057-
notify_type,
1058-
func_args,
1059-
)
1060-
1061-
async def do_func_call(func, ast_ctx, task_unique, task_unique_func, hass_context, **kwargs):
1062-
# Store HASS Context for this Task
1063-
Function.store_hass_context(hass_context)
1064-
1065-
if task_unique and task_unique_func:
1066-
await task_unique_func(task_unique)
1067-
await func.call(ast_ctx, **kwargs)
1068-
if ast_ctx.get_exception_obj():
1069-
ast_ctx.get_logger().error(ast_ctx.get_exception_long())
1070-
1071-
last_trig_time = time.monotonic()
1072-
1073-
Function.create_task(
1074-
do_func_call(
1075-
self.action,
1076-
action_ast_ctx,
1077-
self.task_unique,
1078-
task_unique_func,
1079-
hass_context,
1080-
**func_args,
1081-
)
1082-
)
1025+
if self.call_action(notify_type, func_args):
1026+
last_trig_time = time.monotonic()
10831027

10841028
except asyncio.CancelledError:
10851029
raise
@@ -1094,3 +1038,63 @@ async def do_func_call(func, ast_ctx, task_unique, task_unique_func, hass_contex
10941038
if self.mqtt_trigger is not None:
10951039
Mqtt.notify_del(self.mqtt_trigger[0], self.notify_q)
10961040
return
1041+
1042+
def call_action(self, notify_type, func_args, run_task=True):
1043+
"""Call the trigger action function."""
1044+
action_ast_ctx = AstEval(f"{self.action.global_ctx_name}.{self.action.name}", self.action.global_ctx)
1045+
Function.install_ast_funcs(action_ast_ctx)
1046+
task_unique_func = None
1047+
if self.task_unique is not None:
1048+
task_unique_func = Function.task_unique_factory(action_ast_ctx)
1049+
1050+
#
1051+
# check for @task_unique with kill_me=True
1052+
#
1053+
if (
1054+
self.task_unique is not None
1055+
and self.task_unique_kwargs
1056+
and self.task_unique_kwargs["kill_me"]
1057+
and Function.unique_name_used(action_ast_ctx, self.task_unique)
1058+
):
1059+
_LOGGER.debug(
1060+
"trigger %s got %s trigger, @task_unique kill_me=True prevented new action",
1061+
notify_type,
1062+
self.name,
1063+
)
1064+
return False
1065+
1066+
# Create new HASS Context with incoming as parent
1067+
if "context" in func_args and isinstance(func_args["context"], Context):
1068+
hass_context = Context(parent_id=func_args["context"].id)
1069+
else:
1070+
hass_context = Context()
1071+
1072+
# Fire an event indicating that pyscript is running
1073+
# Note: the event must have an entity_id for logbook to work correctly.
1074+
ev_name = self.name.replace(".", "_")
1075+
ev_entity_id = f"pyscript.{ev_name}"
1076+
1077+
event_data = dict(name=ev_name, entity_id=ev_entity_id, func_args=func_args)
1078+
Function.hass.bus.async_fire("pyscript_running", event_data, context=hass_context)
1079+
1080+
_LOGGER.debug(
1081+
"trigger %s got %s trigger, running action (kwargs = %s)", self.name, notify_type, func_args,
1082+
)
1083+
1084+
async def do_func_call(func, ast_ctx, task_unique, task_unique_func, hass_context, **kwargs):
1085+
# Store HASS Context for this Task
1086+
Function.store_hass_context(hass_context)
1087+
1088+
if task_unique and task_unique_func:
1089+
await task_unique_func(task_unique)
1090+
await func.call(ast_ctx, **kwargs)
1091+
if ast_ctx.get_exception_obj():
1092+
ast_ctx.get_logger().error(ast_ctx.get_exception_long())
1093+
1094+
func = do_func_call(
1095+
self.action, action_ast_ctx, self.task_unique, task_unique_func, hass_context, **func_args,
1096+
)
1097+
if run_task:
1098+
Function.create_task(func)
1099+
return True
1100+
return func

docs/new_features.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ The new features since 1.0.0 in master include:
3232
- ``pyscript.reload`` only reloads changed files (changed contents, mtime, or an app's yaml configuration).
3333
All files in an app or module are reloaded if any one has changed, and any script, app or module that
3434
imports a changed modules (directly or indirectly) is also reloaded. Setting the optional ``global_ctx``
35-
service parameter to ``*`` forces reloading all files (which is the behavior in 1.0.0 and earlier).
35+
service parameter to ``*`` forces reloading all files (which is the behavior in 1.0.0 and earlier). See #106.
3636
- Adding new decorator ``@mqtt_trigger`` by @dlashua (#98, #105).
3737
- Added ``state_hold_false=None`` optional period in seconds to ``@state_trigger()`` and ``task.wait_until()``.
3838
This requires the trigger expression to be ``False`` for at least that period (including 0) before

0 commit comments

Comments
 (0)