Synacl Gateway Protocol v1

Protocol v1 · 24 message schemas · Published · index.json · topics.json

This page is the contract between a gateway and Synacl. A gateway is anything that connects to the Synacl MQTT broker with gateway credentials, publishes readings for the devices behind it, and accepts configuration and commands. The Synacl ESP32 firmware implements it; so can a Raspberry Pi script, a PLC, a container next to your SCADA, or a Node-RED flow. Every message has a JSON Schema you can validate against, and the whole contract is machine-readable at /protocol/v1/index.json.

What this is, and why it is open

Synacl is the platform: the broker, the storage, the dashboards, rules, monitors, macros and the MCP server for AI agents. The gateway is the piece in your building, and there is no reason it has to be our hardware. The ESP32 firmware is the reference implementation, not the only one.

So the contract is published in full. Nothing on this page is a simplification: the uplink schemas describe what the platform's validators accept, the downlink schemas describe what the platform sends, and the numbers (heartbeats, windows, rate limits, batch sizes) are the ones in the code. Where the reference firmware does something odd, the known quirks section says so rather than hiding it.

Transport MQTT 3.1.1 over TLS
Payloads JSON, UTF-8, one document per message
Schemas JSON Schema draft-07, one per message, $id = https://synacl.com/protocol/v1/schemas/<name>.json
Machine-readable index /protocol/v1/index.json · /protocol/v1/topics.json · also served by the API at https://api.synacl.com/protocol/v1
Version 1 — frozen; see versioning

Identity and credentials

A gateway is registered in the app before it connects. For an ESP32 the web flasher does this. For anything else: Gateways → Add gateway → Software gateway. The gateway's Connection Info then shows everything below; the password is shown once.

Broker mqtt.synacl.com, port 8883, TLS. Plain port 1883 is also open on the hosted broker for hardware that cannot do TLS; prefer 8883 — on 1883 the gateway's credential crosses the internet in clear.
Username / password Issued per gateway. The username is the identity; the client id can be any string, one per running gateway process.
Topic prefix {gw} tenants/{tenantId}/sources/gateway/{chipId}
{tenantId} The id of the account owner. Team members share it; it is not the id of whoever registered the gateway.
{chipId} The gateway id shown in the app. The ESP32 uses its decimal eFuse MAC; a software gateway is given an id of the form gw_… on registration.
Access control The broker's ACL confines a gateway to its own prefix. A publish or subscribe outside it is refused by the broker — MQTT 3.1.1 carries no error back, so nothing happens and nothing tells you.
Device binding Every device id belongs to one tenant, and the platform binds it to the gateway it is assigned to; enforcement of the gateway binding is being rolled out. A device id you did not get from the config document will not be accepted.

Devices without a gateway. A device that speaks MQTT itself uses tenants/{tenantId}/devices/{deviceId}/… with its own credentials — the {dev} prefix in the topic table. The data, status, alert and command messages are the same ones described here; registration is covered in connect a direct MQTT device and the actuator command contract.

Topic tree

Everything a gateway publishes or subscribes to sits under its prefix. Levels are the conformance levels: implement Core and the platform treats you as a gateway; the rest is what your hardware can do.

{gw} = tenants/{tenantId}/sources/gateway/{chipId} (a gateway and everything behind it) · {dev} = tenants/{tenantId}/devices/{deviceId} (a device that connects on its own, without a gateway).

Core — every gateway implements these

TopicDirection · QoSPayloadNotes
{gw}/status↑ gateway → platformQoS 0 · retainedsource-statusJSON SchemaPresence. Set the MQTT last will to exactly {"online":false} on this topic, retained. Publish {"online":true,…} on connect and then every 60 s; a gateway whose last heartbeat is older than 180 s is shown offline. online:false also marks every device behind the gateway offline.
{gw}/firmware/response↑ gateway → platformQoS 0firmware-responseJSON SchemaCapability report on every connect (and in reply to a firmware/request without `type`); OTA progress while updating. The report REPLACES the stored capabilities wholesale: a protocol you do not list cannot be configured on this gateway, a feature flag you omit reads as unsupported.
{gw}/config/request↑ gateway → platformQoS 0config-requestJSON SchemaSend on every boot AND every reconnect (config/push is not retained). The answer arrives on config/push; the same request also triggers macros/push and the job/config command, so ignore those if you do not implement them.
{gw}/config/push↓ platform → gatewayQoS 1config-pushJSON SchemaReply to config/request: the full device list, {"unchanged":true}, or one chunk of a large config. Also pushed unprompted when the user edits the gateway's devices or network settings.
{gw}/devices/{deviceId}/data↑ gateway → platformQoS 0dataJSON SchemaTelemetry for one device. Each message also refreshes the device's presence. Publishing faster than the account's minimum interval or hourly quota gets messages dropped and, if sustained, the device suspended.
{gw}/devices/{deviceId}/status↑ gateway → platformQoS 0 · retaineddevice-statusJSON SchemaPer-device reachability, retained. A device behind a gateway goes offline 90 s after it was last seen, so republish reachable:true (or data) at least every ~30 s for each healthy device.
{dev}/data↑ device → platformQoS 0dataJSON SchemaTelemetry from a directly connected device (protocol mqtt-direct), using the per-device MQTT credentials issued when the device was created.
{dev}/status↑ device → platformQoS 0 · retaineddevice-statusJSON SchemaReachability of a directly connected device. Set the MQTT last will to {"ts":…,"reachable":false,"reason":"lwt"} on this topic, retained.

Optional — implement what your hardware can do; the platform copes without them

TopicDirection · QoSPayloadNotes
{gw}/data/backfill↑ gateway → platformQoS 0data-backfillJSON SchemaReplay of telemetry buffered while offline: at most 40 records per message, oldest first, each naming its deviceId. NEVER replay on the live data topic — the live rate gate would suspend the device. Stored only: no dashboards, rules or last-known values.
{gw}/cmd↓ platform → gatewayQoS 1gateway-cmdJSON SchemaCommands to the gateway itself (restart, reset/config, debug, ble/scan, job/config, …). Ignore any `command` you do not recognise. job/config is re-sent after every config/request.
{gw}/devices/{deviceId}/cmd↓ platform → gatewayQoS 1device-cmdJSON SchemaCommands to one device behind the gateway: read control, Modbus writes, actuator writes. Reply on devices/{deviceId}/cmd/ack when the body carries a correlationId.
{gw}/devices/{deviceId}/cmd/ack↑ gateway → platformQoS 0cmd-ackJSON SchemaAcknowledges a device command, echoing its correlationId.
{dev}/cmd↓ platform → deviceQoS 1device-cmdJSON SchemaCommands to a directly connected device (Modbus and actuator writes; read control is gateway-only).
{dev}/cmd/ack↑ device → platformQoS 0cmd-ackJSON SchemaAcknowledges a command sent to a directly connected device.
{gw}/devices/{deviceId}/alert↑ gateway → platformQoS 0alertJSON SchemaAn alert raised at the edge for one device, e.g. a tag outside its config/push threshold band.
{dev}/alert↑ device → platformQoS 0alertJSON SchemaAn alert raised by a directly connected device.
{gw}/firmware/request↓ platform → gatewayQoS 1firmware-requestJSON SchemaOver-the-air update request. Report progress on firmware/response. The gateway is held busy (restart/reset refused) until a terminal status or 5 minutes.
{gw}/debug/response↑ gateway → platformQoS 0debug-responseJSON SchemaDiagnostics snapshot, the reply to the debug/diag gateway command. Relayed live, not stored.
{gw}/debug/log↑ gateway → platformQoS 0debug-logJSON SchemaLive log lines between debug/logs/start and debug/logs/stop, filtered by the start command's category mask. Relayed live, not stored.
{gw}/macros/push↓ platform → gatewayQoS 1macros-pushJSON SchemaThe complete set of macro programs for this gateway. Re-sent after every config/request; replace your whole set each time.
{gw}/macros/run↓ platform → gatewayQoS 1macros-runJSON SchemaStart a macro run. Moves physical equipment. Report it on macro/run/status.
{gw}/macros/abort↓ platform → gatewayQoS 1macros-abortJSON SchemaAbort one run ({runId}) or every run ({}). A network message — not a safety function.
{gw}/macro/run/status↑ gateway → platformQoS 0macro-run-statusJSON SchemaRun lifecycle: started, step, then done or error.
{gw}/macro/step↑ gateway → platformQoS 0macro-stepJSON SchemaA running macro's CALL instruction: asks the backend to run a backend step (a saved webhook/MQTT action). Fire-and-forget.
{gw}/job↑ gateway → platformQoS 0job-eventJSON SchemaJob boundaries from the input configured by the job/config command. localJobId is the idempotency key — re-announce the same id after a reboot.
{gw}/discovery/ble↑ gateway → platformQoS 0ble-discoveryJSON SchemaResult of a ble/scan gateway command: the Bluetooth LE sensors heard nearby.

Experimental — may change without a version bump — do not build on them yet

TopicDirection · QoSPayloadNotes
{gw}/devices/{deviceId}/image/meta↑ gateway → platformQoS 0image-metaJSON SchemaHeader of an event-triggered camera frame relayed by a fog node; image/chunk messages follow.
{gw}/devices/{deviceId}/image/chunk↑ gateway → platformQoS 0image-chunkJSON SchemaOne base64 slice of the image announced by image/meta, in idx order.

QoS and retain. The QoS in the table is what the reference firmware publishes with. The platform's own subscriptions are shared subscriptions at QoS 0, so a QoS 1 publish is acknowledged by the broker but reaches the platform at most once; publish at QoS 1 for the broker's benefit, not for end-to-end delivery. The platform publishes to a gateway at QoS 1 and never retained: a command or config push sent while you are disconnected is not queued for you. That is deliberate — a stale actuator command replayed after an outage is worse than a lost one — and it is why the lifecycle re-requests the config on every connect.

