Appearance
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
| Topic | Direction | Retained | Payload |
|---|---|---|---|
synapse/<id> | engine → all | yes | Automation status (below). |
synapse/<id>/command | frontend → engine | no | Control command (enable/disable). |
synapse/<id>/feedback | frontend → engine | no | User's answer to a popup. |
synapse/<id>/debug | requester → engine | no | On-demand debug request for one automation. |
synapse/<id>/debug/dump | engine → requester | no | One-shot debug snapshot (reply to the request). |
synapse/debug | requester → engine | no | On-demand engine-wide debug request. |
synapse/debug/dump | engine → requester | no | One-shot engine-wide snapshot (reply). |
synapse/validate | requester → engine | no | Validate a script (raken authoring an inline script). |
synapse/validate/result | engine → requester | no | Validation result (reply). |
app/synapse/info | engine → all | yes | Daemon 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 }
}| Field | Meaning |
|---|---|
id, name, version | id 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. |
enabled | Whether the automation is enabled (persisted; survives restart). |
running | Whether it is currently started (and not in an error-stopped state). |
last_run | ISO-8601 timestamp of the last callback/handler execution. |
last_error | Last error string, or null. |
popup | Present only while a confirmation is pending; otherwise omitted/null. |
metadata | Standard 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:
| Field | Meaning |
|---|---|
requestId | UUID identifying this popup instance. At most one popup is pending per <id>, so it serves only to reject stale/duplicate feedback. |
key | The prompt's snooze key (default "default"). An ignore/snooze suppresses only this key, so distinct prompts from one automation snooze independently. |
title | Plain-text heading. |
content | Body 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 }| Field | Meaning |
|---|---|
enabled | true 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 }| Field | Meaning |
|---|---|
requestId | Must match the automation's currently-pending popup; otherwise the answer is ignored (stale/duplicate). |
action | The chosen action id (e.g. "ok", "ignore"). |
duration | Optional, 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 value | Returns |
|---|---|
fsm | Every FSM the script created: name, current state, time in state, valid events. |
variables | The values the script currently references (from the engine's app/sensor/… cache), with age. |
config | The script's effective config map from deploy.json (synapse.config). |
state | The automation's persisted key/value store (synapse.state). |
wiring | Active subscriptions, timers, and any pending popup. |
commands | Recent 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
namefield onsynapse.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 } ]
}| Field | Meaning |
|---|---|
ok | true if the script loads cleanly and declares meta. |
errors | Load/syntax/strict-global errors (with line where available); empty when ok. |
warnings | Non-fatal notes (e.g. a supplied config key not in the schema). |
meta | Parsed synapse.meta (name/description/version). |
schema | The 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) fromsynapse/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
}
}| Field | Meaning |
|---|---|
name | Daemon executable name. |
version | Build version (git describe). Display only — branch on features, never on version. |
hostname | Host the daemon runs on. |
online | true on every (re)connect; false on a clean shutdown and through the MQTT last will when the connection is lost. |
features | What this engine serves (below). Clients ignore features they do not know. |
metadata | Standard Brain metadata; expireAfterSec is ~99 years because liveness is carried by online, not by age. |
| Feature | Meaning |
|---|---|
status | Retained synapse/<id> status is published. |
command | synapse/<id>/command enable/disable is honoured. |
popup | synapse.ui popups are published and answered on synapse/<id>/feedback. |
debug | synapse/<id>/debug and synapse/debug snapshots are served. |
validate | synapse/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>/commandsynapse.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.
| Device | FC | Topic | Example payload |
|---|---|---|---|
| Bloc8 power relay | 1 | device/1/<i>/command | { "channel": 5, "requestOn": true } / { "channel": 5, "requestOff": true } |
| Interconnexion | 3 | device/3/<i>/command | { "channel": 5, "requestOn": true } |
| Battery | 5 | device/5/<i>/command | { "requestOn": true } |
| Power converter | 6 | device/6/<i>/command | { "forceInverterOnly": true } |
| Solar (MPPT) | 10 | device/10/<i>/command | { "requestOn": true } / { "SetBatteryFloatVoltage": 27.2 } |
| Water maker | 20 | device/20/<i>/command | { "command": 1, "parameter": 0 } |
| Air conditioning | 21 | device/21/<i>/command | { "command": 1, "parameter": 18 } (set 18 °C) |
| Battery switch | 25 | device/25/<i>/command | { "relay": "auto" } |
| Alternator / thermal | 27 | device/27/<i>/command | { "ON": true } |
| Bow thruster | 32 | device/32/<i>/command | { "AUTO": true } |
The
FCcolumn is the wire function code fromlibstdmuxen'sFunctionCodeenum — the single source of truth. In Lua, scripts pass the named constantconst.FunctionCode.<Name>(e.g.Bloc8= 1) rather than the raw number — see Lua API reference. The table above is the practically useful subset; see theboatdispatcher 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.
