Stellar ControlMission control · by Stellar Systems v0.1.0

SDKs and Integration

Writing a Transport

A transport frames the units of a driver for a medium, both ways, and may run COP-1.

A transport sits between a driver and a gateway when a link declares one (transport: ccsds-tc@1 in the topology). The driver produces application units defined by the ICD of the target (space packets, CSP packets); the transport frames them for the medium of the gateway (CCSDS TC frames, CAN frames, KISS frames) and turns the frames received back into units. The same driver can then serve a CAN bench in AIT and an RF link in orbit, only the transport of the link changing.

A transport is a component of its own, like a driver: its own process, its registration, its bindings and credentials. See Drivers, Transports and Gateways for the topology side.

LayerRoleExample: ping_obc("HELLO") on a CAN bus
DriverTelecommand ↔ application unit, after the ICDa CSP 1 packet: destination and port fixed per telecommand, source read from the link parameters
TransportUnit ↔ frames of the medium, both waysthe packet cut into CAN frames, reassembled downlink
GatewayFrames ↔ mediumSocketCAN

Without transport, the driver produces the frames of the medium itself, like tcu-can or lab-psu.

Registration and chain#

A transport registers with the kind transport and a transport section:

FieldMeaning
inputWhat it frames: the output of the drivers (csp1, space-packet…)
outputWhat it produces: the uplink link_type of the gateways (can, rf, serial…)
params_schemaOptional JSON Schema of the link parameters it reads, an object schema with properties

The reconciler binds a transport like a driver: the transports registered under the name of the link, in a version satisfying it (csp1-kiss@1 means 1.x); a valid binding is kept, else the least loaded instance is chosen. It binds the chain only when:

  • the output of the driver is the input of the transport;
  • the output of the transport is the uplink link type of the gateway;
  • the parameters of the link, in each environment, satisfy the params_schema of the driver and of the transport, each checked on the keys it declares. A key that neither declares is refused.

What an instance does not declare is not checked. Otherwise the link is not ready, with the reason.

Data flow#

sequenceDiagram
    participant D as Driver
    participant T as Transport
    participant G as Gateway
    D->>T: unit on stellar.tc.wrap.<transport>.<target> (ACK 1 already given)
    T->>G: frame(s) on stellar.tc.uplink.<gateway>.<target>
    G-->>D: SENT / SEND_FAILED on stellar.tc.evt.<target>.<tc_id>
    G->>T: raw frame on stellar.tm.raw.<gateway>.<target>
    T->>D: unit on stellar.tm.unit.<transport>.<target>
  • Up. The driver publishes each unit on the wrap subject of its binding (stream TC_COMMANDS, Nats-Msg-Id <tc_id>-wrap), with Stellar-Correlation, Stellar-Deadline, Stellar-Link and Stellar-Environment. The SDK consumes it with the durable consumer wrap_<transport>_<target>, calls wrap(unit, context) in the link context of its link and environment, and publishes the frames on the gateway's uplink subject, with the same correlation and deadline and Nats-Msg-Id <tc_id>-uplink (<tc_id>-uplink-<i> for several).
  • Down. The SDK reads the raw telemetry of the gateway, calls unwrap(frame, context) in the context of the last telecommand of the link, and publishes each unit a frame completes on the units subject, with the Stellar-Ground-Time and Stellar-Delivery of its last frame. The driver decodes the units instead of the raw telemetry, which stays archived as received.

Errors#

WhereWhat happens
wrap failsthe telecommand is SEND_FAILED with the reason (ACK 1 was given by the driver)
unit received after its deadlineSEND_FAILED, not framed
unwrap failsthe frame is dropped (logged); it stays archived in TM_RAW

Fragments#

A unit framed in several frames: wrap returns them in their order, and each carries Stellar-Fragment: <i>/<n>. The gateway publishes SENT after the last one, SEND_FAILED at the first failure (frame 1 of 3: …), and drops the next frames of that telecommand.

wrap and unwrap may be called for several targets and links. Keep reassembly state per (target, link), taken from the context. State that must survive a restart belongs in JetStream, as the SDK does for COP-1.

A transport in Rust#