Connection lifecycle

  1. Connect with TLS, the username and password from Connection Info, a clean session, and a Last Will on {gw}/status with payload {"online":false}, retained, QoS 1.
  2. Subscribe to {gw}/config/push, {gw}/cmd and {gw}/devices/+/cmd. Add {gw}/firmware/request, {gw}/macros/push, {gw}/macros/run and {gw}/macros/abort if you implement those optional parts.
  3. Announce yourself by publishing {gw}/status retained: {"online":true,"ts":<epoch ms>} plus whatever you know (ip, fw, rssi, uplink). Repeat it every 60 s. Readers treat a gateway as stale after 180 s without one.
  4. Report capabilities on {gw}/firmware/response: {version, schemaVersion: 2, board, mac, protocols[], sensorModels{}, buffering, jobs, modbusFormats, configChunked, maxConfigBytes, mqttPayloadBytes, debug}. The platform gates what it will ask of you by this message — a device protocol not in protocols[] cannot be assigned to a device under this gateway in the app.
  5. Ask for the config on {gw}/config/request: {"hash": <n>}, where n is the FNV-1a 32-bit hash of the raw bytes of the last config/push you applied, or 0 if you have none.
  6. Receive {gw}/config/push. One of three payloads: the full document, {"unchanged":true}, or a chunk (see the config document). Apply the document, persist the exact bytes, and re-send config/request with the new hash — that round trip is what clears the "applying configuration" state on the gateway page.
  7. Run the data loop for every device in the document: publish readings on {gw}/devices/{deviceId}/data and presence on {gw}/devices/{deviceId}/status.
  8. Repeat 5–6 on every reconnect. config/push is not retained, so a reconnecting gateway must ask. There is one unsolicited case: while you are connected, editing the gateway's devices or network settings in the app pushes a new document immediately — apply it and answer with config/request and the new hash, exactly as in step 6.

The config document

The full config/push is the list of devices the platform wants this gateway to read, plus optional network settings:

{ "devices": [ { "_id": "…", "protocol": "rs485", "conn": { … }, "tags": [ { "name": "voltage", … } ] } ],
  "success": true,
  "net": { … } }

Each device carries its _id (the {deviceId} in its topics), its protocol, the connection settings for that protocol (conn) and its tags. A tag's name is the key you use in values when you publish a reading for that device; a tag key that is absent means the firmware default given in the schema. The document is authoritative: replace your whole device set with it, and stop polling anything it no longer lists — a gateway the platform does not recognise gets devices: []. Directly connected devices are never included. The exact fields per protocol are in the config-push schema; a gateway ignores what it does not understand.

Hashing. The hash identifies the document you hold, so the platform can answer {"unchanged":true} instead of re-sending it. It is FNV-1a, 32-bit (seed 0x811c9dc5, prime 0x01000193), computed over the raw UTF-8 bytes of the config/push payload exactly as received — not over a re-serialised copy, which would change key order and whitespace. The platform emits a deterministic payload (devices sorted by id, tag keys in a fixed order, defaults omitted, net last) precisely so that an unchanged configuration hashes the same every time. Persist those bytes; on boot, hash them and ask.

function fnv1a32(bytes) {                  // bytes: the config/push payload, byte for byte
  let h = 0x811c9dc5;
  for (const b of bytes) { h ^= b; h = Math.imul(h, 0x01000193) >>> 0; }
  return h >>> 0;                           // publish as a JSON number: {"hash": 2166136261}
}

Chunking. A large fleet on one gateway can outgrow the largest MQTT payload a small device can hold. A gateway that can receive only bounded payloads adds cap — how many payload bytes fit in one MQTT packet — to config/request (max, the largest document it can store, is informational). When the document is larger than cap, the platform answers with one chunk {p, n, h, d} instead: part p (0-based) of n, h the hash of the complete document, d a base64 slice of its UTF-8 bytes that decodes on its own. Decode and append in p order, asking for each next part with {"hash": …, "cap": …, "part": p + 1} — the platform keeps no transfer state, so any part can be served by any worker. After part n − 1 the reassembled bytes must hash to h; from then on h is the hash you send. The configChunked, maxConfigBytes and mqttPayloadBytes fields of the capability report tell the platform the same limits up front. The field list is in the config-request schema.

The data loop

For each device in the config document, poll it at the interval in its conn (tickDuration or sampleIntervalMs, depending on the protocol — or whatever a later set/interval command set) and publish:

Topic Payload Notes
{gw}/devices/{deviceId}/data {"ts": <epoch ms>, "values": {"<tag name>": number | boolean | string}, "q"?: …, "seq"?: n} Values are raw — the platform applies each tag's scale and offset. Keys are the tags' names from the config document, at least one per message. ts is when the values were read, not when they were sent.
{gw}/devices/{deviceId}/status {"ts": <epoch ms>, "reachable": true | false, "reason"?: "…"}, retained Devices behind a gateway have a fixed 90 s offline window, so publish it at least every 30 s while the device answers, and immediately with reachable:false when it stops.
{gw}/devices/{deviceId}/alert optional, see the alert schema A threshold the gateway evaluated itself. Platform rules do not need it.

The numbers that govern presence:

Signal Interval Considered gone after
Gateway heartbeat, {gw}/status every 60 s 180 s
Gateway Last Will on unclean disconnect immediately; every device behind the gateway is marked offline with it
Device presence, {gw}/devices/{d}/status at least every 30 s 90 s

A device is also marked online whenever a reading for it arrives, so a device that reports data faster than every 90 s stays online through its data alone; the status message is what carries reachable:false and a reason.

Commands and acknowledgements

The platform sends commands on two topics. Gateway-level commands change the gateway itself; device-level commands act on one device and are acknowledged.

Topic Payload Reply
{gw}/cmd {"command": "restart" | "reset/config" | "sim/start" | "sim/stop" | "debug/diag" | "debug/logs/start" | "debug/logs/stop" | "ble/scan" | "job/config", …} No cmd/ack. debug/diag answers on {gw}/debug/response and ble/scan on {gw}/discovery/ble; the rest produce no reply. Ignore a command you do not know.
{gw}/devices/{d}/cmd Read control: {"command": "read/once", "tag", "correlationId"}, {"command": "set/interval", "interval": <ms>}, {"command": "read/disable", "mode": "manual" | "restart" | "timed", "durationMs"?}, {"command": "read/enable"}. A Modbus write {"correlationId", "fc", "address", "value", "register_type": "coil" | "holding"}. An actuator write {"correlationId", "value", "dir"?}; a stepper {"correlationId", "command": "move", "steps", "dir"}. {gw}/devices/{d}/cmd/ack with {"correlationId", "status": "ok" | "error", "value"?, "error"?, "ts"} whenever the command carried a correlationId

Echo the correlationId exactly; the platform matches the ack to the command by it and shows the confirmed value on the device page, in dashboards and in the mobile app — the applied value is reported only when the ack arrives. Through a gateway a Modbus write is always a single write and register_type selects the function (coil = FC05, holding = FC06); switch on it, not on fc. The exact fields, including the actuator variants (on/off, level, position, move, speed), are in the device-cmd and gateway-cmd schemas.

Store-and-forward

If the uplink drops, buffer readings and send them later on {gw}/data/backfill:

{ "batch": [ { "deviceId": "…", "ts": 1758780000000, "values": { "voltage": 2301 } }, … ] }
Rule Value
Records per batch ≤ 40
Bytes per batch ≤ 3500
Batches per second 1
Order oldest first
Per-tenant quota 3000 records per minute
What belongs in it only readings that could not be sent live

Never replay buffered readings on the live data topic. The rate limiter measures the gap between arrivals, not the timestamps you send, so a burst of catch-up readings is a run of violations, and 20 violations in 24 hours suspends the device. The backfill topic is exempt from that gate. Backfilled readings are written to history only: they do not update live widgets, do not evaluate rules and do not become the device's last value. Store-and-forward is enabled per account — see gateway offline buffering.

Validation and rate limits

What happens when you get it wrong is worth knowing before you get it wrong:

Situation What the platform does
A message fails its schema Discarded. Nothing is sent back — no error topic, no event.
A message carries keys the schema does not list The unknown keys are dropped silently; the rest is processed.
A reading arrives faster than the device's minimum interval Counted as a violation. 20 violations in 24 h suspends the device: its readings are dropped until the suspension lifts. See device suspension.
A backfill batch is too large or too frequent Discarded.
A publish outside your prefix Refused by the broker, silently.

The per-device minimum interval comes from the plan: half of the plan's advertised cadence, so a device polled at the advertised rate has headroom for jitter but not for double-sending. Plan cadences are in the plan comparison and understanding rate limits. A late reading — one delayed past the next one by the network — is skipped, not counted.

Because nothing is sent back for an invalid message, validate against the schemas on your side before publishing. The schemas are plain draft-07 and load into any validator; the examples under /protocol/v1/examples/ are known-valid payloads to test yours against.

Message reference

One entry per message: the property table generated from its JSON Schema, then a valid example. Property tables show one level of nesting; the linked schema has everything.

source-status — gateway presence

The retained heartbeat, and the Last Will ({"online":false}). Core.

