Skip to content

MQTT interface ​

Everything synapse does happens over MQTT. If you are building a client — a frontend listing automations, a script toggling one, a dashboard rendering popups — this chapter is the contract: every topic, every payload field, and what the engine does with it.

You do not need it to use synapse. muxen-synapse-ctl (muxen-synapse-ctl — the operator CLI) already speaks all of it.

Synapse owns the root topic synapse/ for its control plane, and otherwise reuses the existing Brain topic conventions for reading boat data and commanding devices. All payloads are JSON. The broker is the local Mosquitto instance (127.0.0.1:1883 by default), client id muxen-synapse.

The engine subscribes to # — a single subscription covering the control plane, its data caches and every script subscription — and routes internally.

The synapse/ topic tree ​

TopicDirectionRetainedPayload
synapse/<id>engine → allyesAutomation status (below).
synapse/<id>/commandfrontend → enginenoControl command (enable/disable).
synapse/<id>/feedbackfrontend → enginenoUser's answer to a popup.
synapse/<id>/debugrequester → enginenoOn-demand debug request for one automation.
synapse/<id>/debug/dumpengine → requesternoOne-shot debug snapshot (reply to the request).
synapse/debugrequester → enginenoOn-demand engine-wide debug request.
synapse/debug/dumpengine → requesternoOne-shot engine-wide snapshot (reply).
synapse/validaterequester → enginenoValidate a script (raken authoring an inline script).
synapse/validate/resultengine → requesternoValidation result (reply).
app/synapse/infoengine → allyesDaemon info: version, online, features (below).

<id> is the instance id from the deploy.json synapse section (one template can run under several ids — see Deploying automations).

The debug topics are request/response and never retained — the engine publishes a snapshot only in reply to a request, so there is zero traffic or overhead at rest. They are a diagnostics aid, not a live feed.

Access: open to anything connected to the Brain's local Mosquitto broker, like every other MUXEN topic — access control is the broker/network boundary, not per-topic gating. No dev-mode toggle is required to use them.

synapse/<id> — status (retained) ​

Published retained so a frontend connecting later immediately sees current state. Re-published (a) whenever anything changes (enable/disable, error, popup raised/cleared) and (b) periodically as a heartbeat, so a low-evolution automation — e.g. an FSM that sits in one state for hours — does not appear expired to consumers that apply the standard MUXEN metadata.expireAfterSec staleness check. The heartbeat period is below expireAfterSec, so a healthy instance's retained status never goes stale.

Stale-topic reconciliation. synapse/<id> is cleared — published with an empty payload and retain=true, which deletes the retained message — when the automation no longer owns a status topic: it was removed from deploy.json (deleted) or is force-off via deploy enabled: false. The engine subscribes broadly, so on (re)connect the broker replays every retained synapse/<id>; the engine tombstones any whose id is no longer a live automation, so a deleted or force-off automation stops appearing in the frontend. A runtime-disabled automation (deploy enabled: true, toggled off via synapse/<id>/command) is not cleared — it keeps its topic, published with enabled: false, so it stays listed as present-but-off.

json
{
  "id": "shore-power-load-shed",
  "name": "Shore-power load shedding",
  "name.FR": "Délestage sur secteur",
  "name.ES": "Deslastre en toma de puerto",
  "version": "1.0.0",
  "enabled": true,
  "running": true,
  "last_run": "2026-06-23T14:30:00Z",
  "last_error": null,
  "popup": {
    "requestId": "9f1c2e7a-...",
    "title": "Shore power lost",
    "content": "Shed non-essential loads to preserve the batteries?",
    "actions": [
      { "id": "ok",     "label": "Shed now",      "style": "primary" },
      { "id": "ignore", "label": "Ignore for 3h", "snooze": 10800     }
    ]
  },
  "metadata": { "rxDate": "2026-06-23T14:30:00Z", "expireAfterSec": 60 }
}
FieldMeaning
id, name, versionid is the deploy.json key; name/version come from the script metadata. name is the English default — the deploy.json name overrides it if set.
name.<LANG>Localized display-name variants (e.g. name.FR, name.ES) echoed from deploy.json, if any. A client picks name.<its-locale> and falls back to name. raken authors these per-instance; the Lua script is never localized.
enabledWhether the automation is enabled (persisted; survives restart).
runningWhether it is currently started (and not in an error-stopped state).
last_runISO-8601 timestamp of the last callback/handler execution.
last_errorLast error string, or null.
popupPresent only while a confirmation is pending; otherwise omitted/null.
metadataStandard MUXEN freshness block: rxDate (publish time) + expireAfterSec. Refreshed on every publish, including the heartbeat, so consumers can detect a genuinely dead daemon (no update within expireAfterSec) while a quiet-but-healthy automation stays fresh.