Implement stellar_sdk::Transport and call transport_main (or run_transport in your own runtime). The csp1-kiss and csp2-kiss transports (examples/rust/transports/csp-kiss) add the CRC32 of a CSP packet when the link asks for it, then its KISS frame; downlink, they read the KISS stream of the gateway, a frame possibly split across its messages, and check and remove the CRC32:

Rust
impl Transport for CspKiss {
    fn software(&self) -> (String, String) {
        (software(self.version).to_owned(), env!("CARGO_PKG_VERSION").to_owned())
    }

    fn capabilities(&self) -> TransportCapabilities {
        TransportCapabilities {
            input: self.version.name().to_owned(),   // csp1 or csp2
            output: self.output.clone(),             // rf or serial
            params_schema: Some(params_schema()),    // {"crc32": boolean}
        }
    }

    fn wrap(&self, unit: &[u8], context: &LinkContext) -> Result<Vec<Vec<u8>>, String> {
        let mut packet = Packet::decode(unit, self.version)?;
        if crc32(context)? {
            packet.header.flags |= FLAG_CRC32;
        }
        Ok(vec![kiss::encode(&packet.encode(self.version)?)])
    }

    fn unwrap(&self, frame: &[u8], context: &LinkContext) -> Result<Vec<Vec<u8>>, String> {
        // One KISS decoder per target and link: a frame may span several messages.
        let frames = self
            .decoders()
            .entry((context.target.clone(), context.link.clone()))
            .or_default()
            .push(frame);
        let mut units = Vec::new();
        for data in frames {
            let mut packet = Packet::decode(&data, self.version)?;
            packet.header.flags &= !FLAG_CRC32;   // the driver sees the packet as sent
            units.push(packet.encode(self.version)?);
        }
        Ok(units)
    }
}

fn main() -> anyhow::Result<()> {
    stellar_sdk::transport_main(CspKiss::new(Version::V1, "rf"), "csp1-kiss-1")
}
MethodRequiredRole
software()yesName and version, for the registration
capabilities()yesinput, output, params_schema
wrap(unit, context)yesThe frames of a unit, in order: one, or several (fragments)
unwrap(frame, context)yesThe units a frame completes: none while a unit is incomplete
cop1(context)no, NoneCOP-1 settings of a link: the SDK then runs a FOP for it
clcw(frame, context)no, NoneThe CLCW carried by a frame received, for the FOPs

The SDK counts units framed (stellar_transport_units_total, by target and outcome: framed, failed, expired) and frames received (stellar_transport_frames_total: unwrapped, failed) on its Prometheus endpoint.

A transport in Python#

Transport takes the same declarations as decorators. Both functions may be plain or async; wrap returns one frame or an iterable of frames, unwrap an iterable of units or None:

Python
from stellar_mcs import Transport
from stellar_mcs.can import CanFrame
from stellar_mcs.cfp import Reassembler, fragment
from stellar_mcs.csp import Version

transport = Transport(
    "csp1-can", "1.0.0",
    input="csp1",
    output="can",
    params_schema={"type": "object", "properties": {"via": {"type": "integer"}}},
)
reassembly = {}   # per (target, link)


@transport.wrap
def wrap(unit, context):
    frames = fragment(unit, Version.V1, via=context.param("via"))
    return [frame.encode() for frame in frames]


@transport.unwrap
def unwrap(frame, context):
    key = (context.target, context.link)
    packet = reassembly.setdefault(key, Reassembler(Version.V1)).push(CanFrame.decode(frame))
    return [packet] if packet else []   # the units it completes


transport.run()

The whole transport, CRC32 and counters included, is examples/python/csp_can.py (uv run csp_can.py --version 2 for csp2-can).

An exception raised by wrap fails the telecommand (SEND_FAILED) with its message as the reason; one raised by unwrap drops the frame. COP-1 is not available in the Python SDK.

The Python SDK has the helpers of CSP and CAN, the same as the Rust SDK's:

ModuleContent
stellar_mcs.cspCSP 1 and CSP 2: Version, Header, Packet (CRC32 added and checked by its flag), crc32c, FLAG_CRC32, FLAG_RDP
stellar_mcs.canCanFrame on NATS, standard or extended identifier
stellar_mcs.cfpThe CAN Fragmentation Protocol of libcsp: fragment, Reassembler

