Five condition operators against Tasker's fourteen: designing an automation artifact
What we’re building
$ python3 -m compiler build ./demo.py
automation 'bank-alerts': condition field 'sms.slot' is declared int, and its value does not fit. A leaf is a int, a list of them meaning OR, or one operator: exists takes a bool; prefix and suffix take a string and only apply to a string field; numeric takes a non-empty even-length list alternating one of < <= = >= > with an integer, and only applies to an int or long field; anythingBut takes any of the above.
automation 'bank-alerts': the string "SMS from ${from}" opens a placeholder that is not of the form ${namespace.field}.
Every placeholder is namespaced, even when the automation has one trigger. Write ${sms.from}, not ${from}.
automation 'from-home': condition field 'location.zone' is not provided by trigger 'sms.onReceived'.
Available: sms.from, sms.body, sms.timestamp, sms.subscriptionId, sms.slot
$ echo $?
1
Three mistakes in two automations, all reported in one run, none of which can reach the phone. A phone automation system where the rules are typed Python, the compiler rejects a rule that could never fire, and the matcher that decides on the phone is the same source file that decides on my laptop.
The design questions behind that come from somewhere specific. I decompiled Tasker 6.6.20, the mature commercial product in this space, to see how it answers the same questions. It answers most of them differently, and the exercise found one bug in my own compiler that I would not otherwise have looked for.
Why it works this way
The artifact in the middle is a contract between two languages. Automations are authored in Python and matched by Kotlin, so something has to carry the rules across, and the shape of that something is the whole design. Mine is JSON with named arguments and string action names, validated before it exists.
Tasker’s is XML where an action is a numeric code with positional children. Its own shipped JSON Schema describes the argument element as, verbatim, "_sr": { "type": "string", "description": "Argument index (e.g., arg0, arg1)." }. There are no argument names in the artifact at all. The names live in a runtime table inside the app, one constructor call per action:
new y0(61, R.string.an_vibrate, 10, 4, "vibrate",
0, Integer.valueOf(R.string.pl_time), "1:1000:200", 0, 1);
That is the entire declaration of the Vibrate action: code 61, a name resource, a category, a flags bitmask, a help-page slug, then repeating groups of five per argument. The "1:1000:200" is a constraint in a stringly-typed mini-language, min:max:default, parsed and checked at runtime.
Positional codes lose for a measurable reason. Tasker 6.6 ships an AI generator, which means it has to explain its own format to a language model, which means the explanation is compiled into the APK as string literals: 187,670 bytes across twelve blobs, including sections on operator codes, boolean connector precedence and pattern-matching case rules. A format that needs 183 KB of prose to be usable by a machine is a format that needs the same from a human.
The alternative I actually tried and abandoned was not Tasker’s, though. The first version of this compiler was written in Nix, with the schema as module options and the checks as assertions. Its validator file was rewritten twelve times in its short life. The Python replacement has been edited once since it was written. The commit that made the switch put the reason in one sentence: “The callable an author writes with and the schema that validates it are one declaration, so they cannot drift.”
What you need
| Thing | Version | Where it comes from |
|---|---|---|
| Python | 3.12 | python_version = "3.12" in pyproject.toml |
| JDK, APK build | 21 | jdk21 in the APK derivation |
| Android build-tools | 35.0.0 | buildToolsVersion in the APK derivation |
| Android platform | 35 | platformVersion, the android.jar compiled against |
| minSdk / targetSdk | 30 / 36 | the <uses-sdk> line in the manifest |
| Artifact schema version | 1 | VERSION = 1 in the compiler |
Kotlin and the host JDK are not pinned by version number; they follow the nixpkgs lock. There are no Python dependencies: the compiler is stdlib only, and pytest is the single dev dependency.
Tasker’s own numbers, for scale: minSdk 27, targetSdk 35, four dex files totalling 23 MB, 110 <uses-permission> declarations. Mine ships eight.
How the pieces fit
One pipeline, two languages, one artifact between them. The author writes a dataclass; the compiler validates and emits JSON; Nix bakes that JSON into an APK as an asset; the phone parses it and matches events against it.
flowchart TD
DEV["fairphone.py<br/>Automation(trigger=sms.on_received())"]
REG["registry.py<br/>@trigger wrapper -> TriggerCall"]
LOAD["compile.py load_device<br/>-> DeviceConfig"]
CHK["validate.py check<br/>-> list[str]"]
PAT["pattern.py flatten"]
ART["config.json, version 1"]
DEV --> REG --> LOAD --> CHK --> PAT
CHK --> ART
subgraph engine["engine sources, compiled twice"]
PARSE["Model.kt parseConfig"]
MATCH["Match.kt matches"]
INTERP["Interpolate.kt interpolate"]
end
ART --> HOST["host: java -jar engine.jar simulate"]
ART --> APK["APK: assets/config.json"]
HOST --> engine
APK --> RCV["SmsReceiver.kt onReceive<br/>-> Dispatcher.kt dispatch"]
RCV --> engine
engine --> RUN["ActionRunner.kt run"]
Trace one SMS rule the whole way. The author calls sms.on_received(), which is not the function they think it is: the decorator replaced the body, and the call returns a TriggerCall("sms", "onReceived", None). load_device execs the module and reads three names off it, device, zones and automations. check walks the result and returns a list of problems, empty in the good case. The emitted JSON carries RECEIVE_SMS because the trigger declared it and POST_NOTIFICATIONS because the action did. Nix bakes it in at assets/config.json. On the phone, SmsReceiver.onReceive reassembles the PDUs into an Event, Dispatcher.dispatch compares the trigger key, matches walks the condition, interpolate resolves ${sms.from} against the event fields, and ActionRunner.run posts the notification. The same matches and the same interpolate, compiled from the same files, run on the host when I simulate a timeline.
Step 1: Put the schema in the function signature
The goal: a trigger or action declared once, so the thing an author calls and the thing that validates the call cannot disagree.
def _params(fn: Callable[..., object]) -> tuple[Param, ...]:
hints = get_type_hints(fn, include_extras=True)
out: list[Param] = []
for name, p in inspect.signature(fn).parameters.items():
hint = hints[name]
markers: tuple[object, ...] = ()
if get_origin(hint) is Annotated:
args = get_args(hint)
hint, markers = args[0], args[1:]
out.append(Param(name, hint, markers, p.default is inspect.Parameter.empty))
return tuple(out)
_params reads the decorated function’s own signature and returns the schema. The decorator then registers that schema and replaces the function with a wrapper that binds arguments and returns a call object:
@functools.wraps(fn)
def call(*args: object, **kwargs: object) -> ActionCall:
bound = sig.bind(*args, **kwargs)
# Applied here rather than at render time, so the artifact carries
# complete arguments instead of only the ones the author wrote.
bound.apply_defaults()
return ActionCall(registered, dict(bound.arguments))
Every decorated body is raise NotImplementedError, because it never runs. The declared return type is what makes mypy check both the arguments and the return type at every call site in a device file.
Bounds a type cannot express travel in Annotated: Range(1, 5) on a priority, Pattern(regex) on a string, ZoneRef() on a parameter that has to name a declared zone. This is the same idea as Tasker’s "1:1000:200", arrived at independently, and the difference is where it is checked. Theirs is an unparsed string compared at runtime by the dialog code. Mine is a dataclass the validator reads before the artifact exists.
Check it:
$ python3 -c 'import triggers, actions; from registry import TRIGGERS, ACTIONS
print(len(TRIGGERS), "triggers,", len(ACTIONS), "actions")'
4 triggers, 4 actions
Four and four, against Tasker’s 92 events, 51 states and 432 actions, counted straight out of its string table. The vocabulary gap is real and it is the honest cost of this design: every trigger is four coordinated edits, so the catalog grows slowly.
Step 2: Make the condition a tree, not an expression
The goal: a condition that cannot be malformed, rather than one that is checked for being malformed.
The whole pattern language is one file:
OPERATORS = ("numeric", "prefix", "suffix", "exists", "anythingBut")
Path = tuple[str, ...]
def is_operator(v: object) -> bool:
"""A single-key dict whose key names an operator. It terminates the walk
because it is a leaf value, not more structure."""
return isinstance(v, dict) and len(v) == 1 and next(iter(v)) in OPERATORS
def flatten(node: object, prefix: Path = ()) -> list[tuple[Path, object]]:
"""{"a": {"b": 1}} -> [(("a", "b"), 1)].
Descends only into dicts that are not operators. An empty dict below the
root is a leaf rather than nothing, so a condition written as {"sms": {}}
is reported instead of vanishing."""
if isinstance(node, dict) and not is_operator(node) and (node or not prefix):
out: list[tuple[Path, object]] = []
for k, v in node.items():
out.extend(flatten(v, (*prefix, k)))
return out
return [(prefix, node)]
Sibling keys AND. A list means OR. A single-key dict naming an operator is a leaf, which is what stops the walk. Five operators, and numeric carries its own comparators as a flat alternating list, so a range is one clause.
Tasker combines conditions with infix connectors instead. Its own documentation states the rule: a list of N conditions must be accompanied by N-1 connector elements, <bool0> through <bool{N-2}>, drawn from And, Or, Xor, And2, Or2, Xor2, evaluated across six precedence levels from And2 highest to Xor lowest.
Two things are wrong with that from a schema’s point of view, and neither is a matter of taste. The arity invariant, N conditions to N-1 connectors, is a thing a config can get wrong and a type cannot see. The six precedence levels are a thing an author can get wrong silently, since a misread precedence still produces a valid config that matches the wrong events. A tree has neither: there is no count to keep in sync and no order to misread.
The engine walks the same shape in Kotlin, and the comment says what the shape buys:
/** Walks the pattern, which mirrors the event's own shape. Sibling keys AND, a
* list leaf means OR, and an operator attrset is a leaf. This is a tree walk
* and not an expression evaluator, which is what makes it exhaustively
* testable and what keeps the config free of strings the schema cannot see
* inside of. */
fun matches(condition: Map<String, Json>, event: Event): MatchResult {
One Tasker rule is worth naming as a thing not to copy. Its simple patterns are case insensitive by default, and become case sensitive if the pattern contains any uppercase letter. The meaning of a comparison therefore depends on the data someone happened to type into it.
Check it, with a condition leaf whose type is wrong:
$ python3 -m compiler build ./demo.py
automation 'bank-alerts': condition field 'sms.slot' is declared int, and its value does not fit.
sms.slot is declared int by the trigger, and "1" is a string. That rule would have compiled, installed, and never matched.
Step 3: Return every problem, not the first one
The goal: one run of the compiler tells you everything wrong with the config.
def check(cfg: DeviceConfig) -> list[str]:
"""Every problem in the configuration, collected rather than raised one at a
time, because a configuration usually has more than one."""
problems: list[str] = []
seen: set[str] = set()
for a in cfg.automations:
if a.name in seen:
problems.append(f"automation '{a.name}' is declared twice")
seen.add(a.name)
for name, zone in cfg.zones.items():
problems.extend(_check_zone(name, zone))
for a in cfg.automations:
definition = TRIGGERS[a.trigger.key]
problems.extend(_check_trigger_arg(a, definition, cfg.zones))
problems.extend(_check_condition(a, definition))
problems.extend(_check_placeholders(a, definition))
problems.extend(_check_action_args(a, cfg))
return problems
check returns a list and never raises. The single raise happens one layer up, in the compile step, with every message joined. That is the difference between the transcript at the top of this article, which reports three problems, and a compiler that would have reported the first and made you run it again twice.
Five checks are deliberately absent, and this is the part worth stealing. An unknown trigger or action is an ImportError naming the module. An unknown or missing action argument is a TypeError from Signature.bind, with a line number. More than one trigger per automation is unrepresentable, because the field holds one value. A missing mode is a TypeError from the dataclass, because it has no default:
@dataclass(frozen=True)
class Automation:
name: str
# No default. Users predict re-entrancy behaviour correctly 22.4% of the
# time when it is implicit, which is worse than chance on a four-way
# choice, so omitting it is a TypeError from Python itself.
mode: Mode
Writing a check for something the host language already rejects, at the right line, with a better message, is work that makes the compiler worse.
Check it:
$ pytest -q
................................................. [100%]
49 passed in 0.09s
Step 4: Compile the matcher once and run it in two places
The goal: a simulation on the laptop that is evidence about the phone, not a second implementation that agrees with the first until it does not.
# engine/src compiles into the app verbatim, minus Cli.kt: that file is
# host-only, calls exitProcess, and has no reason to exist on a phone.
# The identical matcher/interpolator source is what makes `simulate`
# trustworthy, so this must stay a copy of the same files, not a
# reimplementation.
cp ${../engine/src}/*.kt enginesrc/
rm enginesrc/Cli.kt
The engine sources are compiled twice: once by kotlinc into a jar for the host CLI, once against android.jar and then dexed into the APK. Two compilations of identical source, not one binary used twice, and nothing in the build verifies more than that both sides dereference the same directory. rm deletes exactly one file, the host entry point.
This is the piece Tasker has no equivalent of, and it is downstream of the artifact design rather than a separate feature. A config that is a validated document can be replayed against a timeline offline. A config that is a GUI’s serialised state cannot.
Check it by replaying a timeline through the host jar. Every rule that fires prints its trigger argument and its rendered actions, and the command exits 1 if any rule never fires, which is what makes it usable as a gate:
$ simulate fairphone --timeline timeline.json
event 2: location.onEnter zone=home timestamp=1760000060000
arriving-home MATCHED location.onEnter=home
ntfy message=fairphone entered home priority=3 topic=phones
event 3: location.onExit zone=home timestamp=1760003600000
leaving-home MATCHED location.onExit=home
notify body=geofence exit title=left home
message=fairphone entered home is the interpolator resolving ${location.zone} against the event, on the laptop, using the file the phone runs.
Step 5: Diff the findings against your own checks
The goal: turn the comparison into a decision, which is the only reason to do it.
Tasker’s string table holds 92 event names and 51 state names. Events are edge triggered and fire once. States are level triggered, hold over an interval, and a profile with a state condition gets an exit task for free, from a second task reference on the profile itself. My design has said the same thing since it was written: events are onX and valid only as triggers, states are bare and valid only as conditions.
Reading their 51 states sent me to check mine, and my validator rejects exactly that design:
if len(path) != 2 or path[0] != definition.ns or path[1] not in definition.event_fields:
path[0] != definition.ns means a condition may only name fields of the trigger that fired. The engine agrees with it:
if (ns != event.ns) {
return MatchResult.Failed(ns, "condition is on namespace '$ns', event is '${event.ns}'")
}
And so does the interpolator, in a third place. So an SMS rule predicated on being at home, the exact example in my own design document, is a build error today and has been since the checks were written:
$ python3 -m compiler build ./demo.py
automation 'from-home': condition field 'location.zone' is not provided by trigger 'sms.onReceived'.
Available: sms.from, sms.body, sms.timestamp, sms.subscriptionId, sms.slot
Three one-line comparisons, and then the real work, which is having somewhere for the state to live and something keeping it current. That is the finding worth the whole teardown, and it was in my code rather than theirs.
Two smaller ones came out of the same pass. Their profiles carry a Cooldown Time, described in their own help text as “The times after the profile has become active before it can again become active”, reset by a reboot. Mine has no rate concept at all, and mode does not substitute for one: mode answers what happens when a rule fires while the last run is still going, and cooldown answers what happens when it fires again immediately after. A geofence sitting at the edge of its radius is the second question. And their per-action <se>false</se>, continue-on-error, with %err and %errmsg readable by the next action, is a minimum viable error model. Mine returns Unit from every action and logs failures.
File formats
The artifact, version 1, as the compiler emits it. Deterministic: json.dumps(sort_keys=True, indent=2), so a diff of two builds is a diff of two configs.
"automations": {
"arriving-home": {
"action": [
{
"ntfy": {
"message": "fairphone entered ${location.zone}",
"priority": 3,
"topic": "phones"
}
}
],
"condition": {},
"description": "Tell the fleet when the phone gets home",
"mode": "single",
"persistence": "ephemeral",
"trigger": {
"location": {
"onEnter": "home"
}
}
},
Every action argument is named. "priority": 3 is present although the author never wrote it, because the decorator applied the signature’s defaults at call time. An empty condition means “always”. The trigger is a doubly nested singleton, namespace then event, whose value is the trigger’s whole argument.
The trigger table it references travels in the same file, which is what lets the engine check an incoming event against the shape its receiver claimed to build:
"triggers": {
"location.onEnter": {
"delivery": "pendingIntent",
"event": {
"timestamp": "long",
"zone": "string"
},
"permissions": [
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.ACCESS_BACKGROUND_LOCATION"
]
},
Tasker’s export, for contrast, reduced to a skeleton. Every element carries sr, a source reference used as an index:
<TaskerData sr="" dvi="1" tv="6.6.20">
<Profile sr="prof75" ve="2">
<mid0>76</mid0> <!-- entry task id -->
<mid1>77</mid1> <!-- exit task id, states only -->
<State sr="con0" ve="2">
<code>165</code> <!-- which state, numerically -->
<pin>true</pin> <!-- present only when inverted -->
</State>
</Profile>
<Task sr="task76">
<Action sr="act0" ve="7">
<code>547</code> <!-- Variable Set -->
<se>false</se> <!-- continue on error -->
<Str sr="arg0" ve="3">%myvar</Str>
</Action>
</Task>
</TaskerData>
mid0 and mid1 are the mechanism behind the event and state split: one profile declaration, two task references, and the second only means anything for a condition that has an end. <pin>true</pin> is whole-condition negation as a flag on the condition rather than an operator inside it, which is the right shape and the one gap the comparison found in my operator set: anythingBut negates a leaf and nothing negates a condition.
What bit us
Epoch milliseconds do not fit in an int. A trigger field declared int is parsed into a Kotlin Int, which is 32 bits on the JVM, and a timestamp is around 1.76e12. The compiler is Python, where integers are arbitrary precision, so nothing surfaced until the engine existed and silently truncated. Every millisecond field is declared long, and the reason is a comment on the field type itself.
A path check that tested >= 2 and then read only segments 0 and 1. Anything nested deeper validated silently, including the exact case the check exists to catch: a misspelled operator name kept the walk descending, so {"sms": {"body": {"numerc": ...}}} passed. A condition path is always exactly namespace.field, because everything below a field is a leaf.
The registries are module-level dicts populated by import. A test that registers its own trigger leaks it into every later test in the run. Worse, from actions import send_sms does not raise when the package stops exporting that name: Python falls back to importing the submodule, which succeeds and runs the decorator. A test asserting a name is absent can register it in the act of failing.
A check that cannot fail is worse than no check, because the comment above it becomes a lie. The coverage gate ran a simulation and asserted the exit code, but every rule still fired at least once in the broken world, so it exited 0 either way. It now counts matched lines, and the commit that fixed it records watching it fail on purpose before believing it.
Verify the round numbers, including the ones in your own notes. My teardown recorded Tasker’s constraint mini-language with "0:100:50" as the min:max:default example. That string occurs zero times in the decompiled sources: it is the illustrative example from Tasker’s own documentation blob, not a real constraint. The format is real, and real ones look like "1:1000:200" and "100:15000:1000". Two other notes moved on re-checking: the permission count is 110 rather than “about 100”, and the rate-limiter list is ordered Throttle, Sample, Debounce, Buffer, None, Buffer Debounce, with None fifth rather than last, which matters because the artifact stores the ordinal.
References
- Android background execution limits, the reason a modern automation app needs a foreground service at all
- Foreground service types, including
specialUse - jadx and Apktool, the two decoders an APK needs
typing.Annotatedandinspect.Signature.bind, which are the whole of the registry- Hadlow’s configuration complexity clock, the failure mode a deliberately dumb placeholder syntax is avoiding