Skip to content

Lua API reference ​

This is the complete surface an automation can use — every function, its arguments and its behaviour. You need it to write an automation; you do not need it to deploy one that already exists (The shipped automations), and Writing an automation is the shorter guided tour.

The runtime is Lua 5.5, vendored and statically linked into the daemon, so it is the same on every target.

The engine injects a single global table synapse into each automation's lua_State, organised into sub-namespaces (synapse.mqtt.*, synapse.device.*, synapse.timer.*, synapse.time.*, synapse.sextant.*, synapse.config.*, synapse.state.*, synapse.log.*, synapse.ui.*, synapse.fsm.*). A separate top-level global const holds the constant table const.FunctionCode. The lifecycle hooks onStart/onStop are plain globals.

Each automation runs in its own dedicated lua_State, so a script's globals and helpers are private to it and cannot overlap with another's (How the engine runs automations). All states are driven single-threaded from the GLib main loop, so every callback is invoked from that loop and must return promptly — non-blocking, within the 50 ms CPU watchdog.

Sandbox & strict globals ​

Synapse does not call luaL_openlibs. The engine builds each lua_State with a curated, safe environment, enforced in C at state creation so it cannot be undone from a script.

LibraryStatus
baseLoaded, minus dofile, loadfile, load, loadstring, collectgarbage, warn, and pcall/xpcall — no arbitrary code loading, no GC control, and no in-script error capture (see below). print is redirected to synapse.log.info. Kept: pairs, ipairs, next, select, type, tostring, tonumber, error, assert, setmetatable, getmetatable, rawget/rawset/rawequal/rawlen.
string, table, math, utf8Available (pure).
coroutineRemoved — coroutine.resume re-captures errors (same escape as pcall).
osRemoved. Time/date is provided by synapse.time.* instead; os.execute/exit/remove/rename/getenv/… are gone.
ioRemoved entirely (no file or pipe access).
package / requireRemoved (no external modules); the synapse API is injected directly.
debugRemoved (except the engine's own watchdog hook).

Headline: no shell, no filesystem, no process control, no dynamic code loading.

No error capture (by design). pcall, xpcall and coroutine are removed because they are the only ways a script could catch an error — including the watchdog's "exceeded time budget" abort. With them gone, a runaway callback cannot swallow the timeout and keep looping: the error always unwinds to the engine's protecting lua_pcall, the callback is aborted, and the fault is counted. A genuine script error likewise propagates and faults the instance (the desired behaviour) rather than being silently swallowed.

Strict globals (Lua 5.5). The sandbox enforces global declarations: assigning to an undeclared global is a load-time error, catching typos before a script ever runs. The engine pre-declares synapse, const, onStart, onStop. Author your own variables as local (top-level locals live for the script's lifetime). Example:

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

Strictness is enforced via a protected _G metatable (__newindex + a locked __metatable), so a script cannot setmetatable(_G, …) it away. It is a typo guard, not a security boundary: a deliberate rawset(_G, …) bypasses it (rawset is intentionally kept). That is harmless — globals are private to the one automation's lua_State (no cross-automation effect), and the engine independently validates anything a script passes across the C boundary.

Namespace overview ​

NamespacePurpose
synapse.meta{…}Declare identity + config schema (top level).
synapse.log.*journald logging.
synapse.mqtt.*Raw subscribe/publish/unsubscribe.
synapse.variable.getRead a cached app/sensor/<name> value.
synapse.device.*High-level device commands.
synapse.timer.*Periodic / one-shot timers.
synapse.time.*Clock & calendar.
synapse.sextant.*Astronomical data (sun/moon/tides/magnetic) from muxen-sextant.
synapse.config.*Read-only per-instance tuning (deploy.json).
synapse.state.*Writable persisted key/value store.
synapse.ui.*User-confirmation popups.
synapse.fsm.*Finite state machines.
const.FunctionCodeDevice function-code constants (separate top-level global).

Metadata & config schema ​

FunctionSignatureDescription
synapse.metasynapse.meta{ name, description?, version?, id?, config? }Declare the template/automation's identity and config schema. Called once at top level.
  • name, description, version — human metadata, shown in the template picker.
  • id — optional. The running instance id comes from deploy.json; a template instantiated several times gets its id per instance. Omit in reusable templates.
  • config — the config schema: an ordered list of tuning fields, exported in the HTTP template index so raken can render a form, and supplying defaults for synapse.config.get.
lua
synapse.meta{
  name        = "Automatic bilge pump",
  description = "Runs the bilge pump while the bilge level is high.",
  version     = "1.0.0",
  config = {
    { key = "high_threshold", type = "number",  default = 80, label = "High level (%)", min = 0, max = 100 },
    { key = "low_threshold",  type = "number",  default = 20, label = "Low level (%)",  min = 0, max = 100 },
    { key = "pump_channel",   type = "integer", default = 3,  label = "Bloc8 channel",  min = 1, max = 8  },
  },
}

Field descriptor: key (required), type (number/integer/string/boolean/enum/output/button/tor/analog), default, label, description?, min?/max? (numeric), options? (for enum), required?. Declarative metadata only — it runs no logic.

The output, button, tor and analog types are valued as an IOChannel — { deviceId, index } (both required integers; deviceId = functionCode*64 + instance, index the 0-based channel). The frontend renders a device picker for these; the script receives the IOChannel as a Lua table and acts on it with the matching synapse.device helper:

Field typePicksScript helper
outputa commandable outputsetOutput(ioc, on, dimming?)
outputsan array of outputssetOutput(iocs, on, dimming?)
buttona buttonpressButton(ioc, button, release?)
tora digital inputreadTOR(ioc) -> bool
analogan analog inputreadAnalog(ioc) -> number

The engine validates the IOChannel types identically and strictly — a malformed IOChannel falls back to the field's default; the type only differs as a picker hint. outputs is the array form of output: its value is a Lua array of IOChannels (every element must be valid, the empty array is allowed). setOutput accepts either a single IOChannel or an array of them, so an outputs config value can be driven in one call:

lua
synapse.device.setOutput(synapse.config.get("lights"), true, 80) -- all on at 80%

Lifecycle hooks (globals) ​

FunctionSignatureDescription
onStartfunction onStart()Called once when the automation is enabled. Register subscriptions/timers/FSMs here (not at file top level).
onStopfunction onStop()Called once when disabled or on shutdown. Return devices to a safe state.

Declare synapse.meta first. It resolves the effective config, so any synapse.config.* read AFTER it (top level or in onStart/handlers) returns the real effective value (deploy override → schema default). A synapse.config.get/.all called before synapse.meta is a hard error that stops the script. Dynamic registrations and mutable state still belong in onStart (not at top level).

Logging — synapse.log ​

FunctionSignatureDescription
synapse.log.debug/info/warn/errorsynapse.log.info(message)Write to journald at the given level, tagged with the instance id.

MQTT — synapse.mqtt ​

FunctionSignatureDescription
synapse.mqtt.subscribesynapse.mqtt.subscribe(topic_pattern, handler)Subscribe; handler(topic, msg) runs on each match.
synapse.mqtt.publishsynapse.mqtt.publish(topic, payload, opts?)Publish. payload is a table (JSON-encoded) or string. opts = { retain=bool, qos=int }.
synapse.mqtt.unsubscribesynapse.mqtt.unsubscribe(topic_pattern)Remove a subscription owned by this automation.

Handler message. msg is the payload decoded to a Lua table when it is JSON (so msg.data.value, msg.metadata.expireAfterSec work); if the payload is not JSON, msg is the raw string. topic_pattern supports MQTT wildcards (+, #).

Sensor values moved to app/sensor/<name>. They used to be published on variable/<name> (muxen-sensors < 5.0.0). A subscribe / unsubscribe whose pattern still starts with variable/ is accepted and rewritten onto the current prefix, so an automation written against the old name keeps working untouched — subscribe("variable/fresh-water-portside", …) behaves exactly as subscribe("app/sensor/fresh-water-portside", …). Only the prefix is rewritten; every other topic is passed through verbatim, and publish is never rewritten. muxen-synapse-ctl debug <id> --what wiring reports the pattern the engine actually holds, so a rewritten subscription is listed under app/sensor/…. Write new automations against app/sensor/<name>; the old spelling is a compatibility shim, not a second supported name.

Reading nmea/* reports. These are plain MQTT topics (not app/sensor/<name>), so they are not in the variable cache — synapse.variable.get won't see them; subscribe and keep the last value + a synapse.time.monotonic() timestamp yourself. Each report is { data, metadata }-wrapped, so read msg.data.<…> (e.g. msg.data.motors["0"].engine.rpm, msg.data.speed_course.sog). Two traps: 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 an rpm == 0 message; and speed_course.sog is in knots and is JSON null (→ Lua nil) when there is no valid speed source, so nil-check it.

Cross-automation coordination is done via MQTT only — automations share no Lua memory (states are isolated). Publish/subscribe on a shared topic to coordinate (e.g. one automation gates another). There is no other cross-talk channel.

Variables — synapse.variable ​

FunctionSignatureDescription
synapse.variable.getsynapse.variable.get(name) → table|nilLatest cached app/sensor/<name> value as { value, unit, min, max, ... }, or nil when absent or stale. Staleness is measured from the payload's own publish time (metadata.rxTimestamp) against its metadata.expireAfterSec window.

Auto-expiry is deliberate: a script never acts on stale sensor data unknowingly — handle nil. Freshness is anchored on the payload's metadata.rxTimestamp, not the engine's receive time, so a retained value replayed when synapse first subscribes (e.g. from a dead/idle sensor at boot) is correctly seen as stale rather than momentarily fresh. A value must carry both metadata.rxTimestamp and metadata.expireAfterSec — every MUXEN publisher emits them; a payload missing either is treated as permanently stale (get returns nil) rather than trusted.

Devices — synapse.device ​

FunctionSignatureDescription
synapse.device.commandsynapse.device.command(functionCode, instance, payload)Publish payload (table) to device/<functionCode>/<instance>/command. functionCode is a const.FunctionCode constant (preferred) or raw integer. Channel numbers in payload are 1-based (Bloc8 1–8, not 0–7); synapse maps to the wire — see Writing an automation.
synapse.device.setOutputsynapse.device.setOutput(ioc_or_array, on, dimming?)Turn an output IOChannel on/off and set its dimming level. ioc_or_array is either a single IOChannel (a table with deviceId) or an array of them (an outputs config value) — an array drives every channel in one call (empty array → nothing; a bad element raises before anything publishes). Publishes device/<fc>/<inst>/command { channel=ioc.index, requestOn, requestOff, dimming } per channel (here index is the 0-based wire channel). dimming is an optional brightness percent in [1,100] (default 100; needs muxen-boat ≥ 9.4.0). Pass on = nil to update only the dimming and leave the on/off state untouched: setOutput(ioc, nil, 40).
synapse.device.readTORsynapse.device.readTOR(ioc) -> boolean|nilCurrent digital-input (TOR) state: digitalInput<index>. Returns nil when no fresh value is cached — handle it like synapse.variable.get. Not for keypad buttons — to read a button's on/off state, subscribe device/0/<inst>/state and inspect led<index>Code (see the LED-code note below).
synapse.device.readAnalogsynapse.device.readAnalog(ioc) -> number|nilCurrent analogInput<index> value, or nil when no fresh value is cached.
synapse.device.pressButtonsynapse.device.pressButton(ioc, button, release?)Simulate a key press on a button device (ioc must address functionCode 0). button is 1–8; release defaults to true (momentary tap — the device auto-releases ~200 ms later), pass false to press-and-hold.

readTOR/readAnalog read the engine's device-state cache, fed from device/<fc>/<inst>/io. A value past its metadata.expireAfterSec window reads as nil — a script must never act on stale I/O. readTOR is a digital-input reader; it does not report a keypad button's LED (the button serialiser emits off/green/green-blink/red/red-blink, never "on").

Reading a keypad button (fc 0). Subscribe device/0/<inst>/state and read the integer led<index>Code: 0=off, 1=green-blink, 2=green, 3=red-blink, 4=red. A plain on/off light button is "lit" at any non-zero code; a status button (e.g. a navigation/pilote button) is "on" at the green codes 1/2. ⚠️ In a type="button" config the IOChannel index is 0-based (LED key led<index>Code, and pressButton uses index+1), whereas the project buttonMapping.index is 1-based — don't cross the two.

Channel range is 1-based universally; the upper bound is per-device. The channel ≥ 1 floor is enforced engine-wide (channel 0 is always rejected). The upper bound depends on the device — Bloc8 is 1–8; other channel-addressed devices (e.g. Interconnexion) have their own count. Don't rely on a universal ≤ 8 guard for non-Bloc8 devices.

Commands are fire-and-forget (QoS 0). A command published during a broker reconnect can be silently dropped — the engine does not queue it. Make commands idempotent and re-assert the desired state (in onStart, an FSM state's enter, and/or a periodic synapse.timer.every) rather than relying on a one-shot edge command sticking. synapse does not arbitrate between two instances driving the same output — it is last-write-wins at the device (How the engine runs automations).

lua
synapse.device.command(const.FunctionCode.Bloc8, 0, { channel = 3, requestOn = true })
synapse.device.command(const.FunctionCode.AirConditioning, 0, { command = 1, parameter = 18 })
synapse.device.command(const.FunctionCode.Lighting, 0, { channel = 1, on = true, dimming = 80 })

requestOn/requestOff is the Bloc8/output convention (fc 1), not Lighting. The Lighting device (fc 8) decodes only { channel, on, dimming } — channel 1-based (6 channels), on a boolean, dimming 0–100. A { requestOn = true } payload sent to Lighting is silently ignored (the light won't switch). Use setOutput (or a requestOn/requestOff command) for Bloc8 power outputs, and { on = … } for the Lighting controller. On many boats the nav/anchor/steaming lights are wired to keypad buttons, not the Lighting device — drive those with pressButton.

const.FunctionCode — device function-code constants ​

A constants table mirroring the canonical FunctionCode enum in libstdmuxen. Use the named constant instead of a magic number — const.FunctionCode.Bloc8 reads like the TypeScript FunctionCode.Bloc8. Its values are the integers, so it is a drop-in for the numeric argument.

The table is read-only, and so is const itself:

  • Every assignment raises, to an existing name or a new one: const.FunctionCode.Bloc8 = 99 and const.FunctionCode.Foo = 1 both fail with const.FunctionCode is read-only (cannot assign '…') and fault the instance; const.FunctionCode = {} fails with const is read-only. The constants keep their values.
  • An unknown name reads as nil, without an error: const.FunctionCode.NoSuchDevice == nil. Check for nil if a name may be missing from an older build.
  • pairs(const.FunctionCode) iterates every constant (deprecated aliases included), and #const.FunctionCode is 0 as for any table keyed by names. next(const.FunctionCode) returns nil: iterate with pairs.
  • getmetatable(const.FunctionCode) returns the string "const.FunctionCode", and setmetatable on it raises (cannot change a protected metatable).

Both are empty proxies whose metatable forwards reads to the real table (__index), rejects writes (__newindex) and supplies __pairs/__len. As with the strict-globals guard above, rawset(const.FunctionCode, k, v) is not blocked: it writes into the proxy and shadows that name, but only within that one automation's state, and synapse.device.command re-validates the code, so it cannot mis-address another instance's device.

ConstantCodeConstantCode
Button0CurrentLimiter19
Bloc81WaterMaker20
Hydrogenerator2AirConditioning21
Interconnexion3SFSPReceptor22
PowerGenerator4SFSPSwitch23
Battery5MagicTrim24
PowerConverter6BatterySwitch25
Motor7CGS26
Lighting8Alternator27
Display9ThermalEngine28
SolarPanel10KeelMotor29
WindTurbine11BatteryConcentrator30
NavigationInstruments12BusExtender31
ImocaKeelTeam16BowThruster32
GenericIO17
BlinkKeypad18

Deprecated: const.FunctionCode.SFSPRecepter still resolves to 22, as an alias of SFSPReceptor, so existing scripts keep working. It follows @muxen/device-id 9.5.1, which renamed the member and keeps SFSPRecepter as a @deprecated alias. Write SFSPReceptor in new scripts.

Generated at build time from libstdmuxen (MUXEN_FUNCTION_* in stdmuxen/functions.h, the canonical source @muxen/device-id also mirrors), via the existing .wrap dependency — never hand-maintained, never drifts. Codes 13–15 are reserved (internal IMOCA keel) and omitted.

Timers — synapse.timer ​

FunctionSignatureDescription
synapse.timer.everysynapse.timer.every(interval_ms, handler) → handleRun handler() every interval_ms.
synapse.timer.aftersynapse.timer.after(delay_ms, handler) → handleRun handler() once after delay_ms.
synapse.timer.cancelsynapse.timer.cancel(handle)Cancel a timer created above.

Timers are monotonic — a GPS/system clock jump does not distort an interval. FSM after/every inherit this (the FSM is built over these primitives). Snoozes, by contrast, are wall-clock; see How the engine runs automations.

Clock & calendar — synapse.time ​

FunctionSignatureDescription
synapse.time.nowsynapse.time.now() → numberCurrent wall-clock time, Unix epoch seconds (UTC, fractional).
synapse.time.monotonicsynapse.time.monotonic() → numberMonotonic seconds for measuring durations — unaffected by clock/GPS time jumps.
synapse.time.datesynapse.time.date(fmt?, t?) → stringFormat t (default now()) via strftime; default fmt is ISO-8601 UTC.
synapse.time.parsesynapse.time.parse(iso) → number|nilParse an ISO-8601 string to epoch seconds (handy for comparing sextant timestamps with now()).
synapse.time.componentssynapse.time.components(t?) → tableLocal-time breakdown { year, month, day, hour, min, sec, wday, yday, isdst }.

Use synapse.time.now() for "what time is it / is it after 22:00" logic and synapse.time.monotonic() for "has 30 s elapsed" logic.

Math — synapse.math ​

Small numeric helpers not in Lua's standard math library, implemented in C so they're fast in tight per-tick loops and shared across automations instead of being re-defined in every script.

FunctionSignatureDescription
synapse.math.clampsynapse.math.clamp(lo, v, hi) → numberConstrain v to the range [lo, hi]: returns lo if v < lo, hi if v > hi, else v.

⚠️ Argument order is (lo, v, hi) — the value in the middle. The value that wins is returned unchanged, so an integer stays an integer and a float stays a float (no surprise 85 → 85.0).

lua
local dimming = synapse.math.clamp(20, 100 - level * 15, 100)  -- floor 20, ceil 100

Astronomical data — synapse.sextant ​

Reads the retained topics published by the muxen-sextant daemon (sextant/sun, sextant/moon, sextant/tides, sextant/magnetic). Two styles: poll the current values, or register a callback that fires at an astronomical event (sunrise, dusk, …).

Poll current values ​

Each accessor returns the latest data object, or nil if sextant isn't publishing or the value is stale (expireAfterSec = 3600). Timestamps in the returned tables are ISO-8601 strings — pass them through synapse.time.parse to compare with now().

FunctionReturns (nil if unavailable)
synapse.sextant.sun(){ rise, set, transit, civilTwilightStart/End, nauticalTwilight…, astronomicalTwilight…, altitude, azimuth }
synapse.sextant.moon(){ rise, set, transit, altitude, azimuth, phase, illumination, daysToNewMoon, daysToFullMoon }
synapse.sextant.tides(){ stations = { { name, distanceNm, height, nextHighTide, nextLowTide, tidalCoefficient, curve… }, … } }
synapse.sextant.magnetic(){ declination, inclination, totalIntensity }
synapse.sextant.is_day()bool|nil — convenience derived from the sun's altitude (≥ 0).
synapse.sextant.is_night()bool|nil — sun below the horizon.
synapse.sextant.nearestHarbour(){ name, distanceNm, latitude, longitude, … }|nil — the closest tides() station (nearest station = nearest harbour), or nil if tides are absent/stale.

⚠️ is_day/is_night are tri-state — nil ≠ false. With no GPS fix or no sextant, they return nil (unknown), which is falsy in Lua: a bare if synapse.sextant.is_night() then … treats "unknown" as "not night" and silently never fires. For safety-relevant actions, branch on == true / == false / unknown explicitly and choose the fail-safe direction.

lua
-- only run the anchor light after dusk; if night is UNKNOWN (no fix), fail safe → on
local night = synapse.sextant.is_night()
if night == true or night == nil then
  synapse.device.command(const.FunctionCode.Lighting, 0, { channel = 1, on = true })
end

Event callbacks ​

Register a handler that fires at an astronomical moment — far cleaner than polling for "do X at sunset". The engine derives the next occurrence from the sextant data and schedules it; it re-arms automatically each day as new sextant data arrives. Registrations are auto-removed on disable (like subscriptions) and return a handle you can pass to synapse.timer.cancel.

Each event fires once, at the instant it happens. They are listed below in daily order — first as night falls (each step darker), then as day breaks (each step lighter). The "sun below horizon" column is how far the sun has dropped at that moment: the further down, the darker it is outside. The four twilight stages are the standard astronomical definitions (civil 6°, nautical 12°, astronomical 18°).

FunctionFires at — in plain termsSun below horizondata field
synapse.sextant.onSunSet(handler)the sun's upper edge dips below the horizon — the visible sunset0° (setting)set
synapse.sextant.onCivilDusk(handler)civil dusk — usable daylight is gone and you'd reach for the lights; the sea horizon is still faintly visible6°civilTwilightEnd
synapse.sextant.onNauticalDusk(handler)nautical dusk — too dark to tell the sea horizon from the sky12°nauticalTwilightEnd
synapse.sextant.onAstronomicalDusk(handler)astronomical dusk — full night; the sky is as dark as it gets18°astronomicalTwilightEnd
synapse.sextant.onAstronomicalDawn(handler)astronomical dawn — the first faint trace of light, sky still essentially dark18°astronomicalTwilightStart
synapse.sextant.onNauticalDawn(handler)nautical dawn — the sea horizon becomes distinguishable again12°nauticalTwilightStart
synapse.sextant.onCivilDawn(handler)civil dawn — the sky is clearly lightening and objects are visible, before the sun is up6°civilTwilightStart
synapse.sextant.onSunRise(handler)the sun's upper edge appears over the horizon — daybreak0° (rising)rise
synapse.sextant.onSolarNoon(handler)the sun reaches its highest point of the day (solar culmination — not clock noon)— (highest)transit
synapse.sextant.onMoonRise(handler) / onMoonSet(handler)the moon rises above / sets below the horizon—sextant/moon.rise / .set

handler(when) receives the event's ISO-8601 time. Behaviour:

  • It is an edge trigger — it fires when the clock crosses the event instant, i.e. as you "pass" the upcoming sunrise/sunset time. It does not fire retroactively for an instant already in the past at registration: the engine arms the next future occurrence (a sunrise already gone today fires tomorrow).
  • A forward clock jump (GPS correction) that lands past the armed instant still fires it once (no double-fire, no skip); the next re-arm uses the corrected times.
  • If sextant isn't publishing yet, nothing arms until it does.
  • Fires once per occurrence, within a small tolerance of the computed time.

⚠️ Edge ≠ state. Because these fire only at the transition, a script that enables/starts after the last transition has "missed" it. For on-at-sunset/off-at-sunrise logic, set the initial state by polling in onStart (e.g. is_night()), then let the events handle subsequent transitions:

lua
-- anchor light: correct state now + event-driven transitions
local function light(on)
  synapse.device.command(const.FunctionCode.Lighting, 0, { channel = 1, on = on })
end

function onStart()
  local night = synapse.sextant.is_night()      -- set the right state on enable…
  if night ~= nil then light(night) end          -- (nil = unknown → leave as-is)
  synapse.sextant.onSunSet(function() light(true)  end)   -- …then react to crossings
  synapse.sextant.onSunRise(function() light(false) end)
end

Astro data is sourced, not computed — synapse never duplicates sextant's almanac. If muxen-sextant isn't deployed, accessors return nil and events never fire.

Harbour geofence — onEnter / onLeaveHarbour ​

Unlike the astronomical events above (which fire at a time), these fire on a distance edge: as the boat crosses a radius around the nearest harbour. The "nearest harbour" is the closest sextant/tides station (each station carries a name + distanceNm), so no extra data source or GPS handling is needed.

FunctionFires whenHandler
synapse.sextant.onEnterHarbour(nm, handler [, hysteresis_nm])the nearest harbour comes within nmhandler(harbour)
synapse.sextant.onLeaveHarbour(nm, handler [, hysteresis_nm])the nearest harbour recedes beyond nm (+ hysteresis)handler(harbour)
  • nm — the geofence radius in nautical miles (required, > 0). The two events are independent, so you can register only the one you need, or give enter and leave different radii (e.g. enter at 1 nm, leave at 3 nm) for explicit hysteresis.
  • handler(harbour) — harbour is the nearest station table ({ name, distanceNm, … }), or nil when none is in range. The direction is implied by which event you registered, so there's no boolean.
  • Hysteresis (default nm * 0.1, overridable via the 3rd arg) — a leave only fires once the distance exceeds nm + hysteresis_nm, so the events don't chatter when the distance wobbles right at the boundary.
  • Each returns a handle usable with synapse.timer.cancel; auto-removed on disable.

Behaviour mirrors the astronomical events: these are edge triggers and do not fire for the state already true at registration. The engine establishes the initial inside/outside state silently on the first reading; poll synapse.sextant.nearestHarbour() in onStart if you need the current state up front.

⚠️ Refresh latency & range. The distance comes from sextant/tides, which muxen-sextant recomputes on a throttle (≈30 min) / large position drift — not continuously — and only lists stations within its search radius (≈50 nm by default). So a crossing can be reported minutes late, and a threshold nm should stay comfortably below the tide search radius to be meaningful (beyond it the station list empties and the nearest distance becomes "infinite" → treated as left). This is fine for "we've left the harbour, resume passage automations", but do not use it for prompt, metre-accurate geofencing.

lua
-- "Are we at least 30 nm from the nearest harbour?" — arm passage automations offshore.
function onStart()
  -- seed the current state so we don't assume we start at sea
  local h = synapse.sextant.nearestHarbour()
  synapse.log.info(h and ("nearest harbour: "..h.name.." @ "..h.distanceNm.." nm") or "no harbour fix")

  synapse.sextant.onLeaveHarbour(30, function(harbour)
    synapse.log.info("offshore now — nearest harbour ≥ 30 nm away")   -- e.g. arm passage automations
  end)
  synapse.sextant.onEnterHarbour(30, function(harbour)
    synapse.log.info("back within 30 nm of "..(harbour and harbour.name or "?"))
  end)
end

Configuration — synapse.config (read-only tuning) ​

Tuning for this instance only — the config map of its own synapse.<id> entry in /etc/muxen/deploy.json. A script can never read another instance's config. Read-only to the script.

FunctionSignatureDescription
synapse.config.getsynapse.config.get(key, default?) → valueEffective value for key.
synapse.config.allsynapse.config.all() → tableThe instance's effective config — schema defaults included.

Both resolve against the schema, so defaults are always applied. Precedence for a key is: deploy value (validated/clamped) → schema default → the caller's default arg (config.get only) → nil.

  • config.all() returns the merged effective map: every key in the template's synapse.meta.config schema is present with its effective value (deploy override if set, else the schema default), plus any extra deploy keys not in the schema. So with no deploy config at all, config.all() still returns the schema defaults — not an empty table. It is empty only when the script has neither a config schema nor deploy config.
  • Because config.get already applies the schema default, the caller's default argument is just a last-resort fallback for a key that exists in neither the deploy config nor the schema.
lua
-- Read after synapse.meta (top level is fine; before synapse.meta is an error).
local HIGH = synapse.config.get("high_threshold", 80)  -- deploy → schema default → 80
local PUMP = synapse.config.get("pump")                -- output IOChannel { deviceId, index }
local cfg  = synapse.config.all()                      -- e.g. { high_threshold=80, low_threshold=20, pump={…} }

config vs state: config is operator-set and read-only (you receive tuning); state is script-owned and writable (you persist runtime values).

Persistent state — synapse.state ​

FunctionSignatureDescription
synapse.state.getsynapse.state.get(key) → value|nilRead a value persisted for this instance.
synapse.state.setsynapse.state.set(key, value)Persist a JSON-serialisable value (per-instance namespace in state.json).

Survives daemon restart and explicit disable/enable (it is the script's durable store). For small values, not bulk data. (Contrast with FSM position — see below — which resets on explicit disable.)

User-interaction — synapse.ui ​

FunctionSignatureDescription
synapse.ui.promptsynapse.ui.prompt{ title, content, actions, key?, timeout?, onAnswer } → boolRaise a confirmation popup (published in synapse/<id> status). Returns false (does nothing) if a popup is already pending — only one popup may be pending per script.
  • content is Markdown (rendered by the frontend apps).
  • actions — list of { id, label, style?, snooze? } (snooze = seconds for an ignore action).
  • timeout (seconds) — how long the popup waits for an answer before the engine clears it and calls onAnswer("timeout"). Absent or 0 uses the engine default of 300 s. Every popup times out; there is no "wait forever".
  • key (default "default") scopes the snooze — an ignore suppresses only the same key, so distinct prompts from one automation snooze independently.
  • onAnswer(action, duration) fires when the user responds, on timeout (action == "timeout"), or immediately if key is currently snoozed (action == "snoozed"). duration carries the snooze seconds for an ignore; the engine arms/persists the snooze itself.
lua
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 = function(action, duration)
    if action == "ok" then
      synapse.device.command(const.FunctionCode.AirConditioning, 0, { command = 0 })
    end
  end,
}

Finite state machines — synapse.fsm ​

Automations are often stateful, driven by asynchronous events (MQTT, timers, popup answers). Each state declares its own async 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. An FSM lives in the script's own lua_State.

FunctionSignatureDescription
synapse.fsm.newsynapse.fsm.new(spec) → fsmBuild an FSM. Optional spec.name (default "main") labels it in debug dumps and persistence.
fsm:firefsm:fire(event, data?) → boolTrigger event (optional payload). Transitions if valid in the current state; else returns false. Safe from any callback.
fsm:statefsm:state() → stringCurrent state name.
fsm:canfsm:can(event) → boolWhether event is valid now.
fsm:isfsm:is(state) → boolWhether currently in state.

State spec ​

Per state, alongside on (event → target state) and enter/leave:

KeyMeaning
onMessagemap topic_pattern → handler(self, topic, msg). Subscribed on enter, unsubscribed on leave.
aftermap delay_ms → handler(self). One-shot timers armed on enter, cancelled on leave.
everymap interval_ms → handler(self). Repeating timers, armed on enter, cancelled on leave.
prompta popup { key?, title, content, actions, onAnswer(self, action, duration) }, raised on enter, cleared on leave. key defaults to the state name (not "default"), so each state's prompt snoozes independently; set key explicitly only to share a snooze across states.

Handlers receive the FSM as self and call self:fire(event).

lua
local function bilge_high(self, _, msg)
  if msg.data.value > 80 then
    self:fire("rise")
  end
end

local function bilge_low(self, _, msg)
  if msg.data.value < 20 then
    self:fire("drained")
  end
end

local function pump_on()
  synapse.device.command(const.FunctionCode.Bloc8, 0, { channel = 3, requestOn = true })
end

local function pump_off()
  synapse.device.command(const.FunctionCode.Bloc8, 0, { channel = 3, requestOff = true })
end

local function onRetry(self, action)
  if action == "retry" then
    self:fire("reset")
  end
end

local fsm = synapse.fsm.new{
  initial = "idle",
  states = {
    idle = {
      onMessage = { ["app/sensor/bilge-level"] = bilge_high },
      on         = { rise = "pumping" },
    },
    pumping = {
      enter      = pump_on,
      leave      = pump_off,
      onMessage = { ["app/sensor/bilge-level"] = bilge_low },
      after      = { [30000] = function(self) self:fire("timeout") end },  -- safety timeout
      on         = { drained = "idle", timeout = "error" },
    },
    error = {
      prompt = {
        title   = "Bilge pump timeout",
        content = "The pump ran 30 s without draining. Retry?",
        actions = {
          { id = "retry",  label = "Retry" },
          { id = "ignore", label = "Ignore for 3h", snooze = 3 * 3600 },
        },
        onAnswer = onRetry,
      },
      on = { reset = "idle" },
    },
  },
  onTransition = function(from, to, event) end,  -- optional, fires on every change
}

Semantics ​

  • Auto-teardown of every onMessage/after/every/prompt is the core guarantee — scripts never manually unsubscribe or cancel between states.
  • Serialised firing: events run on the single main loop; an event fired during a transition is queued and processed after it completes (no re-entrancy).
  • Manual + async coexist: fsm:fire can be called from a top-level synapse.mqtt.subscribe/timer callback for global triggers.
  • A state's prompt obeys the one-pending-popup-per-script rule.
  • Persistence (automatic): the FSM's current state is tracked by the engine. On a daemon restart (crash/redeploy) it resumes its last state — long sequences aren't lost. On an explicit disable→re-enable it resets to initial — a deliberate "off then on" re-derives from live state. (synapse.state values, by contrast, survive both.) See Deploying automations.
  • Deliberately minimal for v1: named states/events, enter/leave, state-scoped async triggers, one global transition hook. Guards / hierarchical states are out of scope.
  • Implemented as a pure-Lua module over the C primitives (subscribe/timer/prompt) — same script-facing API regardless.

Script validation (raken) ​

raken can validate a script before saving it (syntax, strict-global typos, missing meta) and obtain its config schema for the form, over MQTT — see MQTT interface synapse/validate. Validation loads the script in an inert sandbox (same sandbox + strict globals; synapse.* I/O stubbed, onStart not called), so it has no side effects.

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