Skip to content
This library binds the workload runtime contract, which SpaceOS does not publish a versioned ABI for yet. The signatures on this page are what the library offers today, not a surface to build a released product against.

OCaml library

This library is the OCaml binding for the workload runtime contract. The SpaceOS SDK page covers payloads written in other languages. That page comes with the sample apps.

A payload app links space-sdk into its binary. The app uses the library to exchange frames with the spacecraft. It subscribes to telemetry, handles commands and emits events. It reads parameters, asks the camera for a picture and sends the result to the downlink. One 256-byte frame bus carries all of this traffic. You run the same binary on your laptop against space-sim and on the flight computer. The only difference is the Transport that you pass to connect.

The bus carries four kinds of traffic. Telemetry is spacecraft state that your app observes. A command is a directive that the flight side issues to your app. An event is an occurrence that your app reports to the ground. A parameter is configuration that survives a restart. The library also has two device services: the camera and the downlink. Service addresses any other platform service by APID. Shared is the supervision page that the host supervisor reads.

The flight side controls the spacecraft. Your app observes telemetry and handles the commands that the flight side issues. It requests device services. It emits events and data products. It never commands the flight side.

Getting the library

space-sdk ships with the Platform’s source. No separate release of the library exists. Inside that source tree, add space-sdk to the libraries field of your dune file. You cannot install the library outside that source tree.

Connecting to the bus

Space_sdk.connect attaches your app to the bus. It takes two arguments: the APID of your app, and a transport. It returns a connection. Every other call takes that connection as its first argument.

Space_sdk.run then processes inbound frames:

  • telemetry goes to your subscribers;
  • commands go to your handler;
  • ERROR frames go to your error handler.

run blocks until the bus closes. Param.get, Device.Camera.capture, Service.command and Service.request read the same transport as run. Use one or the other. Do not use both at the same time.

An APID is a CCSDS application process id. It is eleven bits wide, so the values run from 0x000 to 0x7FF. Your partition gets a range of APIDs. Your app sends from one APID in that range. Three APIDs are fixed:

EndpointAPID
Space_sdk.camera_apid0x00A
Space_sdk.param_apid0x00B
Space_sdk.downlink_apid0x100

You write two functions, and Transport.v builds a transport from them. The examples below pass two queues. You can then run each example in a toplevel without a spacecraft. A recv that returns None means end of stream. An empty queue therefore closes the bus, and run returns.

# module Sdk = Space_sdk;;
module Sdk = Space_sdk
# module Msg = Space_wire.Msg;;
module Msg = Space_wire.Msg
# let payload_apid = 0x010;;
val payload_apid : int = 16
# let bus inbound =
let inbox = Queue.create () and outbox = Queue.create () in
List.iter (fun frame -> Queue.push frame inbox) inbound;
let transport =
Sdk.Transport.v
~send:(fun frame -> Queue.push frame outbox)
~recv:(fun () -> Queue.take_opt inbox)
in
(transport, outbox);;
val bus : Msg.t list -> Sdk.Transport.t * Msg.t Queue.t = <fun>
# let from_flight kind ~apid payload =
{ (Msg.v kind ~apid payload) with Msg.reserved = payload_apid };;
val from_flight : Msg.kind -> apid:int -> string -> Msg.t = <fun>

The reserved field of a frame holds the destination APID. from_flight sets that field for this reason. The library sets the same field when your app sends a frame.

Telemetry

Telemetry arrives as TM or HEALTH frames. The source APID identifies the sender. F-Prime calls these telemetry channels. CCSDS-MO calls them parameters.

Telemetry.subscribe registers a callback for one source APID. Every telemetry frame from that APID reaches the callback while run is active. You can register several callbacks for one APID. Telemetry.latest returns the most recent sample without a callback. Use it when your app needs the current value only at the moment it makes a decision.

A sample holds its source APID and the payload bytes. The flight side chooses the encoding. space-sim sends text.