Protocol helpers of the Rust SDK#

ModuleContent
stellar_sdk::ccsdsSpace packets (SpacePacket, packets, idle_packet), TC Transfer Frames (tc_frame, read_tc_frame, TcChannel, CRC-16 crc16), TM frames (TmFrame, TmLayout), the CLCW (Clcw)
stellar_sdk::cspCSP 1 (32-bit header) and CSP 2 (48-bit header): Header, Packet, Version, crc32c, FLAG_CRC32, FLAG_RDP
stellar_sdk::kissKISS framing: encode, and a Decoder of a stream received in pieces
stellar_sdk::canClassic CAN frames on NATS: CanFrame, a standard identifier (11 bits, 2 octets) or an extended one (29 bits, 4 octets, high bit set), big-endian, then 0 to 8 data octets
stellar_sdk::cfpThe CAN Fragmentation Protocol of libcsp, CFP 1 and CFP 2: fragment (a packet into CAN frames), Reassembler (frames back into packets, per sender and packet, with a timeout)
stellar_sdk::cop1The FOP-1 (Fop, Cop1Config, FopState, Directive) and a FARM-1 (Farm) for boards and simulators

The example transports#

ccsds-tc#

Space packets in CCSDS TC frames (CCSDS 232.0) under COP-1 uplink; packets pulled out of TM frames (CCSDS 132.0) downlink, reassembled across frames with the first header pointer, the CLCW of each frame handed to the FOPs. Its settings are the link parameters:

ParameterMeaningDefault
scidSpacecraft identifier of the TC and TM frames (required)—
vcidVirtual channel of the telecommands0
windowSliding window K of the FOP, below W / 2 of the FARM10
t1Timer T1 of the FOP, in seconds5
transmission_limitTransmissions of a frame, the first one included3
tm_lengthLength of the TM frames, in octets1115
tm_ocfTM frames with an Operational Control Fieldtrue
tm_fecfTM frames with a Frame Error Control Fieldtrue
YAML
nominal:
  driver: platform-v3-ccsds@4
  transport: ccsds-tc@1
  gateway: bench-rf-1
  default: true
  params: {scid: 0x2C6, tm_length: 256}

csp1-kiss and csp2-kiss#

CSP packets in KISS frames (FEND, data command, escaped octets), on serial lines or radios in raw mode. Uplink, the packet gets its CRC32 (CRC-32C, flagged in the header; over the data in CSP 1, over header and data in CSP 2) when crc32 is true, the default. Downlink, frames are read across the messages of the gateway and their CRC32 checked and removed. --output rf (the default) or --output serial sets the gateway link type it produces. The obc-csp driver goes through csp1-kiss on the RF bench and through csp1-can on the flatsat bus, unchanged.

csp1-can and csp2-can#

CSP packets on a CAN bus, in the CAN Fragmentation Protocol (CFP) of libcsp: frames with extended identifiers (29 bits) to a gateway of can links (bench-can). Uplink, the packet gets its CRC32 as for KISS (crc32, true by default), then is split into frames; downlink, the frames are put back together per link, interleaved packets included, and the CRC32 checked and removed. Rust (examples/rust/transports/csp-can) and Python (examples/python/csp_can.py) do the same, both checked bit for bit against the frames of libcsp 2.1, both ways.

CFP 1 (csp1-can)CFP 2 (csp2-can)
Identifiersource 5 bits, destination 5 (or via), type (BEGIN, MORE) 1, frames remaining 8, packet identifier 10priority 2 bits, destination 14, sender 6 (can_address), packet counter 2, fragment counter 3, BEGIN 1, END 1
First frameCSP header (4 octets), data length (2 octets, big-endian), 2 data octetssource, ports and flags (4 octets), 4 data octets
Next frames8 data octets8 data octets, the last one with END
Largest packet2042 data octets65535 octets

