The datapath is the DMA path between satlinkd and the modem: one IIO device for transmit
(satlink-modem-tx) and one for receive (satlink-modem-rx). This page covers how bytes get in
and out.
Claiming the modem#
The daemon must acquire the datapath (open both IIO devices) before it can transmit or
receive. On the board, [datapath] autostart = false: the daemon claims nothing until something
asks it to, so bring-up tools that open the IIO devices directly are not locked out.
satlinkctl datapath status # who holds the modem, and is the TX egress open
satlinkctl datapath enable # claim it, then read back whether the claim took
satlinkctl datapath disable # release it| Route | Purpose |
|---|---|
GET /api/v1/datapath | Current state |
POST /api/v1/datapath/enable | Acquire |
POST /api/v1/datapath/disable | Release |
chain up acquires the datapath as its last step. The measurement commands (capture,
loopback) claim it themselves and want it exclusively: release it first, or they return 409.
Blocks and latency#
Data moves in DMA blocks of 4096 useful bytes (16 384 bytes on the wire, see Bench Architecture). The block assembler only submits full blocks, so:
- a packet does not go on the air until its block is full;
- at a low packet rate you must flush, or the packets wait indefinitely;
- an RX block is returned only when it is entirely full, and the receive side always comes back a little short (the first frame of a freshly armed chain is lost to acquisition). Flush two blocks whenever you want to see the result come back.
satlinkctl tx flush --blocks 2 # POST /api/v1/datapath/flush {"blocks": 2}The flush reply gives the useful bytes handed to the DMA (padding included; zero when nothing was
pending) and tx_chain_running, which says whether the egress is open. There is deliberately no
automatic flush timer: padding goes on the air, and spending it is the caller's decision.
Framing: stream or slot#
The modem's framer cuts the byte stream at FRAME_LEN without looking at packet boundaries. How
you lay packets out decides whether their boundaries survive:
| Mode | Boundaries | Overhead | Use for |
|---|---|---|---|
stream | None: the receiver gets the bytes re-cut at FRAME_LEN | Zero | Bulk payloads that carry their own delimiting |
slot | One packet per slot, recoverable | The fill of each slot | A packet feed (CSP, CCSDS) |
A slot is [length: 2 bytes, big-endian][packet][padding…], exactly slot_len bytes. The
length prefix is needed because CSP has no length field: without it the padding would be
indistinguishable from payload.
Two constraints on slot_len, both enforced by refusal:
- it must equal the PL's
FRAME_LEN(the profile'sframing.mtu_byteswithout coding); - it must divide 4096: 1024, 512, 256, 128, 64, 16 work; 223 does not, so slot framing is unavailable under Reed-Solomon.
Pushing packets#
One-off: POST /api/v1/datapath/transmit#
{
"packets": [
{"protocol": "csp",
"header": {"priority": "normal", "src": 10, "dst": 1, "dport": 7, "sport": 31,
"flags": {"crc32": false, "xtea": false, "rdp": false, "hmac": false}},
"payload_hex": "01020304"},
{"protocol": "raw", "payload_hex": "deadbeef"}
],
"framing": {"mode": "slot", "slot_len": 256}
}| Protocol | Effect |
|---|---|
raw | The bytes are passed through untouched |
csp | SatLink builds a CSP packet (4-byte header) from header and the payload |
ccsds | SatLink builds a CCSDS space packet (6-byte primary header) |
Packet shapes (the same JSON is accepted by /ws/tx and returned by the frames store):
{"protocol": "raw", "payload_hex": "deadbeef"}
{"protocol": "csp",
"header": {"priority": "normal", "src": 10, "dst": 1, "dport": 7, "sport": 31,
"flags": {"crc32": false, "xtea": false, "rdp": false, "hmac": false}},
"payload_hex": "01020304"}
{"protocol": "ccsds",
"header": {"version": 0, "kind": "tc", "secondary_header": false, "apid": 42,
"seq_flags": "standalone", "seq_count": 0, "data_length": 3},
"payload_hex": "01020304"}Every CSP header field is required: priority is critical, high, normal or low;
addresses are 5 bits (0 to 31) and ports 6 bits (0 to 63). CSP payloads are limited to 256 bytes.
In slot mode the encoded packet, header included, must fit in slot_len − 2 bytes.
For CCSDS, kind is tm or tc and seq_flags is continuation, first, last or
standalone.
The reply reports packets, useful_bytes, wire_bytes, boundaries_preserved,
tx_chain_running and block_useful_bytes. It returns once the data is queued, not once it
has been transmitted. When the chain cannot carry an emission the request is refused with 409,
naming the first missing prerequisite and the command that clears it; "force": true transmits
anyway (for bench work on an unconditioned chain).
From the CLI, which builds the request and always flushes:
satlinkctl tx send --protocol csp --src 10 --dst 1 --hex 01020304
satlinkctl tx send --file payload.bin --framing slot --slot-len 256Sustained feed: ws://<board>:8080/ws/tx#
For continuous traffic, keep one WebSocket open rather than issuing one HTTP request per packet. The first message fixes the framing for the session:
{"framing": {"mode": "slot", "slot_len": 256}}{"type": "ready", "framing": {"mode": "slot", "slot_len": 256},
"boundaries_preserved": true, "block_useful_bytes": 4096}Then push as often as you like:
{"packets": [{"protocol": "raw", "payload_hex": "01020304"}]}{"type": "queued", "packets": 1, "useful_bytes": 256}A malformed packet is answered {"type": "rejected", "reason": "…"} and the socket stays open.
The ZeroMQ PUSH socket of the link service is the other way to feed the modem continuously.
Receiving#
Real time: ws://<board>:8080/ws/frames#
/ws/frames?framing=slot&slot_len=256&payload=true| Parameter | Meaning |
|---|---|
framing | slot to parse slots, stream (default) to receive whole blocks |
slot_len | Slot pitch for slot |
payload | Include the payload as hex (off by default: it doubles the volume) |
Messages:
type | When |
|---|---|
packet | One recovered packet per slot (slot, len, payload) |
fill | A padding slot; never reported as a zero-length packet |
unparsed | A slot whose length prefix does not fit; reported, not dropped |
block | Stream mode: one DMA block, 4096 useful bytes |
lagged | The subscriber fell behind by blocks blocks |
Polling: GET /api/v1/frames#
Returns a page from a ring of recent RX blocks:
{"granularity": "dma_block", "clock": "boot_relative", "empty_reason": null, "frames": [ ... ]}Each entry is one DMA block, not one protocol frame. Times are relative to boot. When the page is
empty, empty_reason says whether the link carried nothing or nothing was reading the modem.
There is no per-frame FEC or CRC verdict: fec_status and crc_ok stay "none"/null.
DELETE /api/v1/frames clears the ring.
Capture and loopback measurements#
Three routes measure the link at the byte level. They claim the datapath themselves.
| Route | CLI | What it does |
|---|---|---|
POST /api/v1/datapath/capture | satlinkctl capture -o rx.bin --blocks 4 | Returns raw RX DMA blocks, concatenated |
POST /api/v1/datapath/loopback | satlinkctl loopback --payload p.bin --blocks 4 | Feeds a payload (raw le:u8/32) while capturing, returns the capture |
POST /api/v1/datapath/measure | — | Loopback plus comparison, returns per-block match counts |
Parameters common to all three:
| Parameter | Default | Meaning |
|---|---|---|
blocks | 4 | Blocks to collect in a row. Getting them is the proof of a sustained regime; a short capture fails (504) rather than being truncated |
warmup | 0 | Blocks to obtain and discard first. Every capture re-arms the chain, so the first block can carry an acquisition transient. Explicit and zero by default, so real head-of-capture errors are never discarded silently |
timeout_ms | 30000 | Give up after this long |
release / --keep | release | Release the IIO devices afterwards |
satlinkctl loopback compares the capture with the payload and prints a verdict
(BytePerfect, Degraded, …). satlinkctl compare capture.bin payload.bin does the same offline
on the host. See Measurement Tools for how to read the result.