# let orbit_apid = 0x101;;
val orbit_apid : int = 257
# let transport, _ =
bus [ from_flight Msg.TM ~apid:orbit_apid "orbit:lat=48.86,lon=2.35,alt=705.1" ]
in
let sdk = Sdk.connect ~self:payload_apid transport in
Sdk.Telemetry.subscribe sdk ~apid:orbit_apid (fun sample ->
print_endline sample.Sdk.Telemetry.payload);
Sdk.run sdk;
Sdk.Telemetry.latest sdk ~apid:orbit_apid;;
orbit:lat=48.86,lon=2.35,alt=705.1
- : Sdk.Telemetry.sample option =
Some
{Space_sdk.Telemetry.apid = 257;
payload = "orbit:lat=48.86,lon=2.35,alt=705.1"}

Commands

A command arrives as a TC frame. F-Prime calls these commands. CCSDS-MO calls them actions.

Command.handle registers the handler that answers them. There is one handler, and a second call replaces the first. Your handler receives an opcode and its argument bytes. Return Ok () to acknowledge the command. Return Error reason to refuse it. run sends the acknowledgement to the APID that sent the command. Ok () sends CMD_ACK:1. Error reason sends CMD_NACK:1:reason.

# let ground_apid = 0x030;;
val ground_apid : int = 48
# let transport, outbox =
bus [ from_flight Msg.TC ~apid:ground_apid "\001scene-4" ]
in
let sdk = Sdk.connect ~self:payload_apid transport in
Sdk.Command.handle sdk (fun command ->
if command.Sdk.Command.opcode = 1 then Ok ()
else Error "unknown opcode");
Sdk.run sdk;
Msg.payload_bytes (Queue.pop outbox);;
- : string = "CMD_ACK:1"

The first payload byte is the opcode. The rest is the argument. So "\001scene-4" is opcode 1 with the argument scene-4.

Events

Event.emit sends an EVR frame to the ground. F-Prime calls these EVRs. CCSDS-MO calls them alerts. The call sends the frame and returns. The flight side sends no acknowledgement.

The five severities are Debug, Info, Warning, Error and Fatal. The name of the severity prefixes the message on the wire.

# let transport, outbox = bus [] in
let sdk = Sdk.connect ~self:payload_apid transport in
Sdk.Event.emit sdk ~severity:Sdk.Event.Info "captured scene-4";
Msg.payload_bytes (Queue.pop outbox);;
- : string = "INFO:captured scene-4"

Parameters

A parameter is configuration in the on-board parameter store. The value survives a restart of your app. F-Prime and CCSDS-MO both call these parameters. The library sends them as PRM_GET, PRM_SET and PRM_RSP frames.

Param.get asks for one by integer id and waits for the answer. Ok None means that the parameter is unset. Param.set writes one and returns immediately.

# let transport, outbox =
bus [ from_flight Msg.PRM_RSP ~apid:Sdk.param_apid "0.62" ]
in
let sdk = Sdk.connect ~self:payload_apid transport in
let threshold = Sdk.Param.get sdk ~id:7 in
(threshold, Msg.payload_bytes (Queue.pop outbox));;
- : (string option, string) result * string = (Ok (Some "0.62"), "7")

Param.get takes an optional ?timeout in seconds. It limits how long the call waits for the next inbound frame. A transport built with Transport.with_recv_timeout enforces the limit. Any other transport ignores it, and the call then waits until the bus answers.

Only the parameter service can answer. A PRM_RSP or an ERROR from any other APID answers a different request. The library dispatches that frame and continues to wait.

Devices

The flight side owns the hardware. Your app asks it for a service and waits for the answer.

The camera

Device.Camera.capture asks for an image of the ground below the spacecraft. It then reassembles the answer from several frames.

