satlinkd bridges the modem to anything that speaks ZeroMQ: a control centre, a test harness, a
notebook. The bridge runs whenever the daemon is up; a scenario is not needed to have a link.
control centre satlinkd
PUSH -------------------------> PULL tx_port (5555) -> the modem
SUB <------------------------- PUB rx_port (5556) <- the modem
SUB <------------------------- PUB telemetry_port (5557)- PUSH/PULL in gives real back-pressure on the way to the modem: when the modem cannot keep up, the sender's PUSH blocks.
- PUB/SUB out lets several observers watch one link without taking part. A PUB socket
drops messages for a subscriber that cannot keep up; that is ZeroMQ's documented behaviour,
and the
seqfield is how you see it.
Configuration#
In /etc/satlinkd/satlinkd.toml:
[link]
enabled = true
kind = "zmq" # the only transport implemented; an unknown name is refused
bind_address = "0.0.0.0"
tx_port = 5555
rx_port = 5556
telemetry_port = 5557 # 0 disables it
frame_slot_bytes = 0 # 0 = publish whole blocks; N = parse N-byte slotsframe_slot_bytes must match the active profile's framing.mtu_bytes when you use slot
framing; nothing can check that for you. It is checked against the DMA block size when the service
starts: a value that does not divide 4096 is refused.
Sending frames to the modem#
Connect a PUSH socket to tcp://<board>:5555 and send each frame as one message of raw bytes.
The bridge expands them to the DMA wire format, queues them, and flushes after each burst (it
drains what is already waiting before flushing, so a sustained feed is not padded frame by frame).
Frames are sent in stream framing: packet boundaries survive only if you lay your frames out
in FRAME_LEN slots yourself (see Datapath and Transmission).
When the active profile declares a CCSDS transfer frame, each message is treated as an application message and wrapped in a transfer frame (primary header, counters, FECF) before it reaches the modem. A message that does not fit in one frame is refused, not split.
If the TX egress is closed, inbound frames are accepted and flushed, and the modem discards them;
the daemon logs a warning. Check satlinkctl chain status before blaming the far end.
Receiving: the message format#
Every published message has three parts:
| Part | Content |
|---|---|
| 0 | Topic: frame.rx, frame.tx or telemetry. A SUB socket filters on a prefix without decoding |
| 1 | Metadata, a JSON object |
| 2 | The payload, raw (for telemetry, a JSON telemetry snapshot) |
{"ts_ms": 4156411, "ts_source": "daemon_uptime", "seq": 1287,
"framing": "slot", "run_id": "daacba57-…", "interface": "ground_segment"}| Field | Meaning |
|---|---|
ts_ms | Milliseconds since the daemon started, not since the Unix epoch. The board has no real-time clock; subtracting this from your wall clock gives about 56 years. Stamp arrival yourself to correlate with anything off the board |
ts_source | daemon_uptime, on every message, so nobody has to remember the above |
seq | Per-topic counter. A gap in seq is the only evidence of messages dropped by the PUB socket |
framing | How the payload was cut from the RX stream: slot (one packet), block (a whole lane-extracted DMA block: several frames plus padding), transfer_frame (one located CCSDS transfer frame) |
run_id | The scenario run in progress, when there is one |
interface | The scenario interface that produced a mirrored message |
quality | On frame.rx whenever a transfer frame is configured: good, erred or undetermined |
margin | How hard the channel worked around this payload (below) |
Frame quality#
With a transfer frame configured, each received frame carries a verdict in the vocabulary of the CCSDS SLE services:
quality | Meaning |
|---|---|
good | Located, and its Frame Error Control Field checks |
erred | Located, and its FECF does not check. The payload is delivered anyway; this is the only thing between you and silently corrupt bytes |
undetermined | No verdict: no FECF in the profile, the frame could not be located, or Reed-Solomon declared the codeword uncorrectable so the CRC means nothing |
Link margin#
margin holds three deltas of the PL's counters since the previously published block:
| Field | Register | Meaning |
|---|---|---|
asm_soft | deframer.ERR_CNT | Sync markers matched with exactly one wrong bit out of 32: a bit-error estimate on known bits. A subset of the frames delivered, never frames rejected |
rs_corrected | fec_rx.CORR_CNT | Bytes Reed-Solomon repaired |
rs_failed | fec_rx.FAIL_CNT | Codewords Reed-Solomon declared uncorrectable (delivered anyway, errors included) |
rs_correctedandrs_failedare absent, not zero, when the RS decoder is not running (fec: noneorconv). A zero would read as a flawless decoder rather than no decoder.marginis absent on the first block of a session: there is nothing to difference against.- It is approximate: the counters are read when the block is published, and a block spans several codewords. It says how hard the channel was working, not whether a given payload is good.
A subscriber in Python#
import json, zmq
ctx = zmq.Context()
sub = ctx.socket(zmq.SUB)
sub.connect("tcp://192.168.2.1:5556")
sub.setsockopt_string(zmq.SUBSCRIBE, "frame.rx") # "" for everything
while True:
topic, meta, payload = sub.recv_multipart()
m = json.loads(meta)
print(topic.decode(), m["seq"], m.get("framing"), len(payload), "bytes")Subscribe before you send. A SUB socket drops everything until its subscription reaches the publisher, so a script that sends first and subscribes second sees nothing.
A pusher:
push = ctx.socket(zmq.PUSH)
push.connect("tcp://192.168.2.1:5555")
push.send(b"hello from the ground segment")The example client in examples/src/satlink_examples/board.py wraps both sides (see
Example Clients).
Scenario-scoped endpoints#
A scenario can open its own endpoints for the length of a run, separate from the permanent ones, so a run can be watched without disturbing whatever is already connected:
topology:
role: "ground_station_emulator"
interfaces:
ground_segment:
kind: "zmq"
tx_port: 6001
rx_port: 6002A step naming the interface (interface: "ground_segment" on inject_packet) publishes a
copy of its packet on frame.tx there; the packet goes to the modem exactly as it would
without the line. The endpoints are released when the run ends, however it ends. See
Scenarios.
Implementation notes#
The bridge uses the pure-Rust zeromq crate, not bindings to libzmq, so nothing extra is
needed on the board's root filesystem. ZeroMQ was chosen because it needs no broker: the board
binds, the client connects. The kind field and a transport abstraction leave room for NATS or
MQTT, which are not implemented.