source-status.json · Gateway presence (heartbeat + last will) — The gateway's presence on `<prefix>/status`, published RETAINED. Two uses of one schema: (1) the MQTT last will, set at connect time, is exactly `{"online":false}` so the broker announces an ungraceful disconnect; (2) a heartbeat `{"online":true, …}` on connect and then every 60 s. The platform treats a gateway whose last heartbeat is older than 180 s as offline, so a missing heartbeat is itself evidence. An `online:false` also marks every device behind the gateway offline. Every field but `online` is optional and self-reported; the ones present are stored on the gateway record and shown in the app. Unknown keys are silently stripped (Ajv `removeAdditional`), so an undeclared field never reaches the handler.

PropertyTypeRequiredConstraintsDescription
onlinebooleanyes——
tsinteger—≥ 0Epoch milliseconds.
ipstring——The gateway's local IP address (self-reported).
fwstring——Firmware version string.
rssinumber——Wi-Fi (or cellular) signal strength in dBm. A number — fractional values are accepted.
simModeboolean——The gateway is running in simulation mode (synthetic readings).
uplinkstring—one of "wifi" "ethernet" "cellular"Which interface carries the internet connection right now.
ethIpstring—length 0–64The Ethernet interface's own address; present when Ethernet is used only for a local segment (LAN mode) rather than as the uplink.
bufRaminteger—≥ 0Store-and-forward buffer: records currently held in RAM. The buffer fields are absent on firmware without the `buffering` capability.
bufFlashinteger—≥ 0Store-and-forward buffer: records currently held on flash.
bufDroppedinteger—≥ 0Cumulative records dropped (ring overflow, no valid clock, identity mismatch).
flashErrorsinteger—≥ 0Cumulative flash write-verify / mount failures. Greater than 0 is the replace-this-unit signal.
flashWearPctinteger—≥ 0, ≤ 100Estimated flash erase-cycle consumption, 0-100.

Keys not listed here are removed before the message is processed.

{
  "online": true,
  "ts": 1758801600000,
  "ip": "192.168.1.42",
  "fw": "1.5.4",
  "rssi": -61,
  "uplink": "wifi",
  "bufRam": 0,
  "bufFlash": 0,
  "bufDropped": 0,
  "flashErrors": 0,
  "flashWearPct": 1
}

The Last Will, exactly:

{
  "online": false
}

firmware-response — capability report and OTA progress

Two shapes on one topic: the capability report published after connect, and progress reports during an over-the-air update requested on firmware/request. Core (the report); optional (OTA).

firmware-response.json · Firmware response: capability report or OTA progress — `<prefix>/firmware/response` carries two distinct shapes, told apart by which fields are present. (1) The CAPABILITY REPORT — `version` plus `protocols`/`sensorModels` and the feature flags below — sent on every connect and in reply to a bare `firmware/request`. The platform stores it as the gateway's capability snapshot and REPLACES the previous one wholesale, and the app gates features on it: a protocol missing from `protocols` cannot be configured on this gateway, and a flag that is absent is indistinguishable from unsupported. (2) OTA PROGRESS — `status` (+ `correlationId`, `progress`, `error`) while applying a `firmware/request` update. Unknown keys are silently stripped (Ajv `removeAdditional`) before the handler runs, so a capability that is not declared here does not exist as far as the platform is concerned.

PropertyTypeRequiredConstraintsDescription
correlationIdstring——OTA progress: correlates with the update request.
statusstring—one of "accepted" "downloading" "installed" "failed"OTA progress. `installed` and `failed` are terminal and clear the gateway's busy state; `downloading` is progress only and stores no event.
progressinteger—≥ 0, ≤ 100OTA progress percentage.
errorstring—length 0–512OTA failure reason.
tsinteger—≥ 0Epoch milliseconds.
versionstring——Capability report: the running firmware version (semver). Stored as the gateway's firmware version and compared against the OTA catalog.
schemaVersioninteger——Capability report: version of the capability-report shape itself.
boardstring——Capability report: board identifier, e.g. `nodemcu-32s`.
macstring——Capability report: the chip id / MAC the gateway identifies itself by (the `{chipId}` in its topic prefix).
protocolsarray of string——Capability report: the device protocols this firmware can drive, e.g. `rs485`, `modbus-tcp`, `gpio-digital-in`, `i2c`, `ble`. The app refuses to attach a device of any other protocol to this gateway.
sensorModelsobject——Capability report: per-bus sensor models this firmware has drivers for, e.g. `{"i2c":["BME280","generic"],"spi":["MAX31855"]}`.
debugboolean——Capability report: supports remote diagnostics — the `debug/diag` snapshot and the `debug/logs/start|stop` live log tail.
ethernetboolean——Capability report: has W5500 Ethernet support. Gates the network-configuration UI.
netobject——Capability report: the Ethernet configuration the firmware is actually running, e.g. `{mode, addr, ip, mask, gw, dns}`.
bufferingboolean——Capability report: has the store-and-forward buffer (replays on `data/backfill`, reports buffer health in the heartbeat). Gates the buffer UI.
jobsboolean——Capability report: can watch a digital input and open/close jobs from it (`job` topic, `job/config` command). Without it the app does not offer edge-owned jobs.
modbusFormatsboolean——Capability report: decodes per-tag Modbus data formats (s16/u32/s32/f32 + word order) by reading two registers. Without it the platform refuses every non-u16 Modbus tag on this gateway.
configChunkedboolean——Capability report: can reassemble a config/push delivered in base64 parts (see config-request `cap`/`part`).
maxConfigBytesinteger—≥ 0, ≤ 1048576Capability report: the TOTAL config size the unit can store and parse, computed on-device from free flash and heap. The platform's save-time size guard enforces it; absent means the legacy single 4096-byte packet limit.
mqttPayloadBytesinteger—≥ 0, ≤ 65535Capability report: how many payload bytes fit in ONE MQTT packet on this unit. Decides whether a config is chunked and whether a macros/push or job/config fits at all.

Keys not listed here are removed before the message is processed.

{
  "version": "1.5.4",
  "schemaVersion": 2,
  "board": "nodemcu-32s",
  "mac": "123456789012345",
  "debug": true,
  "ethernet": true,
  "buffering": true,
  "modbusFormats": true,
  "net": {
    "mode": "auto",
    "addr": "dhcp",
    "ip": "",
    "mask": "",
    "gw": "",
    "dns": ""
  },
  "protocols": [
    "rs485",
    "modbus-tcp",
    "mqtt-direct",
    "http",
    "gpio-digital-in",
    "gpio-digital-out",
    "gpio-analog",
    "gpio-pwm",
    "gpio-stepper",
    "gpio-servo",
    "gpio-motor",
    "i2c",
    "spi",
    "1-wire",
    "uart",
    "can",
    "mbus",
    "gps"
  ],
  "sensorModels": {
    "i2c": [
      "BME280",
      "BMP280",
      "SHT31",
      "ADS1115",
      "generic"
    ],
    "spi": [
      "MAX31855",
      "MAX6675"
    ]
  }
}

OTA progress on the same topic:

{
  "correlationId": "b7e4c2a1-5d3f-4a9e-8c7b-6f5e4d3c2b1a",
  "status": "downloading",
  "progress": 40,
  "ts": 1758801600000
}

config-request — ask for the config

hash is the FNV-1a 32-bit of the raw bytes of the last applied config/push, 0 if none. cap, max and part are for chunked delivery. An empty object is a valid request. Core.

config-request.json · Configuration request — The gateway asks for its device configuration on `<prefix>/config/request`; the answer arrives on `<prefix>/config/push` (see config-push). Send it on every boot AND every reconnect: config/push is not retained, and the same request also makes the backend re-send the gateway's macros (`macros/push`) and job settings (`job/config` on `<prefix>/cmd`). An empty object is a valid request. `hash` is the FNV-1a 32-bit hash of the config the gateway currently holds (0 or absent = none): when it matches, the reply is a tiny `{"unchanged":true}` instead of the full payload. `cap` and `part` drive the chunked transfer for configs larger than one MQTT packet. This schema deliberately keeps `additionalProperties: true`: older and other gateways send extra keys, and the handler dispatches on whichever of the declared keys are present — an unknown key is ignored, never an error.

PropertyTypeRequiredConstraintsDescription
tsinteger—≥ 0Legacy, ignored by the backend. Epoch milliseconds.
currentConfigVersionstring——Legacy, ignored by the backend.
hashinteger——FNV-1a 32-bit (seed 0x811c9dc5, prime 0x01000193) over the exact UTF-8 bytes of the last config/push payload the gateway applied, as an unsigned decimal integer. 0 or absent means no usable cached config, so the full payload is always sent. Deliberately unbounded: any integer the platform cannot match (a negative one from a signed-int32 FNV, an oversized one) is treated as 'no config applied' and answered with the full payload — a hashing mistake on the gateway costs one redundant push, never a gateway that silently never receives its config.
capinteger—≥ 1How many payload bytes this gateway can receive in ONE MQTT packet. When present and the config is larger, the backend answers with one base64 chunk (`{p,n,h,d}`) instead of the whole payload. Absent means the gateway cannot reassemble chunks and gets the single-shot payload.
maxinteger—≥ 1The largest total config (bytes) this gateway can store and parse. Informational; the backend's own size guard reads the `maxConfigBytes` capability instead.
partinteger—≥ 0During a chunked transfer: which part (0-based) to send next. The gateway pulls each part itself; the backend keeps no transfer state, so any backend worker can serve any part.
{
  "hash": 3735928559
}

Pulling part 2 of a chunked transfer:

{
  "hash": 3735928559,
  "cap": 3900,
  "max": 16384,
  "part": 2
}

data — a reading

Raw values keyed by tag name. q and seq are optional. Core.