FieldWhat it holds
width, heightThe image dimensions are in pixels.
geoThe sub-satellite point at capture time is in degrees.
bitsThe sample width is 8, or 16 for an instrument raster.
acquisitionThis is the acquisition that the pixels come from. It is empty when the camera declares none.
pixelsThese are the raw bytes. They are big-endian when bits is 16.
# let transport, _ =
bus
[ from_flight Msg.DP ~apid:Sdk.camera_apid
"w=2,h=2,lat=48.86,lon=2.35,len=4,bits=8,acq=LC08_L1TP_026033";
from_flight Msg.DP ~apid:Sdk.camera_apid "\x10\x20\x30\x40" ]
in
let sdk = Sdk.connect ~self:payload_apid transport in
match Sdk.Device.Camera.capture sdk with
| Ok image ->
Ok
( image.Sdk.Device.Camera.width,
image.Sdk.Device.Camera.height,
image.Sdk.Device.Camera.bits,
image.Sdk.Device.Camera.acquisition )
| Error reason -> Error reason;;
- : (int * int * int * string, string) result =
Ok (2, 2, 8, "LC08_L1TP_026033")

The camera answers with a text header and then the pixel bytes. The header declares the shape. The library checks the header before it allocates memory or waits for pixels:

  • the dimensions must be positive;
  • the sample width must be 8 or 16;
  • the declared length must equal width * height * bits / 8 exactly.

The length must also stay within Device.Camera.max_capture_bytes. That limit is 16 MiB. At 8 bits this is a 4096 by 4096 image. At 16 bits it is 2896 by 2896. The library rejects a header that declares more. It rejects the header before your app allocates memory for it.

Device.Camera.raster turns the image into a validated single-band Space_sdk.Raster.t. Raster also maps pixels to geodetic coordinates and finds connected components in an evidence mask. The conversion fails if the sample width is not 8 or 16. It also fails if the pixel bytes do not match the declared shape.

capture takes the same optional ?timeout as Param.get. The transport must again come from Transport.with_recv_timeout.

Device.Comms.downlink sends a data product to the bus. It splits the data into DP frames of 248 payload bytes.

# let transport, outbox = bus [] in
let sdk = Sdk.connect ~self:payload_apid transport in
let sent = Sdk.Device.Comms.downlink sdk "fire-mask:118 pixels over threshold" in
(sent, Msg.payload_bytes (Queue.pop outbox));;
- : (unit, string) result * string =
(Ok (), "fire-mask:118 pixels over threshold")

The call returns once the frames are on the bus. It does not wait for the flight side to accept them. The call itself cannot fail. The flight side sends an ERROR frame later if it refuses the data, and your error handler receives that frame.

Any service, by APID

Your platform may expose services beyond the camera, the parameter store and the downlink. Service addresses one of those services by its APID. It uses the same two conventions as the built-in services.

Service.command sends a telecommand and waits for the acknowledgement. Service.request sends a payload and waits for data. The service answers with a DP frame. The call returns the payload of that frame. Use Service.request when the service answers with exactly one frame.

# let magnetometer_apid = 0x055;;
val magnetometer_apid : int = 85
# let transport, outbox =
bus [ from_flight Msg.DP ~apid:magnetometer_apid "b=48.2,52.1,-14.7" ]
in
let sdk = Sdk.connect ~self:payload_apid transport in
let reading = Sdk.Service.request sdk ~apid:magnetometer_apid "read" in
(reading, Msg.payload_bytes (Queue.pop outbox));;
- : (string, string) result * string = (Ok "b=48.2,52.1,-14.7", "read")

Service.command returns Ok () on a CMD_ACK. On a CMD_NACK it returns an error that contains the reason.

Errors from the flight side

on_error registers the handler for inbound ERROR frames. There is one handler, and a second call replaces the first. run calls the handler. A request and response call also calls it.

An ERROR from the service you addressed also makes the pending call return an error. capture then returns that error, and it does not wait for pixels that will never arrive. An ERROR from any other service reaches your handler. The pending call continues to wait for its own answer.

The library drops an ERROR frame if it cannot decode the payload.

