Expedite packets

Expedite is the software-defined modem at every Remos ground station. It demodulates and decodes what your spacecraft sends down, and encodes and modulates what you send up. On the ground side of the modem, towards the station agent, Umoja and your Mission Control System, bytes travel in Expedite packets: a small envelope with a start marker, a length, a type and a checksum around the data, and an end marker after it.

Umoja relays these packets as the modem produced them. A telemetry frame you receive is an Expedite packet with your spacecraft's bytes inside it, and a telecommand you send is put into one before the modem sees it. This page describes that envelope, how to take it off and put it on, and what Expedite does to your bytes on the air side of the modem.

Where the Expedite User Manual disagrees, this page follows what the station's software does. The differences are listed in Where the Expedite manual differs.

Two layers

DOWNLINK

  spacecraft radio
        |   radio frame: sync marker, length, DATA, error-correction bytes,
        |   scrambled and modulated
        v
  Expedite (the station's modem)
        |   removes the air layer: demodulates, descrambles, corrects
        |   errors, strips sync marker and length, checks the CSP CRC/HMAC
        |   wraps DATA in a packet:
        |   [preamble|length|0x00|time tag|pass info|DATA|CRC|postamble]
        v
  station agent, then Umoja
        |   relay the packet unchanged
        v
  you

UPLINK

  you
        |   DATA only
        v
  Umoja, then the station agent
        |   the agent wraps DATA in a packet:
        |   [preamble|length|0x01|DATA|CRC|postamble]
        v
  Expedite
        |   checks the packet and takes DATA out of it, then adds the air
        |   layer: CSP CRC/HMAC, error correction, scrambling, sync marker,
        |   line code, modulation
        v
  spacecraft radio

The two layers change for different reasons.

The packet is the ground-side envelope. It is the same at every station and for every satellite, and it never goes on the air.

The frame on the air is what your spacecraft's radio sends and expects. Its sync marker, length field, error correction and scrambling are set per satellite in the mission configuration. See On the air.

DATA is the same bytes in both. On downlink it is what is left of the radio frame once Expedite has removed the air layer. On uplink it is what you send, and Expedite builds the air layer around it.

Which layer you see where

Where Direction What it carries
WebSocket telemetry down The station header, then one telemetry packet (type 0x00) per message.
WebSocket command, binary messages down The station header, then either the telecommand packet the station built from your send (type 0x01) or the modem's acknowledgement (type 0x02).
WebSocket command, what you send up The payload only. The station builds the packet. See WebSockets (realtime), "Sending a telecommand".
WebSocket antenna, ephemeris down The station header, then an antenna-angle packet (type 0x03).
WebSocket doppler down The station header, then a predicted-Doppler packet (type 0x06).
TT&C archive, frame_b64 One complete packet (0x00, 0x01 or 0x02), no station header. See Replaying a session.
Replay chunks antenna, ephemeris, doppler Each record's payload is one packet (0x03, 0x03, 0x06).
Mission files .tm, and .trk, .ant, .dop when the station records them Complete packets back to back (0x00, 0x03, 0x03, 0x06), with no file header.
portal-link tm, command, antenna, ephemeris, doppler down Complete packets back to back on the TCP stream. See Realtime & Data.
portal-link tc up The payload only. The station builds the packet.
portal-link ext_antenna, ext_doppler both Packets you build (0x04, 0x07) and the modem's acknowledgements (0x05, 0x08).

spectrum and constellation carry float arrays, not Expedite packets, and .iq files are raw samples. Neither is covered here.

A pass folder normally has a .tm file when telemetry was decoded. The .dop, .trk and .ant files exist only if the station is set to record them.

The station header is added by Umoja on the WebSocket only, so a browser can tell the streams apart. Every binary message on a station stream, spectrum and constellation included, starts with these 19 bytes; on the packet streams the packet follows them:

offset  size  field
0       2     45 58, ASCII "EX"
2       1     stream: 0x02 spectrum, 0x03 constellation, 0x04 doppler,
              0x05 ephemeris, 0x06 antenna, 0x07 telemetry, 0x08 command
3       16    the station's client_id, ASCII, padded with zero bytes

Drop the first 19 bytes and you have the packet. The stream byte is Umoja's own numbering and has nothing to do with the packet's message type: a telemetry message has stream byte 0x07 and message type 0x00, and 0x07 is also the message type of an external Doppler packet. The archive and portal-link carry no station header.

The packet

Layout

Every packet has the same frame. All multi-byte fields are little-endian, so 0xA1B2C3D4 is sent as D4 C3 B2 A1.

offset  size  field
0       4     preamble    0xA1B2C3D4, bytes D4 C3 B2 A1
4       4     length      of the whole packet, preamble to postamble
8       4     message type
12      n     body, which depends on the type
12+n    4     CRC-32      Expedite's own variant, over bytes 0 to 11+n
16+n    4     postamble   0x5E4D3C2B, bytes 2B 3C 4D 5E

The preamble and postamble are fixed markers for the start and end of a packet, so a receiver that joins a byte stream part way through can find where the next packet begins. The postamble is the preamble with every bit inverted. The CRC (cyclic redundancy check) is a checksum over everything before it: recompute it, and a mismatch means the packet was damaged or cut on the way.

The length counts every byte of the packet, header and trailer included, so the shortest packet is 20 bytes: a telecommand with no payload.

Message types

Type Packet From Size in bytes Where you meet it
0x00 Telemetry modem 44 + data telemetry, tm, .tm, archive
0x01 Telecommand ground 20 + payload command, archive
0x02 Telecommand acknowledgement modem 32 + echo command, archive
0x03 Antenna angles modem 44 antenna and .ant (rotor), ephemeris and .trk (predicted)
0x04 External antenna control ground 36 ext_antenna
0x05 External antenna acknowledgement modem 40 ext_antenna
0x06 Predicted Doppler modem 48 doppler, .dop
0x07 External Doppler ground 24 ext_doppler
0x08 External Doppler acknowledgement modem 36 ext_doppler
0x09 Sequencer relay command modem 32 station-internal, not relayed

Telemetry is always type 0x00. Expedite builds 0x06 and 0x09 for other ports, and neither ever appears on the telemetry stream or in a .tm file.

Every type except 0x01 and 0x07 carries an 8-byte time tag at offset 12.

The time tag

The time tag is 8 bytes, each holding two decimal digits in binary-coded decimal (BCD): the high four bits are the tens and the low four bits the units, so 0x56 means 56.

Byte Offset Holds
0 12 Years since 1900. The tens digit can exceed 9: 2025 is 125, written 0xC5 (12 tens, 5 units).
1 13 Day of the year modulo 100.
2 14 Day of the year divided by 100.
3 15 Hour.
4 16 Minute.
5 17 Second.
6 18 Millisecond divided by 10.
7 19 Millisecond modulo 10.

The day of the year counts from zero: 1 January is day 0. So C5 91 00 18 56 11 11 09 is year 125 + 1900 = 2025, day 0 × 100 + 91 = 91, which is 2 April, 18:56:11 and 11 × 10 + 9 = 119 ms.

The time is UTC, read from the station computer's clock when Expedite wrapped the packet. That is shortly after the frame was decoded, not the moment the signal arrived, and it is not your spacecraft's onboard time. It is only as right as the station's clock.

The decoder below also accepts a plain two-digit BCD year, 0x26 for 2026: a value of 100 or more is years since 1900, anything less is years since 2000.

Telemetry (0x00)

Offset Size Field
0 4 preamble
4 4 length, 44 + N
8 4 type 0x00
12 8 time tag
20 4 NORAD catalogue number, u32
24 4 slant range in km, f32
28 4 azimuth in degrees, f32
32 4 elevation in degrees, f32
36 N data: your spacecraft's bytes
36 + N 4 CRC
40 + N 4 postamble

Bytes 20 to 35 come from the station, not from your spacecraft. They are the satellite the station is tracking and the station's predicted look angles to it, refreshed at each tracking step of a scheduled pass. They are the values from the latest step, not computed for the packet's time tag, and all four are zero when no pass is being tracked. The Expedite manual calls offset 28 altitude; it carries the azimuth.

Expedite builds before late September 2026 wrote the NORAD number as a float32, at offset 20 in telemetry and at offset 24 in predicted Doppler (0x06). A float32 of 1 or more has a bit pattern of at least 0x3F800000 (1,065,353,216), which is larger than any NORAD number, so read the field as a u32 and reinterpret it as a float32 only if it is that large. The code below does this.

N is at least 1: Expedite sends no packet for an empty frame. Packets carry no sequence number, so use your spacecraft's own counters to spot a gap.

Telecommand (0x01)

Offset Size Field
0 4 preamble
4 4 length, 20 + N
8 4 type 0x01
12 N payload
12 + N 4 CRC
16 + N 4 postamble

There is no time tag. The payload is the command as you sent it. Expedite then adds the air layer to it, as the satellite's mission configuration says.

On the WebSocket command stream and in the archive you see this packet as the station built it from your send, once the station had handed it to the modem. That is not proof that the modem accepted it: the acknowledgement is.

Telecommand acknowledgement (0x02)

Offset Size Field
0 4 preamble
4 4 length, 32 + N
8 4 type 0x02
12 8 time tag
20 4 status, u32
24 N echo
24 + N 4 CRC
28 + N 4 postamble
Status Meaning Echo
0 The packet's length and CRC were good, and the data and error-correction stages encoded it. The result was queued for the transmitter. your payload
1 The packet's length or CRC check failed. Nothing was transmitted. the whole packet as the modem received it, preamble to postamble
2 Error-correction encoding failed, usually because the payload does not fit the configured code (see On the air). Nothing was transmitted. your payload
3 The data stage failed. On current Expedite only the SATLAB data format can fail. Nothing was transmitted. your payload

Status 0 is the modem's answer, not the spacecraft's: it says the frame was queued to go out, not that it was radiated or received.

An acknowledgement carries no command id. Match it to what you sent by its echo. Expedite sends it to every client of its telecommand port, and the station's echo of your packet (0x01) and the acknowledgement can arrive in either order.

Antenna angles (0x03) and predicted Doppler (0x06)

Both have a fixed size and layout:

0x03, 44 bytes                         0x06, 48 bytes
offset  field                          offset  field
12      time tag                       12      time tag
20      status u32: on ephemeris,      20      Doppler in Hz, f32, normalised
        0 tracking, 1 parked; on               to a 100 MHz carrier (for its
        antenna, always 0                      sign, see below)
24      satellite id u32, always 0     24      NORAD number, u32 (see 0x00)
28      azimuth in degrees, f32        28      slant range in km, f32
32      elevation in degrees, f32      32      altitude in km, f32
36      CRC                            36      elevation in degrees, f32
40      postamble                      40      CRC
                                       44      postamble

On the antenna stream and in .ant files, 0x03 is where the rotator reports it is pointing. On ephemeris and in .trk files it is where Expedite predicts the satellite to be.

The Doppler value follows the sign of the range rate: it is positive while the satellite moves away, so it is the negative of the frequency shift you receive. At your own carrier, the shift you receive is −value × (carrier frequency / 100 MHz). An external Doppler packet (0x07) uses the opposite sign: the shift itself, positive while the satellite approaches. Negate a 0x06 value before you send it as 0x07.

External antenna and Doppler (0x04, 0x05, 0x07, 0x08)

These are the packets an MCS sends and receives on portal-link's ext_antenna and ext_doppler ports. Their layouts and rules are in Realtime & Data, "External antenna control and external Doppler". 0x07 has no time tag: it is the 12-byte header, an f32 Doppler shift and the trailer. An example is in Worked examples.

The CRC

The CRC is Expedite's own variant of CRC-32:

crc = 0xFFFFFFFF
for each byte b, from the preamble to the last byte before the CRC:
    crc = crc XOR b
    repeat 8 times:
        if crc is odd: crc = (crc >> 1) XOR 0x04C11DB7
        else:          crc = crc >> 1
crc = crc XOR 0xFFFFFFFF

It is stored little-endian in the four bytes before the postamble.

It is not the standard CRC-32. zlib.crc32, binascii.crc32, Java's CRC32 and most JavaScript CRC libraries shift right as Expedite does but use the bit-reversed polynomial 0xEDB88320. Expedite shifts right with 0x04C11DB7 as written, a combination no standard CRC uses, so no library computes it. Use the few lines above. To test an implementation, the Expedite CRC of the ASCII string 123456789 is 0xFC4F2BE9; the standard CRC-32 of it is 0xCBF43926.

Finding packets in a byte stream

Nothing inside a packet is escaped. The preamble and postamble bytes can occur in the data, in the time tag or in the CRC, so a marker alone does not prove a packet starts or ends there.

To read packets from a stream, frame them by their length and then confirm:

  1. Find the preamble, D4 C3 B2 A1. Drop anything before it.
  2. Read the length at bytes 4 to 7. If it is below 20 or above 16428, this was not a real preamble: drop one byte and search again. Expedite delivers at most 16384 bytes of data in a packet (a CADU frame is as long as its configured framelen, in practice far less), so no packet it sends is longer than 16428 bytes. On the command stream, the station's echo of a telecommand over the modem's limit is longer; the modem does not transmit such a command, and the splitter below skips its echo.
  3. Wait until you hold that many bytes. If the input ends first, this was not a real packet: drop one byte and search again. On a live stream, a damaged length holds back the packets behind it until that many bytes have arrived.
  4. Check that the last four are the postamble and that the CRC matches.
  5. If both hold, that is a packet: take it and carry on after it. If not, drop one byte and search again.

Never end a packet at the first postamble you see. Telemetry data contains 2B 3C 4D 5E whenever your spacecraft happens to send those bytes.

Expedite's own telecommand input does exactly that. It ignores the length and ends a packet at the first 2B 3C 4D 5E after the preamble. Two things follow:

  • A telecommand whose payload or CRC contains 2B 3C 4D 5E, even split across the two, is cut short. Its length and CRC no longer match, the modem answers with status 1, and nothing is transmitted. There is no escape sequence for it: change something in the command that does not matter, such as a sequence counter, and send it again.
  • A complete Expedite packet sent as a payload always fails this way, because its own postamble ends the outer packet. See Sending a payload, not a packet.

The same input accepts at most 16384 bytes after the preamble, so the largest telecommand payload is 16368 bytes. A larger one is never transmitted. Usually no acknowledgement comes back for it; a payload of exactly 16369 bytes gets status 1 with a 4-byte echo.

Decoding packets

The examples on this page build on each other: paste them into one file in the order they appear. The Python needs 3.10 or later and nothing outside the standard library, except the WebSocket client.

Python

The CRC:

import struct
import zlib

PREAMBLE = 0xA1B2C3D4
POSTAMBLE = 0x5E4D3C2B


def expedite_crc32(data: bytes) -> int:
    """The CRC in every Expedite packet. It is not zlib.crc32."""
    crc = 0xFFFFFFFF
    for byte in data:
        crc ^= byte
        for _ in range(8):
            crc = ((crc >> 1) ^ 0x04C11DB7) if crc & 1 else (crc >> 1)
    return crc ^ 0xFFFFFFFF


print(hex(expedite_crc32(b"123456789")))  # Expedite
print(hex(zlib.crc32(b"123456789")))      # standard CRC-32, not what Expedite uses

It prints:

0xfc4f2be9
0xcbf43926

The time tag, both ways:

from datetime import datetime, timedelta, timezone


def _bcd(byte: int) -> int:
    return (byte >> 4) * 10 + (byte & 0x0F)


def decode_time_tag(tag: bytes) -> datetime:
    """Packet bytes 12-19, as a UTC datetime."""
    years = _bcd(tag[0])
    year = 1900 + years if years >= 100 else 2000 + years
    day_index = _bcd(tag[2]) * 100 + _bcd(tag[1])  # 1 January is 0
    return datetime(year, 1, 1, tzinfo=timezone.utc) + timedelta(
        days=day_index,
        hours=_bcd(tag[3]),
        minutes=_bcd(tag[4]),
        seconds=_bcd(tag[5]),
        milliseconds=_bcd(tag[6]) * 10 + _bcd(tag[7]),
    )


def encode_time_tag(when: datetime) -> bytes:
    """The reverse, written the way Expedite writes it."""
    when = when.astimezone(timezone.utc)
    day_index = when.timetuple().tm_yday - 1
    ms = when.microsecond // 1000
    fields = (when.year - 1900, day_index % 100, day_index // 100,
              when.hour, when.minute, when.second, ms // 10, ms % 10)
    return bytes((value // 10) << 4 | value % 10 for value in fields)


print(decode_time_tag(bytes.fromhex("c5 91 00 18 56 11 11 09")).isoformat(timespec="milliseconds"))
print(encode_time_tag(datetime(2026, 11, 3, 12, 34, 56, 789000, tzinfo=timezone.utc)).hex(" "))

It prints:

2025-04-02T18:56:11.119+00:00
c6 06 03 12 34 56 78 09

One packet, unwrapped. It raises ValueError for bytes that are not a packet, and reports a CRC mismatch as crc_ok rather than raising, so you can decide what to do with a damaged packet:

NO_TIME_TAG = {0x01, 0x07}  # telecommand, external Doppler
MIN_LENGTH = {0x00: 44, 0x02: 32, 0x03: 44, 0x06: 48}  # the types decoded below


def _norad(body: bytes, offset: int) -> int:
    raw, = struct.unpack_from("<I", body, offset)
    if raw >= 0x3F800000:  # an older Expedite build wrote a float32
        return round(struct.unpack_from("<f", body, offset)[0])
    return raw


def parse_packet(packet: bytes) -> dict:
    """Unwrap one complete Expedite packet."""
    if len(packet) < 20:
        raise ValueError("shorter than the smallest packet (20 bytes)")
    preamble, length, msg_type = struct.unpack_from("<III", packet, 0)
    if preamble != PREAMBLE:
        raise ValueError("no preamble at byte 0")
    if length != len(packet):
        raise ValueError(f"length field says {length} bytes, got {len(packet)}")
    crc, postamble = struct.unpack_from("<II", packet, length - 8)
    if postamble != POSTAMBLE:
        raise ValueError("no postamble at the end")
    smallest = MIN_LENGTH.get(msg_type, 20 if msg_type in NO_TIME_TAG else 28)
    if length < smallest:
        raise ValueError(f"a type {msg_type:#04x} packet has at least {smallest} bytes, got {length}")
    body = packet[12:length - 8]
    out = {"type": msg_type, "length": length,
           "crc_ok": crc == expedite_crc32(packet[:length - 8])}
    if msg_type not in NO_TIME_TAG:
        out["time"] = decode_time_tag(body[0:8])
    if msg_type == 0x00:    # telemetry
        slant, azimuth, elevation = struct.unpack_from("<fff", body, 12)
        out.update(norad_id=_norad(body, 8), slant_km=slant, azimuth_deg=azimuth,
                   elevation_deg=elevation, data=body[24:])
    elif msg_type == 0x02:  # telecommand acknowledgement
        out["status"], = struct.unpack_from("<I", body, 8)
        out["data"] = body[12:]  # the echo
    elif msg_type == 0x03:  # antenna angles
        out["status"], _, out["azimuth_deg"], out["elevation_deg"] = (
            struct.unpack_from("<IIff", body, 8))
    elif msg_type == 0x06:  # predicted Doppler
        doppler, slant, altitude, elevation = struct.unpack_from("<f4xfff", body, 8)
        out.update(doppler_hz_at_100mhz=doppler, norad_id=_norad(body, 12),
                   slant_km=slant, altitude_km=altitude, elevation_deg=elevation)
    else:                   # the rest: what follows the header and time tag
        out["data"] = body[8:] if msg_type not in NO_TIME_TAG else body
    return out

Reading a stream, a file or a socket

A WebSocket message and an archived frame_b64 each hold exactly one packet, so parse_packet is enough for them. A mission file and a portal-link port are byte streams with packets back to back, and a read from them can end in the middle of a packet. This splitter follows the rules in Finding packets in a byte stream:

import itertools

MAX_PACKET = 16428  # 16384 bytes of data in the 44-byte telemetry wrapper


def iter_packets(chunks):
    """Yield whole packets from byte chunks: a file read in blocks, a TCP stream."""
    buf = bytearray()
    for chunk in itertools.chain(chunks, [None]):  # None: the input has ended
        ended = chunk is None
        if not ended:
            buf += chunk
        while True:
            start = buf.find(b"\xd4\xc3\xb2\xa1")
            if start < 0:
                del buf[:-3]  # keep a preamble that may be split across chunks
                break
            del buf[:start]
            if len(buf) < 8:
                break
            length = int.from_bytes(buf[4:8], "little")
            if not 20 <= length <= MAX_PACKET:
                del buf[:1]   # not a real preamble: search again one byte on
                continue
            if len(buf) < length:
                if not ended:
                    break     # wait for the rest
                del buf[:1]   # the input ended first: not a real packet
                continue
            packet = bytes(buf[:length])
            crc = int.from_bytes(packet[-8:-4], "little")
            if packet[-4:] == b"\x2b\x3c\x4d\x5e" and crc == expedite_crc32(packet[:-8]):
                yield packet
                del buf[:length]
            else:
                del buf[:1]

Feeding it a mission file and portal-link's tm port:

import socket

with open("pass.tm", "rb") as f:
    for packet in iter_packets(iter(lambda: f.read(65536), b"")):
        p = parse_packet(packet)
        print(p["time"].isoformat(timespec="milliseconds"), len(p["data"]))

with socket.create_connection(("localhost", 10025)) as tm:
    for packet in iter_packets(iter(lambda: tm.recv(65536), b"")):
        p = parse_packet(packet)
        print(p["time"].isoformat(timespec="milliseconds"), p["data"].hex())

The date and time in a mission file's name are the station's local time, which need not be UTC. Take times from the time tags inside it.

From the WebSocket

With the websockets package (pip install websockets), authenticating as WebSockets (realtime) describes:

import asyncio

import websockets

URL = "wss://umoja.remosspace.com/ws/realtime?stream=telemetry&station_id=<station-uuid>"


async def watch(access_token: str) -> None:
    async with websockets.connect(URL, subprotocols=["bearer", access_token]) as ws:
        async for message in ws:
            if isinstance(message, str):    # the "ready" frame
                print(message)
                continue
            p = parse_packet(message[19:])  # drop the 19-byte station header
            print(p["time"].isoformat(timespec="milliseconds"), p["crc_ok"], p["data"].hex())


asyncio.run(watch("<access token>"))

JavaScript

The same in JavaScript, for a browser or Node 18 and later. packet is a Uint8Array holding one whole packet:

const PREAMBLE = 0xa1b2c3d4;
const POSTAMBLE = 0x5e4d3c2b;

// The CRC in every Expedite packet. It is not the standard CRC-32.
function expediteCrc32(bytes) {
  let crc = 0xffffffff;
  for (const byte of bytes) {
    crc ^= byte;
    for (let i = 0; i < 8; i++) {
      crc = crc & 1 ? (crc >>> 1) ^ 0x04c11db7 : crc >>> 1;
    }
  }
  return (crc ^ 0xffffffff) >>> 0;
}

const bcd = (b) => (b >> 4) * 10 + (b & 0x0f);

function decodeTimeTag(t) {
  const years = bcd(t[0]);
  const year = years >= 100 ? 1900 + years : 2000 + years;
  const dayIndex = bcd(t[2]) * 100 + bcd(t[1]); // 1 January is 0
  const ms = bcd(t[6]) * 10 + bcd(t[7]);
  return new Date(Date.UTC(year, 0, 1 + dayIndex, bcd(t[3]), bcd(t[4]), bcd(t[5]), ms));
}

function parsePacket(packet) {
  const view = new DataView(packet.buffer, packet.byteOffset, packet.byteLength);
  if (packet.length < 20 || view.getUint32(0, true) !== PREAMBLE) {
    throw new Error("not an Expedite packet");
  }
  const length = view.getUint32(4, true);
  if (length !== packet.length) {
    throw new Error(`length field says ${length} bytes, got ${packet.length}`);
  }
  if (view.getUint32(length - 4, true) !== POSTAMBLE) {
    throw new Error("no postamble at the end");
  }
  const type = view.getUint32(8, true);
  const hasTimeTag = type !== 0x01 && type !== 0x07;
  const smallest = { 0x00: 44, 0x02: 32, 0x03: 44, 0x06: 48 }[type] ?? (hasTimeTag ? 28 : 20);
  if (length < smallest) {
    throw new Error(`a type ${type} packet has at least ${smallest} bytes, got ${length}`);
  }
  const out = {
    type,
    length,
    crcOk: view.getUint32(length - 8, true) === expediteCrc32(packet.subarray(0, length - 8)),
  };
  const norad = (offset) => {
    const raw = view.getUint32(offset, true);
    return raw >= 0x3f800000 ? Math.round(view.getFloat32(offset, true)) : raw; // older builds
  };
  if (hasTimeTag) out.time = decodeTimeTag(packet.subarray(12, 20));
  if (type === 0x00) {
    out.noradId = norad(20);
    out.slantKm = view.getFloat32(24, true);
    out.azimuthDeg = view.getFloat32(28, true);
    out.elevationDeg = view.getFloat32(32, true);
    out.data = packet.subarray(36, length - 8);
  } else if (type === 0x02) {
    out.status = view.getUint32(20, true);
    out.data = packet.subarray(24, length - 8); // the echo
  } else if (type === 0x03) {
    out.status = view.getUint32(20, true);
    out.azimuthDeg = view.getFloat32(28, true);
    out.elevationDeg = view.getFloat32(32, true);
  } else if (type === 0x06) {
    out.dopplerHzAt100MHz = view.getFloat32(20, true);
    out.noradId = norad(24);
    out.slantKm = view.getFloat32(28, true);
    out.altitudeKm = view.getFloat32(32, true);
    out.elevationDeg = view.getFloat32(36, true);
  } else {
    out.data = packet.subarray(hasTimeTag ? 20 : 12, length - 8);
  }
  return out;
}

const fromHex = (s) => Uint8Array.from(s.replace(/\s+/g, "").match(/../g) ?? [], (h) => parseInt(h, 16));
const toHex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(" ");

console.log(expediteCrc32(new TextEncoder().encode("123456789")).toString(16));
const tm = parsePacket(fromHex(
  "d4c3b2a1 30000000 00000000 c6060312 34567809 c8630000" +
  "00509a44 00003443 00003442 cafebabe 1e6a3bff 2b3c4d5e",
));
console.log(tm.time.toISOString(), tm.noradId, tm.slantKm, tm.azimuthDeg, tm.elevationDeg, toHex(tm.data));

It prints:

fc4f2be9
2026-11-03T12:34:56.789Z 25544 1234.5 180 45 ca fe ba be

Receiving in a browser, with the subprotocol login from WebSockets (realtime):

const ws = new WebSocket(
  "wss://umoja.remosspace.com/ws/realtime?stream=telemetry&station_id=<station-uuid>",
  ["bearer", accessToken],
);
ws.binaryType = "arraybuffer";
ws.onmessage = (event) => {
  if (typeof event.data === "string") return; // the "ready" frame
  const p = parsePacket(new Uint8Array(event.data, 19)); // drop the station header
  console.log(p.time.toISOString(), p.crcOk, toHex(p.data));
};

Building packets

Sending a payload, not a packet

On both of Umoja's telecommand paths, the WebSocket command stream and portal-link's tc port, you send the payload only. The station agent builds the telecommand packet around it, and the modem then adds the air layer. Send the bytes your spacecraft's command decoder expects, starting with your CSP header if your link uses CSP.

Do not wrap the payload in an Expedite packet yourself. The station would wrap it a second time, the modem would end the outer packet at your packet's postamble, and it would answer with status 1 and transmit nothing. The WebSocket's ttc.ack still says ok: true in that case, because it only reports that the station handed the packet to the modem; the modem's verdict is the acknowledgement packet (0x02) on the command stream.

portal-link's tc port takes each read from the TCP socket as one telecommand, and TCP keeps no message boundaries: two writes close together can arrive as one read and go up as one telecommand, and one large write can arrive as two reads and go up as two. Send one telecommand per write, keep telecommands small (well under the 16368-byte limit), and wait for its echo (0x01) on the command port before you send the next, with a timeout of a few seconds: a refused or failed telecommand produces no echo, and portal-link reports the refusal only in its own log, with --verbose.

The packets you build yourself are the ones for portal-link's ext_antenna and ext_doppler ports, and a telecommand packet when you want to check a payload before you send it, or to match the echo the station sends back.

Python

def build_packet(msg_type: int, body: bytes) -> bytes:
    """Wrap a body (time tag included, where the type has one) in a packet."""
    head = struct.pack("<III", PREAMBLE, 12 + len(body) + 8, msg_type) + body
    return head + struct.pack("<II", expedite_crc32(head), POSTAMBLE)


def build_telecommand(payload: bytes) -> bytes:
    """The packet the station builds around a telecommand payload."""
    packet = build_packet(0x01, payload)
    if b"\x2b\x3c\x4d\x5e" in packet[12:-4]:
        raise ValueError("the modem would cut this packet short at 2B 3C 4D 5E")
    if len(packet) > 16388:
        raise ValueError("the modem drops telecommands over 16368 payload bytes")
    return packet


print(build_telecommand(bytes.fromhex("01 02 03 04")).hex(" "))
try:
    build_telecommand(build_telecommand(bytes.fromhex("01 02 03 04")))
except ValueError as err:
    print("double-wrapped:", err)

It prints:

d4 c3 b2 a1 18 00 00 00 01 00 00 00 01 02 03 04 42 8c 67 f8 2b 3c 4d 5e
double-wrapped: the modem would cut this packet short at 2B 3C 4D 5E

Calling build_telecommand on a payload and discarding the result is a cheap check that the modem's packet input will take it whole. Error correction and the data format can still refuse it (status 2 or 3). Then send the payload, not the packet.

JavaScript

function buildPacket(msgType, body) {
  const packet = new Uint8Array(12 + body.length + 8);
  const view = new DataView(packet.buffer);
  view.setUint32(0, PREAMBLE, true);
  view.setUint32(4, packet.length, true);
  view.setUint32(8, msgType, true);
  packet.set(body, 12);
  view.setUint32(12 + body.length, expediteCrc32(packet.subarray(0, 12 + body.length)), true);
  view.setUint32(16 + body.length, POSTAMBLE, true);
  return packet;
}

function buildTelecommand(payload) {
  const packet = buildPacket(0x01, payload);
  const inner = toHex(packet.subarray(12, packet.length - 4));
  if (inner.includes("2b 3c 4d 5e")) {
    throw new Error("the modem would cut this packet short at 2B 3C 4D 5E");
  }
  if (packet.length > 16388) {
    throw new Error("the modem drops telecommands over 16368 payload bytes");
  }
  return packet;
}

console.log(toHex(buildTelecommand(fromHex("01020304"))));

It prints:

d4 c3 b2 a1 18 00 00 00 01 00 00 00 01 02 03 04 42 8c 67 f8 2b 3c 4d 5e

Worked examples

The manual's three examples

The Expedite User Manual (revision 6) prints a telecommand (Table 4.7), a telemetry packet (Table 4.5) and the acknowledgement of that telecommand (Table 4.9). They make good test vectors, because each verifies with the Expedite CRC and fails with the standard one:

MANUAL_TC = bytes.fromhex(
    "d4 c3 b2 a1 94 00 00 00 01 00 00 00 1c 71 c8 dc"
    "8d 28 d2 82 d7 d2 61 57 dd a1 57 3d a1 58 c2 5e"
    "44 b3 96 98 3e be 4a bc 69 98 dd 3e 45 7c 69 78"
    "dd 31 ba 83 75 09 15 ed 37 ab a7 8b c2 3f 56 fc"
    "7a 2a 95 02 f7 a4 b8 74 d1 b1 61 3c 86 ca 9b 0d"
    "08 a4 5b f4 de 71 61 dc 86 c5 64 f2 14 d5 93 28"
    "53 59 b3 5e 51 17 05 a5 c9 74 c4 15 f2 01 71 00"
    "15 a4 93 3d f7 ca 8e a9 9b 99 ac 3e 50 d8 fa 45"
    "2a fb 34 2a ee 90 b9 d3 67 73 5d ce 82 29 9b f8"
    "2b 3c 4d 5e")
MANUAL_TM = bytes.fromhex(
    "d4 c3 b2 a1 ac 00 00 00 00 00 00 00 c5 91 00 18"
    "56 11 11 09 00 00 00 00 00 00 00 00 00 00 00 00"
    "00 00 00 00 02 0c 28 f2 2c ea 7d 0e 24 da de c6"
    "97 73 2a fe 04 18 51 e4 59 d4 fa 1c 49 b5 bd 8d"
    "2e e6 55 fc 08 30 a3 c8 b3 a9 f4 38 93 6b 7b 1a"
    "5d cc ab f8 10 61 47 91 67 53 e8 71 26 d6 f6 34"
    "bb 99 57 f0 20 c2 8f 22 ce a7 d0 e2 4d ad ec 69"
    "77 32 af e0 41 85 1e 45 9d 4f a1 c4 9b 5b d8 d2"
    "ee 65 5f c0 83 0a 3c 8b 3a 9f 43 89 36 b7 b1 a5"
    "dc ca bf 81 06 14 79 16 75 3e 87 12 6d 6f 63 4b"
    "b9 95 7f 02 ab bf 64 f8 2b 3c 4d 5e")
MANUAL_ACK = bytes.fromhex(
    "d4 c3 b2 a1 a0 00 00 00 02 00 00 00 c5 91 00 19"
    "29 20 24 00 00 00 00 00 1c 71 c8 dc 8d 28 d2 82"
    "d7 d2 61 57 dd a1 57 3d a1 58 c2 5e 44 b3 96 98"
    "3e be 4a bc 69 98 dd 3e 45 7c 69 78 dd 31 ba 83"
    "75 09 15 ed 37 ab a7 8b c2 3f 56 fc 7a 2a 95 02"
    "f7 a4 b8 74 d1 b1 61 3c 86 ca 9b 0d 08 a4 5b f4"
    "de 71 61 dc 86 c5 64 f2 14 d5 93 28 53 59 b3 5e"
    "51 17 05 a5 c9 74 c4 15 f2 01 71 00 15 a4 93 3d"
    "f7 ca 8e a9 9b 99 ac 3e 50 d8 fa 45 2a fb 34 2a"
    "ee 90 b9 d3 67 73 5d ce 17 3f fd fb 2b 3c 4d 5e")

for name, packet in (("TC", MANUAL_TC), ("TM", MANUAL_TM), ("ACK", MANUAL_ACK)):
    p = parse_packet(packet)
    stored = int.from_bytes(packet[-8:-4], "little")
    print(f"{name:3} type {p['type']:#04x}  {p['length']} bytes  CRC {stored:#010x}  "
          f"ok {p['crc_ok']}  zlib {zlib.crc32(packet[:-8]):#010x}")

tm = parse_packet(MANUAL_TM)
print(tm["time"].isoformat(timespec="milliseconds"), tm["norad_id"], tm["slant_km"],
      tm["azimuth_deg"], tm["elevation_deg"], len(tm["data"]))
ack = parse_packet(MANUAL_ACK)
print(ack["time"].isoformat(timespec="milliseconds"), ack["status"],
      ack["data"] == parse_packet(MANUAL_TC)["data"])
print(build_telecommand(MANUAL_TC[12:-8]) == MANUAL_TC)

It prints:

TC  type 0x01  148 bytes  CRC 0xf89b2982  ok True  zlib 0x4a8fb687
TM  type 0x00  172 bytes  CRC 0xf864bfab  ok True  zlib 0x84ad3060
ACK type 0x02  160 bytes  CRC 0xfbfd3f17  ok True  zlib 0x8c384d68
2025-04-02T18:56:11.119+00:00 0 0.0 0.0 0.0 128
2025-04-02T19:29:20.240+00:00 0 True
True

Annotated, the telemetry packet reads:

d4 c3 b2 a1                 preamble
ac 00 00 00                 length 172
00 00 00 00                 type 0x00, telemetry
c5 91 00 18 56 11 11 09     time tag, 2025-04-02 18:56:11.119 UTC
00 00 00 00                 NORAD 0: no pass was being tracked
00 00 00 00                 slant range 0.0
00 00 00 00                 azimuth 0.0
00 00 00 00                 elevation 0.0
02 0c 28 f2 ... b9 95 7f 02 data, 128 bytes
ab bf 64 f8                 CRC 0xF864BFAB
2b 3c 4d 5e                 postamble

The acknowledgement is that telecommand's answer: status 0 at offset 20, and from offset 24 the telecommand's 128-byte payload, not the whole telecommand packet. The last line shows the station would build exactly the manual's telecommand from that payload.

A small telemetry packet

Telemetry as current Expedite writes it during a pass, with four bytes of spacecraft data, and the splitter recovering it from a stream:

when = datetime(2026, 11, 3, 12, 34, 56, 789000, tzinfo=timezone.utc)
header = encode_time_tag(when) + struct.pack("<Ifff", 25544, 1234.5, 180.0, 45.0)
small = build_packet(0x00, header + bytes.fromhex("ca fe ba be"))
print(small.hex(" "))
p = parse_packet(small)
print(p["time"].isoformat(timespec="milliseconds"), p["norad_id"], p["slant_km"],
      p["azimuth_deg"], p["elevation_deg"], p["data"].hex(" "))

# Data that contains both markers, which the splitter must not cut at
tricky = build_packet(0x00, encode_time_tag(datetime(2026, 11, 3, tzinfo=timezone.utc))
                      + bytes(16) + bytes.fromhex("2b 3c 4d 5e d4 c3 b2 a1"))
stream = b"\x00\xd4\xc3\xb2\xa1junk" + MANUAL_TM + tricky + small
chunks = [stream[i:i + 7] for i in range(0, len(stream), 7)]
for packet in iter_packets(chunks):
    p = parse_packet(packet)
    print(f"{p['type']:#04x} {p['length']} {p['crc_ok']} {p['data'].hex(' ')[:23]}")

It prints:

d4 c3 b2 a1 30 00 00 00 00 00 00 00 c6 06 03 12 34 56 78 09 c8 63 00 00 00 50 9a 44 00 00 34 43 00 00 34 42 ca fe ba be 1e 6a 3b ff 2b 3c 4d 5e
2026-11-03T12:34:56.789+00:00 25544 1234.5 180.0 45.0 ca fe ba be
0x00 172 True 02 0c 28 f2 2c ea 7d 0e
0x00 52 True 2b 3c 4d 5e d4 c3 b2 a1
0x00 48 True ca fe ba be

The false preamble at the start is skipped, and the second packet is kept whole although its data holds a postamble and a preamble.

An external Doppler packet

A 0x07 packet for portal-link's ext_doppler port, asking for −1234.5 Hz at the 100 MHz reference. It has no time tag:

print(build_packet(0x07, struct.pack("<f", -1234.5)).hex(" "))

It prints:

d4 c3 b2 a1 18 00 00 00 07 00 00 00 00 50 9a c4 c8 8b ce fb 2b 3c 4d 5e

On the air

Everything in this section is set per satellite in the mission configuration: in a booking's mission parameters before the pass, or live on the station during your window. See Operating a satellite end to end, "Configure the radio". The radio frame Expedite builds has to be the one your spacecraft's radio expects, and the frame your spacecraft sends has to be one Expedite is configured to find.

What you set

The telemetry section sets the downlink and command the uplink; in the table, * stands for either. The paths and values are the current Expedite firmware's. Which ones a station offers depends on its firmware, so the station's own list, GET /api/v1/stations/{station_id}/mission-fields, is the authority.

Path Sets Values
telemetry.packet.type, command.packet.type How a frame is marked on the air ASM1, ASM2, ASM3, ASM4, HDLC, AX25; also CADU for telemetry, and RAW and AIRMAC for command
*.packet.pkt_params.* The marker and length settings of that type for example asm (the marker, in hex), len (its length in bytes), offset (the width of the ASM1 length field), data, head, headlen, tail, taillen
*.data.data The data format NONE, CSP1, SATLAB
*.data.data_params.* CSP CRC and HMAC, SATLAB options on/off flags, all off by default
*.fec.fec Error correction NONE, Conv, Inverted Conv, RS(255,223), RS(255,239), a convolutional code combined with either RS code, LDPC C2; for command also CLTU BCH and CLTU LDPC codes
*.fec.fec_params.depth, *.fec.fec_params.format Reed–Solomon interleave depth, symbol basis 1 to 8; conventional or dualbasis
*.scrambler.scrambler Scrambler NONE, CCSDSTM, G3RUH; for command also CCSDSTC
command.plop.plop Uplink acquisition sequence NONE, PLOP1, PLOP2
rxdsp.linecode, txdsp.linecode Line code NRZ-L, NRZ-M, NRZ-S, BP-L, BP-M, BP-S

A few terms. An attached sync marker (ASM) is a fixed bit pattern sent before each frame; the receiver searches the bit stream for it to find where a frame begins. Forward error correction (FEC) adds redundant bytes or bits so the receiver can repair errors without asking for a resend. A scrambler or randomiser mixes the bits with a pseudo-random sequence, so that long runs of zeros or ones, which make a receiver lose its bit timing, do not occur. CSP is the CubeSat Space Protocol, whose packets start with a 4-byte header and can end with a CRC and an HMAC tag.

In this order, to the payload of your telecommand:

  1. Data format (command.data.data). NONE passes your bytes through. CSP1 appends an HMAC tag, if that is on, then a CSP CRC, if that is on (see CSP CRC and HMAC). Expedite never writes or changes a CSP header, so your payload has to start with your own. SATLAB can prepend a 2-byte header (an 11-bit length), pad with zeros to 219 bytes, header included (with ASM2, to its data length), and append a big-endian CRC-32C. Its other flags add a 55 preamble, idle frames or a termination byte.
  2. Reed–Solomon, if selected. RS(255,223) adds 32 parity bytes per codeword and RS(255,239) adds 16. With interleave depth I, the frame's length has to be a multiple of I and at most 223 × I (or 239 × I) bytes; otherwise the modem answers status 2 and transmits nothing.

Both codes are over GF(256) with field polynomial 0x187 and generator roots α^(11·j). RS(255,223) uses j = 112 to 143, the CCSDS E=16 code. RS(255,239) uses j = 112 to 127: this is not the CCSDS E=8 code (j = 120 to 135). A short codeword is shortened at the front: the calculation puts zeros before the data, which are not sent, and the codeword is the data, then the parity. With depth I, byte k goes to codeword k mod I, and the output is interleaved byte by byte, parity included. dualbasis treats the bytes as CCSDS dual-basis symbols: Expedite converts them to the conventional basis, encodes, and converts the codeword back. 3. CCSDS randomiser (CCSDSTM or CCSDSTC), if selected. The frame, parity included, is XORed with a fixed 255-byte sequence from its first byte. The CCSDSTM sequence starts FF 48 0E C0 9A 0D 70 BC, the CCSDSTC one FF 39 9E 5A 68 E9 06 F5. The sync marker and length field added next are not randomised. 4. Framing (command.packet.type): - ASM1: the marker, then a big-endian length field 1 to 4 bytes wide (offset), then the frame. The length counts the bytes after it, CSP CRC, HMAC and RS parity included. A length too large for the field is cut to its low bytes without an error, so a 1-byte field limits the frame to 255 bytes. - ASM2: the marker, then a frame of fixed length. That length is data plus whatever the RS code, CSP CRC, HMAC and SATLAB CRC add. The frame is cut or padded to it after Reed–Solomon, so with RS your payload has to be exactly data bytes. A payload of the wrong length is not refused: the modem cuts it, or pads it with zeros that are not randomised, and transmits it with status 0. - ASM3: a 1- to 4-byte head, the frame, a 1- to 4-byte tail. The framing adds no length field; error correction and scrambling still apply. On downlink a frame ends at the first place the tail appears, at any bit offset, so a frame whose bits contain the tail is cut short: use a long tail and a scrambler. - ASM4: the marker, then a 3-byte length field protected by a Golay(24,12) code (12 parity bits, then the 12-bit length), then the frame. The length can be at most 4095 bytes; a longer one is cut to its low 12 bits without an error. - HDLC: the frame and a CRC-16/X.25 frame check, low byte first, sent least significant bit first with a 0 inserted after every five 1s, and 50 flag bytes (7E) before and after. - AX25: Expedite builds the 16-byte AX.25 address header from dsthdr, dstssid, srchdr and srcssid, with control 03 and PID F0, puts your payload after it and sends the result as HDLC. Do not put an AX.25 header in your payload. - AIRMAC: an AX.25 frame as above, line-coded, then wrapped in the AIRMAC framing. - RAW: no framing. With RAW, data format NONE, FEC NONE, no scrambler, line code NRZ-L and PLOP NONE, the modem sends your payload's bits as they are, so you can supply a complete frame of your own.

Except for HDLC and AX25, bytes are sent most significant bit first. 5. G3RUH scrambler (1 + x^12 + x^17), if selected, over the whole bit stream, marker included, preceded by 17 zero bits. It is self-synchronising, so your receiver needs no frame alignment for it. 6. Convolutional code, if selected: rate 1/2, constraint length 7, the CCSDS polynomials 171 and 133 (octal), the first one's bit first, applied after framing, so the marker is encoded as well. Inverted Conv inverts the second output, as CCSDS does. With a convolutional code, the NRZ-M or NRZ-S line coding is applied before the encoder. By default the encoder runs on from frame to frame. On firmware that offers command.fec.termination, turning it on follows each frame with 49 zero bits, so every frame starts and ends in state 0, as a burst decoder expects. command.fec.rate accepts other rates, but only 1/2 is implemented. 7. Line code and modulation, and the PLOP acquisition sequence (command.plop), which sends a plain carrier and an acquisition pattern before the frames, and an idle pattern after them.

The CLTU codes replace steps 2 to 5 and ignore the packet type. CLTU BCH randomises the frame if a CCSDS randomiser is set, pads it with 55 to a multiple of 7 bytes, adds one BCH parity byte per 7, and puts the CCSDS start sequence EB 90 before the code blocks and the tail C5 C5 C5 C5 C5 C5 C5 79 after them. The CLTU LDPC codes put their own start sequence first, pad the frame with 55 to whole blocks, encode each block, and then randomise each code block, parity included, if a randomiser is set.

LDPC C2 takes the place of Reed–Solomon: it pads the frame with zeros to 892 bytes and encodes it into a 1020-byte code block, which is then randomised and framed as above, without G3RUH. A frame over 892 bytes gets status 2. Your spacecraft receives all 892 bytes, the zero fill included. LDPC C2 needs ASM1 (with a length field of 2 to 4 bytes), ASM2, ASM3, ASM4, HDLC or AX25 (on downlink also CADU), and no G3RUH.

A DVB-S2 uplink skips all of this: the payload goes into the DVB-S2 frame as it is.

Expedite undoes the same stages in reverse: demodulation, the convolutional code, the line code (NRZ-M or NRZ-S; with a convolutional code it is undone after the code, because the transmitter applies it before), the G3RUH scrambler, then the frame marker, then the CCSDS randomiser, then Reed–Solomon, then the data format. What is left is the data in your telemetry packet.

The downlink also offers CADU: a marker followed by a frame of fixed length, framelen, which counts the Reed–Solomon parity. Unlike the ASM types, it can lock on to a marker with bit errors and keep its place from frame to frame.

Removed before you see the data:

  • the sync marker, the ASM1 length field, the ASM4 Golay field, and the ASM3 head and tail,
  • HDLC flags, stuffed bits and the frame check,
  • scrambling and randomisation, and the Reed–Solomon parity,
  • the CSP CRC and HMAC tag, and the SATLAB CRC, when their checks are on.

Kept in the data:

  • your CSP header,
  • the 16-byte AX.25 address header, unless telemetry.packet.pkt_params.strip is on or a Reed–Solomon or LDPC code is used, which always strip it,
  • SATLAB padding, unless SATLAB decoding is on,
  • with LDPC C2, the zero fill: the data is always 892 bytes, so with the CSP checks on, your CRC and HMAC have to be the last bytes of those 892.

Some frames never reach you:

  • An ASM1 to ASM4 marker or ASM3 head is found only on an exact match. It is also found inverted, and then the frame's bits are inverted back.
  • A frame whose HDLC frame check fails, or that is shorter than 5 or longer than 1275 bytes, is dropped, and so is an ASM4 frame whose length cannot be decoded.
  • With the CSP CRC or HMAC check on, a frame that fails it is dropped, and so is a frame that carries no CRC or tag: the check follows the configuration, not the flags in your CSP header.
  • With the SATLAB CRC check on, a frame that fails it is dropped.
  • On a half-duplex station, frames that arrive while the station is transmitting are discarded.

A frame Reed–Solomon cannot correct may still be passed on, parity removed and errors left in: current Expedite firmware does this by default. With the data format NONE nothing else catches it, so keep a CRC of your own in the frame, or turn on the CSP CRC.

The downlink cannot use RAW, AIRMAC or the CLTU codes, and its scrambler is one of NONE, CCSDSTM and G3RUH. G3RUH works with HDLC, AX25 and CADU on binary modulations or with a convolutional code, and with ASM1 to ASM4 only on binary modulations without one. It cannot be combined with LDPC C2.

CSP CRC and HMAC

With CSP1 and every other flag off, Expedite computes both over the bytes after the 4-byte CSP header:

  • The HMAC tag (m_bTC_HMAC on uplink, m_bTM_HMAC on downlink) is the first 4 bytes of HMAC-SHA1, keyed with the first 16 bytes of the SHA-1 of your key. It comes first.
  • The CRC (m_bTC_CRC, m_bTM_CRC) is a CRC-32C (Castagnoli), not CRC-32 and not the packet CRC above, appended big-endian after the tag, so it covers the tag too.

This is the convention of libcsp 1. On uplink Expedite appends them; on downlink it checks them and removes them. The other flags change the convention:

Flag, under command.data.data_params / telemetry.data.data_params Effect
m_bTC_CRC_HDR / m_bTM_CRC_HDR The CRC covers the CSP header too.
m_bTC_CRC_L_ENDIAN / m_bTM_CRC_L_ENDIAN The CRC is sent little-endian.
m_bTC_HMAC_HDR / m_bTM_HMAC_HDR The HMAC covers the CSP header too.
m_bTC_HMAC_L_ENDIAN / m_bTM_HMAC_L_ENDIAN The 4 tag bytes are sent in reverse order.

Expedite assumes a 4-byte header, the CSP 1 size, and skips either exactly those 4 bytes or none (the _HDR flags). It cannot skip the 6-byte header of CSP 2. libcsp computes the HMAC over the data only, header excluded, so with CSP 2 Expedite's HMAC cannot match in either direction: leave m_bTC_HMAC and m_bTM_HMAC off and authenticate in your own payload. For the CRC, turn on m_bTC_CRC_HDR on uplink: libcsp 2 also accepts a CRC that covers the header. On downlink, m_bTM_CRC with m_bTM_CRC_HDR works only if your flight software is libcsp built with CSP_21. Otherwise libcsp 2 appends a CRC over the data only, which Expedite cannot check, so leave the downlink check off.

In Python, to check what your spacecraft will receive:

import hashlib
import hmac


def crc32c(data: bytes) -> int:
    """CRC-32C (Castagnoli), the CSP CRC."""
    crc = 0xFFFFFFFF
    for byte in data:
        crc ^= byte
        for _ in range(8):
            crc = ((crc >> 1) ^ 0x82F63B78) if crc & 1 else (crc >> 1)
    return crc ^ 0xFFFFFFFF


def csp1_uplink(frame: bytes, hmac_key: bytes | None, with_crc: bool) -> bytes:
    """What CSP1 makes of a telecommand payload, with every other flag off."""
    if hmac_key is not None:
        key16 = hashlib.sha1(hmac_key).digest()[:16]
        frame += hmac.new(key16, frame[4:], hashlib.sha1).digest()[:4]
    if with_crc:
        frame += crc32c(frame[4:]).to_bytes(4, "big")
    return frame


command = bytes.fromhex("94 12 a0 09") + b"PING"  # a CSP 1 header, then the data
print(hex(crc32c(b"123456789")))
print(csp1_uplink(command, None, True).hex(" "))
print(csp1_uplink(command, bytes.fromhex("000102030405060708090a0b0c0d0e0f"), True).hex(" "))

It prints:

0xe3069283
94 12 a0 09 50 49 4e 47 ce ad a4 a6
94 12 a0 09 50 49 4e 47 16 f4 0e a6 c8 f1 a0 d2

The header 94 12 A0 09 is priority 2, source 10, destination 1, destination port 10, source port 32, with the HMAC and CRC flags set. The key is a throwaway example. Sent down by a spacecraft with the same settings, that last frame reaches you as 94 12 a0 09 50 49 4e 47: header kept, tag and CRC checked and removed.

A frame on the air, end to end

The PING command above, with HMAC and CRC, uplinked with command.fec.fec = RS(255,223) (conventional basis, depth 1), command.scrambler.scrambler = CCSDSTM and command.packet.type = ASM4 with the marker 930B51DE. These bytes were computed with Expedite's own encoder code:

CSP frame, 16 bytes        94 12 a0 09 50 49 4e 47 16 f4 0e a6 c8 f1 a0 d2

+ RS(255,223) parity       8c 72 6d b9 8a 76 26 48 09 ab b2 40 d4 10 fd a0
  32 bytes                 0d e0 bf 99 6d d1 73 62 6e 2e 4f e5 b0 17 98 83

after CCSDSTM, 48 bytes    6b 5a ae c9 ca 44 3e fb 98 d8 9d 0b 6f 46 e6 1c
                           d6 e5 10 75 b8 d4 99 76 03 bb 43 c8 40 dd 17 11
                           f3 70 a2 18 59 cb 92 1b 72 77 68 be ff 79 15 1f

on the air                 93 0b 51 de | dc 80 30 | 6b 5a ae c9 ... 79 15 1f
                           marker      | Golay    | randomised frame

The Golay field DC 80 30 ends in the 12-bit length 0x030, 48 bytes. The marker and the Golay field are not randomised. The same CSP frame with ASM1, marker C9D08A7B and a 1-byte length field (offset = 1), no FEC and no scrambler, CRC only, is c9 d0 8a 7b 0c 94 12 a0 09 50 49 4e 47 ce ad a4 a6.

A CCSDS CLTU for the 10-byte frame 01 02 … 0A, with CLTU BCH and no randomiser, is:

eb 90 | 01 02 03 04 05 06 07 70 | 08 09 0a 55 55 55 55 ee | c5 c5 c5 c5 c5 c5 c5 79
start   code block, parity 70     code block, parity ee     tail

HMAC keys

Umoja never accepts or stores an HMAC key. The mission configuration refuses any field that names one, so a key cannot be set in a booking, a template or a live write, and it is never shown in what Umoja reads back from the station. If your link uses CSP HMAC, contact Remos support to have the key installed on the station. Give it as the hex of the key's raw bytes, the same bytes your flight software passes to csp_hmac_set_key. The station keeps separate uplink and downlink keys.

Where the Expedite manual differs

Revision 6 of the Expedite User Manual (2 April 2025) is wrong or incomplete in these places. Each correction comes from the station software, and the manual's own hex examples agree with it.

Where The manual says What Expedite does
§4, CRC32 Polynomial 0x04C11DB7, "the calculated CRC should be reflected". A right-shifting CRC with 0x04C11DB7 unreflected, start and final XOR 0xFFFFFFFF. The standard CRC-32 gives wrong values. Check value 0xFC4F2BE9. See The CRC.
Table 4.3, generic packet Every packet has a time tag. Telecommands (0x01) and external Doppler (0x07) have none.
Table 4.4, time tag Byte 1 is day / 100 and byte 2 is day % 100; all values BCD. Byte 1 is day % 100 and byte 2 day / 100. The day counts from 0. The year byte is years since 1900, with a tens digit above 9, so it is not valid BCD.
Table 4.5, telemetry Offset 20 is a float satellite id; offset 28 is altitude. Offset 20 is a u32 NORAD number on current firmware. Offset 28 is the azimuth. Both are 0 outside a scheduled pass.
Tables 4.5, 4.7, 4.9 Total length 1 to 16384. The minimum is 20. A telecommand can be 16388 bytes (payload up to 16368); a longer one is never transmitted.
Table 4.9, acknowledgement Status and echo both at offset 20. Status at 20, echo from 24, total 32 + N. Status 0 means queued for the transmitter. On status 1 the echo is the whole received packet.
§4, framing Packets are delimited by preamble, length and postamble. The telecommand input ends a packet at the first postamble and ignores the length. Nothing is escaped.
Table 4.11, antenna angles Offset 24 is a float satellite id. A u32, always 0.
§4.5, Table 4.14 Doppler shift for a 100 MHz downlink carrier. The negative of it: +100 MHz × range rate / c, positive while the satellite moves away. Table 4.15's external value is the shift itself, so the two have opposite signs.
Appendix B.1, structs External antenna 44 bytes, its acknowledgement 44, internal Doppler 36. 36, 40 and 48 bytes.
Table 4.2, raw IQ Type 0x0A on ports 2008 and 3008. Expedite sends raw IQ only as unframed samples; there is no 0x0A packet.
§4.12, mission files Folders named after the satellite and the pass start. The time in the name is the station's local time; the time tags inside are UTC.
§2.3, ASM1 A 1- or 2-byte length. 1 to 4 bytes, big-endian, counting CRC, HMAC and RS parity.
§2.3, ASM3 A CCSDS CLTU. A head and a tail around the frame, with no length field and no CLTU coding. A real CLTU is the FEC option CLTU BCH.
A.5, ASM4 A Golay-encoded sync word. The sync word is sent plain and the length field after it is Golay-encoded.
§2.3, ASM4 Up to 2^12 bytes. At most 4095 bytes.
§2.3, convolutional code The encoder is reset for every block. By default Expedite 4.0 carries the encoder state from frame to frame; command.fec.termination, where the firmware offers it, flushes it after every frame.
§3.1, scrambler NONE, CCSDS, G3RUH. NONE, CCSDSTM, G3RUH, and on uplink CCSDSTC.
§3.1, ASM2 data length RS parity and CSP CRC/HMAC are added automatically. They are, but the frame is padded after Reed–Solomon, so with RS the payload has to be exactly the data length.
§4.2, status 0x03 CSP failure. Only the SATLAB data format can fail; CSP1 encoding always succeeds.

The older Expedite interface control document (ICD) gives the preamble as 0xA1B2C34D or 0x1A2B3C4D and other sizes that do not match. Use neither it nor the manual's tables where this page differs.

Troubleshooting

Symptom Cause What to do
The CRC never matches. You use the standard CRC-32 (zlib.crc32 and the like), or the wrong range. Use Expedite's CRC, over the preamble up to the byte before the CRC. Check 123456789 gives 0xFC4F2BE9.
A binary WebSocket message does not start with D4 C3 B2 A1. It starts with the 19-byte station header (45 58 …). Drop 19 bytes.
Times are a day out, or the month is wrong. Bytes 13 and 14 swapped, as the manual has them, or the day counted from 1. Day = byte 14 × 100 + byte 13, and 1 January is day 0.
The year decodes as 2125, or 2026 as 1926. The year byte read as BCD plus 2000, or a two-digit year read as years since 1900. Use the rule in The time tag.
The NORAD number reads as 0.0 or a tiny float. Offset 20 read as a float32. Read a u32. Zero means no pass was being tracked.
"Altitude" in telemetry is between 0 and 360. Offset 28 is the azimuth. Read it as azimuth.
Packets go missing or come out garbled after a while. Each recv() or read treated as a packet, or packets cut at the first postamble. Frame by the length and confirm, as iter_packets does.
Acknowledgement status 1, nothing transmitted. You sent a complete packet as the payload, or the payload or its CRC contains 2B 3C 4D 5E. Send the payload only. Change a byte that does not matter, such as a counter.
ttc.ack says ok: true but no acknowledgement packet follows. The payload is longer than 16368 bytes, and the modem dropped it. Keep telecommands to 16368 bytes.
Acknowledgement status 2. The frame does not fit the FEC: an RS length rule (after the CSP CRC and HMAC are added), or LDPC C2 over 892 bytes. See Uplink: what Expedite adds.
Acknowledgement status 3. The SATLAB format refused the frame. Check the SATLAB settings and the frame size.
Two commands arrive on board as one, or one as two. portal-link's tc port read two quick writes at once, or one large write in two reads. One small telecommand per write, and wait for its echo, with a timeout, before the next.
A HEX telecommand transmits different bytes from the ones you typed. The station skips anything that is not a hex digit and drops an odd last digit. Check the hex before you send it.
The spacecraft rejects the CSP CRC. CRC-32 used instead of CRC-32C, the wrong byte order, or the header included on one side only. Match the _CRC_HDR and _CRC_L_ENDIAN flags to your flight software.
The spacecraft rejects the HMAC. The key is not installed on the station, or the tag's byte order or coverage differs. Ask Remos about the key; match the _HMAC_HDR and _HMAC_L_ENDIAN flags.
Signal on the spectrum, but no telemetry. The marker, packet type, FEC or scrambler differ from the spacecraft's, or the CSP CRC or HMAC check is on and the frames fail it or carry none. Compare the telemetry settings with your radio's. Turn the CSP checks off to see whether frames arrive.
Telemetry arrives with damaged data. A frame Reed–Solomon could not correct, passed on with data format NONE. Check a CRC of your own, or turn on the CSP CRC.