data.json · Device telemetry — One reading of one device: a timestamp and a map of tag name to value. Published on `<prefix>/devices/{deviceId}/data` by a gateway, or on `tenants/{tenantId}/devices/{deviceId}/data` by a directly connected device. Also the body shape (after normalisation) of the HTTP webhook ingest, which validates with this same schema. The backend validates with Ajv `removeAdditional: true`: an undeclared top-level key is SILENTLY DROPPED, not rejected, so do not put anything here that is not declared below.

PropertyTypeRequiredConstraintsDescription
tsintegeryes≥ 0When the values were read, in Unix epoch MILLISECONDS (not seconds). Stamped by the publisher at read time, not at publish time.
valuesobjectyes1–∞ keysTag name -> value. The key is the tag's canonical stream key (for a Modbus tag its subtype, for a bus sensor its name). At least one entry.
values.<key>number | boolean | string—any key—
qstring—one of "good" "bad" "uncertain" "stale", default "good"Data quality for the whole message. Omitted means `good`.
seqinteger—≥ 0Optional per-device message counter. Recorded as provenance on the events a message causes (Event.cause.seq), so the same seq under two trace ids reveals one message processed twice.

Keys not listed here are removed before the message is processed.

{
  "ts": 1758801600000,
  "seq": 1042,
  "q": "good",
  "values": {
    "voltage": 231.6,
    "current": 4.12,
    "power_factor": 0.97
  }
}

device-status — device presence

Retained. reachable:false with a reason when the device stops answering. Core.

device-status.json · Device reachability — Whether one device is reachable. A gateway publishes it on `<prefix>/devices/{deviceId}/status` for each device it polls; a directly connected device publishes it on `tenants/{tenantId}/devices/{deviceId}/status` (and should set it as its MQTT last will with `reachable:false`). Publish it retained. A gateway's child device is marked offline 90 s after it was last seen, so a gateway should republish `reachable:true` (or data) at least every ~30 s for each healthy device.

PropertyTypeRequiredConstraintsDescription
tsintegeryes≥ 0Epoch milliseconds.
reachablebooleanyes——
reasonstring—length 0–128Why the device is unreachable, e.g. `timeout`, `crc`, `lwt`.

Keys not listed here are removed before the message is processed.

{
  "ts": 1758801600000,
  "reachable": true
}

data-backfill — store-and-forward batch

Oldest first, ≤ 40 records, ≤ 3500 bytes, one batch per second. Optional.

data-backfill.json · Buffered telemetry replay — Telemetry a gateway buffered while its uplink was down, replayed in oldest-first batches on the GATEWAY-scoped topic `<prefix>/data/backfill`. The gateway's buffer is a single FIFO ring across all its devices, so one batch legitimately mixes devices and every record names its own `deviceId` — that is the only field a record adds over the live `data` schema, and ts/values/q/seq mean exactly what they mean there because the records feed the same telemetry store. Never replay on the live `data` topic: the live path rate-gates per device and would suspend a device replaying a backlog. Replayed records are stored only; they do not drive dashboards, rules or last-known values. The batch is capped at 40 records to bound parse cost (the reference firmware packs under 3,500 bytes). An over-long batch fails validation and the WHOLE message is dropped with a logged error — loud, not silently truncated. Unknown keys are silently stripped (Ajv `removeAdditional`), which is why every field is declared.

PropertyTypeRequiredConstraintsDescription
batcharray of objectyes1–40 items1 to 40 records, oldest first.
batch[].deviceIdstringyesmatches ^[0-9a-fA-F]{24}$The device the record belongs to — its 24-hex-character id.
batch[].tsintegeryes≥ 0When the values were read (epoch milliseconds) — the original read time, not the replay time.
batch[].valuesobjectyes1–∞ keysTag name -> value, as in the live `data` message.
batch[].qstring—one of "good" "bad" "uncertain" "stale", default "good"Data quality. Omitted means `good`.
batch[].seqinteger—≥ 0Optional per-device message counter, as in the live `data` message.

Keys not listed here are removed before the message is processed.

{
  "batch": [
    {
      "deviceId": "66f1a2b3c4d5e6f708192a3b",
      "ts": 1758801420000,
      "values": {
        "voltage": 230.9,
        "current": 4.02
      },
      "q": "good",
      "seq": 1001
    },
    {
      "deviceId": "66f1a2b3c4d5e6f708192a3d",
      "ts": 1758801425000,
      "values": {
        "temperature": 22.8,
        "humidity": 51.2
      },
      "q": "good",
      "seq": 388
    },
    {
      "deviceId": "66f1a2b3c4d5e6f708192a3b",
      "ts": 1758801480000,
      "values": {
        "voltage": 231.1,
        "current": 4.05
      },
      "q": "good",
      "seq": 1002
    }
  ]
}

alert — a gateway-evaluated threshold

Optional.

alert.json · Device alert — An alert raised at the edge for one device (for example a threshold the gateway evaluated locally). Published on `<prefix>/devices/{deviceId}/alert` or `tenants/{tenantId}/devices/{deviceId}/alert`. Stored as an event and shown to the user; it does not change device presence.

PropertyTypeRequiredConstraintsDescription
tsintegeryes≥ 0When the condition was detected (epoch milliseconds).
severitystringyesone of "info" "warning" "critical"—
codestringyeslength 0–64Short machine-readable reason, e.g. `THRESHOLD_VIOLATION`.
messagestring—length 0–512Human-readable text.
tagstring——Optional — the tag the alert is about, when it is tag-specific.
valueany——Optional — the value that triggered it. Any JSON type.

Keys not listed here are removed before the message is processed.

{
  "ts": 1758801600000,
  "severity": "warning",
  "code": "THRESHOLD_VIOLATION",
  "message": "temperature 81.2 is above 80",
  "tag": "temperature",
  "value": 81.2
}

cmd-ack — command acknowledgement

Optional (required if you accept device commands). An ack without a correlationId is rejected.

cmd-ack.json · Command acknowledgement — The reply to a device command (see device-cmd): on `<prefix>/devices/{deviceId}/cmd/ack` from a gateway, or `tenants/{tenantId}/devices/{deviceId}/cmd/ack` from a directly connected device. Echo the command's `correlationId` — an ack without one is rejected, and it is how the app matches the ack to the command it sent. For an actuator write, `value` is the value actually applied (digital 0|1, PWM duty 0..255, servo microseconds, …) and becomes the confirmed actuator state.

PropertyTypeRequiredConstraintsDescription
correlationIdstringyes—Copied from the command being acknowledged.
statusstringyesone of "ok" "error"—
errorstring | null—length 0–512Failure reason, or null on success.
valuenumber | null——The applied value echoed back on actuator writes. `null` means the gateway could not read a value back (the reference firmware serialises a NaN as null); the ack is still processed, only the confirmed state is left unchanged.
tsinteger—≥ 0Epoch milliseconds.

Keys not listed here are removed before the message is processed.

{
  "correlationId": "b7e4c2a1-5d3f-4a9e-8c7b-6f5e4d3c2b1a",
  "status": "ok",
  "value": 1,
  "error": null,
  "ts": 1758801600000
}

A failed command:

{
  "correlationId": "b7e4c2a1-5d3f-4a9e-8c7b-6f5e4d3c2b1a",
  "status": "error",
  "value": 0,
  "error": "device unreachable",
  "ts": 1758801600000
}

A successful write whose applied value could not be read back — value: null is accepted and leaves the confirmed state untouched (the reference firmware serialises a NaN this way):

{
  "correlationId": "b7e4c2a1-5d3f-4a9e-8c7b-6f5e4d3c2b1a",
  "status": "ok",
  "value": null,
  "error": null,
  "ts": 1758801600000
}

job-event — a job boundary

A limit switch or similar input opening or closing a bounded run; the payload names the job. Optional, gateway-level.

job-event.json · Job boundary — A job boundary the gateway owns — a limit switch (or any digital input) opening or closing a production run. GATEWAY-scoped (`<prefix>/job`): the switch is one input for the whole machine, and the payload names the job by the gateway's own counter rather than by a device. The input, debounce and bands come from the `job/config` gateway command.

PropertyTypeRequiredConstraintsDescription
eventstringyesone of "start" "end" "resume"`start` opens a job, `end` closes it, `resume` re-announces a job that was already open when the gateway rebooted.
tsintegeryes≥ 0The moment of the EDGE, not of the publish (epoch milliseconds). The gateway stamps it when the input changes and may send it much later (offline, or on the next tick) — the job report needs when the work started, not when the message arrived.
localJobIdintegeryes≥ 0The gateway's monotonic, persisted job counter and the IDEMPOTENCY KEY: a gateway that reboots mid-job must re-announce the SAME id, which is what stops a power blip opening a second job for a run that never stopped.
signalstring—length 0–128Which input drove it, for the audit trail.
seqinteger—≥ 0—
endTsUncertainboolean——The job ended while the gateway was down: it knows the job ended but not when. Reported as a window (see lastKnownGoodTs) instead of an invented time.
lastKnownGoodTsinteger—≥ 0With endTsUncertain: the last moment (epoch milliseconds) the gateway knew the job was still running.

Keys not listed here are removed before the message is processed.

{
  "event": "start",
  "ts": 1758801600000,
  "localJobId": 57,
  "signal": "door_switch",
  "seq": 3
}

macro-run-status — macro lifecycle

started, step, done or error for a macro running on the gateway. Optional.

macro-run-status.json · Macro run lifecycle — Progress of a macro run on the gateway, published on `<prefix>/macro/run/status`: `started` when a `macros/run` begins, `step` as instructions execute (and for EMIT messages), then exactly one of `done` or `error`. `runId` and `macroId` are copied from the `macros/run` message. Recorded as the run's history.

