Appearance
Deploying automations
A boat runs the automations its deployed configuration says it runs. Nothing else. Installing the package puts six templates on the disk; it starts none of them.
This chapter covers how an automation gets declared, how it is tuned, how it is turned on and off, and what survives a restart.
The two files
Everything about a deployed automation lives in one of two places, and they are easy to confuse:
| File | Owned by | Holds | The script |
|---|---|---|---|
/etc/muxen/deploy.json | the config daemon | which instances run, and their tuning | reads it (synapse.config) |
/var/lib/muxen-synapse/state.json | synapse | enable overrides, snoozes, FSM positions, script values | writes it (synapse.state) |
Synapse only ever reads deploy.json — raken changes an automation by asking the config daemon to rewrite it. state.json is synapse's only writable artifact; the systemd unit's StateDirectory=muxen-synapse provisions the directory with the right ownership, and ProtectSystem=strict makes the rest of the filesystem read-only to the daemon.
Declaring an instance
The synapse section of deploy.json is the authoritative list. Each entry is keyed by its instance id and sources its Lua code either from a shipped template or from an inline script:
json
{
"synapse": {
"fresh-water-port": {
"template": "sensor-level-detection",
"enabled": true,
"name": "Fresh water — port",
"name.FR": "Eau douce — bâbord",
"config": {
"variable": "fresh-water-portside",
"button": { "deviceId": 0, "index": 3 },
"lowLevel": 20
}
},
"fresh-water-stbd": {
"template": "sensor-level-detection",
"config": {
"variable": "fresh-water-starboard",
"button": { "deviceId": 0, "index": 4 },
"lowLevel": 20
}
},
"shore-mgmt": {
"script": "synapse.meta{ name = 'Custom shore logic' }\nfunction onStart() … end",
"config": { "prompt_timeout_s": 120 }
}
}
}| Field | Meaning |
|---|---|
| key | The instance id. Must match [a-zA-Z0-9-]+ and be unique. Becomes synapse/<id>. |
template | Name of a template in /usr/share/muxen-synapse/, without .lua. Code is read from that file at load time. |
script | Inline Lua source, stored in deploy.json. Used instead of template. |
config | Per-instance tuning, exposed read-only to the script. |
enabled | Deployed default. Optional, defaults to true. false is a hard force-off — see below. |
name | Operator-facing display name (English). Overrides the script's own name in the published status. |
name.<LANG> | Localised display names, e.g. name.FR, name.ES. Republished verbatim so a frontend picks name.<its-locale> and falls back to name. |
Exactly one of template or script per instance. Both, or neither, is an invalid instance: it is rejected and logged, and the others still run.
Template or inline — which to use
template reads the code from the package file every time the instance loads. When a package update ships an improved template, every boat referencing it follows automatically with no per-boat edit. This is what you want for anything the fleet shares.
script freezes the Lua source inside deploy.json. Use it for a boat-specific automation you want without cutting a package release — typically authored in raken, often seeded by fetching a template's .lua and editing it. It does not track template updates; it is a private copy.
Inline scripts run under exactly the same sandbox and the same 50 ms watchdog as templates. They carry a soft size cap of about 64 KB: exceeding it logs a warning but the script still runs. A large automation is better shipped as a template.
Config and its validation
config is arbitrary JSON, scoped to the instance. A script reads it with synapse.config.get(key) and can never see another instance's config. It is read-only to the script.
Every template declares a config schema — the ordered list of tuning fields with their types, defaults, labels and ranges. That schema does two jobs: raken renders a form from it, and the engine validates against it at load.
Validation corrects rather than rejects, and logs a warning each time:
| Problem | What the engine does |
|---|---|
Numeric value outside its min/max | Clamped to the range. 12 with max: 8 becomes 8. |
| Type mismatch that can be resolved | Coerced — "8" becomes 8. |
| Type mismatch that cannot | Falls back to the schema default. |
Malformed output/button/tor/analog value | Falls back to the schema default. These must be an IOChannel { deviceId, index } — both integers, deviceId in 0–4095, index ≥ 0. The stored value is strict: extra keys are dropped. |
Malformed element in an outputs array | Any invalid element invalidates the whole value, which falls back to the default. An empty array is valid. |
| Key not in the schema | Kept as-is with a warning, for forward compatibility. |
The intent is that a bad config value runs with a safe in-range value instead of failing silently later at the device.
Resolution order for a key is: the deploy value (validated and clamped) → the schema default → the caller's own fallback argument → nil. So an instance with no config at all still gets every schema default.
muxen-synapse-ctl config <id> prints the effective, post-validation map.
Enable / disable
Three things decide whether an automation runs, in this order.
1. Is it in deploy.json? Only instances listed there exist. An id dropped from the file is forgotten at the next start: its stored state is discarded and its retained status topic is cleared, so it disappears from the frontend.
2. Does deploy force it off? "enabled": false on the instance is a hard force-off. The instance is not loaded at all — no interpreter is built — and a runtime enable command is refused and logged. This is the operator escape hatch: pushing enabled: false reliably stops a misbehaving automation on every boat, including ones where a user toggled it on. A force-off instance publishes no status; its retained topic is cleared so it drops off the frontend list entirely.
3. Otherwise, the user's last choice wins. With "enabled": true (or the field omitted), the effective state is the runtime override in state.json if there is one, else on. So a normal enable/disable from the interface sticks across restarts, and a later deploy setting enabled: true re-applies the override that force-off had masked.
The asymmetry is deliberate: deploy can always force off, but it defers on forcing on.
What a disable actually does
Disabling is stop, not forget. In order, the engine:
- calls
onStop()on the script, under the watchdog, so it can put devices in a safe state — synapse never auto-reverts a device, that is the script's job; - tears down the instance's wiring: MQTT subscriptions (broker subscriptions are reference-counted, so a topic stays subscribed only while another instance still needs it), timers, and FSM triggers;
- clears any pending popup — no feedback is awaited and
onAnsweris not called; - destroys the instance's Lua interpreter, so it consumes nothing while dormant;
- persists
enabled: falseand republishes the retained status.
Surviving a disable, because they live in state.json rather than in the interpreter: the script's persisted synapse.state values, and any active snoozes — so re-enabling does not immediately re-raise a popup the crew had just snoozed.
Cleared on an explicit disable: the FSM position. A deliberate off-then-on should re-derive from live state, so each state machine restarts at its initial state.
Re-enabling is a clean start. The engine builds a fresh sandboxed interpreter, reloads the code — so a template or config update is picked up — re-reads the config, restores the persisted state values, and calls onStart().
Fault auto-disable
An automation that keeps failing stops itself. The engine counts consecutive callback faults per instance — script errors and watchdog timeouts both count — and a successful callback resets the counter. On reaching five, the engine calls onStop, tears the instance down, and stops running it.
This is a runtime fault state, not a change to what the user asked for:
enabledis left untouched. The status showsenabled: true,running: false, withlast_errorexplaining the fault, so a frontend can render "enabled but faulted".- It is not persisted. On the next daemon restart — for example a redeploy shipping fixed code — the instance starts normally with a fresh counter, so a fix recovers automatically. Code that is still broken simply faults to the threshold again.
- An explicit enable command also clears the fault state and retries immediately.
What survives a restart
state.json is keyed by instance id:
json
{
"automations": {
"shore-mgmt": {
"enabled": true,
"snoozes": { "load-shed": { "until": "2026-08-16T17:30:00Z" } },
"state": { "last_shed": "2026-08-16T14:30:00Z" },
"fsm": { "main": "producing" },
"fsmVersion": "1.0.0"
},
"fresh-water-stbd": { "enabled": false }
}
}| Key | Meaning |
|---|---|
enabled | Runtime enable/disable override. Outranks the deploy.json default, but not a force-off. |
snoozes | Active popup snoozes with their expiry, keyed by the prompt's snooze key. Distinct prompts from one automation snooze independently. |
state | The script's own key/value store (synapse.state). Survives both restart and explicit disable. |
fsm | Engine-managed state-machine positions, one per named machine. |
fsmVersion | The script version the stored positions were written under. |
The file is written with atomic-write helpers, incrementally as things change and flushed at the start of shutdown — before any onStop runs — so positions and snoozes survive even a teardown that is cut short.
If the file is missing or corrupt at boot, the daemon starts with empty state and logs it. It does not crash. If a write fails, state is kept in memory and the failure logged.
FSM position: disable versus restart
| Event | FSM position |
|---|---|
| Explicit disable → re-enable | Reset to the machine's initial state |
| Daemon restart (crash, redeploy, reboot) | Resumed from state.json, so long sequences are not lost |
Resume is best-effort and self-healing. A redeploy can change a template, so a persisted state name may no longer exist. On resume the engine validates the stored name against the freshly loaded machine: if it is absent, the machine falls back to its initial state (firing that state's enter), a warning is logged and last_error is set. The stored fsmVersion does the same job for a revised script — a version change resets to initial. A resumed machine is never parked in a state it no longer defines with its entry side effects un-run.
Applying a change
A config change is applied by restarting the daemon. Synapse has no live-reload path: it reads deploy.json fresh at startup and never again.
On a deployment this is automatic — the config daemon's deploy phase restarts the MUXEN services through the muxen-restart-target trigger, which the package activates. By hand:
sh
sudo systemctl restart muxen-synapseThe absence of live reload is why disable→enable already rebuilds a clean interpreter: a redeploy is just a full restart, so new instances, changed config and edited inline scripts all take effect with no partial-reload edge cases.
What happens at boot
state.jsonis loaded — runtime enable overrides and snoozes; expired snoozes are dropped.- The
deploy.jsoninstance list is read. Each instance's code is resolved (template file or inline script) and its config validated against the schema. Effective enabled state is computed:falseif deploy forces off (and the instance is not loaded at all), else the stored override if there is one, elsetrue. Stored entries for ids no longer indeploy.jsonare dropped. - Enabled instances are started —
onStart()— and each state machine's last position is restored. - A fresh retained status is published for every deploy-enabled instance. Stale retained topics for deleted or force-off ids are cleared, reconciled against the retained messages the broker replays when synapse subscribes.
If deploy.json has no synapse section at all, nothing runs.