Popup object:

FieldMeaning
requestIdUUID identifying this popup instance. At most one popup is pending per <id>, so it serves only to reject stale/duplicate feedback.
keyThe prompt's snooze key (default "default"). An ignore/snooze suppresses only this key, so distinct prompts from one automation snooze independently.
titlePlain-text heading.
contentBody text in Markdown. Synapse publishes it verbatim; the consuming frontend apps (raken, etc.) render the Markdown (emphasis, lists, line breaks, …).
actions[]Buttons: id (returned in feedback), label, optional style (primary/danger/…), optional snooze (seconds) for ignore-type actions.

synapse/<id>/command — control (frontend → engine) ​

json
{ "enabled": true }
FieldMeaning
enabledtrue enables (calls onStart), false disables (calls onStop). Persisted to state.json.

Future verbs (e.g. reload) may be added; unknown fields are ignored.

synapse/<id>/feedback — popup answer (frontend → engine) ​

json
{ "requestId": "9f1c2e7a-...", "action": "ignore", "duration": 10800 }
FieldMeaning
requestIdMust match the automation's currently-pending popup; otherwise the answer is ignored (stale/duplicate).
actionThe chosen action id (e.g. "ok", "ignore").
durationOptional, seconds. For an ignore/snooze action, how long to suppress re-prompting (e.g. 10800 = 3 h). Omitted for an immediate Ok.

On receipt the engine invokes the script's onAnswer(action, duration), clears the popup from status, and — for an ignore action — arms and persists a snooze so the same popup is not re-raised until it expires.

synapse/<id>/debug — on-demand debug request (→ …/debug/dump) ​

A request asks the engine for a one-shot snapshot of a single automation. The payload is optional and may filter what is returned; an empty payload returns everything.

