All docsStellar LinkRF bench · by Stellar Systems v0.1.0

API Reference

Datapath

GET/api/v1/datapath

Status#

Responses

200OKapplication/json
acquiredrequired

boolean

True while the daemon holds the modem's IIO devices.

Request

curl "http://192.168.2.1:8080/api/v1/datapath"

Response

{
  "acquired": true
}
POST/api/v1/datapath/capture

Capture#

Collect blocks raw DMA blocks and return them as application/octet-stream.

Request body application/jsonrequired

blocksrequired

integer

Number of RAW DMA blocks to collect. Obtaining them in a row is the proof of a sustained regime; a short capture is refused, not truncated.

≥ 0

warmup

integer

Blocks to obtain and DISCARD before the measured window starts.

Every capture re-arms the chain, so the leading blocks carry the acquisition transient. Skipping them is legitimate; skipping them SILENTLY, or guessing which ones look like transient, would throw away real head-of-capture errors and call the result clean. Hence explicit, and zero by default.

≥ 0

timeout_ms

integer · int64

Give up after this many milliseconds.

≥ 0

release

boolean

Release the IIO devices afterwards so bring-up tools can open them.

Responses

200Raw DMA blocks, concatenated
503No datapath in this daemon
504The chain did not sustain the requested blocks

Request

curl -X POST "http://192.168.2.1:8080/api/v1/datapath/capture" \
  -H "Content-Type: application/json" \
  -d '{
  "blocks": 0,
  "warmup": 0,
  "timeout_ms": 0,
  "release": true
}' \
  -o response.bin
POST/api/v1/datapath/disable

Disable#

Responses

200OKapplication/json
enabledrequired

boolean

Request

curl -X POST "http://192.168.2.1:8080/api/v1/datapath/disable"

Response

{
  "enabled": true
}
POST/api/v1/datapath/enable

Enable#

Responses

200OKapplication/json
enabledrequired

boolean

Request

curl -X POST "http://192.168.2.1:8080/api/v1/datapath/enable"

Response

{
  "enabled": true
}
POST/api/v1/datapath/flush

Flush#

Transmit whatever is buffered, padded out to a full block.

A caller pushing at a low rate MUST call this: the assembler only emits FULL blocks, so a handful of packets sit there until the datapath is released. Measured on hardware 2026-09-12 — a scenario injected one 8-byte CSP packet and tx.framer.frame_cnt never moved.

There is deliberately no timer doing it automatically: the padding goes on the air, and spending it is the caller's decision rather than a hidden latency policy.

Request body application/jsonrequired

blocks

integer

How many blocks to emit. Use 2 whenever you intend to see the result come back. An RX block is returned only when ENTIRELY full, and the receive side is always short of what was sent — the first frame of a freshly-armed chain is lost to acquisition, so 4096 useful bytes out come back as 4080. One block therefore stops just before the line and waits for traffic a one-shot sender never sends.

Measured on hardware 2026-09-12: one flushed block never surfaced in 60 s (with tx.framer.frame_cnt confirming all 256 frames had crossed); two came back in 2 s.

≥ 0

Responses

200OKapplication/json
useful_bytesrequired

integer

Useful bytes HANDED TO THE DMA, padding included. Zero means nothing was pending — a block of pure padding would put silence on the air.

Handed to the DMA is not "on the air". This field used to say it was, and it was wrong: with the TX egress closed the modem discards the block and this number is exactly the same. tx_chain_running is the only field here that tells the two apart.

≥ 0

tx_chain_running

boolean · nullable

Whether the modem's TX egress is open. null when there is no PL driver to ask, which is not the same as false.

404no datapath in this daemon

Request

curl -X POST "http://192.168.2.1:8080/api/v1/datapath/flush" \
  -H "Content-Type: application/json" \
  -d '{
  "blocks": 0
}'

Response

{
  "useful_bytes": 0,
  "tx_chain_running": true
}
POST/api/v1/datapath/loopback

Loopback#

Feed a payload while capturing: the whole loopback measurement in one call.

The payload is the RAW BODY, so it stays byte-identical from the host file to the DAC. Query parameters carry the rest.

Request body application/octet-streamrequired

string · binary

Responses

200Raw DMA blocks captured while feeding
504The chain did not sustain the requested blocks

Request

curl -X POST "http://192.168.2.1:8080/api/v1/datapath/loopback" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @payload.bin \
  -o response.bin
POST/api/v1/datapath/measure

Measure#

Feed a payload, capture, and COMPARE — the whole measurement, verdict included.

WHY THE COMPARISON LIVES HERE. satlink-measure exists because its predecessor, an untested python script, twice declared a healthy chain dead (lane extraction applied to one side only; a single aggregate hiding a per-block truth). Both defects are replayed as tests in that crate. A second implementation in a UI — untested, and reading the same raw bytes — would reintroduce exactly the class of failure the crate was written to end. So the caller receives the verdict, never the raw bytes to interpret itself.

