Skip to content

Writing an automation ​

An automation is a single Lua file. It is the same file whether it ships as a template in the package or is stored inline in the boat's configuration — what differs is only where the code comes from and where its id comes from (Deploying automations).

This chapter is the shape of that file. The full binding reference is Lua API reference.

If you only want to deploy the automations that already exist, you do not need this chapter — see The shipped automations.

The shape of the file ​

Four parts, in this order: metadata and config schema, config reads and script-local state, handlers, lifecycle hooks.

lua
-- 1. METADATA + CONFIG SCHEMA ---------------------------------------------
synapse.meta{
  name        = "Sensor level detection",
  description = "Clicks a keypad button when a variable crosses a threshold.",
  version     = "1.0.0",
  config = {
    { key = "variable", type = "string", required = true,
      label = "Variable name to watch" },
    { key = "lowLevel", type = "number", default = 20,
      label = "Trigger below" },
    { key = "button",   type = "button", required = true,
      label = "Keypad button to click" },
  },
}

-- 2. CONFIG + SCRIPT-LOCAL STATE ------------------------------------------
-- Strict globals: everything you declare is `local`. onStart/onStop are the
-- only globals you define. Reading config here is fine — it comes AFTER
-- synapse.meta, which is what resolves the effective values.
local VARIABLE = synapse.config.get("variable")
local LOW      = synapse.config.get("lowLevel")
local BUTTON   = synapse.config.get("button")   -- IOChannel { deviceId, index }

local below = false   -- edge tracking

-- 3. HANDLERS (local, declared before use) --------------------------------
local function onLevel(topic, msg)
  local level = msg.data.value
  if level == nil then return end
  if level < LOW and not below then
    below = true
    synapse.device.pressButton(BUTTON, BUTTON.index + 1)
    synapse.log.info("level " .. level .. " below " .. LOW .. " — clicked")
  elseif level >= LOW then
    below = false
  end
end

-- 4. LIFECYCLE ------------------------------------------------------------
function onStart()
  synapse.mqtt.subscribe("app/sensor/" .. VARIABLE, onLevel)
end

function onStop()
  below = false
end

Metadata and the config schema ​

synapse.meta declares who the automation is and what it can be tuned with. It runs once, at the top of the file, and it must come first: it is what resolves the effective config, so a synapse.config read before it is a hard error that stops the script.

FieldRequiredMeaning
nameyesHuman-readable name, shown in the template picker
descriptionrecommendedOne line on what it does
versionrecommendedAuthor-managed semver. Also what invalidates a stored FSM position on change
configrecommendedThe config schema — form fields for raken, defaults for synapse.config.get
idoptionalFallback instance id only. The real id comes from deploy.json; omit it in a reusable template

Each schema entry needs a key and a type, and may carry default, label, description, min/max, options (for enum) and required. It is declarative metadata — it runs no logic. The full type list is in Reference.

A field marked required = true with no default makes a deploy that omits it fail the load loudly, which is what you want for something like "which button do I press" — better than silently tapping a placeholder device.

Strict globals ​

The sandbox enforces global declarations. Assigning to a global you did not declare is a load-time error, which catches typos before the script ever runs:

lua
local pump_on = false   -- ok: local
function onStart() end  -- ok: onStart is pre-declared
pmup_on = true          -- ERROR at load: undeclared global 'pmup_on'

Only synapse, const, onStart and onStop are pre-declared. Everything else you write is local — a top-level local lives for the script's lifetime, which is what you want for config values and handlers.

Lifecycle hooks ​

  • onStart() runs once when the automation becomes enabled, at boot or on an enable command. Register subscriptions, timers and state machines here, and initialise mutable state.
  • onStop() runs once on disable or shutdown. Put devices back in a safe state here. The engine then removes the script's subscriptions and timers itself.

Both are optional. A script with neither is legal but inert.

What belongs at top level: synapse.meta, and synapse.config reads — they are available at load and immutable. What belongs in onStart: dynamic registrations and mutable state, so they are cleanly set up on each enable and torn down on each disable.

Triggers ​

A running automation reacts to three kinds of event.

TriggerHow you register it
An MQTT messagesynapse.mqtt.subscribe(pattern, handler) — the handler runs on each match; + and # wildcards work
A timersynapse.timer.every(interval_ms, handler), or synapse.timer.after(delay_ms, handler) for one-shot
An astronomical momentsynapse.sextant.onSunSet(handler) and friends — fires at the crossing
A harbour crossingsynapse.sextant.onEnterHarbour(nm, handler) / onLeaveHarbour
Lifecycledefine onStart / onStop