A packet whose frames do not all arrive within reassembly_timeout_ms is dropped, and so is one with a frame lost or out of order. The obc-csp driver goes through them unchanged: its end-to-end test plays the same scenario over csp1-kiss and csp1-can, the model of the computer at the other end of an in-memory CAN bus.

YAML
flatsat:
  driver: obc-csp@1
  transport: csp1-can@1
  gateway: bench-can-1
  params: {csp_address: 10, via: 5}

COP-1 in the SDK#

COP-1 (CCSDS 232.1) runs in the transport of a link. A transport whose links are sequenced by COP-1 returns their settings with Transport::cop1(context), read from the link parameters: virtual channel (TcChannel {scid, vcid}), window K, timer T1 and transmission limit (Cop1Config::new(channel): 10 frames, 5 s, 3 transmissions). The SDK then runs one FOP-1 per link and builds the TC frames. The driver knows nothing of it: it encodes packets.

  • Units. The unit of a telecommand becomes the data field of an AD frame; the FOP numbers it as soon as the window allows. A telecommand still waiting for the window at its deadline fails (SEND_FAILED), never numbered. While the FOP is not initialised or locked out, telecommands are refused (SEND_FAILED, the reason telling to unlock the link). A unit of no telecommand (a CFDP PDU) still goes through wrap (a BD frame for ccsds-tc).
  • Frames. The first transmission of an AD frame carries the correlation of its telecommand (<tc_id>-uplink), without deadline: a numbered frame must leave. The gateway publishes SENT. Retransmissions carry no correlation: the gateway sends them without event.
  • CLCW. Transport::clcw extracts the CLCW of a frame received; the SDK hands it to the FOPs of the target served by the gateway of the frame. A FOP initialises itself from the first clean CLCW (V(S) = N(R)). A lockout, or no acknowledgement after the transmission limit, stops it (alert, stellar_transport_cop1_alerts_total): frames in flight are forgotten, waiting ones fail.
  • Directives. The telecommands of the standard link component (instance = link) are the COP-1 directives: unlock sends a BC Unlock, set_vr (hazardous) a BC Set V(R). The driver of the link publishes ENCODED and hands them to the transport on its wrap subject with the header Stellar-Directive (unlock, set_vr) and the arguments as JSON payload, without calling Driver::encode. A link without transport refuses them (ENCODE_FAILED).
  • State. The state of each FOP (V(S), frames in flight and waiting, counters, last CLCW) is kept in the KV bucket stellar_cop1, key <target>.<link>, after every change and before any frame leaves: a restarted transport goes on with the sequence, and the first retransmission of each restored frame carries its correlation again, in case it never left. A telecommand delivered again after a restart is recognised and not sent twice.
  • Measures. The SDK publishes the measures of the link component on stellar.tm.decoded.<target> whenever they change: fop_state, vs, retransmissions, tc_sequence and, from the last CLCW, lockout, wait, retransmit, nr, farm_b_counter. Metric: stellar_transport_cop1_frames_total by target, link and kind (first, retransmitted).
Rust
fn cop1(&self, context: &LinkContext) -> Option<Cop1Config> {
    match Settings::of(context) {
        Ok(settings) => Some(settings.cop1),
        Err(error) => {
            tracing::error!(target = %context.target, link = %context.link, %error,
                "the link runs no COP-1");
            None
        }
    }
}

fn clcw(&self, frame: &[u8], context: &LinkContext) -> Option<Clcw> {
    let settings = Settings::of(context).ok()?;
    TmFrame::decode(frame, settings.tm).ok()?.ocf.and_then(Clcw::decode)
}

Operators see and act on COP-1 through the link component: see COP-1 and the Link Component.

Running a transport#

Same environment as a driver (STELLAR_NATS_URL, STELLAR_INSTANCE, STELLAR_NATS_CREDENTIALS, STELLAR_NATS_CA, STELLAR_NATS_CERT, STELLAR_NATS_KEY, STELLAR_METRICS_ADDR in Rust): see Writing a Driver. Once bound, its JWT allows what its bindings need: its wrap consumers, the uplink subjects of its gateways, the raw telemetry it reads, the units it publishes, its keys of stellar_cop1 and the measures of the link component.

Stellar Control · v0.1.0

↑↓ to moveEnter to open