Skip to content

Commit 59c12f9

Browse files
committed
state_trigger now allows a simple variable name, and multiple arguments; see #44
1 parent 8aacdc6 commit 59c12f9

File tree

7 files changed

+232
-84
lines changed

7 files changed

+232
-84
lines changed

custom_components/pyscript/eval.py

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -384,15 +384,26 @@ async def do_service_call(func, ast_ctx, data):
384384
# strings
385385
#
386386
arg_check = {
387-
"event_trigger": {1, 2},
388-
"state_active": {1},
389-
"state_trigger": {1},
390-
"task_unique": {1},
391-
"time_active": {"*"},
392-
"time_trigger": {"*"},
387+
"event_trigger": {"arg_cnt": {1, 2}},
388+
"state_active": {"arg_cnt": {1}},
389+
"state_trigger": {"arg_cnt": {"*"}, "type": {list, set}},
390+
"task_unique": {"arg_cnt": {1}},
391+
"time_active": {"arg_cnt": {"*"}},
392+
"time_trigger": {"arg_cnt": {0, "*"}},
393393
}
394-
for dec_name, arg_cnt in arg_check.items():
395-
if dec_name not in trig_args or trig_args[dec_name]["args"] is None:
394+
for dec_name, arg_info in arg_check.items():
395+
arg_cnt = arg_info["arg_cnt"]
396+
if dec_name not in trig_args:
397+
continue
398+
if trig_args[dec_name]["args"] is None:
399+
if 0 not in arg_cnt:
400+
self.logger.error(
401+
"%s defined in %s: decorator @%s needs at least one argument; ignoring decorator",
402+
self.name,
403+
self.global_ctx_name,
404+
dec_name,
405+
)
406+
del trig_args[dec_name]
396407
continue
397408
if "*" not in arg_cnt and len(trig_args[dec_name]["args"]) not in arg_cnt:
398409
self.logger.error(
@@ -405,18 +416,30 @@ async def do_service_call(func, ast_ctx, data):
405416
" or ".join([str(cnt) for cnt in sorted(arg_cnt)]),
406417
)
407418
del trig_args[dec_name]
408-
break
419+
continue
409420
for arg_num, arg in enumerate(trig_args[dec_name]["args"]):
410-
if not isinstance(arg, str):
411-
self.logger.error(
412-
"%s defined in %s: decorator @%s argument %d should be a string; ignoring decorator",
413-
self.name,
414-
self.global_ctx_name,
415-
dec_name,
416-
arg_num + 1,
417-
)
418-
del trig_args[dec_name]
419-
break
421+
if isinstance(arg, str):
422+
continue
423+
mesg = "string"
424+
if "type" in arg_info:
425+
if type(arg) in arg_info["type"]:
426+
for val in arg:
427+
if not isinstance(val, str):
428+
break
429+
else:
430+
continue
431+
for ok_type in arg_info["type"]:
432+
mesg += f", or {ok_type.__name__}"
433+
self.logger.error(
434+
"%s defined in %s: decorator @%s argument %d should be a %s; ignoring decorator",
435+
self.name,
436+
self.global_ctx_name,
437+
dec_name,
438+
arg_num + 1,
439+
mesg,
440+
)
441+
del trig_args[dec_name]
442+
continue
420443
if arg_cnt == {1}:
421444
trig_args[dec_name]["args"] = trig_args[dec_name]["args"][0]
422445

custom_components/pyscript/state.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,6 @@ def notify_var_get(cls, var_names, new_vars):
9595
notify_vars[var_name] = new_vars[var_name]
9696
elif 1 <= var_name.count(".") <= 2 and not cls.exist(var_name):
9797
notify_vars[var_name] = None
98-
_LOGGER.debug(
99-
"notify_var_get var_names=%s, new_vars=%s, notify_vars=%s", var_names, new_vars, notify_vars
100-
)
10198
return notify_vars
10299

103100
@classmethod
@@ -115,6 +112,7 @@ def set(cls, var_name, value, new_attributes=None, **kwargs):
115112
new_attributes = new_attributes.copy()
116113
new_attributes.update(kwargs)
117114
_LOGGER.debug("setting %s = %s, attr = %s", var_name, value, new_attributes)
115+
cls.notify_var_last[var_name] = str(value)
118116
cls.hass.states.async_set(var_name, value, new_attributes)
119117