# let error_from_flight ~apid code =
let payload =
Bytes.create (Wire.Codec.wire_size Space_wire.Error_payload.codec)
in
Wire.Codec.encode Space_wire.Error_payload.codec
(Space_wire.Error_payload.v code
~offending_type:(Msg.int_of_kind Msg.DP) ~offending_apid:apid
~offending_pay_len:0)
payload 0;
from_flight Msg.ERROR ~apid (Bytes.to_string payload);;
val error_from_flight :
apid:int -> Space_wire.Error_payload.error_code -> Msg.t = <fun>
# let transport, _ =
bus
[ error_from_flight ~apid:Sdk.downlink_apid
Space_wire.Error_payload.Host_busy ]
in
let sdk = Sdk.connect ~self:payload_apid transport in
Sdk.on_error sdk (fun error ->
print_endline
(Format.asprintf "%a" Space_wire.Error_payload.pp error));
Sdk.run sdk;;
error(code=0x05 type=0x06 apid=256 pay_len=0)
- : unit = ()

The supervision page

The supervision page is 4096 bytes of memory that your app shares with the host supervisor. It is separate from the frame bus. Your app publishes a heartbeat counter and a health string. The host publishes mission time and a command word.

Call Shared.heartbeat from a timer. The host watches that counter. It marks a guest as unhealthy when the counter stops. Shared.set_health publishes a string of up to 256 bytes. It truncates anything longer.

Shared.poll returns the host directives that you have not acknowledged yet:

DirectiveWhat the host is asking for
ShutdownThe host is reclaiming the partition. Stop cleanly.
Param_reloadThe parameters changed. Read them again.
Dp_ackThe host took your latest data product.

Shared.ack then marks the command word as read.

# let supervision =
Sdk.Shared.v (Bytes.make Space_wire.Shared_mem.page_size '\000');;
val supervision : Sdk.Shared.t = <abstr>
# Sdk.Shared.heartbeat supervision;
Sdk.Shared.set_health supervision "captures=3 downlink=idle";
Sdk.Shared.poll supervision;;
- : Sdk.Shared.command list = []

In flight your app maps the real shared page. A test passes a plain buffer, like the example above. Shared.v raises Invalid_argument on anything shorter than 4096 bytes.

Transports

A transport moves 256-byte frames in both directions. The library has two of them.

A deployed payload uses Transport.unix_socket. Call Space_sdk.Transport.unix_socket ~sw ~clock ~net path inside an Eio fiber. Pass the result to connect. space-net gives each tenant its own Unix socket and mounts that socket into the container. space-net puts the path of the socket in the SPACE_NET_SOCKET environment variable. Read that variable. The path is per tenant and is not fixed. This transport has a bounded receive, so it enforces every ?timeout on this page.

Transport.v takes a send function and a recv function. It has no bounded receive. Add one with Transport.with_recv_timeout. It takes a function that waits at most the given number of seconds. That function returns Frame, Eof or Timeout.

The workload runtime contract describes the socket, the environment variable, the 256-byte frame layout and the message kinds. A payload written in C, C++, Python or Rust implements that contract directly.

Limits

space-sim provides telemetry, events, the camera and the downlink:

  • it sends orbit and power telemetry once a second;
  • it sends an event when a ground station becomes visible;
  • it returns the acquisition beneath the ground track when you ask for a capture;
  • it accepts a downlink only while a station is above the horizon.

It does not answer PRM_GET or PRM_SET. It does not serve any custom APID. It never sends your app a command. Param.get and Service.request wait forever against space-sim, because no answer arrives. A ?timeout is the only way to end the wait. Test those two calls against your own flight-side peer.

space-sdk exists in OCaml only. A payload written in C, C++, Python or Rust implements the workload runtime contract directly. No library exists for those languages.

Device.Comms.downlink has the type (unit, string) result and returns Ok () in every case. Treat the Error case as unreachable. Handle the flight side’s ERROR frame in your error handler.

Shared.ack acknowledges the whole command word of the host. ack ignores the value that poll returned. The host can set a directive between your poll and your ack. Your ack then marks that directive as read, and poll never returns it. The lost directive can be Shutdown. The time between the two calls is short. The host cannot see this loss. Your app cannot see it either.

Shared.mission_time reads again until the seqlock returns a consistent value. The retry has no bound. The read never returns if a writer leaves the version odd.