REST & WebSocket API¶
This is the complete wire reference for the gateway server. All paths below
are relative to the server root (default http://127.0.0.1:8765); every
/api/* route accepts and returns JSON. This page mirrors what the browser
UI itself calls, with one noted exception: the older /scope/command route
(see Scope configuration) is public API that still
works, but the shipping frontend calls the kind-agnostic
command console route instead.
Every route below requires Authorization: Bearer <token> except
GET /api/health and POST /api/join. The latter exchanges an invitation for
a token — {"code": "417902"} or {"invite": "<nonce from an invite link>"}
in, {"token", "identity"} back — so it cannot require the credential it
issues. Every rejected invitation — wrong code, expired, already redeemed —
returns the same 401 with byte-identical wording, so the route cannot be
used to probe for which invitations exist; too many failures return 429,
and a body that is not valid JSON of the shape above is rejected 422 by
request validation before the handler runs (which reveals nothing: the answer
is the same whether or not an invitation is live). See the
Gateway security guide for how to
invite someone or mint a token, how ownership gates writes, and the WebSocket
authentication handshake.
The admin panel has routes of its own — identities and
invitations — but they belong to a separate application on a separate,
loopback-bound port (8766 by default) and are not part of this reference.
They are not reachable from the LAN, and nothing on this page serves them.
Sessions & discovery¶
| Method | Path | Body / params | Response | Errors |
|---|---|---|---|---|
GET |
/api/models |
— | List of {model_name, series, num_channels, bandwidth_mhz, dialect} — the built-in model registry |
— |
GET |
/api/sessions |
— | List of session objects (see below) | — |
POST |
/api/sessions |
{label?, address?, port?, mock?, model?} |
201 session object | 400 invalid params; connection failures surface as the instrument-error codes below |
GET |
/api/sessions/{id} |
— | Session object | 404 unknown session |
DELETE |
/api/sessions/{id} |
— | 204 No Content | 404 unknown session |
GET |
/api/discover |
?cidr= optional (defaults to the gateway's local /24) |
List of discovered/connected instruments (see below) | 400 invalid CIDR, range wider than /22, or local network undetectable |
A session object: {id, label, mock, address, state, idn, model, dialect, num_channels, viewers}. state is one of connecting / connected / error / closed; dialect is "legacy" or "modern" once connected; viewers counts currently-subscribed WebSocket clients.
A discover entry is either a live session — {address, idn, manufacturer, model, dialect, kind, connected: true, session_id, viewers} — or an unconnected device found by the scan — the same shape minus session_id/viewers, with connected: false. kind is "scope", "psu", "awg", "daq", or "unknown", guessed from the model prefix.
address/port/mock/model on POST /api/sessions are all optional: pass {"mock": true} for a hardware-free session, or {"address": "192.168.1.100"} for a real instrument (port defaults to 5025).
Command console¶
| Method | Path | Body / params | Response | Errors |
|---|---|---|---|---|
POST |
/api/sessions/{id}/command |
{command} — a raw SCPI string |
{command, response} — response is the query reply, or null for a write |
400 empty command; 404 unknown session; 409 if the session is in an error/closed state |
The kind-agnostic console route: it works for any connected instrument kind
(scope, PSU, or AWG today), because sending a raw command needs nothing
kind-specific — every driver exposes write/query. This is what the browser UI's Terminal
drawer calls (see Browser UI Tour). A command
ending in ? is a query and returns the instrument's answer; anything else is
a write and returns a null response — never a fabricated one.
Power supply configuration¶
All paths below are under /api/sessions/{id}/psu/, and require a session created with
kind: "psu".
| Method | Path | Body / params | Response | Errors |
|---|---|---|---|---|
GET |
state |
— | {outputs: [{output, voltage, current, enabled, measured_voltage, measured_current, measured_power}, ...]} |
400 non-psu session; 404 unknown session; 409 if the session is in an error/closed state |
PATCH |
outputs/{n} |
{voltage?, current?} |
{outputs: [...]} (same shape as GET state) |
400 unknown output or non-psu session; 409 not the session owner, or session not accepting jobs |
PATCH |
outputs/{n}/enable |
{enabled} |
{outputs: [...]} |
400 unknown output or non-psu session; 409 not the session owner, or session not accepting jobs |
Every field in an output is read through the driver, never fabricated: a value the supply will not
answer (an SPD3303X's CH3 has no output-state query at all) comes back as null, and the browser
UI shows that as --.--, or as an unknown output state rather than a confident off. Every mutation
also broadcasts the fresh outputs list to the session's WebSocket subscribers as a state
message with "kind": "psu".
Function generator configuration¶
All paths below are under /api/sessions/{id}/awg/, and require a session created with
kind: "awg".
| Method | Path | Body / params | Response | Errors |
|---|---|---|---|---|
GET |
state |
— | {channels: [{channel, function, frequency, amplitude, offset, phase, enabled, duty_cycle, symmetry}, ...]} |
400 non-awg session; 404 unknown session; 409 if the session is in an error/closed state |
PATCH |
channels/{n} |
{function?, frequency?, amplitude?, offset?, phase?, duty_cycle?, symmetry?} — function one of SINE/SQUARE/RAMP/PULSE/NOISE/ARB/DC |
{channels: [...]} (same shape as GET state) |
400 unsupported function, unknown channel, or non-awg session; 409 not the session owner, or session not accepting jobs |
PATCH |
channels/{n}/enable |
{enabled} |
{channels: [...]} |
400 unknown channel or non-awg session; 409 not the session owner, or session not accepting jobs |
POST |
outputs/off |
— | {channels: [...]} |
400 non-awg session; 409 not the session owner, or session not accepting jobs |
duty_cycle is only read back while function is PULSE, and symmetry only while it is RAMP
— both come back null otherwise, matching what the channel panel shows. Every other field is
read through the driver like the PSU's, so a value the generator will not answer, or an enable
state a model cannot query, comes back as null/unknown rather than a guess. outputs/off kills
every channel in one request rather than one PATCH per channel, because turning outputs off one
at a time on a live circuit races. Every mutation also broadcasts the fresh channels list to the
session's WebSocket subscribers as a state message with "kind": "awg".
Scope configuration¶
All paths below are under /api/sessions/{id}/scope/.
| Method | Path | Body / params | Response | Errors |
|---|---|---|---|---|
GET |
state |
— | Full state snapshot (channels, timebase, trigger, run state) | 404 unknown session; 409 if the session is in an error/closed state |
PATCH |
channels/{n} |
{enabled?, voltage_scale?, voltage_offset?, coupling?, probe_ratio?} |
State snapshot | 400 invalid coupling (must be DC/AC/GND) or unknown channel; 409 if the session is in an error/closed state |
PATCH |
timebase |
{timebase} (seconds/div, required) |
State snapshot | 409 session not accepting jobs |
PATCH |
trigger |
{mode?, source?, level?, slope?, coupling?} |
State snapshot | 400 invalid value (trigger coupling must be DC/AC/HFREJ/LFREJ); 500 if the connected dialect cannot express an otherwise-valid value (e.g. AC trigger coupling on Tektronix — see SCPI Dialects); 409 session not accepting jobs |
POST |
run | stop | single | auto |
— | State snapshot | 400 unknown operation (any other {op} value); 409 session not accepting jobs |
POST |
command |
{command} — a raw SCPI string |
{command, response} — response is the query reply, or null for a write |
400 empty command or non-scope session; 409 if the session is in an error/closed state |
PUT |
measurements |
[{channel, mtype}] — mtype one of the 17 supported types (PKPK, MAX, MIN, AMPL, TOP, BASE, CMEAN, MEAN, RMS, CRMS, FREQ, PER, RISE, FALL, WID, NWID, DUTY) |
{measurements: [{channel, mtype}]} |
400 unknown mtype or out-of-range channel; 409 while a trend recording is active (selection is locked) |
GET |
measurements |
— | {measurements: [{channel, mtype}]} |
— |
The state snapshot returned by GET state and every scope mutation: {run_state, timebase, channels: {"<n>": {enabled, voltage_scale, voltage_offset, coupling, probe_ratio}}, trigger: {mode, source, level, slope, coupling}}. Every mutation also broadcasts this snapshot to the session's WebSocket subscribers as a state message.
Acquisition & export¶
All paths under /api/sessions/{id}/scope/.
| Method | Path | Params | Response | Errors |
|---|---|---|---|---|
GET |
capture.csv |
?channels=1,2&max_points=N (comma-separated channels) |
text/csv, streamed — one time_s column plus one C{n}_V column per requested channel, aligned to the shortest capture, capped to max_points rows (0/omitted = full resolution) |
400 no/invalid channels; 409 if the session is in an error/closed state; 413 record too large (see below) |
GET |
screenshot.png |
— | image/png — the instrument's display |
409 if the session is in an error/closed state |
GET |
waveform |
?channels=1,2&max_points=N |
{"channels": [{channel, t0, dt, sample_rate, voltage_scale, voltage_offset, points}, ...]}, streamed — points decimated to max_points (0/omitted = full resolution) |
400 no/invalid channels; 409 if the session is in an error/closed state; 413 record too large (see below) |
Both routes refuse rather than truncate an oversized capture: if the record holds more points than
MAX_EXPORT_POINTS (2,000,000) and no max_points (or one still above that cap) was given, the server
returns 413 naming the actual point count and the max_points value that would let the export proceed
— nothing is ever silently decimated down to a size the caller didn't ask for. This check runs before a
single sample is fetched, by asking the instrument its record length (:ACQuire:POINts?). On a dialect that
doesn't implement that query — legacy Siglent among them — the size can't be checked in advance, so the
export proceeds unguarded on those dialects exactly as it always has, and the server logs a warning after
the fact if the fetch turns out to have been oversized.
Analysis¶
All paths under /api/sessions/{id}/scope/.
| Method | Path | Body | Response | Errors |
|---|---|---|---|---|
GET |
math |
— | [{n, expression, enabled}] for M1 and M2 |
409 if the session is in an error/closed state |
PATCH |
math/{n} |
{expression?, enabled?} (n is 1 or 2) |
[{n, expression, enabled}] (both channels) |
400 unknown n, or an empty expression; 409 if the session is in an error/closed state |
GET |
spectrum |
— | {enabled, channel, window, db} |
— |
PATCH |
spectrum |
{enabled?, channel?, window?, db?} |
Updated {enabled, channel, window, db} |
400 unknown window (must be one of rectangular/hanning/hamming/blackman/bartlett/flattop) or out-of-range channel |
GET |
filters |
— | [{n, source, kind, cutoff_low, cutoff_high, order, enabled}] for F1 and F2 |
— |
PATCH |
filters/{n} |
{source?, kind?, cutoff_low?, cutoff_high?, order?, enabled?} (n is 1 or 2) |
Full filter list (as above) | 400 unknown n/kind, out-of-range source or order (1-10), non-positive cutoff, or missing cutoff(s) required by kind when enabled: true |
Math, spectrum, and filter results are not returned inline — they stream to WebSocket subscribers as waveform (math/filters) or spectrum frames on the next poll tick. PATCH here only changes configuration.
References¶
All paths under /api/sessions/{id}/scope/.
| Method | Path | Body | Response | Errors |
|---|---|---|---|---|
GET |
references |
— | List of saved references: [{name, channel, timestamp, num_samples, time_span}] |
— |
POST |
references |
{name, channel} — snapshots the channel's current waveform |
201 — the full updated reference list (same shape as GET references, replace-on-save if name already existed) |
400 empty name or out-of-range channel; 409 if the session is in an error/closed state |
DELETE |
references/{name} |
— | 204 No Content | 404 unknown reference name |
GET |
reference |
— | The active overlay: {name, channel, t0, dt, points} (name/channel are null when no reference is active) |
— |
PUT |
reference |
{name} or {name: null} to clear |
The active overlay (as above) | 404 unknown reference name (400 if the store holds un-migrated pre-5.0 files — run scpi-web references migrate) |
Setting the active reference (via PUT) broadcasts a reference message, and every poll tick afterward broadcasts a reference_stats message with live correlation/deviation.
Trend log¶
All paths under /api/sessions/{id}/scope/.
| Method | Path | Params | Response | Errors |
|---|---|---|---|---|
POST |
log/start |
— | Recorder status (see below) | 400 no measurements selected; 409 already recording |
POST |
log/stop |
— | Recorder status | — |
GET |
log |
— | Recorder status | — |
GET |
log/data |
?since=<unix-seconds> |
{columns: [{channel, mtype}], rows: [[timestamp, v1, v2, ...], ...]} — rows after since |
— |
GET |
log.csv |
— | text/csv — timestamp,elapsed_s,C{n} {mtype}... |
404 if no recording has ever been started this session |
Recorder status: {state, started_at, columns: [{channel, mtype}], row_count, max_rows} (state is "idle" or "recording"; max_rows is the ring-buffer cap, 86400 rows = 24 h at 1 Hz). Starting or stopping a recording also broadcasts a log_status WebSocket message with the same fields except max_rows, which is REST-only.
WebSocket¶
GET /api/sessions/{id}/stream (upgrade to WebSocket) — the server pushes JSON messages as the session's state changes; there is nothing to send except an open connection (the socket is read from only to detect disconnects). On connect the server immediately sends a state message with the current snapshot, then streams the following message types:
| Type | Example |
|---|---|
state |
{"type": "state", "state": {"run_state": "STOP", "timebase": 0.001, "channels": {...}, "trigger": {...}}} |
waveform |
{"type": "waveform", "channel": 1, "t0": 0.0, "dt": 1e-6, "points": [0.01, 0.02, ...]} — channel is an int (1-4) for a real channel, or "M1"/"M2"/"F1"/"F2" for math/filter traces; an empty points: [] marks a math/filter trace being cleared |
measurements |
{"type": "measurements", "values": [{"channel": 1, "mtype": "FREQ", "value": 1000.0}], "timestamp": 1752600000.0} |
measurements_config |
{"type": "measurements_config", "items": [{"channel": 1, "mtype": "FREQ"}]} |
spectrum |
{"type": "spectrum", "channel": 1, "f0": 0.0, "df": 500.0, "points": [-80.0, -75.2, ...], "db": true, "window": "hanning", "peaks": [[1000.0, -12.5]], "thd": 0.02} |
reference |
{"type": "reference", "name": "baseline", "channel": 1, "t0": 0.0, "dt": 1e-6, "points": [...]} |
reference_stats |
{"type": "reference_stats", "correlation": 0.998, "max_deviation": 0.012} |
log_status |
{"type": "log_status", "state": "recording", "started_at": 1752600000.0, "row_count": 42, "columns": [{"channel": 1, "mtype": "FREQ"}]} |
error |
{"type": "error", "detail": "connection lost"} |
closed |
{"type": "closed"} |
Dense binary frames (?format=binary)¶
GET /api/sessions/{id}/stream?format=binary opts the socket into the dense
path used by the browser UI. format absent or json is the contract above,
unchanged; any other value closes the socket with code 4400 rather than
falling back silently.
In binary mode every waveform and reference message arrives as one
WebSocket binary frame; every other message type (state, measurements,
spectrum, reference_stats, log_status, error, closed, …) is still a
JSON text frame on the same socket. Layout:
| Offset | Size | Content |
|---|---|---|
| 0 | 4 | header_len, uint32 little-endian |
| 4 | header_len |
UTF-8 JSON header, space-padded to a multiple of 4 bytes |
4 + header_len |
4 × n |
n samples, float32 little-endian |
Header: {"type":"waveform","channel":1,"t0":-0.007,"dt":5e-8,"seq":17,"n":100000,"dtype":"f32"} —
channel is an int or "M1"/"M2"/"F1"/"F2"; seq counts acquisitions,
so traces published from the same poll tick share it. A reference header
carries name and channel instead of seq. A clear is n: 0 with an empty
payload. Because the payload starts 4-byte aligned, a browser can wrap it in a
Float32Array view without copying; in Python,
scpi_control.server.frames.decode_binary(blob) returns (header, samples).
A dense frame holds up to --stream-max-points samples (default 100 000; the
instrument strides above that). The JSON path stays capped at 2 000 points,
and --stream-max-points cannot be set below that cap (the gateway rejects a
lower value at startup) — lowering it would silently give a JSON client fewer
than the 2 000 points every prior release guaranteed.
Measured on an SDS824X HD, a waveform read costs about 250 ms whether it
returns 700 or 100 000 points, so density is free at the instrument; the
per-channel update rate is unchanged from before this dense path existed —
about 1.3 frames/s either way, since the read cost is per-transfer, not
per-point (see the Siglent-modern note below — on that scope, on the order of
one full-record update a second per channel once the session's read-time
backoff is accounted for). What changed is resolution (2 000 → up to 100 000
samples per frame), not rate. --stream-max-fps (default 20) only bounds the
mock and any instrument that could go faster.
On a Siglent-modern instrument, a waveform frame for a real channel (not math/filter) is gated on the
instrument's own "new acquisition" flag (INR? bit 0): the poll loop asks that question every tick, and
only fetches and publishes a frame when the answer is yes. In practice this means the live view updates
once per completed acquisition — at a slow sweep that means a slow update, by design, matching what the
instrument's own display is doing. Every other dialect (legacy Siglent, Tektronix, LeCroy) has no such
flag to ask — legacy's closest equivalent (SAST?) reports a latching trigger state, not an edge on new
data, so it can't answer the question honestly — and instead falls back to an adaptive poll rate that never
retries sooner than the previous poll took.
The connection closes with code 4404 if the session ID does not exist, 4410 when the session itself closes (after which the socket receives a final closed message), and 4403 if the identity that opened the socket is revoked while it is still connected — distinct from 4410 because the session is untouched; only the viewer's credential is gone (see Gateway security). The server-side outbox is bounded (256 frames); under sustained backpressure it drops the oldest queued waveform frame to make room rather than growing unbounded — state/error/closed control frames are never dropped.
Errors¶
Every error response is JSON: {"error": "<ExceptionClassName>", "detail": "<message>"}.
| Status | Meaning |
|---|---|
| 400 | Validation failure — bad/missing parameters, invalid enum value, out-of-range channel |
| 404 | Unknown resource — session ID, reference name, or (for log.csv) no recording ever started |
| 409 | State conflict — session not accepting jobs (error/closed), already recording, or measurement selection locked while recording |
| 413 | Export refused — the record (or the requested max_points) exceeds what the server will fetch in one go; see Acquisition & export |
| 504 | Instrument communication timed out |
| 500 | Other instrument error |
Curl quickstart¶
Create a mock session, configure a channel, and fetch its waveform as JSON — no hardware required. Start the gateway first, then mint a token for this script with scpi-web token add curl-demo (that is what token add is for: scripts and CI, where a long-lived secret is the right answer) and export it:
export TOKEN=scpi_... # printed once by `scpi-web token add curl-demo`
# 1. Create a mock scope session
curl -s -X POST http://127.0.0.1:8765/api/sessions \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"mock": true, "label": "curl demo"}'
# -> 201 {"id": "a1b2c3d4", "label": "curl demo", "mock": true, "address": null,
# "state": "connected", "idn": "Siglent Technologies,SDS1104X-E,MOCK0001,1.0.0.0",
# "model": "SDS1104X-E", "dialect": "legacy", "num_channels": 4, "viewers": 0}
SESSION_ID=a1b2c3d4 # substitute the "id" from the response above
# 2. Enable channel 1 and set its vertical scale
curl -s -X PATCH "http://127.0.0.1:8765/api/sessions/$SESSION_ID/scope/channels/1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"enabled": true, "voltage_scale": 0.5}'
# 3. Fetch channel 1's waveform as JSON, decimated to 200 points
curl -s -H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:8765/api/sessions/$SESSION_ID/scope/waveform?channels=1&max_points=200"
# 4. Clean up
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:8765/api/sessions/$SESSION_ID"