Skip to content

Gateway API

Alex Van de Putte edited this page Jul 15, 2026 · 22 revisions

The Gateway API (REST & MQTT)

This is the integrator's reference for driving a gateway programmatically — both the Gateway (the ESP32 driving a real RS-485 wall) and the Matrix Portal Gateway (the same firmware family drawing virtual modules on an LED panel). They speak the same API by design; where they differ, this page says so. Every display endpoint here ultimately wraps the raw bus frames documented in Bus ProtocolPOST /api/flap/char with {"id":5,"char":"A"} becomes m5-A\n on the wire.

(The Companion exposes a separate, Vestaboard-compatible surface of its own — that's Vestaboard API, not this page.)

Orientation

  • Base URL: http://<gateway-ip> — or http://192.168.4.1 while on the setup AP. The ESP32 gateway advertises itself over mDNS as http://splitflap-gw.local; the Matrix Portal's hostname is configurable and defaults to splitflap-gw-<6 hex digits>. mDNS is best-effort — see finding it on your network.
  • JSON in, JSON out. All POST endpoints accept and return application/json (the two binary exceptions — OTA upload and the companion settings blob — are called out below).
  • CORS is open. Every endpoint sends Access-Control-Allow-Origin: *, so a page served from anywhere on your LAN can call the API straight from the browser.
  • No authentication. Anything that can reach the gateway's IP can drive the wall — the API treats your LAN as the trust boundary. The only credential anywhere is the optional OTA password.
  • Which wall am I talking to? GET /api/config reports version — the API level, not the build: the Matrix Portal deliberately answers 3.1.0 there (clients gate features on it) and puts its real build in fwVersion, next to product. GET /api/capabilities also reports product, fw and api. Version pairings live on Compatibility.

REST endpoints

Bus

Method Path Body Reply Gateway
GET /api/rs485/messages Frames buffered since the last call (up to 64); each call drains the buffer ESP32 only
GET /api/log Commands acted on (REST + MQTT) since the last call, up to 64 — same drain-cursor idea, but there is no wire to trace on a drawn wall Matrix only
POST /api/rs485/send {"data":"m5-A\n"} — optional "raw":true {ok,bytes} both
POST /api/rs485/batch {"frames":["m00-A\n","m01-B\n",…],"step_ms":15} {ok,sent} both

Framing is normalized for you. Unless "raw":true, the gateway strips/re-adds the trailing newline and trims junk past a complete known command — m5-A and m5-A\n behave identically. raw is the debugging escape hatch that sends bytes verbatim. Frame syntax lives on Bus Protocol.

Batch is how pages get drawn. One POST /api/rs485/batch carries a whole wall — up to 512 frames — in a single HTTP call; it's what the Companion uses for every page, with no broker in the display path. step_ms (0–30) paces the cascade device-side so the wall animates instead of snapping over at once. Since gateway 3.4 paced frames are queued on the bus task and the call returns immediately: 200 means accepted, not yet on the wire, and the pacing queue holds 127 frames in flight (a cascade longer than that loses its stagger past that point; the frames still all arrive).

Modules

Method Path Body Reply Gateway
GET /api/flap/modules Array of module objects (id, serial, firmware, current character, last-seen) both
POST /api/flap/char {"id":5,"char":"A"}id:-1 broadcasts {ok} both
POST /api/flap/index {"id":5,"index":1} {ok} both — 0–63 on a real 64-flap reel, 0–236 on the Matrix Portal's 237-flap reel
POST /api/flap/text {"text":"HELLO","start":0} {ok,chars} both
POST /api/flap/home {"id":5}id:-1 homes all {ok} both

char and text take UTF-8 and transcode to the single Windows-1252 byte the bus uses, so and accented letters work. ASCII letters are uppercased — except the seven lowercase colour codes r o y g b p w, which address the colour flaps and go through as-is. Characters with no Windows-1252 representation are rejected.

The ESP32 gateway also carries a whole hardware-maintenance tier under /api/flap/* — version/EEPROM queries, calibration, provisioning, self-diagnostics, per-module flap-set config, restore-by-serial. The Matrix Portal omits all of it by design: its modules are drawn, so there is nothing to calibrate or provision. That tier is documented in the SplitFlapGateway README and openapi.yaml (see the footer).

Display

Method Path Body Reply Gateway
GET /api/display/state {rows,cols,cells:[…]} — cell index = row·cols + col = module id; null = no module there both
POST /api/display/cells {"start":0,"step_ms":15,"cells":[{"ch":"H"},{"color":"red"},{"blank":true},{"skip":true}]} ESP32: {ok,cells,sent,skipped} · Matrix: {ok,cells,sent} both — gateway 3.8, Matrix 1.6

/api/display/state drives the Live Display. On the ESP32 gateway a cell is the tracked character ("?" when unknown, e.g. after a home or an index set); on the Matrix Portal it is read straight from the module and reported as a code point, so lowercase, accents and a read back as themselves (a colour flap reports as its protocol letter).

/api/display/cells is the index-addressed display API — the same JSON contract on both gateways, so one client can drive either wall through one endpoint. Each cell is exactly one of ch (a character), color (a named flag: red orange yellow green blue purple white), blank (home the module) or skip (leave it alone); step_ms (0–30) paces the cascade without ever blocking the web server. It exists because the one-byte character protocol cannot say everything: the byte for lowercase r already means red, and a heart has no byte at all — see Reaching the extra flaps.

One deliberate difference:

ESP32 gateway Matrix Portal
A cell the wall can't show Lenient — skipped, reported in skipped (real reels differ per module; only structural errors 400) Strict — the whole request is a 400 ("no flap for U+1F600"), resolved before anything is sent
Sent on the (emulated) bus as m<id>-<char> — each module maps the byte against its own reel m<id>+<n> — one shared reel, so an index always names the same flap

A client that needs certainty consults /api/capabilities first.

Status & configuration

Method Path Body Reply Gateway
GET /api/status Uptime, IP, MQTT state, RTC time, NTP, heap, maintenance and quiet flags both
GET /api/config Current configuration, passwords excluded; includes version (the API level) both
POST /api/config/wifi {"ssid":"…","pass":"…"} {ok} — reconnects both
POST /api/config/mqtt {"host":"…","port":1883,"user":"…","pass":"…","prefix":"splitflap"} {ok} — reconnects both
POST /api/config/settings Send only what you're changing: posixTZ, ntpServer, serialDebug, haEnabled, otaPassword — the Matrix Portal adds grid, panel and flip-effect fields {ok} both
POST /api/config/rs485 Bus parameters (baud, data bits, parity, stop bits) {ok} ESP32 only — the Matrix Portal removed it (there is no UART)
GET /api/capabilities What the wall can show — see below both, identical shape
GET / POST /api/maintenance {"on":true} {ok,on} both
GET / POST /api/quiet {"on":true} {ok,on} both
GET / POST /api/quiet/schedule {"enabled":true,"start":"22:00","end":"07:00","days":127} {enabled,start,end,days} both
POST /api/mqtt/test {"host","port","user","pass"} — all optional, defaults to saved config Broker reachability + credentials, without touching the live connection both

Maintenance mode makes the gateway ignore externally-originated MQTT commands (the web UI keeps working) so calibration work isn't disturbed. Quiet time blanks the wall — every reel homes to its blank flap — and restores it when turned off; the schedule's days is a bitmask, bit 0 = Sunday. Both always reset to off on reboot.

OTA & UI

Method Path Body Reply Gateway
GET /ota Browser firmware-upload page both
POST /api/ota/upload Multipart upload of firmware.bin — the plain app image, never the factory image Flashes and reboots both
GET /lang/{code} The dashboard's translation dictionary for one language (keys are the English strings); 404 for a code the firmware doesn't ship both

/lang/{code} is served with Content-Encoding: gzip — a transfer encoding the client inflates transparently. Note the contrast with the companion settings blob below, where the gzip bytes are the payload.

/api/capabilities — one contract across walls

GET /api/capabilities answers "what characters can this display show?" in one call, made once when a client connects. Both gateways answer the same URL with the same shape by design — a client never has to know which kind of wall it is talking to. (It is the "what can you show" companion to /api/display/cells' "show this".)

The subtlety it exists for: on a real wall the answer is not one set. Every module owns its reel, and since module firmware v31 each can be told a different one — so the response reports set arithmetic, not a single alphabet:

Field The question it answers
charset.union Can this wall show a Z anywhere? — every character some module can show
charset.common Can I lay this text across arbitrary cells? — every character every module can show
charset.uniform Does every module carry the same reel? (always true on the Matrix Portal — one drawn reel)
charset.assumed Module ids too old (pre-v31) to report a reel — theirs is assumed to be the firmware default, and the guess is visible rather than folded in silently
charset.unknown Module ids whose reel is genuinely not known yet — excluded from both sets
sets Each distinct reel once: flaps, source (reported/assumed/builtin), the reel chars verbatim in flap-index order, and the ids that carry it as a compressed list ("0-44,50")
colors The colour flaps the wall actually has, by name (["red","orange",…])
maxFlaps Flaps a module can carry — 64 real, 237 drawn
features e.g. ["colors","index","batch","quiet","maintenance","ha","ota","flapconfig"]
motion How the wall moves (Gateway 3.10+, Matrix Portal 1.12+): {"kind": "drawn" | "mechanical", "settleMs": …}. drawn — a cell is a repaint, interruptible, nothing queues, so sub-second updates are honest; mechanical — motion must physically complete, and settleMs (~4000: a full revolution) is a real constraint. On the drawn wall settleMs is the worst-case flip animation (flapMs × flapMax, live-configurable) — cosmetic pacing, advisory. Stated directly so a client never infers the wall's nature from which endpoints exist.

Plus product, fw, api, grid (rows × cols) and modules.

union and common genuinely differ: if module 1 carries A-Z and module 2 carries 0-9, the union is A-Z0-9 but the common set is empty — the wall cannot show HI42 wherever it likes, and only common says so. Reporting each distinct reel once (rather than one entry per module) is what keeps the response small: a uniform wall is a few hundred bytes however large it is.

Two translations are applied to the character sets, matching exactly what the gateway does when it resolves a frame: the seven colour flaps r o y g b p w are kept out of the sets and reported by name under colors, and q — which the classic reel borrows for the double-quote flap — is reported as ". A client that read those as letters would believe a classic reel can show a lowercase w.

The companion contract

Two endpoint pairs make the Companion and the gateway feel like one product — and let a containerized companion run diskless.

Registration: /api/companion

Method Body / reply Behavior
POST {"url":"http://192.168.1.60:8000","status":"Running: Weather","tabs":[{"id":"apps","label":"Apps"},…]} Register and heartbeat. The URL is persisted (debounced, so heartbeats don't wear the flash); status and tabs are runtime-only and clear on reboot. An empty url deregisters. Either field may be sent alone
GET {url,status,tabs,gwTabs} The registered companion's URL, its last reported status, and both sides' tab lists

The tabs advertisement (gateway 3.4) is how the two navs stay in sync without matched releases: the companion sends tabs — the deep links its UI actually has — and the reply always carries gwTabs, the gateway's own. Each side then links exactly what the other really offers. Both halves are optional and independent, so any old/new pairing works: a peer that says nothing simply gets the built-in list.

Settings blob: /api/companion/settings (gateway 3.1)

The gateway is deliberately a dumb blob store for the companion's settings, playlists and triggers. The payload is gzip(minified JSON) whose schema belongs entirely to the companion — the firmware stores the bytes verbatim, hands them back byte-for-byte, and never parses them. A companion container becomes effectively stateless: destroy it, start another on a different host, and it restores its configuration from the gateway on boot.

Method Behavior
GET Returns the stored blob as application/gzip, or 404 when nothing is stored yet
PUT Stores the gzipped request body atomically; replies 200 {"ok":true,"bytes":N}
  • Atomic writes. The body streams to a temp file, renamed over the live one only once the last byte lands — a crash or dropped connection mid-upload can never corrupt settings that were already good.
  • No Content-Encoding: gzip. The gzip bytes are the payload, not a transfer encoding of it — declaring the encoding would make HTTP clients silently decompress the body. The companion decompresses it itself.
  • Bounded. Blobs over 64 KB are rejected with 413; real ones are 1–2 KB, and the companion debounces its writes so a burst of edits becomes one write.
  • Durable. The blob lives on the FATFS partition (/compset.gz), which a firmware update doesn't touch — it survives OTA.
  • Errors: 400 empty or truncated body · 413 too large · 503 filesystem not mounted · 507 write failed.

On the companion side this is COMPANION_SETTINGS_STORE: mirror (default — local file primary, mirrored here), local, or gateway (diskless). The companion gates the feature on GET /api/config reporting version >= 3.1 and quietly falls back to local storage against an older gateway.

MQTT

Optional. Default topic prefix splitflap (configurable in Settings → MQTT), default port 1883. MQTT never carries display frames for the companion — that path is always REST — but it is the transport for Home Assistant and for external automations.

Published

Topic Payload When
splitflap/rx {"ts":…,"wt":"…","command":"m5-A"} Frame received from the bus
splitflap/tx {"ts":…,"wt":"…","command":"m5-A"} Frame transmitted to the bus
splitflap/status Heartbeat with the full diagnostic set — uptime, frame counters, heap, RSSI, module count, IP, version, maintenance/quiet flags Once per minute
splitflap/flap/adv "AABBCCDD…" Unprovisioned module advertisement
splitflap/flap/ack {"id":5,"sn":"…"} Provisioning acknowledgement
splitflap/flap/version {"id":5,"ver":"12","reportedId":5,"sn":"…"} Version response
splitflap/flap/calibrated {"id":5,"stepsPerRev":4096} Calibration result
splitflap/flap/dump {"id":5,"dump":"…"} EEPROM dump response
splitflap/availability online / offline Retained; offline via MQTT Last Will
splitflap/display/state HELLO WORLD Best-known display contents (? = unknown), on change
splitflap/maintenance/state ON / OFF Maintenance mode
splitflap/quiet/state ON / OFF Quiet time

The rx/tx/status/flap/* topics publish whenever MQTT is connected; availability, display/state, maintenance/state and quiet/state (plus the discovery configs) publish only when Home Assistant integration is enabled on the Settings tab.

Subscribed

Topic Payload Action
splitflap/send m9h\n or {"data":"m9h\n"} (optional "raw":true) Send a raw frame — normalized like /api/rs485/send
splitflap/flap/set {"id":5,"char":"A"} Show character
splitflap/flap/home {"id":5} Home module
splitflap/flap/provision {"sn":"AABBCC…","id":5} Provision module
splitflap/flap/flapconfig {"id":5,"flapCount":40,"charSet":" ABC…€é"} Configure a module's flap set (fw v31+) — ESP32 only; the Matrix Portal dropped this topic (its reel isn't configurable)
splitflap/display/set HELLO Show a string from module 0 (the Home Assistant text entity)
splitflap/maintenance/set ON/OFF/true/1 Set maintenance mode (reachable even while it's on)
splitflap/quiet/set ON/OFF/true/1 Set quiet time

The Matrix Portal carries this MQTT surface over from the gateway untouched (minus flap/flapconfig); topics tied to physical-bus events — provisioning adverts, calibration results — simply never have anything to report there, since its modules are born provisioned.

With MQTT configured, both gateways can also announce themselves to Home Assistant via retained MQTT discovery (opt-in on the Settings tab) — a device with a Display text entity, Maintenance and Quiet switches, and diagnostic sensors. That, and what the companion adds on top, is covered on Home Assistant.

Canonical sources

The two repos are the source of truth; each ships a machine-readable OpenAPI 3.1 spec you can import straight into Postman (Import → drop the file, then set a baseUrl collection variable to your gateway's IP), Swagger Editor (editor.swagger.io for interactive try-it-out docs), or Insomnia:

Gateway README (API sections) OpenAPI spec
ESP32 Split-Flap Gateway README.md openapi.yaml
Matrix Portal Gateway README.md openapi.yaml

The specs can lag the READMEs in places — the Matrix Portal spec, for instance, still caps /api/flap/index at 63 from before the 237-flap reel. Where the two disagree, the README is current.


See also: Gateway · Matrix-Portal-Gateway · Bus-Protocol · Vestaboard-API · Home-Assistant · Compatibility

Clone this wiki locally