/api/v1/datapathStatus#
Responses
200OKapplication/json
acquiredrequiredboolean
True while the daemon holds the modem's IIO devices.
Request
curl "http://192.168.2.1:8080/api/v1/datapath"import requests
BASE_URL = "http://192.168.2.1:8080"
response = requests.get(f"{BASE_URL}/api/v1/datapath")
response.raise_for_status()
print(response.json())let base_url = "http://192.168.2.1:8080";
let response = reqwest::Client::new()
.get(format!("{base_url}/api/v1/datapath"))
.send()
.await?
.error_for_status()?;
let body: serde_json::Value = response.json().await?;
println!("{body:#}");Response
{
"acquired": true
}/api/v1/datapath/captureCapture#
Collect blocks raw DMA blocks and return them as application/octet-stream.
Request body application/jsonrequired
blocksrequiredinteger
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.
warmupinteger
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.
timeout_ms integer · int64
Give up after this many milliseconds.
releaseboolean
Release the IIO devices afterwards so bring-up tools can open them.
Responses
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.binimport requests
BASE_URL = "http://192.168.2.1:8080"
response = requests.post(
f"{BASE_URL}/api/v1/datapath/capture",
json={
"blocks": 0,
"warmup": 0,
"timeout_ms": 0,
"release": True,
},
)
response.raise_for_status()
data = response.contentlet base_url = "http://192.168.2.1:8080";
let response = reqwest::Client::new()
.post(format!("{base_url}/api/v1/datapath/capture"))
.json(&serde_json::json!({
"blocks": 0,
"warmup": 0,
"timeout_ms": 0,
"release": true
}))
.send()
.await?
.error_for_status()?;
let bytes = response.bytes().await?;Request
curl -X POST "http://192.168.2.1:8080/api/v1/datapath/disable"import requests
BASE_URL = "http://192.168.2.1:8080"
response = requests.post(f"{BASE_URL}/api/v1/datapath/disable")
response.raise_for_status()
print(response.json())let base_url = "http://192.168.2.1:8080";
let response = reqwest::Client::new()
.post(format!("{base_url}/api/v1/datapath/disable"))
.send()
.await?
.error_for_status()?;
let body: serde_json::Value = response.json().await?;
println!("{body:#}");Response
{
"enabled": true
}Request
curl -X POST "http://192.168.2.1:8080/api/v1/datapath/enable"import requests
BASE_URL = "http://192.168.2.1:8080"
response = requests.post(f"{BASE_URL}/api/v1/datapath/enable")
response.raise_for_status()
print(response.json())let base_url = "http://192.168.2.1:8080";
let response = reqwest::Client::new()
.post(format!("{base_url}/api/v1/datapath/enable"))
.send()
.await?
.error_for_status()?;
let body: serde_json::Value = response.json().await?;
println!("{body:#}");Response
{
"enabled": true
}/api/v1/datapath/flushFlush#
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
blocksinteger
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.
Responses
200OKapplication/json
useful_bytes requiredinteger
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.
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.
Request
curl -X POST "http://192.168.2.1:8080/api/v1/datapath/flush" \
-H "Content-Type: application/json" \
-d '{
"blocks": 0
}'import requests
BASE_URL = "http://192.168.2.1:8080"
response = requests.post(
f"{BASE_URL}/api/v1/datapath/flush",
json={
"blocks": 0,
},
)
response.raise_for_status()
print(response.json())let base_url = "http://192.168.2.1:8080";
let response = reqwest::Client::new()
.post(format!("{base_url}/api/v1/datapath/flush"))
.json(&serde_json::json!({
"blocks": 0
}))
.send()
.await?
.error_for_status()?;
let body: serde_json::Value = response.json().await?;
println!("{body:#}");Response
{
"useful_bytes": 0,
"tx_chain_running": true
}/api/v1/datapath/loopbackLoopback#
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
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.binimport requests
BASE_URL = "http://192.168.2.1:8080"
response = requests.post(
f"{BASE_URL}/api/v1/datapath/loopback",
data=open("payload.bin", "rb").read(),
headers={"Content-Type": "application/octet-stream"},
)
response.raise_for_status()
data = response.contentlet base_url = "http://192.168.2.1:8080";
let response = reqwest::Client::new()
.post(format!("{base_url}/api/v1/datapath/loopback"))
.header("Content-Type", "application/octet-stream")
.body(std::fs::read("payload.bin")?)
.send()
.await?
.error_for_status()?;
let bytes = response.bytes().await?;/api/v1/datapath/measureMeasure#
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
verdictrequiredstring
BytePerfect | Degraded | NoCorrelation.
rotation_slip requiredboolean
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.
blocksrequiredarray 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
indexrequiredinteger
matchedrequiredinteger
totalrequiredinteger
rotationrequiredinteger
ratiorequirednumber · double
overall_ratio requirednumber · double
worst_block_ratio requirednumber · double
capture_bytes requiredinteger
warmuprequiredinteger
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.
Request
curl -X POST "http://192.168.2.1:8080/api/v1/datapath/measure" \
-H "Content-Type: application/octet-stream" \
--data-binary @payload.binimport requests
BASE_URL = "http://192.168.2.1:8080"
response = requests.post(
f"{BASE_URL}/api/v1/datapath/measure",
data=open("payload.bin", "rb").read(),
headers={"Content-Type": "application/octet-stream"},
)
response.raise_for_status()
print(response.json())let base_url = "http://192.168.2.1:8080";
let response = reqwest::Client::new()
.post(format!("{base_url}/api/v1/datapath/measure"))
.header("Content-Type", "application/octet-stream")
.body(std::fs::read("payload.bin")?)
.send()
.await?
.error_for_status()?;
let body: serde_json::Value = response.json().await?;
println!("{body:#}");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
}/api/v1/datapath/transmitTransmit#
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
packetsrequiredarray 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".
framingobject
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.
forceboolean
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
packetsrequiredinteger
useful_bytes requiredinteger
Useful bytes queued (before the le:u8/32 lane expansion).
wire_bytes requiredinteger
Bytes actually written to the DMA block.
boundaries_preserved requiredboolean
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_bytes requiredinteger
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.
chainChainStateDRO · nullable
force: true to transmit anywayRequest
curl -X POST "http://192.168.2.1:8080/api/v1/datapath/transmit" \
-H "Content-Type: application/json" \
-d '{
"packets": [
{}
],
"framing": {},
"force": true
}'import requests
BASE_URL = "http://192.168.2.1:8080"
response = requests.post(
f"{BASE_URL}/api/v1/datapath/transmit",
json={
"packets": [
{},
],
"framing": {},
"force": True,
},
)
response.raise_for_status()
print(response.json())let base_url = "http://192.168.2.1:8080";
let response = reqwest::Client::new()
.post(format!("{base_url}/api/v1/datapath/transmit"))
.json(&serde_json::json!({
"packets": [
{}
],
"framing": {},
"force": true
}))
.send()
.await?
.error_for_status()?;
let body: serde_json::Value = response.json().await?;
println!("{body:#}");Response
{
"packets": 0,
"useful_bytes": 0,
"wire_bytes": 0,
"boundaries_preserved": true,
"tx_chain_running": true,
"block_useful_bytes": 0,
"chain": {}
}