json
{ "what": ["fsm", "variables", "config", "state", "wiring"] }
what valueReturns
fsmEvery FSM the script created: name, current state, time in state, valid events.
variablesThe values the script currently references (from the engine's app/sensor/… cache), with age.
configThe script's effective config map from deploy.json (synapse.config).
stateThe automation's persisted key/value store (synapse.state).
wiringActive subscriptions, timers, and any pending popup.
commandsRecent device.command targets (fc/inst/channel + time), to diagnose two instances fighting over one output.

The engine replies on synapse/<id>/debug/dump (non-retained):

json
{
  "id": "watermaker-cycle",
  "ts": "2026-06-23T14:30:00Z",
  "enabled": true,
  "running": true,
  "fsm": [
    {
      "name": "main",
      "state": "producing",
      "since": "2026-06-23T14:22:10Z",
      "can": ["full", "fault"]
    }
  ],
  "variables": {
    "fresh-water-level": { "value": 62, "unit": "%", "age_s": 3, "stale": false }
  },
  "config": { "high_threshold": 80, "low_threshold": 20, "pump_channel": 3 },
  "state": { "cycles_today": 4 },
  "wiring": {
    "subscriptions": ["app/sensor/fresh-water-level", "device/20/0/error/+"],
    "timers": [{ "kind": "after", "remaining_ms": 7200 }],
    "popup": null
  }
}

synapse/debug — on-demand engine-wide request (→ …/debug/dump) ​

A request with no <id> returns an engine overview on synapse/debug/dump: the daemon version/uptime, the list of known automations with enabled/running/ last_error, and the full app/sensor/… cache the engine holds. Useful as a single "what is synapse doing right now" probe without subscribing to every synapse/<id>.

FSMs are enumerated by an optional name field on synapse.fsm.new{ name = … } (defaulting to "main"), so a script with several machines is debuggable.

synapse/validate — validate a script (→ …/validate/result) ​

Lets raken validate an inline script before saving it to deploy.json — catching syntax errors, strict-global typos, and a missing synapse.meta, and extracting the config schema so the same form-from-schema flow used for templates also works for inline scripts. Request:

json
{ "requestId": "a1b2…", "script": "synapse.meta{ name='X' }\n...", "config": { "pump_channel": 3 } }

The engine loads the script in an inert sandbox — the same sandbox + strict globals as a real instance, but synapse.* I/O (subscribe/publish/device/timer/ui) is stubbed and onStart is never called, so validation has no side effects (no subscriptions, no commands). The top-level chunk runs under the 50 ms CPU watchdog (a runaway chunk aborts to ok:false rather than stalling the daemon — the topic is open on the local broker), and at most one validation runs at a time (a concurrent request gets ok:false, error:"validation busy"). It executes the top-level chunk to capture meta, then replies on synapse/validate/result:

json
{
  "requestId": "a1b2…",
  "ok": false,
  "errors": [ { "line": 12, "message": "undeclared global 'pmup_on'" } ],
  "warnings": [ { "message": "config key 'foo' not in schema" } ],
  "meta":   { "name": "X", "version": "1.0.0" },
  "schema": [ { "key": "pump_channel", "type": "integer", "default": 3, "label": "Bloc8 channel", "min": 1, "max": 8 } ]
}
FieldMeaning
oktrue if the script loads cleanly and declares meta.
errorsLoad/syntax/strict-global errors (with line where available); empty when ok.
warningsNon-fatal notes (e.g. a supplied config key not in the schema).
metaParsed synapse.meta (name/description/version).
schemaThe config schema — raken renders the form from this, exactly as for a template's index.json.

Validation is the inline-script counterpart of the build-time template index: templates get their schema from index.json; an inline script gets it (plus a correctness check) from synapse/validate.

Daemon info — app/synapse/info (retained) ​

The engine announces itself on app/synapse/info, retained, QoS 1 — the same topic shape every Brain daemon uses (app/<daemon>/info), so a screen can list every daemon with a single app/+/info subscription.

json
{
  "name": "muxen-synapsed",
  "version": "v2.1.0",
  "hostname": "brain-3",
  "online": true,
  "features": ["status", "command", "popup", "debug", "validate"],
  "metadata": {
    "rxdate": "2026-09-22T07:53:31.999Z",
    "rxTimestamp": 1790063611,
    "expireAfterSec": 3124137600
  }
}
FieldMeaning
nameDaemon executable name.
versionBuild version (git describe). Display only — branch on features, never on version.
hostnameHost the daemon runs on.
onlinetrue on every (re)connect; false on a clean shutdown and through the MQTT last will when the connection is lost.
featuresWhat this engine serves (below). Clients ignore features they do not know.
metadataStandard Brain metadata; expireAfterSec is ~99 years because liveness is carried by online, not by age.
FeatureMeaning
statusRetained synapse/<id> status is published.
commandsynapse/<id>/command enable/disable is honoured.
popupsynapse.ui popups are published and answered on synapse/<id>/feedback.
debugsynapse/<id>/debug and synapse/debug snapshots are served.
validatesynapse/validate script validation is served.

The info is published whether or not any automation is deployed: online says the engine is connected, not that it runs anything.

Topics synapse reads (inputs) ​

Synapse subscribes to existing Brain data topics; scripts consume them via synapse.mqtt.subscribe / synapse.variable.get.

app/sensor/<name> — sensor values ​

Published by sensors/boat. Example app/sensor/cabin-temperature:

json
{
  "name": "cabin-temperature",
  "data":  { "value": 24.3, "unit": "°C", "min": -10, "max": 50 },
  "input": { "functionCode": 17, "instance": 0, "channel": 0, "value": 24.3 },
  "metadata": { "rxDate": "2026-06-23T14:30:00Z", "rxTimestamp": 1781878200, "expireAfterSec": 30 }
}

synapse.variable.get("cabin-temperature") returns the data object (or nil once expireAfterSec has elapsed, measured from the payload's own rxTimestamp — so a retained value replayed at subscribe reads as stale, not fresh; a payload missing rxTimestamp/expireAfterSec is treated as permanently stale).

The payload is unchanged from the variable/<name> topic these values used to be published on (muxen-sensors < 5.0.0) — only the prefix moved. An install still pinned to the old prefix (MUXEN_TOPIC_PREFIX=variable in a drop-in) must be unpinned when synapse is upgraded, or the cache stays empty and every synapse.variable.get returns nil. A deployed automation that still subscribesvariable/<name> needs no edit: the binding rewrites the prefix (Lua API reference).

A <name> is exactly one topic level. app/sensor/ also roofs the calibration request/response pair (app/sensor/calibration/…), which is a separate feature: the engine ignores it rather than caching it as a value.

device/<fc>/<inst>/... — device telemetry ​

Per-device state and errors (e.g. device/5/0/error/0). Scripts may subscribe to these directly when they need device-level signals beyond app/sensor/….

The engine also caches device/<fc>/<inst>/io ({ digitalInput<n>, analogInput<n> }) and device/0/<inst>/state ({ led<n> }, button devices) so synapse.device.readTOR(ioc) / readAnalog(ioc) (Lua API reference) can return an input's current value without the script subscribing by hand. The cache honours each payload's metadata.expireAfterSec (a stale value reads as nil).

system/time — boat wall clock (consumed when remote) ​

boat publishes its UTC clock on system/time (a bare ISO-8601 string, ~1 Hz). When synapse runs remotely it takes its wall-clock "now" from this topic so freshness math (now − rxTimestamp) stays in the publishers' frame instead of trusting a possibly-offset local clock — see Deploying automations §"Wall-clock time source" (--prefer-mqtt-time / --prefer-local-time). On the brain (the default) it is ignored. Scripts may still subscribe system/time directly.

sextant/* — astronomical data (muxen-sextant) ​

Retained topics sextant/sun, sextant/moon, sextant/tides, sextant/magnetic (each { data, metadata }, expireAfterSec 3600). Scripts read them through the synapse.sextant.* helpers (Lua API reference) — e.g. synapse.sextant.sun(), synapse.sextant.is_night() — rather than subscribing by hand. Returns nil if muxen-sextant isn't publishing.

The sextant/tides stations double as the harbour list: their nearest entry backs synapse.sextant.nearestHarbour() and the onEnter/onLeaveHarbour(nm, …) geofence events (Lua API reference). No dedicated harbour topic exists — the geofence therefore inherits the tides refresh cadence (recomputed on a ≈30 min throttle / large position drift) and its ≈50 nm search radius, so a harbour crossing can be reported minutes late and thresholds should stay below the search radius.

Each astronomical instant in data (rise, set, transit, civilTwilightStart, …) is published twice: as an ISO-8601 UTC string (e.g. "rise": "2026-06-25T04:12:00Z") and as a Unix-epoch number under the …Timestamp sibling ("riseTimestamp": 1781…), both naming the same instant to the second. The edge-triggered event callbacks (onSunRise/onSunSet/…) schedule off the numeric field, so no timestamp parsing is involved — synapse hard-depends on muxen-sextant (>= 2.2.0), which always ships it. When an event does not occur (polar day/night) both fields are omitted for that key. The callback still receives the ISO string as its argument.

Topics synapse writes (outputs) ​

Synapse commands devices using the standard Brain command topic:

device/<functionCode>/<instance>/command

synapse.device.command(fc, inst, payload) publishes payload there. Catalogue of common targets (from the existing boat dispatcher). In Lua scripts, channel is 1-based (Bloc8 1–8); synapse maps it to the device's on-wire numbering before publishing.

DeviceFCTopicExample payload
Bloc8 power relay1device/1/<i>/command{ "channel": 5, "requestOn": true } / { "channel": 5, "requestOff": true }
Interconnexion3device/3/<i>/command{ "channel": 5, "requestOn": true }
Battery5device/5/<i>/command{ "requestOn": true }
Power converter6device/6/<i>/command{ "forceInverterOnly": true }
Solar (MPPT)10device/10/<i>/command{ "requestOn": true } / { "SetBatteryFloatVoltage": 27.2 }
Water maker20device/20/<i>/command{ "command": 1, "parameter": 0 }
Air conditioning21device/21/<i>/command{ "command": 1, "parameter": 18 } (set 18 °C)
Battery switch25device/25/<i>/command{ "relay": "auto" }
Alternator / thermal27device/27/<i>/command{ "ON": true }
Bow thruster32device/32/<i>/command{ "AUTO": true }

The FC column is the wire function code from libstdmuxen's FunctionCode enum — the single source of truth. In Lua, scripts pass the named constant const.FunctionCode.<Name> (e.g. Bloc8 = 1) rather than the raw number — see Lua API reference. The table above is the practically useful subset; see the boat dispatcher for the exhaustive command vocabulary per device.

Scripts may also publish to any other topic via synapse.mqtt.publish when an automation needs to talk to a non-device subsystem.

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