Appearance
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.
| Library | Status |
|---|---|
base | Loaded, 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, utf8 | Available (pure). |
coroutine | Removed — coroutine.resume re-captures errors (same escape as pcall). |
os | Removed. Time/date is provided by synapse.time.* instead; os.execute/exit/remove/rename/getenv/… are gone. |
io | Removed entirely (no file or pipe access). |
package / require | Removed (no external modules); the synapse API is injected directly. |
debug | Removed (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,xpcallandcoroutineare 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 protectinglua_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
_Gmetatable (__newindex+ a locked__metatable), so a script cannotsetmetatable(_G, …)it away. It is a typo guard, not a security boundary: a deliberaterawset(_G, …)bypasses it (rawsetis intentionally kept). That is harmless — globals are private to the one automation'slua_State(no cross-automation effect), and the engine independently validates anything a script passes across the C boundary.
Namespace overview
| Namespace | Purpose |
|---|---|
synapse.meta{…} | Declare identity + config schema (top level). |
synapse.log.* | journald logging. |
synapse.mqtt.* | Raw subscribe/publish/unsubscribe. |
synapse.variable.get | Read 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.FunctionCode | Device function-code constants (separate top-level global). |
Metadata & config schema
| Function | Signature | Description |
|---|---|---|
synapse.meta | synapse.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 fromdeploy.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 forsynapse.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 type | Picks | Script helper |
|---|---|---|
output | a commandable output | setOutput(ioc, on, dimming?) |
outputs | an array of outputs | setOutput(iocs, on, dimming?) |
button | a button | pressButton(ioc, button, release?) |
tor | a digital input | readTOR(ioc) -> bool |
analog | an analog input | readAnalog(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)
| Function | Signature | Description |
|---|---|---|
onStart | function onStart() | Called once when the automation is enabled. Register subscriptions/timers/FSMs here (not at file top level). |
onStop | function onStop() | Called once when disabled or on shutdown. Return devices to a safe state. |
Declare
synapse.metafirst. It resolves the effective config, so anysynapse.config.*read AFTER it (top level or inonStart/handlers) returns the real effective value (deploy override → schema default). Asynapse.config.get/.allcalled beforesynapse.metais a hard error that stops the script. Dynamic registrations and mutable state still belong inonStart(not at top level).
Logging — synapse.log
| Function | Signature | Description |
|---|---|---|
synapse.log.debug/info/warn/error | synapse.log.info(message) | Write to journald at the given level, tagged with the instance id. |
MQTT — synapse.mqtt
| Function | Signature | Description |
|---|---|---|
synapse.mqtt.subscribe | synapse.mqtt.subscribe(topic_pattern, handler) | Subscribe; handler(topic, msg) runs on each match. |
synapse.mqtt.publish | synapse.mqtt.publish(topic, payload, opts?) | Publish. payload is a table (JSON-encoded) or string. opts = { retain=bool, qos=int }. |
synapse.mqtt.unsubscribe | synapse.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 onvariable/<name>(muxen-sensors< 5.0.0). Asubscribe/unsubscribewhose pattern still starts withvariable/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 assubscribe("app/sensor/fresh-water-portside", …). Only the prefix is rewritten; every other topic is passed through verbatim, andpublishis never rewritten.muxen-synapse-ctl debug <id> --what wiringreports the pattern the engine actually holds, so a rewritten subscription is listed underapp/sensor/…. Write new automations againstapp/sensor/<name>; the old spelling is a compatibility shim, not a second supported name.
Reading
nmea/*reports. These are plain MQTT topics (notapp/sensor/<name>), so they are not in the variable cache —synapse.variable.getwon't see them; subscribe and keep the last value + asynapse.time.monotonic()timestamp yourself. Each report is{ data, metadata }-wrapped, so readmsg.data.<…>(e.g.msg.data.motors["0"].engine.rpm,msg.data.speed_course.sog). Two traps:nmea/motoris 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 anrpm == 0message; andspeed_course.sogis in knots and is JSONnull(→ Luanil) 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
| Function | Signature | Description |
|---|---|---|
synapse.variable.get | synapse.variable.get(name) → table|nil | Latest 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
| Function | Signature | Description |
|---|---|---|
synapse.device.command | synapse.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.setOutput | synapse.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.readTOR | synapse.device.readTOR(ioc) -> boolean|nil | Current 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.readAnalog | synapse.device.readAnalog(ioc) -> number|nil | Current analogInput<index> value, or nil when no fresh value is cached. |
synapse.device.pressButton | synapse.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/readAnalogread the engine's device-state cache, fed fromdevice/<fc>/<inst>/io. A value past itsmetadata.expireAfterSecwindow reads asnil— a script must never act on stale I/O.readTORis a digital-input reader; it does not report a keypad button's LED (the button serialiser emitsoff/green/green-blink/red/red-blink, never"on").Reading a keypad button (fc 0). Subscribe
device/0/<inst>/stateand read the integerled<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 codes1/2. ⚠️ In atype="button"config the IOChannelindexis 0-based (LED keyled<index>Code, andpressButtonusesindex+1), whereas the projectbuttonMapping.indexis 1-based — don't cross the two.
Channel range is 1-based universally; the upper bound is per-device. The
channel ≥ 1floor is enforced engine-wide (channel0is 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≤ 8guard 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'senter, and/or a periodicsynapse.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/requestOffis the Bloc8/output convention (fc 1), not Lighting. The Lighting device (fc 8) decodes only{ channel, on, dimming }—channel1-based (6 channels),ona boolean,dimming0–100. A{ requestOn = true }payload sent to Lighting is silently ignored (the light won't switch). UsesetOutput(or arequestOn/requestOffcommand) 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 withpressButton.
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 = 99andconst.FunctionCode.Foo = 1both fail withconst.FunctionCode is read-only (cannot assign '…')and fault the instance;const.FunctionCode = {}fails withconst is read-only. The constants keep their values. - An unknown name reads as
nil, without an error:const.FunctionCode.NoSuchDevice == nil. Check fornilif a name may be missing from an older build. pairs(const.FunctionCode)iterates every constant (deprecated aliases included), and#const.FunctionCodeis0as for any table keyed by names.next(const.FunctionCode)returnsnil: iterate withpairs.getmetatable(const.FunctionCode)returns the string"const.FunctionCode", andsetmetatableon 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.
| Constant | Code | Constant | Code | |
|---|---|---|---|---|
Button | 0 | CurrentLimiter | 19 | |
Bloc8 | 1 | WaterMaker | 20 | |
Hydrogenerator | 2 | AirConditioning | 21 | |
Interconnexion | 3 | SFSPReceptor | 22 | |
PowerGenerator | 4 | SFSPSwitch | 23 | |
Battery | 5 | MagicTrim | 24 | |
PowerConverter | 6 | BatterySwitch | 25 | |
Motor | 7 | CGS | 26 | |
Lighting | 8 | Alternator | 27 | |
Display | 9 | ThermalEngine | 28 | |
SolarPanel | 10 | KeelMotor | 29 | |
WindTurbine | 11 | BatteryConcentrator | 30 | |
NavigationInstruments | 12 | BusExtender | 31 | |
ImocaKeelTeam | 16 | BowThruster | 32 | |
GenericIO | 17 | |||
BlinkKeypad | 18 |
Deprecated:
const.FunctionCode.SFSPRecepterstill resolves to22, as an alias ofSFSPReceptor, so existing scripts keep working. It follows@muxen/device-id9.5.1, which renamed the member and keepsSFSPRecepteras a@deprecatedalias. WriteSFSPReceptorin new scripts.
Generated at build time from
libstdmuxen(MUXEN_FUNCTION_*instdmuxen/functions.h, the canonical source@muxen/device-idalso mirrors), via the existing.wrapdependency — never hand-maintained, never drifts. Codes 13–15 are reserved (internal IMOCA keel) and omitted.
Timers — synapse.timer
| Function | Signature | Description |
|---|---|---|
synapse.timer.every | synapse.timer.every(interval_ms, handler) → handle | Run handler() every interval_ms. |
synapse.timer.after | synapse.timer.after(delay_ms, handler) → handle | Run handler() once after delay_ms. |
synapse.timer.cancel | synapse.timer.cancel(handle) | Cancel a timer created above. |
Timers are monotonic — a GPS/system clock jump does not distort an interval. FSM
after/everyinherit this (the FSM is built over these primitives). Snoozes, by contrast, are wall-clock; see How the engine runs automations.
Clock & calendar — synapse.time
| Function | Signature | Description |
|---|---|---|
synapse.time.now | synapse.time.now() → number | Current wall-clock time, Unix epoch seconds (UTC, fractional). |
synapse.time.monotonic | synapse.time.monotonic() → number | Monotonic seconds for measuring durations — unaffected by clock/GPS time jumps. |
synapse.time.date | synapse.time.date(fmt?, t?) → string | Format t (default now()) via strftime; default fmt is ISO-8601 UTC. |
synapse.time.parse | synapse.time.parse(iso) → number|nil | Parse an ISO-8601 string to epoch seconds (handy for comparing sextant timestamps with now()). |
synapse.time.components | synapse.time.components(t?) → table | Local-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 andsynapse.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.
| Function | Signature | Description |
|---|---|---|
synapse.math.clamp | synapse.math.clamp(lo, v, hi) → number | Constrain 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 surprise85→85.0).
lua
local dimming = synapse.math.clamp(20, 100 - level * 15, 100) -- floor 20, ceil 100Astronomical 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().
| Function | Returns (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_nightare tri-state —nil≠false. With no GPS fix or no sextant, they returnnil(unknown), which is falsy in Lua: a bareif 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 })
endEvent 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°).
| Function | Fires at — in plain terms | Sun below horizon | data field |
|---|---|---|---|
synapse.sextant.onSunSet(handler) | the sun's upper edge dips below the horizon — the visible sunset | 0° (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 visible | 6° | civilTwilightEnd |
synapse.sextant.onNauticalDusk(handler) | nautical dusk — too dark to tell the sea horizon from the sky | 12° | nauticalTwilightEnd |
synapse.sextant.onAstronomicalDusk(handler) | astronomical dusk — full night; the sky is as dark as it gets | 18° | astronomicalTwilightEnd |
synapse.sextant.onAstronomicalDawn(handler) | astronomical dawn — the first faint trace of light, sky still essentially dark | 18° | astronomicalTwilightStart |
synapse.sextant.onNauticalDawn(handler) | nautical dawn — the sea horizon becomes distinguishable again | 12° | nauticalTwilightStart |
synapse.sextant.onCivilDawn(handler) | civil dawn — the sky is clearly lightening and objects are visible, before the sun is up | 6° | civilTwilightStart |
synapse.sextant.onSunRise(handler) | the sun's upper edge appears over the horizon — daybreak | 0° (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)
endAstro data is sourced, not computed — synapse never duplicates sextant's almanac. If
muxen-sextantisn't deployed, accessors returnniland 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.
| Function | Fires when | Handler |
|---|---|---|
synapse.sextant.onEnterHarbour(nm, handler [, hysteresis_nm]) | the nearest harbour comes within nm | handler(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)—harbouris the nearest station table ({ name, distanceNm, … }), ornilwhen 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 exceedsnm + hysteresis_nm, so the events don't chatter when the distance wobbles right at the boundary. - Each returns a
handleusable withsynapse.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, whichmuxen-sextantrecomputes 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 thresholdnmshould 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)
endConfiguration — 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.
| Function | Signature | Description |
|---|---|---|
synapse.config.get | synapse.config.get(key, default?) → value | Effective value for key. |
synapse.config.all | synapse.config.all() → table | The 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'ssynapse.meta.configschema 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.getalready applies the schema default, the caller'sdefaultargument 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={…} }
configvsstate: config is operator-set and read-only (you receive tuning); state is script-owned and writable (you persist runtime values).
Persistent state — synapse.state
| Function | Signature | Description |
|---|---|---|
synapse.state.get | synapse.state.get(key) → value|nil | Read a value persisted for this instance. |
synapse.state.set | synapse.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
| Function | Signature | Description |
|---|---|---|
synapse.ui.prompt | synapse.ui.prompt{ title, content, actions, key?, timeout?, onAnswer } → bool | Raise 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. |
contentis 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 callsonAnswer("timeout"). Absent or0uses 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 samekey, so distinct prompts from one automation snooze independently.onAnswer(action, duration)fires when the user responds, on timeout (action == "timeout"), or immediately ifkeyis currently snoozed (action == "snoozed").durationcarries 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.
| Function | Signature | Description |
|---|---|---|
synapse.fsm.new | synapse.fsm.new(spec) → fsm | Build an FSM. Optional spec.name (default "main") labels it in debug dumps and persistence. |
fsm:fire | fsm:fire(event, data?) → bool | Trigger event (optional payload). Transitions if valid in the current state; else returns false. Safe from any callback. |
fsm:state | fsm:state() → string | Current state name. |
fsm:can | fsm:can(event) → bool | Whether event is valid now. |
fsm:is | fsm:is(state) → bool | Whether currently in state. |
State spec
Per state, alongside on (event → target state) and enter/leave:
| Key | Meaning |
|---|---|
onMessage | map topic_pattern → handler(self, topic, msg). Subscribed on enter, unsubscribed on leave. |
after | map delay_ms → handler(self). One-shot timers armed on enter, cancelled on leave. |
every | map interval_ms → handler(self). Repeating timers, armed on enter, cancelled on leave. |
prompt | a 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/promptis 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:firecan be called from a top-levelsynapse.mqtt.subscribe/timercallback for global triggers. - A state's
promptobeys 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.statevalues, 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.