PropertyTypeRequiredConstraintsDescription
runIdstringyes——
macroIdstringyes——
phasestringyesone of "started" "step" "done" "error"—
idxinteger—≥ 0Index of the instruction being executed.
messagestring—length 0–512—
severitystring—one of "info" "warning" "error" "critical"—
tsinteger—≥ 0Epoch milliseconds.

Keys not listed here are removed before the message is processed.

{
  "runId": "3f9d2c1e-7b4a-4e8f-9a6b-2d1c0e5f7a8b",
  "macroId": "66f1a2b3c4d5e6f708192b01",
  "phase": "started",
  "ts": 1758801600000
}

macro-step — a macro asks the platform to do a step

Sent by a macro whose next instruction runs on the platform side. Optional.

macro-step.json · Macro backend-step request — Published on `<prefix>/macro/step` when a running macro reaches a CALL instruction: the gateway asks the backend to run backend step `stepId` of the macro — a saved action (webhook or MQTT publish) that only the cloud can perform, charged against the account's action quota. The gateway does not wait for an answer.

PropertyTypeRequiredConstraintsDescription
runIdstringyes——
macroIdstringyes——
stepIdintegeryes≥ 0The `stepId` operand of the CALL instruction.
tsinteger—≥ 0Epoch milliseconds.

Keys not listed here are removed before the message is processed.

{
  "runId": "3f9d2c1e-7b4a-4e8f-9a6b-2d1c0e5f7a8b",
  "macroId": "66f1a2b3c4d5e6f708192b01",
  "stepId": 0,
  "ts": 1758801600000
}

debug-response — diagnostics snapshot

The reply to a debug/diag command. Optional.

debug-response.json · Diagnostics snapshot — The one-shot diagnostics snapshot a gateway publishes on `<prefix>/debug/response` in reply to the `debug/diag` gateway command. Echo the command's `correlationId`: the app matches the reply to the request by it. Relayed live to the user's debug panel and not persisted. Bounded (arrays capped, strings limited) so a misbehaving firmware cannot flood the relay.

PropertyTypeRequiredConstraintsDescription
correlationIdstringyeslength 0–64Copied from the `debug/diag` command.
tsinteger—≥ 0Epoch milliseconds.
ipstring—length 0–64—
rssiinteger——Wi-Fi signal strength in dBm.
uptimeMsinteger—≥ 0—
freeHeapinteger—≥ 0—
minFreeHeapinteger—≥ 0Heap low-water mark since boot.
maxAllocHeapinteger—≥ 0Largest allocatable heap block. On an ESP32 it must stay well above the ~40 KB a TLS handshake needs.
bleobject——Bluetooth scanner state.
ble.startedboolean———
ble.advTotalinteger—≥ 0Advertisements seen since boot.
ble.slotsSeeninteger—≥ 0—
ble.releasedboolean——true = the Bluetooth controller memory was handed back at boot (no BLE device configured).
fwstring—length 0–32Firmware version.
mqttConnectedboolean———
configHashinteger——FNV-1a 32-bit hash of the config the gateway is running (see config-request `hash`).
uplinkstring—one of "wifi" "ethernet" "cellular"Which interface carries the internet connection right now. Relayed, not persisted.
ethIpstring—length 0–64—
devicesarray of object—0–200 itemsPer-device poll state, at most 200 entries.
devices[].idstring—length 0–64Device id.
devices[].namestring—length 0–128—
devices[].protocolstring—length 0–32—
devices[].onlineboolean———
devices[].lastPollMsinteger——Milliseconds since the last poll.
devices[].lastErrorstring—length 0–256—
recentLogsarray of string—0–100 itemsThe most recent log lines, at most 100.

Keys not listed here are removed before the message is processed.

{
  "correlationId": "b7e4c2a1-5d3f-4a9e-8c7b-6f5e4d3c2b1a",
  "ts": 1758801600000,
  "ip": "192.168.1.42",
  "rssi": -61,
  "uptimeMs": 86400000,
  "freeHeap": 98304,
  "minFreeHeap": 61440,
  "maxAllocHeap": 65524,
  "ble": {
    "started": false,
    "advTotal": 0,
    "slotsSeen": 0,
    "released": true
  },
  "fw": "1.5.4",
  "mqttConnected": true,
  "configHash": 3735928559,
  "uplink": "wifi",
  "devices": [
    {
      "id": "66f1a2b3c4d5e6f708192a3b",
      "name": "Energy meter",
      "protocol": "rs485",
      "online": true,
      "lastPollMs": 850
    },
    {
      "id": "66f1a2b3c4d5e6f708192a3d",
      "name": "Room climate",
      "protocol": "i2c",
      "online": false,
      "lastPollMs": 30500,
      "lastError": "i2c nack at 0x76"
    }
  ],
  "recentLogs": [
    "[net] MQTT connected",
    "[modbus] read ok 66f1a2b3c4d5e6f708192a3b"
  ]
}

debug-log — live log lines

Published while a log tail started by debug/logs/start is active. Optional.

debug-log.json · Live log batch — A batch of recent log lines, published on `<prefix>/debug/log` while a live log tail is active (between the `debug/logs/start` and `debug/logs/stop` gateway commands). Stream only the categories whose bit is set in the start command's `cats` mask, and stop on your own after a while so a forgotten tail cannot run up bandwidth. Relayed live, not persisted.

PropertyTypeRequiredConstraintsDescription
tsinteger—≥ 0Epoch milliseconds.
seqinteger—≥ 0Batch counter, so a reader can spot a gap.
linesarray of stringyes0–50 itemsAt most 50 lines of at most 512 characters each.

Keys not listed here are removed before the message is processed.

{
  "ts": 1758801600000,
  "seq": 12,
  "lines": [
    "[modbus] 66f1a2b3c4d5e6f708192a3b read 2 regs @30000 ok",
    "[net] rssi -61"
  ]
}

ble-discovery — sensors heard nearby

The reply to a ble/scan command. Optional.

ble-discovery.json · Bluetooth discovery result — The reply to the `ble/scan` gateway command, on `<prefix>/discovery/ble`: the recognised Bluetooth LE sensors the gateway heard during the scan window. Echo the command's `correlationId`. The reference firmware caps it at 16 devices sorted by signal strength and keeps it under 3.5 KB. The result is cached for 10 minutes and shown in the Add Device flow. Every field is declared because unknown keys are silently stripped (Ajv `removeAdditional`).

PropertyTypeRequiredConstraintsDescription
correlationIdstringyeslength 0–64Copied from the `ble/scan` command.
tsinteger—≥ 0Epoch milliseconds.
durationMsinteger—≥ 0How long the scan actually ran.
ignoredinteger—≥ 0Advertisers heard but not recognised as a supported sensor format.
errorstring—length 0–64Set, with `devices: []`, when the scan could not run yet: `ble/restarting` (the gateway released its Bluetooth memory at boot and is rebooting to scan — the FINAL result follows under the SAME correlationId and overwrites this one), `ble/busy` (a macro is running, so it refused to restart), `ble/no_memory`, `ble/start_failed`. Without it a failed scan would read as "no sensors found".
devicesarray of objectyes0–32 items—
devices[].macstringyesmatches ^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$Colon-separated, e.g. `A4:C1:38:12:34:56`.
devices[].rssiinteger——Signal strength in dBm.
devices[].namestring—length 0–32Advertised local name, if any.
devices[].formatstringyesone of "bthome" "ruuvi" "atc" "pvvx"The advertisement format the gateway decoded.
devices[].encryptedboolean——The payload is encrypted (e.g. BTHome with a bind key), so `fields` may be empty.
devices[].fieldsobject—0–32 keysDecoded readings: canonical field key -> number (e.g. `temperature`, `humidity`, `battery`). The same keys a BLE tag's `bleField` names.

Keys not listed here are removed before the message is processed.

{
  "correlationId": "b7e4c2a1-5d3f-4a9e-8c7b-6f5e4d3c2b1a",
  "ts": 1758801600000,
  "durationMs": 15000,
  "ignored": 4,
  "devices": [
    {
      "mac": "A4:C1:38:12:34:56",
      "rssi": -61,
      "name": "ATC_123456",
      "format": "pvvx",
      "encrypted": false,
      "fields": {
        "temperature": 21.5,
        "humidity": 48,
        "battery": 91
      }
    },
    {
      "mac": "D4:11:22:33:44:55",
      "rssi": -80,
      "format": "bthome",
      "encrypted": true,
      "fields": {}
    }
  ]
}

image-meta and image-chunk — an event image

Metadata first, then ordered base64 chunks. Experimental — the shape may change.

image-meta.json · Event image header (experimental) — EXPERIMENTAL — may change without a version bump. A fog node relaying an event-triggered camera frame publishes this header on `<prefix>/devices/{deviceId}/image/meta` first, then `chunks` ordered image-chunk messages under the same `imageId`. Bounded so a misbehaving node cannot exhaust memory: at most 1024 chunks (about 1.5 MB of image at 2048 base64 characters per chunk).

PropertyTypeRequiredConstraintsDescription
imageIdstringyeslength 0–96Unique per image; every chunk repeats it.
tsinteger—≥ 0Epoch milliseconds.
triggerstring—length 0–64What caused the capture.
samplesarray of number—0–16 itemsSensor readings around the trigger, at most 16.
formatstring—one of "jpeg" "jpg" "png"—
widthinteger—≥ 0, ≤ 10000—
heightinteger—≥ 0, ≤ 10000—
sizeinteger—≥ 0, ≤ 8388608Decoded image size in bytes, at most 8 MiB.
chunksintegeryes≥ 1, ≤ 1024How many image-chunk messages follow (1-1024).

Keys not listed here are removed before the message is processed.

