All docsStellar LinkRF bench · by Stellar Systems v0.1.0

Control & Automation

ZeroMQ Link Service

Connecting a control centre or a test harness to the modem over ZeroMQ: sockets, the three-part message format, metadata, framing, link margin and frame quality.

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.

text
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 seq field is how you see it.

Configuration#

In /etc/satlinkd/satlinkd.toml:

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 slots

frame_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:

PartContent
0Topic: frame.rx, frame.tx or telemetry. A SUB socket filters on a prefix without decoding
1Metadata, a JSON object
2The payload, raw (for telemetry, a JSON telemetry snapshot)
JSON
{"ts_ms": 4156411, "ts_source": "daemon_uptime", "seq": 1287,
 "framing": "slot", "run_id": "daacba57-…", "interface": "ground_segment"}
FieldMeaning
ts_msMilliseconds 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_sourcedaemon_uptime, on every message, so nobody has to remember the above
seqPer-topic counter. A gap in seq is the only evidence of messages dropped by the PUB socket
framingHow 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_idThe scenario run in progress, when there is one
interfaceThe scenario interface that produced a mirrored message
qualityOn frame.rx whenever a transfer frame is configured: good, erred or undetermined
marginHow 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:

qualityMeaning
goodLocated, and its Frame Error Control Field checks
erredLocated, and its FECF does not check. The payload is delivered anyway; this is the only thing between you and silently corrupt bytes
undeterminedNo verdict: no FECF in the profile, the frame could not be located, or Reed-Solomon declared the codeword uncorrectable so the CRC means nothing

margin holds three deltas of the PL's counters since the previously published block:

FieldRegisterMeaning
asm_softdeframer.ERR_CNTSync 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_correctedfec_rx.CORR_CNTBytes Reed-Solomon repaired
rs_failedfec_rx.FAIL_CNTCodewords Reed-Solomon declared uncorrectable (delivered anyway, errors included)
  • rs_corrected and rs_failed are absent, not zero, when the RS decoder is not running (fec: none or conv). A zero would read as a flawless decoder rather than no decoder.
  • margin is 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#

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:

Python
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:

YAML
topology:
  role: "ground_station_emulator"
  interfaces:
    ground_segment:
      kind: "zmq"
      tx_port: 6001
      rx_port: 6002

A 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.

Stellar Link · v0.1.0

↑↓ to moveEnter to open