120118
@classmethod

custom_components/pyscript/trigger.py

Lines changed: 107 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
_LOGGER = logging.getLogger(LOGGER_PATH + ".trigger")
2222

2323

24+
STATE_RE = re.compile(r"[a-zA-Z]\w*\.[a-zA-Z]\w*$")
25+
26+
2427
def dt_now():
2528
"""Return current time."""
2629
return dt.datetime.now()
@@ -101,32 +104,57 @@ async def wait_until(
101104
await asyncio.sleep(timeout)
102105
return {"trigger_type": "timeout"}
103106
return {"trigger_type": "none"}
104-
state_trig_ident = None
105-
state_trig_expr = None
107+
state_trig_ident = set()
108+
state_trig_ident_any = set()
109+
state_trig_eval = None
106110
event_trig_expr = None
107111
exc = None
108112
notify_q = asyncio.Queue(0)
109113
if state_trigger is not None:
110-
state_trig_expr = AstEval(
111-
f"{ast_ctx.name} state_trigger",
112-
ast_ctx.get_global_ctx(),
113-
logger_name=ast_ctx.get_logger_name(),
114-
)
115-
Function.install_ast_funcs(state_trig_expr)
116-
state_trig_expr.parse(state_trigger)
117-
exc = state_trig_expr.get_exception_obj()
118-
if exc is not None:
119-
raise exc
114+
state_trig = []
115+
if isinstance(state_trigger, str):
116+
state_trigger = [state_trigger]
117+
elif isinstance(state_trigger, set):
118+
state_trigger = list(state_trigger)
120119
#
121-
# check straight away to see if the condition is met (to avoid race conditions)
120+
# separate out the entries that are just state var names, which mean trigger
121+
# on any change (no expr)
122122
#
123-
state_trig_ok = await state_trig_expr.eval()
124-
exc = state_trig_expr.get_exception_obj()
125-
if exc is not None:
126-
raise exc
127-
if state_trig_ok:
128-
return {"trigger_type": "state"}
129-
state_trig_ident = await state_trig_expr.get_names()
123+
for trig in state_trigger:
124+
if STATE_RE.match(trig):
125+
state_trig_ident_any.add(trig)
126+
else:
127+
state_trig.append(trig)
128+
129+
if len(state_trig) > 0:
130+
if len(state_trig) == 1:
131+
state_trig_expr = state_trig[0]
132+
else:
133+
state_trig_expr = f"any([{', '.join(state_trig)}])"
134+
state_trig_eval = AstEval(
135+
f"{ast_ctx.name} state_trigger",
136+
ast_ctx.get_global_ctx(),
137+
logger_name=ast_ctx.get_logger_name(),
138+
)
139+
Function.install_ast_funcs(state_trig_eval)
140+
state_trig_eval.parse(state_trig_expr)
141+
state_trig_ident = await state_trig_eval.get_names()
142+
exc = state_trig_eval.get_exception_obj()
143+
if exc is not None:
144+
raise exc
145+
146+
state_trig_ident.update(state_trig_ident_any)
147+
if state_trig_eval:
148+
#
149+
# check straight away to see if the condition is met (to avoid race conditions)
150+
#
151+
state_trig_ok = await state_trig_eval.eval(State.notify_var_get(state_trig_ident, {}))
152+
exc = state_trig_eval.get_exception_obj()
153+
if exc is not None:
154+
raise exc
155+
if state_trig_ok:
156+
return {"trigger_type": "state"}
157+
130158
_LOGGER.debug(
131159
"trigger %s wait_until: watching vars %s", ast_ctx.name, state_trig_ident,
132160
)
@@ -145,7 +173,7 @@ async def wait_until(
145173
event_trig_expr.parse(event_trigger[1])
146174
exc = event_trig_expr.get_exception_obj()
147175
if exc is not None:
148-
if state_trig_ident:
176+
if len(state_trig_ident) > 0:
149177
State.notify_del(state_trig_ident, notify_q)
150178
raise exc
151179
Event.notify_add(event_trigger[0], notify_q)
@@ -191,11 +219,19 @@ async def wait_until(
191219
ret["trigger_time"] = time_next
192220
break
193221
if notify_type == "state":
194-
new_vars = notify_info[0] if notify_info else None
195-
state_trig_ok = await state_trig_expr.eval(new_vars)
196-
exc = state_trig_expr.get_exception_obj()
197-
if exc is not None:
198-
break
222+
if notify_info:
223+
new_vars, func_args = notify_info
224+
else:
225+
new_vars, func_args = None, {}
226+
227+
state_trig_ok = False
228+
if func_args.get("var_name", "") in state_trig_ident_any:
229+
state_trig_ok = True
230+
elif state_trig_eval:
231+
state_trig_ok = await state_trig_eval.eval(new_vars)
232+
exc = state_trig_eval.get_exception_obj()
233+
if exc is not None:
234+
break
199235
if state_trig_ok:
200236
ret = notify_info[1] if notify_info else None
201237
break
@@ -215,7 +251,7 @@ async def wait_until(
215251
"trigger %s wait_until got unexpected queue message %s", ast_ctx.name, notify_type,
216252
)
217253

218-
if state_trig_ident:
254+
if len(state_trig_ident) > 0:
219255
State.notify_del(state_trig_ident, notify_q)
220256
if event_trigger is not None:
221257
Event.notify_del(event_trigger[0], notify_q)
@@ -454,7 +490,9 @@ def __init__(
454490
self.active_expr = None
455491
self.state_active_ident = None
456492
self.state_trig_expr = None
493+
self.state_trig_eval = None
457494
self.state_trig_ident = None
495+
self.state_trig_ident_any = set()
458496
self.event_trig_expr = None
459497
self.have_trigger = False
460498
self.setup_ok = False
@@ -481,15 +519,36 @@ def __init__(
481519
self.run_on_startup = True
482520

483521
if self.state_trigger is not None:
484-
self.state_trig_expr = AstEval(
485-
f"{self.name} @state_trigger()", self.global_ctx, logger_name=self.name
486-
)
487-
Function.install_ast_funcs(self.state_trig_expr)
488-
self.state_trig_expr.parse(self.state_trigger)
489-
exc = self.state_trig_expr.get_exception_long()
490-
if exc is not None:
491-
self.state_trig_expr.get_logger().error(exc)
492-
return
522+
state_trig = []
523+
for triggers in self.state_trigger:
524+
if isinstance(triggers, str):
525+
triggers = [triggers]
526+
elif isinstance(triggers, set):
527+
triggers = list(triggers)
528+
#
529+
# separate out the entries that are just state var names, which mean trigger
530+
# on any change (no expr)
531+
#
532+
for trig in triggers:
533+
if STATE_RE.match(trig):
534+
self.state_trig_ident_any.add(trig)
535+
else:
536+
state_trig.append(trig)
537+
538+
if len(state_trig) > 0:
539+
if len(state_trig) == 1:
540+
self.state_trig_expr = state_trig[0]
541+
else:
542+
self.state_trig_expr = f"any([{', '.join(state_trig)}])"
543+
self.state_trig_eval = AstEval(
544+
f"{self.name} @state_trigger()", self.global_ctx, logger_name=self.name
545+
)
546+
Function.install_ast_funcs(self.state_trig_eval)
547+
self.state_trig_eval.parse(self.state_trig_expr)
548+
exc = self.state_trig_eval.get_exception_long()
549+
if exc is not None:
550+
self.state_trig_eval.get_logger().error(exc)
551+
return
493552
self.have_trigger = True
494553

495554
if self.event_trigger is not None:
@@ -530,7 +589,10 @@ async def trigger_watch(self):
530589
try:
531590

532591
if self.state_trigger is not None:
533-
self.state_trig_ident = await self.state_trig_expr.get_names()
592+
self.state_trig_ident = set()
593+
if self.state_trig_eval:
594+
self.state_trig_ident = await self.state_trig_eval.get_names()
595+
self.state_trig_ident.update(self.state_trig_ident_any)
534596
_LOGGER.debug("trigger %s: watching vars %s", self.name, self.state_trig_ident)
535597
if len(self.state_trig_ident) > 0:
536598
State.notify_add(self.state_trig_ident, self.notify_q)
@@ -587,11 +649,14 @@ async def trigger_watch(self):
587649
if notify_type == "state":
588650
new_vars, func_args = notify_info
589651

590-
if self.state_trig_expr:
591-
trig_ok = await self.state_trig_expr.eval(new_vars)
592-
exc = self.state_trig_expr.get_exception_long()
593-
if exc is not None:
594-
self.state_trig_expr.get_logger().error(exc)
652+
if func_args["var_name"] not in self.state_trig_ident_any:
653+
if self.state_trig_eval:
654+
trig_ok = await self.state_trig_eval.eval(new_vars)
655+
exc = self.state_trig_eval.get_exception_long()
656+
if exc is not None:
657+
self.state_trig_eval.get_logger().error(exc)
658+
trig_ok = False
659+
else:
595660
trig_ok = False
596661

597662
elif notify_type == "event":

docs/reference.rst

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -165,15 +165,19 @@ function.
165165

166166
.. code:: python
167167
168-
@state_trigger(str_expr)
168+
@state_trigger(str_expr, ...)
169169
170-
``@state_trigger`` takes a single string ``str_expr`` that contains any expression based on one or
170+
``@state_trigger`` takes one or more string arguments that contain any expression based on one or
171171
more state variables, and evaluates to ``True`` or ``False`` (or non-zero or zero). Whenever the
172172
state variables mentioned in the expression change, the expression is evaluated and the trigger
173173
occurs if it evaluates to ``True`` (or non-zero). For each state variable, eg: ``domain.name``,
174174
the prior value is also available to the expression as ``domain.name.old`` in case you want to
175175
condition the trigger on the prior value too.
176176

177+
Multiple arguments are logically "or"ed together, so the trigger occurs if any of the expressions
178+
evaluate to ``True``. Any argument can alternatively be be a list or set of strings, and they are
179+
treated the same as multiple arguments by "or"ing them together.
180+
177181
All state variables in HASS have string values. So you’ll have to do comparisons against string
178182
values or cast the variable to an integer or float. These two examples are essentially equivalent
179183
(note the use of single quotes inside the outer double quotes):
@@ -194,20 +198,15 @@ You can also use state variable attributes in the trigger expression, with an id
194198
form ``DOMAIN.name.attr``. Attributes maintain their original type, so there is no need to cast
195199
then to another type.
196200

197-
If you specify ``@state_trigger("True")`` the state trigger will never occur. While that might seem
198-
counter-intuitive, the reason is that the expression will never be evaluated - it takes underlying
199-
state variables in the expression to change before the expression is evaluated. Since this
200-
expression has no state variables, it will never be evaluated. You can achieve a state trigger
201-
on any value change with a decorator of the form:
201+
You can specify a state trigger on any change with a string that is just the state variable name:
202202

203203
.. code:: python
204204
205-
@state_trigger("True or domain.light_level")
205+
@state_trigger("domain.light_level")
206206
207-
The reason this works is that the expression is evaluated every time ``domain.light_level`` changes.
208-
Because of operator short-circuiting, the expression evaluates to ``True`` without even checking the
209-
value of ``domain.light_level``. So the result is a trigger whenever the state variable changes to
210-
any value. This idea can extend to multiple variables just by stringing them together.
207+
The trigger can include arguments with any mixture of string expressions (that are evaluated
208+
when any of the underlying state variable change) and string state variable names (that trigger
209+
whenever that variable changes).
211210

212211
Note that if a state variable is set to the same value, HASS doesn’t generate a state change event,
213212
so the ``@state_trigger`` condition will not be checked. It is only evaluated each time a state
@@ -649,7 +648,8 @@ Task waiting
649648

650649
It takes the following keyword arguments (all are optional):
651650

652-
- ``state_trigger=None`` can be set to a string just like ``@state_trigger``.
651+
- ``state_trigger=None`` can be set to a string just like ``@state_trigger``, or it can be
652+
a list of strings that are logically "or"ed together.
653653
- ``time_trigger=None`` can be set to a string or list of strings with
654654
datetime specifications, just like ``@time_trigger``.
655655
- ``event_trigger=None`` can be set to a string or list of two strings, just like

0 commit comments

Comments
 (0)