{
  "imageId": "img-123456789012345-1758801600000",
  "ts": 1758801600000,
  "trigger": "motion",
  "samples": [
    0.82,
    0.91
  ],
  "format": "jpeg",
  "width": 640,
  "height": 480,
  "size": 38912,
  "chunks": 26
}

image-chunk.json · Event image chunk (experimental) — EXPERIMENTAL — may change without a version bump. One base64 slice of an image announced by image-meta, on `<prefix>/devices/{deviceId}/image/chunk`. Send them in `idx` order after the header.

PropertyTypeRequiredConstraintsDescription
imageIdstringyeslength 0–96The `imageId` of the image-meta header.
idxintegeryes≥ 0, ≤ 10230-based chunk index, less than the header's `chunks`.
datastringyeslength 0–4096Base64 slice of the image bytes. The reference fog firmware sends 2048 characters per chunk; up to 4096 are accepted.

Keys not listed here are removed before the message is processed.

{
  "imageId": "img-123456789012345-1758801600000",
  "idx": 0,
  "data": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/"
}

config-push — the config document

The full document, {"unchanged":true}, or a chunk. Core.

config-push.json · Configuration push (cloud -> gateway) — The backend's answer to config/request, published on `<prefix>/config/push` (QoS 1, not retained). Exactly one of three shapes. (1) FULL: the authoritative list of devices the gateway must poll — `{devices, success:true, net?}`. Replace your whole device set with it (a device that is no longer listed must stop being polled; an unknown gateway gets `devices: []` so it can drop a stale cache). The reference firmware stores it and reboots whenever its FNV-1a 32-bit hash (seed 0x811c9dc5, prime 0x01000193) over the RAW UTF-8 bytes of the message changes, which is why the backend emits a deterministic payload: devices sorted by id, tag keys in a fixed order, keys equal to the firmware default omitted, and `net` — when present — always the LAST top-level key. Hash the bytes you received, not a re-serialisation. (2) UNCHANGED: `{"unchanged":true}` when the request's `hash` already matches — keep running what you have. (3) CHUNK: `{p, n, h, d}`, one base64 part of a FULL payload too large for one MQTT packet; sent only to a gateway whose request carried `cap`. Decode each `d` and append in `p` order; after part `n-1` the reassembled bytes must hash to `h`. Ask for the next part with config/request `{hash, cap, part: p+1}`. The same request also makes the backend re-send macros/push and the job/config command, so those arrive on every boot and reconnect too. The backend does not validate what it publishes — this schema documents it. Validate with a plain draft-07 validator (do not use Ajv's `removeAdditional` with `oneOf`: it mutates the data while trying branches). New optional keys may be added within v1; ignore keys you do not use.

Full configuration

PropertyTypeRequiredConstraintsDescription
devicesarray of Deviceyes—Every active device attached to this gateway, sorted by `_id`. Directly connected (`mqtt-direct`) devices are never included.
successbooleanyesalways true—
netEthernet settings——Ethernet settings — W5500 Ethernet configuration. OMITTED ENTIRELY when it is the default (auto + DHCP) — so a default gateway's payload hash never changed when this block was introduced. When present all six keys are sent, in this order, as the LAST top-level key.

Unchanged

PropertyTypeRequiredConstraintsDescription
unchangedbooleanyesalways true—

Chunk of a large configuration

PropertyTypeRequiredConstraintsDescription
pintegeryes≥ 0This part's index, 0-based.
nintegeryes≥ 1Total number of parts.
hintegeryes≥ 0, ≤ 4294967295FNV-1a 32-bit hash of the COMPLETE reassembled payload — verify it before applying, and send it as `hash` in later config requests.
dstringyes—Base64 slice of the payload's UTF-8 bytes. Each slice is a whole number of 4-character base64 quanta, so it decodes on its own.

Device

PropertyTypeRequiredConstraintsDescription
_idstringyesmatches ^[0-9a-fA-F]{24}$The device id (24 hex characters). Use it as the `{deviceId}` in this device's data/status/alert/cmd topics.
protocolstringyes—How to reach the device, e.g. `rs485`, `modbus-tcp`, `http`, `gpio-digital-in`, `gpio-digital-out`, `gpio-analog`, `gpio-pwm`, `gpio-servo`, `gpio-stepper`, `gpio-motor`, `i2c`, `spi`, `1-wire`, `uart`, `can`, `mbus`, `gps`, `ble`, and for software gateways `host` and `mqtt-bridge`. A gateway should only be given protocols it listed in its capability report.
connobjectyes—Protocol-specific connection settings, passed through verbatim from the device record (e.g. `rs485`: `{modbusId, baudRate, dataBits, stopBits, parity, tickDuration}`; `modbus-tcp`: `{modbusId, ip, port, tickDuration}`; `gpio-digital-in`: `{pinNumber, activeHigh, pullMode, sampleIntervalMs}`; `i2c`: `{sdaPin, sclPin, clockHz, sampleIntervalMs}`). `tickDuration` / `sampleIntervalMs` is how often to publish a data message. Read the keys your protocol needs and ignore the rest.
tagsarray of Tagyes—The values to read from (or write to) the device.

Tag

One point on a device. The backend emits ONLY the keys below, in this order, and omits a key whose value equals the firmware default given in its description — so an absent key MEANS that default. Note the defaults that are not zero: `isIntervalRead` true, `gpioPin` -1, `readBytes` 2, `bigEndian` true.

PropertyTypeRequiredConstraintsDescription
namestringyes—The tag's key in the `values` map of the data message you publish. Always present.
mbAddressinteger——Modbus register/coil address. Default 0.
registerTypestring——Modbus table: `holding`, `input`, `coil` or `discrete`. Default `holding`.
mbFormatstring—one of "u16" "s16" "u32" "s32" "f32"Modbus value format. 32-bit formats read TWO consecutive registers. Default `u16`.
mbWordOrderstring—one of "big" "little"32-bit formats only: `big` = high word at the lower address (ABCD), `little` = low word first. Default `big`.
isIntervalReadboolean——Read on every poll interval. false = read only on demand (the `read/once` device command). Default TRUE.
scaleFactornumber——Engineering value = raw x scaleFactor + offset. Default 1.
offsetnumber——Added after scaleFactor. Default 0.
thresholdStartnumber——Edge alarm band, in engineering units: raise an alert when the value is below thresholdStart OR above thresholdEnd. [0, 0] means no threshold. Default 0.
thresholdEndnumber——See thresholdStart. Default 0.
i2cAddressinteger——I2C bus address (e.g. 118 = 0x76). Default 0.
sensorModelstring——Driver to use on a bus protocol, e.g. `BME280`, `SHT31`, `ADS1115`, `MAX31855`, `generic`. Default empty.
gpioPininteger——GPIO pin number. Default -1 (NOT 0 — pin 0 is a real pin).
canIdinteger——CAN message id (11-bit or 29-bit). Default 0.
mbusRecordinteger——M-Bus data record index in the device response. Default 0.
jsonPathstring——Dot-notation path into a JSON response (http sources). Default empty.
bleFieldstring——BLE only: the canonical decoded field key (see ble-discovery `fields`). Default empty.
metricstring——`host` protocol (software gateways) only: which metric of the machine the gateway runs on, e.g. `cpu.temp`, `load.1m`, `mem.used_pct`. Lowercase letters, digits, `_` and `.`, at most 64. Default empty.
topicstring——`mqtt-bridge` protocol (software gateways) only: the subscription filter on the customer's LOCAL broker whose messages feed this tag (`+` and `#` allowed); combine with `jsonPath`. Default empty.
cmdTopicstring——`mqtt-bridge` only: the publish topic for writes to this tag (reserved). Default empty.
initByteinteger——Generic I2C only (sensorModel `generic`): byte written at init; 0 = skip. Default 0.
readBytesinteger——Generic I2C only: bytes to read per poll. Default 2.
bigEndianboolean——Generic I2C only: byte order of multi-byte reads. Default TRUE.

Ethernet settings

W5500 Ethernet configuration. OMITTED ENTIRELY when it is the default (auto + DHCP) — so a default gateway's payload hash never changed when this block was introduced. When present all six keys are sent, in this order, as the LAST top-level key.

PropertyTypeRequiredConstraintsDescription
ethModestringyesone of "auto" "uplink" "lan" "off"`auto` = use Ethernet as the uplink when a link is present; `uplink` = force Ethernet as the internet path; `lan` = keep Wi-Fi as uplink and use Ethernet only to reach a local segment (e.g. a PLC); `off` = never probe the Ethernet chip.
ethAddrstringyesone of "dhcp" "static"—
ethIpstringyes—Static address; empty string with DHCP.
ethMaskstringyes——
ethGwstringyes——
ethDnsstringyes——
{
  "devices": [
    {
      "_id": "66f1a2b3c4d5e6f708192a3b",
      "protocol": "rs485",
      "conn": {
        "modbusId": 1,
        "baudRate": 9600,
        "dataBits": 8,
        "stopBits": 1,
        "parity": 0,
        "tickDuration": 10000
      },
      "tags": [
        {
          "name": "voltage",
          "registerType": "input",
          "mbFormat": "f32"
        },
        {
          "name": "current",
          "mbAddress": 6,
          "registerType": "input",
          "mbFormat": "f32",
          "mbWordOrder": "little"
        },
        {
          "name": "energy_kwh",
          "mbAddress": 342,
          "registerType": "input",
          "mbFormat": "u32",
          "isIntervalRead": false,
          "scaleFactor": 0.01
        }
      ]
    },
    {
      "_id": "66f1a2b3c4d5e6f708192a3c",
      "protocol": "gpio-digital-out",
      "conn": {
        "pinNumber": 26,
        "activeHigh": true,
        "defaultState": 0
      },
      "tags": [
        {
          "name": "relay",
          "gpioPin": 26
        }
      ]
    },
    {
      "_id": "66f1a2b3c4d5e6f708192a3d",
      "protocol": "i2c",
      "conn": {
        "sdaPin": 21,
        "sclPin": 22,
        "clockHz": 100000,
        "sampleIntervalMs": 30000
      },
      "tags": [
        {
          "name": "temperature",
          "i2cAddress": 118,
          "sensorModel": "BME280",
          "thresholdStart": -10,
          "thresholdEnd": 80
        },
        {
          "name": "humidity",
          "i2cAddress": 118,
          "sensorModel": "BME280"
        }
      ]
    }
  ],
  "success": true
}

The two short answers — nothing changed, and one part of a chunked transfer:

{
  "unchanged": true
}
{
  "p": 0,
  "n": 3,
  "h": 3735928559,
  "d": "eyJkZXZpY2VzIjpbeyJfaWQiOiI2NmYxYTJiM2M0ZDVlNmY3MDgxOTJhM2IiLCJwcm90b2NvbCI6InJzNDg1In1dfQ=="
}

gateway-cmd — a gateway-level command

Not acknowledged. Ignore what you do not implement. Optional.

gateway-cmd.json · Gateway command (cloud -> gateway) — Commands addressed to the gateway itself, published on `<prefix>/cmd` (QoS 1, not retained). Every body carries `command`; the other keys depend on it. IGNORE a command you do not recognise — new ones are added within v1, and a gateway that errors or reboots on an unknown command will break. `job/config` is also re-sent after every config/request, so it arrives on every boot and reconnect. Commands that expect a reply name the reply topic below; the rest are fire-and-forget. The backend does not validate what it publishes — this schema documents it; validate with a plain draft-07 validator (not Ajv `removeAdditional`, which corrupts `oneOf`). New optional keys may be added within v1; ignore keys you do not use.

restart

Reboot now. The backend refuses to send it while a firmware update or config apply is in flight unless the user forces it.

PropertyTypeRequiredConstraintsDescription
commandstringyesalways "restart"—

reset/config

Discard the stored device configuration (and its hash) and reboot; the next config/request then carries no hash and receives the full configuration.

PropertyTypeRequiredConstraintsDescription
commandstringyesalways "reset/config"—

sim/start

Enter simulation mode: publish synthetic readings instead of polling hardware. Report `simMode: true` in the heartbeat while it is on.

PropertyTypeRequiredConstraintsDescription
commandstringyesalways "sim/start"—

sim/stop

Leave simulation mode.

PropertyTypeRequiredConstraintsDescription
commandstringyesalways "sim/stop"—

debug/diag

Publish one diagnostics snapshot on `<prefix>/debug/response` (see debug-response), echoing `correlationId`.

PropertyTypeRequiredConstraintsDescription
commandstringyesalways "debug/diag"—
correlationIdstringyes—A UUID.

debug/logs/start

Start (or, if already running, re-scope) the live log tail on `<prefix>/debug/log` (see debug-log). Replace the current category mask with `cats`; end the tail on your own after a while.

PropertyTypeRequiredConstraintsDescription
commandstringyesalways "debug/logs/start"—
catsintegeryes≥ 1, ≤ 63Bitmask of log categories to stream: 1 system (always set), 2 network, 4 commands, 8 modbus, 16 sensors, 32 macros. These bit values are a wire contract. At most three categories besides system are selected at once.

debug/logs/stop

Stop the live log tail.

PropertyTypeRequiredConstraintsDescription
commandstringyesalways "debug/logs/stop"—

ble/scan

Listen for Bluetooth LE sensors for `durationMs`, then publish the result on `<prefix>/discovery/ble` (see ble-discovery), echoing `correlationId`. Only sent to gateways whose capability report lists the `ble` protocol.

PropertyTypeRequiredConstraintsDescription
commandstringyesalways "ble/scan"—
correlationIdstringyes—A UUID.
durationMsintegeryes≥ 1000, ≤ 30000Scan window, 1-30 s (default 15 s).

job/config

Edge-owned job settings: which input opens a job, the hooter (alarm output) binding and the operational limit bands. Persist them apart from the device configuration — they deliberately do not ride config/push, whose hash change would reboot the gateway. Only sent when the machine has an edge-mode job profile, and always with at least one of `control`, `hooter`, `limits`. Devices are named by their id (the config/push `_id`).

PropertyTypeRequiredConstraintsDescription
commandstringyesalways "job/config"—
controlobject——The digital input that opens and closes jobs (reported on the `job` topic).
control.deviceNamestringyes—The input device's id.
control.startOnstringyesone of "rising" "falling"Which edge starts a job.
control.debounceMsnumberyes≥ 0—
control.minJobMsnumberyes≥ 0Discard a job shorter than this (switch chatter).
hooterobject——The output to sound on a violation.
hooter.deviceNamestringyes—The output device's id.
hooter.onSafetybooleanyes—Sound on a safety-band violation.
hooter.onJobbooleanyes—Sound on an operational-band violation during a job.
limitsarray of object——Operational bands, already compiled to a single [start, end] range per tag: a reading outside it is a violation. An unbounded side is sent as -1e9 / 1e9.
limits[].deviceNamestringyes—The device's id.
limits[].tagstringyes—The tag's name, as in config/push.
limits[].startnumberyes——
limits[].endnumberyes——
limits[].duringJobstringyesone of "safety_only" "job_only" "both" "none"When the band applies.
{
  "command": "restart"
}

device-cmd — a device-level command

Answered on cmd/ack whenever it carries a correlationId. Read control is gateway-only; a directly connected device receives Modbus and actuator writes. Optional.

device-cmd.json · Device command (cloud -> gateway or device) — Commands addressed to one device: on `<prefix>/devices/{deviceId}/cmd` for a device behind a gateway, or `tenants/{tenantId}/devices/{deviceId}/cmd` for a directly connected device (QoS 1, not retained). Three families, told apart by their keys: READ CONTROL carries `command` (read/once, set/interval, read/disable, read/enable — gateway devices only); a MODBUS WRITE carries `fc` + `address`; an ACTUATOR WRITE carries neither (`value`, plus `dir` for a motor, or `command:"move"` for a stepper). Every command with a `correlationId` expects a reply on the matching `cmd/ack` topic (see cmd-ack) echoing it; the rest are fire-and-forget. Ignore a `command` you do not recognise. The backend does not validate what it publishes — this schema documents it; validate with a plain draft-07 validator (not Ajv `removeAdditional`, which corrupts `oneOf`). New optional keys may be added within v1; ignore keys you do not use.

read/once

Read one tag now — even a tag with `isIntervalRead: false` — and publish the value on the normal data topic; then ack with `correlationId`.

PropertyTypeRequiredConstraintsDescription
commandstringyesalways "read/once"—
tagstringyes—The tag's name, as in config/push.
correlationIdstringyes—24 hex characters.

set/interval

Change how often this device is polled and published, and persist it across reboots (it overrides the config/push `conn` interval). Applied live; no reboot.

PropertyTypeRequiredConstraintsDescription
commandstringyesalways "set/interval"—
intervalintegeryes≥ 250, ≤ 3600000Milliseconds, 250 to 3,600,000 (raised to the account's minimum publish interval when that is higher).

read/disable

Pause polling this device. `manual` = until a read/enable; `restart` = until the gateway next reboots; `timed` = for `durationMs`, then resume on your own.

PropertyTypeRequiredConstraintsDescription
commandstringyesalways "read/disable"—
modestringyesone of "manual" "restart" "timed"—
durationMsnumber—> 0Present only with mode `timed`.

read/enable

Resume polling after a read/disable.

PropertyTypeRequiredConstraintsDescription
commandstringyesalways "read/enable"—

Modbus write

Write one coil or register. Through a gateway ONLY single writes are sent and `register_type` selects the function — `coil` = FC05, `holding` = FC06; switch on `register_type`, not `fc`. A directly connected device receives the body without `register_type` and may also receive FC15/FC16 with an array `value`. Ack with the result.

PropertyTypeRequiredConstraintsDescription
correlationIdstringyes—A UUID.
fcintegeryesone of 5 6 15 16Modbus function code.
addressintegeryes≥ 0, ≤ 65535—
valuenumber | array of numberyes—The value to write: a number for FC05/FC06 (coil: 0 = off, non-zero = on); an array of numbers for FC15/FC16.
register_typestring—one of "coil" "holding"Gateway devices only; always present there.

Actuator write

Drive an actuator device to `value`, then ack with the value actually applied. The meaning of `value` follows the device's protocol: gpio-digital-out 0|1; gpio-pwm duty 0..255; gpio-servo pulse width in microseconds; gpio-motor speed magnitude 0..255 with `dir`. A timed pulse is two writes: the on value now and the off value when the time is up, each with its own correlationId.

PropertyTypeRequiredConstraintsDescription
correlationIdstringyes—A UUID.
valuenumberyes——
dirinteger—one of 0 1gpio-motor only: 1 = forward, 0 = reverse.

move

gpio-stepper: move `steps` in direction `dir`, then ack when the move finishes (or at once with status `error` if it is rejected).

PropertyTypeRequiredConstraintsDescription
correlationIdstringyes—A UUID.
commandstringyesalways "move"—
stepsintegeryes≥ 0—
dirintegeryesone of 0 1—
{
  "correlationId": "b7e4c2a1-5d3f-4a9e-8c7b-6f5e4d3c2b1a",
  "fc": 6,
  "address": 12,
  "value": 1500,
  "register_type": "holding"
}

A Modbus coil write through a gateway, and a poll-interval change:

{
  "correlationId": "b7e4c2a1-5d3f-4a9e-8c7b-6f5e4d3c2b1a",
  "fc": 5,
  "address": 3,
  "value": 1,
  "register_type": "coil"
}
{
  "command": "set/interval",
  "interval": 5000
}

firmware-request — start an OTA update

Optional. Report progress on firmware/response.

firmware-request.json · Firmware update request (cloud -> gateway) — Published on `<prefix>/firmware/request` (QoS 1, not retained) when a user starts an over-the-air update. Download the named version from the OTA server (`GET {otaServer}/firmware/ota/{version}`, public, no token), report progress on `<prefix>/firmware/response` (`status`: accepted, downloading, installed, failed — see firmware-response), and after the reboot send a fresh capability report whose `version` is the new one. The backend holds the gateway busy (restart/reset refused) until a terminal status arrives or 5 minutes pass. The reference firmware also treats a request WITHOUT `type` as "re-send your capability report"; the backend does not currently send that form. The backend does not validate what it publishes — this schema documents it. New optional keys may be added within v1; ignore keys you do not use.

PropertyTypeRequiredConstraintsDescription
typestringyesalways "update"—
versionstringyeslength 1–∞The firmware version to install (semver, e.g. `1.5.4`). It may be OLDER than the running one: a user can deliberately roll back.
{
  "type": "update",
  "version": "1.5.4"
}

macros-push, macros-run, macros-abort — macros

The program set for this gateway, a request to run one, and a request to stop one. Optional. Abort is a network message: it cannot reach a gateway that is offline, so it is not a safety function.

macros-push.json · Macro program set (cloud -> gateway) — The COMPLETE set of enabled macro programs for this gateway, published on `<prefix>/macros/push` (QoS 1, not retained) when a macro is saved and again after every config/request, so it arrives on every boot and reconnect. Replace your whole set with it: a macro that is no longer listed must no longer run. An empty `macros` array is valid and clears the set. Each `program` is a compiled macro — the flat opcode program described by macro-program.json (https://synacl.com/protocol/v1/schemas/macro-program.json); validate it against that schema separately. A macro only runs when a `macros/run` names it. The backend refuses to publish a set larger than the gateway's one-packet capacity rather than letting it be dropped silently. The backend does not validate what it publishes — this schema documents it. New optional keys may be added within v1; ignore keys you do not use.

PropertyTypeRequiredConstraintsDescription
macrosarray of objectyes——
macros[].macroIdstringyes—The macro's id (24 hex characters). `macros/run` and `macros/abort` refer to it.
macros[].programobjectyes—The compiled program: see macro-program.json.
{
  "macros": [
    {
      "macroId": "66f1a2b3c4d5e6f708192b01",
      "program": {
        "v": 1,
        "name": "Pulse pump",
        "params": [
          {
            "name": "ms",
            "default": 2000,
            "min": 100,
            "max": 10000
          }
        ],
        "vars": [
          "ms"
        ],
        "instructions": [
          {
            "op": "DWRITE",
            "dev": "66f1a2b3c4d5e6f708192a3c",
            "value": {
              "lit": 1
            }
          },
          {
            "op": "WAIT",
            "ms": {
              "var": "ms"
            }
          },
          {
            "op": "DWRITE",
            "dev": "66f1a2b3c4d5e6f708192a3c",
            "value": {
              "lit": 0
            }
          },
          {
            "op": "EMIT",
            "message": "pump pulsed",
            "severity": "info"
          }
        ]
      }
    }
  ]
}

macros-run.json · Run a macro (cloud -> gateway) — Start a run of a macro previously delivered by macros/push, published on `<prefix>/macros/run` (QoS 1, not retained). MOVES PHYSICAL EQUIPMENT. Report the run on `<prefix>/macro/run/status` (see macro-run-status) with the same `runId` and `macroId`; answer an unknown `macroId` with phase `error` (message `unknown macro`) rather than silently doing nothing. Seed each program parameter from `params` (falling back to its default) and clamp it to the parameter's min/max yourself — the backend clamps too, but the gateway is the last line. The backend does not validate what it publishes — this schema documents it. New optional keys may be added within v1; ignore keys you do not use.

PropertyTypeRequiredConstraintsDescription
macroIdstringyes—Which macro (its `macroId` in macros/push).
runIdstringyes—A UUID for this run.
paramsobjectyes—Parameter name -> number, for every parameter the program declares.
params.<key>number—any key—
{
  "macroId": "66f1a2b3c4d5e6f708192b01",
  "runId": "3f9d2c1e-7b4a-4e8f-9a6b-2d1c0e5f7a8b",
  "params": {
    "ms": 2000
  }
}

macros-abort.json · Abort macro runs (cloud -> gateway) — Stop a macro run, published on `<prefix>/macros/abort` (QoS 1, not retained). With `runId`, abort that run; with an empty body `{}`, abort EVERY run on the gateway. Report the stop on macro/run/status (phase `error`, message `aborted`). This is a network message: it cannot reach an offline gateway and is NOT a safety function — an emergency stop must be hardwired. The backend does not validate what it publishes — this schema documents it. New optional keys may be added within v1; ignore keys you do not use.

PropertyTypeRequiredConstraintsDescription
runIdstring——The run to abort. Absent = all runs.
{
  "runId": "3f9d2c1e-7b4a-4e8f-9a6b-2d1c0e5f7a8b"
}

macro-program — one macro

The program format carried inside macros/push. Optional.

macro-program.json · Compiled macro program — The compact, flat opcode program a gateway executes — the `program` of each entry in macros/push. Produced by the platform's macro compiler from the visual editor; a gateway only interprets it. Control flow is expressed with absolute jump targets (JMP/JMPF) into the instructions array. Operands are a literal `{lit}`, a variable `{var}` (params, locals and compiler temporaries, all listed in `vars`), or a tag reading `{tag:{dev, tag}}`. CALL asks the backend to run a backend step (see macro-step); EMIT reports a message on macro/run/status. WARNING for implementers: a `{tag}` operand must not read as 0 when the device is missing, the tag is unknown or nothing has been polled yet — a comparison like "below 20" would then actuate hardware on an absent sensor. A copy of the platform's canonical program schema; the two are kept identical by a test.

PropertyTypeRequiredConstraintsDescription
vnumberyesalways 1—
namestring———
paramsarray of object—0–16 itemsRun-time inputs. Each becomes a variable seeded from the run trigger message (falling back to default).
params[].namestringyesmatches ^[a-zA-Z_][a-zA-Z0-9_]*$—
params[].defaultnumber———
params[].minnumber———
params[].maxnumber———
varsarray of stringyes0–32 itemsAll variable names used by the program (params + locals + compiler temporaries).
instructionsarray of instryes0–256 items—

instr

PropertyTypeRequiredConstraintsDescription
opstringyes——
{
  "v": 1,
  "name": "Pulse pump",
  "params": [
    {
      "name": "ms",
      "default": 2000,
      "min": 100,
      "max": 10000
    }
  ],
  "vars": [
    "ms"
  ],
  "instructions": [
    {
      "op": "DWRITE",
      "dev": "66f1a2b3c4d5e6f708192a3c",
      "value": {
        "lit": 1
      }
    },
    {
      "op": "WAIT",
      "ms": {
        "var": "ms"
      }
    },
    {
      "op": "DWRITE",
      "dev": "66f1a2b3c4d5e6f708192a3c",
      "value": {
        "lit": 0
      }
    },
    {
      "op": "EMIT",
      "message": "pump pulsed",
      "severity": "info"
    }
  ]
}

Known quirks

Documented rather than fixed. A new implementation should tolerate all of them and need not reproduce any of them.

Quirk Detail
Two shapes on firmware/response The capability report and OTA progress ({correlationId, status, progress?, error?}) share the topic. Tell them apart by status (OTA) versus version with protocols (the report).
seq is per gateway On the reference firmware seq counts messages for the whole gateway, not per device, and resets on reboot. Treat it as a hint, not a per-device sequence.
q is decorative The reference firmware always sends good.
The net block is named twice The capability report uses mode, addr, ip, mask, gw, dns; config/push uses ethMode, ethAddr, … for the same settings.
Stable firmware 1.5.4 sends only {hash} No cap, max or part; chunked delivery is newer than that build.
The ESP32 subscribes at QoS 0 A command published while its connection blips is lost.
Gateway-level commands have no cmd/ack restart, reset/config, sim/*, debug/logs/* and job/config produce no reply at all; observe the effect instead. Only debug/diag and ble/scan answer, on their own topics.
macros/push and job/config are re-sent on every config/request Even when nothing changed. Apply idempotently.
image/* is experimental Shape and topic may change without a version bump.

Conformance levels

Level Messages Meaning
Core status (Last Will + heartbeat) · capability report on firmware/response · config/request → config/push · devices/{d}/data · devices/{d}/status A gateway that does these shows up in the app, receives its device list and puts readings on dashboards.
Optional data/backfill · cmd / devices/{d}/cmd + cmd/ack · alert · firmware/request (OTA) · debug/* · macros · jobs · BLE discovery Implement what the hardware supports. The platform hides what the capability report does not claim.
Experimental image/* May change.

Implementations

Implementation Status Notes
Synacl ESP32 firmware Reference, shipping Flash it from the browser at /flash/. Implements Core, backfill, commands, OTA, remote debug, macros, jobs and BLE discovery.
synacl-gateway (Node.js) Coming soon Apache-2.0, github.com/synacl-iot/synacl-gateway. Runs with npx synacl-gateway on anything with Node.js. Drivers: host metrics, a bridge from a local MQTT broker, and Modbus TCP.
Yours — Register a software gateway in the app and speak Core. Tell us about it.

Versioning policy