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.
When a link needs a transport#
| Layer | Role | Example: ping_obc("HELLO") on a CAN bus |
|---|---|---|
| Driver | Telecommand ↔ application unit, after the ICD | a CSP 1 packet: destination and port fixed per telecommand, source read from the link parameters |
| Transport | Unit ↔ frames of the medium, both ways | the packet cut into CAN frames, reassembled downlink |
| Gateway | Frames ↔ medium | SocketCAN |
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:
| Field | Meaning |
|---|---|
input | What it frames: the output of the drivers (csp1, space-packet…) |
output | What it produces: the uplink link_type of the gateways (can, rf, serial…) |
params_schema | Optional 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
outputof the driver is theinputof the transport; - the
outputof the transport is the uplink link type of the gateway; - the parameters of the link, in each environment, satisfy the
params_schemaof 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
wrapsubject of its binding (streamTC_COMMANDS,Nats-Msg-Id<tc_id>-wrap), withStellar-Correlation,Stellar-Deadline,Stellar-LinkandStellar-Environment. The SDK consumes it with the durable consumerwrap_<transport>_<target>, callswrap(unit, context)in the link context of its link and environment, and publishes the frames on the gateway'suplinksubject, with the same correlation and deadline andNats-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 theunitssubject, with theStellar-Ground-TimeandStellar-Deliveryof its last frame. The driver decodes the units instead of the raw telemetry, which stays archived as received.
Errors#
| Where | What happens |
|---|---|
wrap fails | the telecommand is SEND_FAILED with the reason (ACK 1 was given by the driver) |
| unit received after its deadline | SEND_FAILED, not framed |
unwrap fails | the 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.
State per link#
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:
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")
}| Method | Required | Role |
|---|---|---|
software() | yes | Name and version, for the registration |
capabilities() | yes | input, output, params_schema |
wrap(unit, context) | yes | The frames of a unit, in order: one, or several (fragments) |
unwrap(frame, context) | yes | The units a frame completes: none while a unit is incomplete |
cop1(context) | no, None | COP-1 settings of a link: the SDK then runs a FOP for it |
clcw(frame, context) | no, None | The 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:
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:
| Module | Content |
|---|---|
stellar_mcs.csp | CSP 1 and CSP 2: Version, Header, Packet (CRC32 added and checked by its flag), crc32c, FLAG_CRC32, FLAG_RDP |
stellar_mcs.can | CanFrame on NATS, standard or extended identifier |
stellar_mcs.cfp | The CAN Fragmentation Protocol of libcsp: fragment, Reassembler |
Protocol helpers of the Rust SDK#
| Module | Content |
|---|---|
stellar_sdk::ccsds | Space 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::csp | CSP 1 (32-bit header) and CSP 2 (48-bit header): Header, Packet, Version, crc32c, FLAG_CRC32, FLAG_RDP |
stellar_sdk::kiss | KISS framing: encode, and a Decoder of a stream received in pieces |
stellar_sdk::can | Classic 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::cfp | The 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::cop1 | The 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:
| Parameter | Meaning | Default |
|---|---|---|
scid | Spacecraft identifier of the TC and TM frames (required) | — |
vcid | Virtual channel of the telecommands | 0 |
window | Sliding window K of the FOP, below W / 2 of the FARM | 10 |
t1 | Timer T1 of the FOP, in seconds | 5 |
transmission_limit | Transmissions of a frame, the first one included | 3 |
tm_length | Length of the TM frames, in octets | 1115 |
tm_ocf | TM frames with an Operational Control Field | true |
tm_fecf | TM frames with a Frame Error Control Field | true |
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) | |
|---|---|---|
| Identifier | source 5 bits, destination 5 (or via), type (BEGIN, MORE) 1, frames remaining 8, packet identifier 10 | priority 2 bits, destination 14, sender 6 (can_address), packet counter 2, fragment counter 3, BEGIN 1, END 1 |
| First frame | CSP header (4 octets), data length (2 octets, big-endian), 2 data octets | source, ports and flags (4 octets), 4 data octets |
| Next frames | 8 data octets | 8 data octets, the last one with END |
| Largest packet | 2042 data octets | 65535 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.
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 throughwrap(a BD frame forccsds-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 publishesSENT. Retransmissions carry no correlation: the gateway sends them without event. - CLCW.
Transport::clcwextracts 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
linkcomponent (instance = link) are the COP-1 directives:unlocksends a BC Unlock,set_vr(hazardous) a BC Set V(R). The driver of the link publishesENCODEDand hands them to the transport on itswrapsubject with the headerStellar-Directive(unlock,set_vr) and the arguments as JSON payload, without callingDriver::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
linkcomponent onstellar.tm.decoded.<target>whenever they change:fop_state,vs,retransmissions,tc_sequenceand, from the last CLCW,lockout,wait,retransmit,nr,farm_b_counter. Metric:stellar_transport_cop1_frames_totalbytarget,linkandkind(first,retransmitted).
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.