Handlers must return promptly. Every entry into Lua runs under a hard 50 ms CPU budget; a handler that overruns is aborted, logged, and counted toward auto-disable. Publish a command and return; never busy-wait. Treat the budget as a safety net, not something to spend — see How the engine runs automations.

Reading boat data ​

Two ways in, and they behave differently.

synapse.variable.get(name) reads the engine's cache of app/sensor/<name> and returns { value, unit, min, max, … }, or nil when the value is absent or stale. Freshness is measured from the payload's own publish time against its own expiry window, so a retained reading replayed from a dead sensor when synapse first subscribes reads as stale rather than momentarily fresh. Always handle nil; a script must never act on data it cannot trust.

synapse.mqtt.subscribe gets you anything else. The payload arrives decoded to a Lua table when it is JSON, so msg.data.value and msg.metadata.expireAfterSec just work; a non-JSON payload arrives as a raw string.

Reading nmea/* reports has two traps worth knowing. They are plain topics, not app/sensor/<name>, so the variable cache does not hold them — subscribe and keep the last value yourself with a synapse.time.monotonic() timestamp. And nmea/motor is published only while a motor is active and is not retained, so a stopped engine shows up as messages ceasing: detect it with a staleness watchdog, not by waiting for an rpm == 0 message.

Commanding devices ​

Three routes, depending on what the operator picked in the form.

lua
-- an "output" or "outputs" config field: on/off plus optional dimming
synapse.device.setOutput(synapse.config.get("lights"), true, 80)

-- a "button" config field: tap a keypad button
synapse.device.pressButton(btn, btn.index + 1)

-- anything else: the raw device command topic
synapse.device.command(const.FunctionCode.WaterMaker, 0, { command = 1 })

Four rules govern all of them.

Commands are fire-and-forget. They are published at QoS 0 and the engine does not queue them; a command issued during a broker reconnect can be silently dropped. Make commands idempotent and re-assert the state you want — in onStart, on a periodic timer, or on an FSM state's enter — rather than relying on a one-shot edge command sticking.

Nothing arbitrates. Two enabled instances driving the same output is last-write-wins at the device; synapse delivers matching messages to every instance and never owns an output. Avoiding overlap is a deployment decision. The engine logs an advisory warning at boot when two enabled instances declare commands to the same target, and the debug dump's commands section lists recent command targets per instance.

Channel numbering differs by route. In a raw synapse.device.command payload, channel is 1-based (Bloc8 is 1–8) and synapse maps it to the 0-based wire number. Channel 0 is always rejected. In an IOChannel from a config field, index is 0-based — which is why pressButton above passes index + 1. The channel ≥ 1 floor is enforced engine-wide; the upper bound is per-device, so do not assume a universal ≤ 8.

A button press toggles. Pressing a keypad button already in the state you want flips it the wrong way. Read the button's LED from device/0/<inst>/state (led<index>Code: 0 off, 1 green-blink, 2 green, 3 red-blink, 4 red) and press only when it disagrees. If the LED is unknown or stale, do not press. Every shipped light template works this way.

Asking the crew first ​

Some automations must not act unattended. synapse.ui.prompt raises a popup, returns immediately, and delivers the answer later:

lua
local function onShedAnswer(action, duration)
  if action == "ok" then
    synapse.device.command(const.FunctionCode.AirConditioning, 0, { command = 0 })
  elseif action == "ignore" then
    synapse.log.info("crew snoozed load-shed for " .. duration .. "s")
  elseif action == "timeout" then
    synapse.log.warn("prompt timed out, no action taken")
  end
end

synapse.ui.prompt{
  title   = "Shore power lost",
  content = "Shed **non-essential loads** to preserve the batteries?",
  key     = "load-shed",
  actions = {
    { id = "ok",     label = "Shed now",      style = "primary" },
    { id = "ignore", label = "Ignore for 3h", snooze = 3 * 3600 },
  },
  onAnswer = onShedAnswer,
}
  • The popup is published in the automation's status; a frontend renders it and the crew's choice comes back over MQTT. content is Markdown.
  • Only one popup may be pending per automation. A second prompt while one is pending returns false and does nothing — it neither replaces nor queues.
  • An ignore action carries a snooze in seconds. The engine arms and persists the snooze itself; while it is active, a prompt with the same key resolves immediately as "snoozed" instead of raising anything.
  • The key scopes the snooze (default "default"), so distinct prompts from one automation snooze independently.
  • Every popup times out after 300 s unless the spec sets its own timeout. On timeout onAnswer is called with "timeout" and the popup is cleared.

Because prompt is a no-op while one is pending or snoozed, it is safe to call from a periodic tick — the engine does the de-duplication. That is how watermaker-start-prompt works: a 60 s timer re-checks and calls prompt every time, and after a snooze expires the next tick re-raises it if the condition still holds.

State machines ​

Automations driven by asynchronous events are often easier as a small state machine than as scattered booleans. Each state declares its own triggers; the engine wires them on enter and tears them down on leave, so a state reacts only to its own sources and nothing leaks between states. Scripts never manually unsubscribe or cancel.

lua
local fsm

function onStart()
  fsm = synapse.fsm.new{
    initial = "idle",
    states = {
      idle = {
        onMessage = { ["app/sensor/fresh-water-level"] = function(self, _, msg)
          if msg.data.value < 30 then self:fire("low") end
        end },
        on = { low = "producing" },
      },
      producing = {
        enter = function()
          synapse.device.command(const.FunctionCode.WaterMaker, 0, { command = 1 })
        end,
        onMessage = { ["app/sensor/fresh-water-level"] = function(self, _, msg)
          if msg.data.value > 95 then self:fire("full") end
        end },
        after = { [1800000] = function(self) self:fire("timeout") end },
        on = { full = "idle", timeout = "fault" },
      },
      fault = {
        prompt = {
          title   = "Water maker fault",
          content = "The cycle ran without filling. Retry?",
          actions = {
            { id = "retry",  label = "Retry now" },
            { id = "ignore", label = "Ignore for 1h", snooze = 3600 },
          },
          onAnswer = function(self, action)
            if action == "retry" then self:fire("reset") end
          end,
        },
        after = { [300000] = function(self) self:fire("reset") end },
        on = { reset = "idle" },
      },
    },
  }
end

function onStop()
  synapse.device.command(const.FunctionCode.WaterMaker, 0, { command = 0 })
end

Two things to copy from that example. Every state needs a way out — the fault state is not a dead end; it owns both a recovery prompt and an auto-retry timer, either of which returns it to idle. And an FSM prompt's snooze key defaults to the state name, so per-state prompts never collide.

Events fired during a transition are queued and processed in order after it completes — no re-entrancy — and a runaway cascade is still bounded by the per-callback watchdog.

FSM positions are persisted by the engine: resumed on a daemon restart, reset to initial on an explicit disable (Deploying automations).

Edge triggers versus state ​

The astronomical and harbour callbacks fire at the crossing, not for a condition that is already true. An automation enabled after sunset has "missed" sunset and will not fire until the next one.

For on-at-dusk / off-at-dawn logic, set the initial state by polling in onStart, then let the events handle later transitions:

lua
function onStart()
  local night = synapse.sextant.is_night()
  if night ~= nil then light(night) end          -- correct state now…
  synapse.sextant.onSunSet(function() light(true)  end)   -- …then crossings
  synapse.sextant.onSunRise(function() light(false) end)
end

Note the night ~= nil guard. is_day and is_night are tri-state: with no GPS fix or no sextant they return nil, which is falsy in Lua, so a bare if synapse.sextant.is_night() then treats "unknown" as "not night" and silently never fires. For anything safety-relevant, branch on == true, == false and unknown explicitly, and pick the fail-safe direction for unknown.

What the sandbox does not give you ​

There is no shell, no filesystem, no process control and no way to load code at runtime. os, io, package/require, debug and coroutine are absent; load, loadfile, dofile, collectgarbage and warn are removed from the base library. print is redirected to synapse.log.info.

pcall and xpcall are also gone, deliberately: they are the only way a script could catch an error, including the watchdog's abort. Without them a runaway callback cannot swallow its own timeout, and a genuine error always propagates to the engine and faults the instance instead of being silently ignored.

string, table, math and utf8 are all available.

Automations share no Lua memory — each has its own interpreter. Two automations coordinate over MQTT and nothing else: publish and subscribe on a shared topic. There is no other channel.

Validating before you deploy ​

An inline script can be checked before it is saved. raken does this over the synapse/validate topic (MQTT interface): the engine loads the submitted source in an inert sandbox — same sandbox, same strict globals, but with all I/O stubbed and onStart never called — so validation has no side effects. It reports syntax errors, strict-global typos and a missing synapse.meta with line numbers where available, and returns the config schema so the same form-from-schema flow used for templates works for inline scripts too.

The inert chunk still runs under the 50 ms watchdog, and only one validation runs at a time.

Integration of multiplexed solutions
MUXEN and the MUXEN logo are trademarks of MUXEN SAS.