A driver is the only component that knows the bytes of a target. It turns the semantic telecommands of the MCS (a name, an instance, typed arguments in the unit of the catalogue) into the application units of the target's ICD, and the telemetry the target sends back into raw values of the catalogue. The MCS applies the calibration, computes derived measures and checks verifications: the driver does not.
A driver is a black box behind the NATS contract. This page shows how to write one with the Rust or the Python SDK; the NATS contract reference gives the messages for any other language.
What a driver does#
| Duty | When | Rust | Python |
|---|---|---|---|
| Declare the catalogue and coverage | registration | Driver::capabilities | Driver(catalogue=…, telecommands=…, measures=…) |
| Encode a telecommand (ACK 1) | each telecommand | encode, or encode_with (link context) | @driver.encode |
| Decode telemetry into raw values | each frame or unit received | decode, or decode_with | @driver.decode |
| Recognise on-board echoes | each frame, for telecommands encoded in the last 60 s | echo | @driver.echo |
| Extract file chunks | each frame | chunks | @driver.chunks |
| Decode a verified file (LTTM…) | on request of the transfer manager | decode_file | @driver.decode_file |
| Run a CFDP entity | platforms with transfer: {protocol: cfdp} | cfdp, cfdp_frame, cfdp_pdus, cfdp_file_name | not available |
Everything else is the SDK's: registration, key pair and credentials, bindings, heartbeats, consumers, deadlines, events, echo bookkeeping, publication of values and chunks, and the decoding requests.
A first driver#
The lab-psu catalogue has one component, psu, three telecommands and three measures. Its
driver turns telecommands into SCPI-like lines and status lines into samples. The same driver in
both SDKs (examples/rust/drivers/lab-psu, examples/python/driver.py):
use stellar_sdk::contract::{DriverCapabilities, RawSample, SemanticTc};
use stellar_sdk::{Driver, driver_main};
/// Encodes `set_voltage 28 V` as `VOLT 28`, decodes `VOLT 28.1;CURR 0.5;OUTP 1`.
struct LabPsu;
impl Driver for LabPsu {
fn software(&self) -> (String, String) {
("scpi-psu".to_owned(), "1.0.0".to_owned())
}
fn capabilities(&self) -> DriverCapabilities {
let names = |items: &[&str]| items.iter().map(|i| format!("psu.{i}")).collect();
DriverCapabilities {
codec: "scpi-psu".to_owned(),
catalogue: "lab-psu@^1.0".to_owned(),
telecommands: names(&["set_voltage", "output_on", "output_off"]),
measures: names(&["voltage", "current", "output_enabled"]),
output: None,
params_schema: None,
}
}
fn encode(&self, tc: &SemanticTc) -> Result<Vec<u8>, String> {
let line = match tc.telecommand.as_str() {
"set_voltage" => {
let volts = tc.args.get("voltage").and_then(serde_json::Value::as_f64);
format!("VOLT {}", volts.ok_or("missing `voltage`")?)
}
"output_on" => "OUTP 1".to_owned(),
"output_off" => "OUTP 0".to_owned(),
other => return Err(format!("unknown telecommand `{other}`")),
};
Ok(line.into_bytes())
}
fn decode(&self, frame: &[u8]) -> Result<Vec<RawSample>, String> {
let text = std::str::from_utf8(frame).map_err(|e| e.to_string())?;
text.split(';')
.map(|field| {
let (key, value) = field.trim().split_once(' ').ok_or("expected `KEY value`")?;
let number: f64 = value.parse().map_err(|_| format!("bad value `{value}`"))?;
let (measure, value) = match key {
"VOLT" => ("voltage", serde_json::json!(number)),
"CURR" => ("current", serde_json::json!(number)),
"OUTP" => ("output_enabled", serde_json::json!(number != 0.0)),
other => return Err(format!("unknown field `{other}`")),
};
Ok(RawSample {
component: "psu".to_owned(),
instance: None,
measure: measure.to_owned(),
value,
onboard_time: None,
})
})
.collect()
}
}
fn main() -> anyhow::Result<()> {
driver_main(LabPsu, "scpi-psu-1")
}from collections.abc import Iterator
from stellar_mcs import Driver, RawSample, SemanticTc
driver = Driver(
"scpi-psu",
"1.0.0",
# 1.0.0 and the later 1.x versions of the catalogue.
catalogue="lab-psu@^1.0",
telecommands=["psu.set_voltage", "psu.output_on", "psu.output_off"],
measures=["psu.voltage", "psu.current", "psu.output_enabled"],
)
@driver.encode
def encode(tc: SemanticTc) -> str:
"""``set_voltage(voltage=28 V)`` → ``VOLT 28.0``. Raising fails it with the message."""
match tc.telecommand:
case "set_voltage":
return f"VOLT {tc['voltage']}"
case "output_on":
return "OUTP 1"
case "output_off":
return "OUTP 0"
raise ValueError(f"unknown telecommand `{tc.telecommand}`")
@driver.decode
def decode(frame: bytes) -> Iterator[RawSample]:
"""``VOLT 28.0;CURR 0.5;OUTP 1`` → three samples of the ``psu`` component."""
for field in frame.decode().strip().split(";"):
key, value = field.split()
match key:
case "VOLT":
yield RawSample("psu", "voltage", float(value))
case "CURR":
yield RawSample("psu", "current", float(value))
case "OUTP":
yield RawSample("psu", "output_enabled", value == "1")
if __name__ == "__main__":
driver.run()The topology names the driver by its software name and a version requirement:
scpi: {driver: scpi-psu@1, gateway: lab-eth-1, default: true}.
Life of an instance#
sequenceDiagram
participant D as Driver
participant R as Reconciler
participant M as MCS (executor, compute stage)
D->>R: stellar.ctl.register (Registration, public key U…)
R-->>D: accepted (or rejected: final)
R->>D: credentials verb (JWT with its permissions)
R->>D: bindings verb (links served)
loop every heartbeat period
D->>R: stellar.ctl.hb.driver.<instance>
end
M->>D: SemanticTc on stellar.tc.encode.<codec>.<target>
D->>M: ENCODED / ENCODE_FAILED, frame or unit- Start. The driver connects with its bootstrap credentials (
STELLAR_NATS_CREDENTIALS), which only allow registration and heartbeats, and generates an nkey user key pair. The private seed never leaves the process. - Registration on
stellar.ctl.register: kinddriver, instance name, software name and semantic version, heartbeat period, public key, and thedriversection below. Without a reply (reconciler not started, fail-over in progress), the SDK retries with an increasing delay up to 10 s. Arejectedreply is final: the reason is logged and the driver stops (SessionError::Rejectedin Rust,RegistrationRejectedin Python). - Credentials. Once bound to a target, the reconciler issues a user JWT for the registered
public key, with exactly the permissions of the driver, and delivers it with the
credentialsverb. It renews it at half-life (reconciler.jwt_ttl, 10 min by default) while the driver stays bound. The SDK reconnects with it, signing the server nonce with its own key, without losing subscriptions. - Bindings. The reconciler delivers the links the driver serves with the
bindingsverb, whenever they change and after a restart. Each link gives the target, the link name, the configuration revision (config, stamped asStellar-Config), the codec, the subject of the telecommands to encode, the subject to publish on (the gateway'suplink, or the transport'swrap), the subject to decode (the gateway's raw telemetry, or the transport'sunits), and the parameters of the link. - Heartbeats every second on
stellar.ctl.hb.driver.<instance>, with the health (healthy, ordegradedwith a reason). After three missed periods the instance is considered gone and its links unbound. - Control verbs on
stellar.ctl.rpc.driver.<instance>.<verb>:status,credentials,bindingsandreregister(the reconciler asks for it when it receives the heartbeat of an instance it no longer knows) are answered by the SDK; add your own with theRpctrait (Rust) or@driver.rpc("verb")(Python). An error reply is{"error": "…"}.
Registration: catalogue and coverage#
| Field | Meaning |
|---|---|
codec | Token of the encoding; subjects stellar.tc.encode.<codec>.<target> and consumer encode_<codec>_<target> are shared by the links with this codec. Python: the software name by default. |
catalogue | name@requirement: platform-v3@^1.4 (1.4.0 and later 1.x), platform-v3@1.4 (1.4.x), platform-v3@1.4.0 (that one). |
telecommands, measures | Coverage, as component.name. |
output | Optional: what its telecommands become (csp1, space-packet, can…): the input of the transport of the link, or the uplink link type of its gateway. Not checked when absent. |
params_schema | Optional: JSON Schema of the link parameters it reads, an object schema with properties. Not checked when absent. |
The reconciler binds a driver to a link when:
- its software name is the driver name of the link, in a version satisfying the link (
@4means4.x); - the version of the catalogue of the target satisfies its catalogue requirement;
- its coverage includes every telecommand and measure of the components the link carries (the
whole catalogue for the default link), except the generated measures of
filesandstream, which the transfer manager produces; - the chain fits: its
outputis theinputof the transport, or the uplink link type of the gateway without transport; - the resolved parameters of the link, in each environment, satisfy its
params_schema.
Otherwise it refuses with the reason, shown as the readiness of the target:
driver … implements platform-v3@^1.4, not platform-v3@2.0.0,
does not cover 1 of platform-v3@1.5.0, such as tcu.reboot.
A link that carries some components only (components: [tcu]) needs a driver covering those
components: tcu-can covers tcu.* of platform-v3@^1.4 and declares nothing else.
Encoding: the telecommand chain#
The executor publishes a SemanticTc on the encode subject of the binding, in the
TC_COMMANDS stream, with the headers Stellar-Config, Stellar-Correlation (the telecommand
id), Stellar-Deadline and Nats-Msg-Id <tc_id>-encode. The SDK consumes it with the durable
consumer encode_<codec>_<target>, one message at a time, so that the telecommands of a target
keep their order.
SemanticTc field | Content |
|---|---|
id | ULID of the telecommand |
target, link | Target, and link when not the default |
component, instance | Component, and instance of a multi-instance component (the file id for files) |
telecommand | Name in the catalogue |
args | Defaults applied; numbers in the unit of the argument, enum values as strings, booleans, byte strings in hexadecimal |
environment | Environment of the run, or declared with a direct telecommand |
Then the SDK:
- refuses a telecommand received after its deadline (
ENCODE_FAILED); - calls
encode(orencode_with): an error givesENCODE_FAILEDwith its message; - publishes
ENCODED(ACK 1) onstellar.tc.evt.<target>.<tc_id>, then the frame on theuplinksubject (Nats-Msg-Id<tc_id>-uplink), or the unit on the transport'swrapsubject (<tc_id>-wrap), so that ACK 1 always precedes ACK 2.
In Python, encode returns bytes or str; an exception fails the telecommand with its
message. tc["voltage"], tc.get("ramp", 10.0) and tc.bytes_arg("data") read the arguments;
tc.name is component.telecommand.
Decoding telemetry#
The SDK subscribes to the raw telemetry of the gateway of each binding
(stellar.tm.raw.<gateway>.<target>), or to the units of the transport
(stellar.tm.unit.<transport>.<target>). For each frame it calls decode (or decode_with) and
publishes the raw values on stellar.tm.decoded.<target> as a list of RawSample:
| Field | Content |
|---|---|
component, instance, measure | The measure, as in the catalogue |
value | Before calibration: number, boolean, enum value, bytes (hexadecimal) |
onboard_time | Optional: time of the sample on board, already converted into the time scale of the chain |
The message carries Stellar-Ground-Time and Stellar-Delivery of the frame, Stellar-Link,
Stellar-Config (revision of the binding), Stellar-Driver (<software>@<version>, so that an
archive can be decoded again with the same driver) and Stellar-Environment when known. A frame
that cannot be decoded is logged; it stays archived as received in TM_RAW.
Links of a target that go through the same gateway share their driver: it decodes the frames of that gateway once, without duplicated samples.
The link context#
The parameters of a link (params in the topology, overridden key by key by
environments.<env>.params) reach the driver with every telecommand and frame, resolved for an
environment, as a LinkContext: target, link, environment, params, and the current
mode of the target with its parameters in that mode (see
Modes and Parameters).
- The environment of a telecommand is its
environment; a telecommand without one (sent by the MCS itself, such as a transfer) takes that of the last telecommand of its link. - A frame received is decoded in the environment of the last telecommand of its link, or with the base parameters before any.
The obc-csp driver reads the CSP address of the ground from the link:
fn encode(&self, _tc: &SemanticTc) -> Result<Vec<u8>, String> {
Err("the CSP address of the ground comes from the link (`csp_address`)".to_owned())
}
fn encode_with(&self, tc: &SemanticTc, context: &LinkContext) -> Result<Vec<u8>, String> {
let src = u16::try_from(context.param_u64("csp_address")?)
.map_err(|_| "parameter `csp_address` out of range".to_owned())?;
self.packet(tc, src)?.encode(self.version)
}@driver.encode
def encode(tc, context):
return csp(src=context.param("csp_address"), dst=1, dport=4, payload=tc["text"])In Rust, LinkContext::param, param_u64 and param_str read a parameter of the link (the last
two with an error naming the link), and LinkContext::parameter("tcu[TCU1].anode_voltage") a
parameter of the target in its mode, falling back on the value given for every instance
(tcu.anode_voltage); in Python, context.parameter(name, default) does the same. In Python, a decorated encode or decode that takes a second argument
receives the context; context.param(name, default) reads a parameter. Declare the parameters
in params_schema, so that the reconciler refuses a link that misses or mistypes them.
Echoes#
For verify: [echo], the driver compares each frame received with the telecommands it encoded in
the last 60 s. echo(tc, frame) returns Some(true) (Python True) for a conforming echo of
tc, Some(false) for a non-conforming one, None when the frame is not its echo. The SDK
publishes an ECHO event with conforming; a non-conforming echo gives VERIFY_FAILED.
The platform-v3 driver recognises the PUS verification reports of the packets it sent:
fn echo(&self, tc: &SemanticTc, unit: &[u8]) -> Option<bool> {
let (_, identification, control) = *lock(&self.sent)
.iter()
.rev()
.find(|(id, ..)| *id == tc.id)?;
tm::decode(unit)
.ok()?
.iter()
.find_map(|report| match report {
Report::Verification { kind, identification: i, control: c }
if *i == identification && *c == control => match kind {
Verification::CompletionSuccess => Some(true),
Verification::AcceptanceFailure | Verification::CompletionFailure => Some(false),
Verification::AcceptanceSuccess => None,
},
_ => None,
})
}Files#
When the catalogue declares a files component (see
Files and Streams):
- Directory. The listing telecommand (
transfer.list) brings the on-board directory down. Decode it intoRawSamples of thefilescomponent, one instance per file named by its decimal identifier:file_type,size,checksum(hexadecimal) andgeneration. - Chunks. The read telecommand (
transfer.read) brings chunks down.chunks(frame)returns theFileChunks a frame carries (file_id,generation,offset,data); the SDK publishes each raw onstellar.file.chunk.<target>.<file_id>withStellar-File-Generation,Stellar-File-Offset,Stellar-LinkandStellar-Config. - Uploads. The write telecommand (
transfer.write) is encoded like any telecommand, its data in hexadecimal. A chunk counts as written once its telecommand isVERIFIED(declareverify: [echo]and recognise the on-board acknowledgement inecho) orCOMPLETE. - Recorded telemetry. Once a file is verified, the transfer manager asks the drivers of its
target to decode it (
stellar.file.decode.<target>, queue group). The SDK reads the file from thestellar_filesobject store and callsdecode_file(file_type, content). ReturnNonefor a type the driver does not decode (the transfer staysVERIFIED); the samples, each with itsonboard_time, are published withStellar-Delivery: deferred, one message per on-board time, and the transfer becomesPROCESSED.
fn decode_file(&self, file_type: &str, content: &[u8]) -> Option<Result<Vec<RawSample>, String>> {
(file_type == "lttm").then(|| tm::decode_lttm(content))
}@driver.decode_file
def decode_file(file_type, content):
if file_type != "lttm":
return None
return list(lttm_samples(content)) # your decoder: RawSample with onboard_timeCFDP#
A platform declaring transfer: {protocol: cfdp} transfers its files with CFDP class 2
(CCSDS 727.0). The ground entity runs in the driver, on the fork of cfdp-rs in
vendor/cfdp-rs; only the Rust SDK provides it.
| Method | Default | Role |
|---|---|---|
cfdp(target) | None | Some(CfdpConfig) runs an entity for the target: entity ids, id length, largest PDU, ACK and NAK timers and limits, inactivity, burst. CfdpConfig::new(local_id, remote_id) gives sensible defaults. |
cfdp_frame(target, pdu) | the PDU | Frames a PDU for the gateway; it goes on the uplink subject without Stellar-Correlation, so the gateway sends it without event. |
cfdp_pdus(frame) | none | Extracts the PDUs of a telemetry frame; such a frame carries no values. |
cfdp_file_name(target, file_id, file_type) | the file id | Name of the file on board (at most 255 bytes). |
The transfer manager drives the entity through the cfdp verb
(stellar.ctl.rpc.driver.<instance>.cfdp) with a CfdpRequest
({target, file_id, generation, direction, file_type, size, source}), every 5 s while the
transfer is active and the link available. Downloads use a Proxy Put Request; the data received is
published as chunks, exactly like the chunked protocol. Uploads read the content from
stellar_uploads. Transactions live in memory: after a restart, the next request starts them
again. See File Transfers.
Testing: vectors from the ICD#
A driver is validated against its ICD, not against itself. The example drivers keep test vectors: telecommand → bytes pairs and frame → values pairs, generated from the ICD by an independent reference implementation and replayed in CI.
examples/rust/drivers/platform-v3/vectors/holdstelecommands.json,telemetry.jsonandfiles.json, written bygenerate.py, a dependency-free Python script that implements the ICD (examples/icd/platform-v3.md) on its own. The CI checks that they are up to date, thentests/vectors.rsreplays them bit for bit through the driver and theccsds-tctransport: telecommands through the FOP from the V(S) of the vector, telemetry frames into packets, values, verification reports, chunks and CLCWs, files into values.examples/rust/drivers/tcu-can/vectors/does the same for the CAN bench bus.tests/catalogue.rsofplatform-v3andobc-cspchecks that the coverage of the driver matches the catalogue ofexamples/config.
The COP-1 state (V(S), CLCW) is part of a vector, so that frames are reproducible bit for bit.
Checking a driver on a running MCS#
Every driver answers the control verbs encode and decode, provided by both SDKs: the MCS asks
it what it makes of a telecommand or a frame, in the context of a link, and nothing is sent or
published. The API and the CLI call them, on the driver bound to the link or on one named with
driver (a driver being developed, registered but not bound):
# The frame of a telecommand, resolved as a direct telecommand (units, ranges, defaults).
stellar encode psu-lab-2 set_voltage 'voltage=28 V' --environment AIT
# psu.set_voltage to psu-lab-2 via scpi (driver scpi-psu-1, environment AIT)
# arguments: voltage=28.0
# frame, 9 B:
# 0000 56 4f 4c 54 20 32 38 2e 30 VOLT 28.0
# text: "VOLT 28.0"
# The samples of a frame, calibrated by the catalogue.
stellar decode psu-lab-2 --text 'VOLT 27.9;CURR 0.5;OUTP 1' --driver scpi-psu-dev
stellar decode sat1-fm 1801c00000050a02... --link nominal
stellar decode sat1-fm --file frame.binThe context is the one the link has in the current configuration: its parameters in the
environment, the mode of the target and its parameters. On a link with a transport, encode
gives the unit the driver hands to the transport, and decode takes the unit the transport
hands to the driver. A driver keeping a state, such as a packet sequence counter, may advance it
when asked to encode. The routes are POST /v1/encode and POST /v1/decode (see the
API reference); driver::unbound when no driver is bound to the link and none is
named, driver::no-answer when the driver does not answer, driver::encode-failed and
driver::decode-failed with the reason of the driver.
Generate a typed skeleton#
For a Python driver, stellar generate driver writes the skeleton from the catalogue:
stellar generate driver lab-psu --repository examples/config -o lab_psu --software scpi-psu_generated.py holds what the catalogue says (coverage and requirement, enums, a typed class of
arguments per telecommand, builders of raw samples per component, and a base class dispatching
each telecommand to its encode_<component>_<telecommand> method); it is generated again with
each version of the catalogue, and --check tells in CI when it is stale. driver.py and
test_driver.py are yours, written once: the encoding and decoding from the ICD, and a test per
telecommand. See Code Generators.
Running a driver#
driver_main(driver, default_instance) (Rust) and driver.run() (Python) read their settings
from the environment and stop cleanly on Ctrl-C or SIGTERM:
| Variable | Default | Meaning |
|---|---|---|
STELLAR_NATS_URL | nats://localhost:4222 | NATS server (nats://, tls://…) |
STELLAR_INSTANCE | the default instance (<software>-1 in Python) | Instance name, unique per kind |
STELLAR_NATS_CREDENTIALS | none | Bootstrap credentials file, limited to registration and heartbeats |
STELLAR_NATS_CA | none | CA certificates (PEM) the server is checked against: TLS only |
STELLAR_NATS_CERT, STELLAR_NATS_KEY | none | Client certificate and key, for mTLS |
STELLAR_METRICS_ADDR | none | Rust only: Prometheus endpoint (127.0.0.1:9101) |
STELLAR_LOG | INFO | Python only: log level |
STELLAR_NATS_URL=tls://nats.lab:4222 \
STELLAR_NATS_CREDENTIALS=/etc/stellar/bootstrap.creds \
STELLAR_NATS_CA=/etc/stellar/ca.pem \
STELLAR_INSTANCE=platform-v3-ccsds-1 \
platform-v3-ccsdsRun several instances under different names for availability: a new link goes to the eligible
instance that serves the fewest, and an instance that leaves hands its links over. To embed a
driver in a larger program, use run_driver(driver, &options, shutdown) (Rust) or
async with driver.running(options): (Python). See Rust SDK and
Python SDK.