/datapath/loopback still returns the raw capture: an operator archiving a campaign needs the bytes, byte-identical. This route is for a client that wants the answer.

Request body application/octet-streamrequired

string · binary

Responses

200OKapplication/json
verdictrequired

string

BytePerfect | Degraded | NoCorrelation.

rotation_sliprequired

boolean

A byte was lost or inserted at a block boundary. NOT a demodulation error — reporting it separately is what stops it being read as one.

blocksrequired

array of BlockMatchDRO

Read this before overall_ratio: an aggregate is exactly what hid the 2026-09-08 defect, where the whole capture scored 49.99 % while every block taken separately scored 99.98 %.

BlockMatchDRO · 5 fields
indexrequired

integer

≥ 0

matchedrequired

integer

≥ 0

totalrequired

integer

≥ 0

rotationrequired

integer

≥ 0

ratiorequired

number · double

overall_ratiorequired

number · double

worst_block_ratiorequired

number · double

capture_bytesrequired

integer

≥ 0

warmuprequired

integer

Blocks obtained and discarded before the measured window. Never hidden: a window that was moved must say so, or the number stops meaning what the reader thinks it means.

≥ 0

504The chain did not sustain the requested blocks

Request

curl -X POST "http://192.168.2.1:8080/api/v1/datapath/measure" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @payload.bin

Response

{
  "verdict": "string",
  "rotation_slip": true,
  "blocks": [
    {
      "index": 0,
      "matched": 0,
      "total": 0,
      "rotation": 0,
      "ratio": 0.0
    }
  ],
  "overall_ratio": 0.0,
  "worst_block_ratio": 0.0,
  "capture_bytes": 0,
  "warmup": 0
}
POST/api/v1/datapath/transmit

Transmit#

Queue packets for transmission.

Returns once QUEUED, not once transmitted: the DAC paces the datapath, and blocking the caller on it would export the modem's back-pressure to HTTP. For a sustained feed use /ws/tx instead — one HTTP request per packet reintroduces the per-block open/close cost measured on 2026-09-08.

Request body application/jsonrequired

packetsrequired

array of object

Packets to transmit, tagged by protocol: {"protocol":"raw","payload_hex":".."} for bytes forged elsewhere, {"protocol":"csp","header":{..},"payload_hex":".."} to have SatLink build the packet, likewise "ccsds".

framing

object

How packets are laid out in the byte stream.

{"mode":"stream"} packs them end to end: maximum throughput, and NO packet boundary survives — the receiver gets a byte stream cut at the PL's FRAME_LEN, which has no relation to what was pushed.

{"mode":"slot","slot_len":256,"pad":0} gives one packet per slot, so the framer's cut lands on the boundary. slot_len must equal the PL's FRAME_LEN and divide the block's useful bytes, or the request is refused.

force

boolean

Transmit even though the chain cannot carry it (STE-995).

The refusal is a guard, not a wall: probing a closed-egress branch or an unconditioned front-end is legitimate bench work, and dsl/scenarios/bringup/tx_egress_probe.yaml exists to do exactly that. It is deliberately not the default, and the reply carries the chain state either way so no report can claim the chain was healthy.

Responses

200OKapplication/json
packetsrequired

integer

≥ 0

useful_bytesrequired

integer

Useful bytes queued (before the le:u8/32 lane expansion).

≥ 0

wire_bytesrequired

integer

Bytes actually written to the DMA block.

≥ 0

boundaries_preservedrequired

boolean

True when packet boundaries survive the crossing. False in stream mode — stated rather than left for the caller to discover from a receiver that returns bytes at the wrong offsets.

tx_chain_running

boolean · nullable

Whether the modem's TX egress is open.

false means these bytes reach the DMA and the modem DISCARDS them — nothing goes on the air, and no other number in this reply changes. null when there is no PL driver to ask.

block_useful_bytesrequired

integer

Useful bytes that fill one DMA block.

Not a measurement of what is buffered — the assembler's fill level lives in the service and is not queried here, and reporting a guessed one would be the snr_db mistake of STE-977. It is the quantum that matters to a caller: a packet does NOT go on the air until its block is complete, so this is the latency granularity.

≥ 0

chain

ChainStateDRO · nullable

400the framing cannot hold its alignment, or a packet does not fit its slot
404no datapath in this daemon
409the chain cannot carry the emission; the body names the first missing step and the command that clears it (STE-995). Pass force: true to transmit anyway

Request

curl -X POST "http://192.168.2.1:8080/api/v1/datapath/transmit" \
  -H "Content-Type: application/json" \
  -d '{
  "packets": [
    {}
  ],
  "framing": {},
  "force": true
}'

Response

{
  "packets": 0,
  "useful_bytes": 0,
  "wire_bytes": 0,
  "boundaries_preserved": true,
  "tx_chain_running": true,
  "block_useful_bytes": 0,
  "chain": {}
}

↑↓ to moveEnter to open