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.eio to the libraries field of your dune file. You cannot install the library outside that source tree.

The library has two halves. Space_sdk is the protocol core: it reads no clock and no socket. Space_sdk_eio carries that core over Eio and is the module your app calls. Every example on this page uses Space_sdk_eio.

Connecting to the bus

Space_sdk_eio.connect attaches your app to the bus. It takes the APID of your app and a transport, and returns a connection. Every other call takes that connection as its first argument. An optional ?authority is what your app seals a request to a flight service with; the camera section says why a request needs one.

Space_sdk_eio.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, Device.Comms.downlink, 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_eio.camera_apid0x00A
Space_sdk_eio.param_apid0x00B
Space_sdk_eio.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_eio;;
module Sdk = Space_sdk_eio
# 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 =
Result.get_ok (Msg.v ~dest_apid:payload_apid kind ~apid payload);;
val from_flight : Msg.kind -> apid:int -> string -> Msg.t = <fun>

Msg.v refuses a frame it cannot build, such as a payload longer than the frame holds, so it returns a result. from_flight builds frames that fit and takes the frame out of it. The dest_apid of a frame is the APID it is routed to. The library sets it when your app sends a frame.

Telemetry

Telemetry arrives as TM or HEALTH frames. The source APID identifies the sender. Two other frameworks name the same things differently. F-Prime, NASA JPL's flight software framework, calls these telemetry channels. The CCSDS Mission Operations (MO) services, the CCSDS service framework for monitoring and controlling a spacecraft, call 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 writes housekeeping as a channel name, a colon, then comma-separated key=value pairs. Telemetry.channel and Telemetry.fields read that convention, and return None for a payload that does not follow it.

# 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
{Sdk.Telemetry.apid = 257; payload = "orbit:lat=48.86,lon=2.35,alt=705.1"}
# Sdk.Telemetry.fields "orbit:lat=48.86,lon=2.35,alt=705.1";;
- : (string * string) list option =
Some [("lat", "48.86"); ("lon", "2.35"); ("alt", "705.1")]

Commands

A command arrives as a PUS telecommand, the mission's application command TC[128,1], in a TC transfer. 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 complete the command. Return Error reason to fail it. run answers the commander with the request verification reports of ECSS-E-ST-70-41C service 1: TM[1,1] when it accepts the command and TM[1,3] when it starts it, if the command asks for them, then TM[1,7] on Ok () if the command asks for it, or TM[1,8] carrying reason on Error reason.

The first helper below builds a command as the ground sends one. The second names the reports your app sent back.

# let ground_apid = 0x030;;
val ground_apid : int = 48
# let command_from_ground ~opcode argument =
let service =
Space_wire.Command.service (Command { opcode; argument })
in
let _, packet =
Result.get_ok
(Space_wire.Reply.telecommand ~apid:payload_apid ~source:ground_apid
~ack:Pus.ack_all ~sequence_count:0 service)
in
Result.get_ok (Space_wire.Segment.frames Msg.TC ~apid:ground_apid packet)
|> List.map (Msg.readdress ~dest_apid:payload_apid);;
val command_from_ground : opcode:int -> string -> Msg.t list = <fun>
# let reports outbox =
Queue.to_seq outbox |> List.of_seq
|> List.filter_map (fun frame ->
Space_wire.Verification_report.decode (Msg.payload frame))
|> List.map (function
| Pus.Acceptance_success _ -> "TM[1,1] accepted"
| Pus.Start_success _ -> "TM[1,3] started"
| Pus.Completion_success _ -> "TM[1,7] completed"
| Pus.Completion_failure { failure; _ } -> "TM[1,8] failed: " ^ failure.data
| _ -> "another report");;
val reports : Msg.t Queue.t -> string list = <fun>
# let transport, outbox = bus (command_from_ground ~opcode:1 "scene-4") in
let sdk = Sdk.connect ~self:payload_apid transport in
Sdk.Command.handle sdk (fun command ->
if command.Sdk.Command.opcode = 1 then (
print_endline ("capture " ^ command.Sdk.Command.arg);
Ok ())
else Error "unknown opcode");
Sdk.run sdk;
reports outbox;;
capture scene-4
- : string list =
["TM[1,1] accepted"; "TM[1,3] started"; "TM[1,7] completed"]

A command with an opcode the handler refuses gets TM[1,8] in place of TM[1,7]:

# let transport, outbox = bus (command_from_ground ~opcode:9 "") 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;
reports outbox;;
- : string list =
["TM[1,1] accepted"; "TM[1,3] started"; "TM[1,8] failed: unknown opcode"]

Events

Event.emit sends an EVR frame, an event record, to the downlink. 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 (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 (Queue.pop outbox));;
- : (string option, Sdk.Fault.t) result * string = (Ok (Some "0.62"), "7")

A call that gets no answer returns a Fault.t: Closed when the bus closes first, Timed_out when its ?timeout passes, and Bus_error when an ERROR frame refuses the request it sent.

Param.get takes an optional ?timeout in seconds. It bounds the whole call, not each frame of it. A transport built with Transport.with_deadline enforces the limit. A transport built with Transport.v alone cannot bound a receive, so a call that passes ?timeout on it fails at once with Unbounded_transport.

Only the parameter service can answer. A PRM_RSP 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.

A request to the camera changes the state of the spacecraft, so the camera admits it only as a telecommand sealed under a key it was provisioned with. Reaching the bus is not authority. Authority.v wraps the function that seals a command, and connect ~authority gives it to your app. The library holds no key. An app connected without an authority sends its request unsealed, and the flight side refuses it with Refusal.Unauthorised.

FieldWhat it holds
width, heightThe image dimensions are in pixels.
geoThe geographic centre of the pixels is in degrees.
bitsThe sample width is 8, or 16 for an instrument raster.
nodataThis is the sample value that means no measurement, when the instrument declares one.
bandThis is what the pixels measure and how a sample turns into a physical quantity.
manifestThis is the description version and the digest of the pixels.
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.

capture returns one of three errors. Refused carries the flight side's refusal, which also reaches the handler that on_refusal registers. Fault carries a Fault.t: the bus closed, the ?timeout passed, or an ERROR frame refused the request. Failed says that an answer arrived and did not describe a capture. Here the bus closes before the camera answers:

# let transport, outbox = bus [] in
let sdk = Sdk.connect ~self:payload_apid transport in
let result = Sdk.Device.Camera.capture sdk in
( Result.map_error (Fmt.str "%a" Sdk.Device.Camera.pp_error) result
|> Result.map (fun image -> image.Sdk.Device.Camera.acquisition),
Msg.dest_apid (Queue.pop outbox) = Sdk.camera_apid );;
- : (string, string) result * bool = (Error "the bus closed", true)

The camera answers with a header, a manifest, a band description 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. The library rejects a header that declares more, before your app allocates memory for it. The manifest carries a digest of the pixels, and capture checks the pixels against it before it returns an image.

?band names the band to capture, as a previous capture's Band.name gives it. An instrument with several bands refuses a capture that names none.

Device.Camera.raster turns the image into a validated single-band Space_sdk_eio.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. Device.Camera.quality tells a measurement from a fill value, a saturated sample or a value outside the band's range.

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

Device.Comms.downlink hands a data product to the downlink as one CFDP transaction (CCSDS 727.0-B-5) over DP frames. The first frame is a Metadata PDU that declares the size of the product, so the flight side can refuse a product that does not fit the contact before the bytes go out. The file data and an EOF PDU follow.

downlink then waits for the flight side's word on the transaction. It returns Ok () when the flight side closes the transaction and reports that it kept the whole product. It returns Refused when the downlink refuses the product, and Failed when the flight side reports that it did not keep all of it. Here the bus closes before the flight side answers:

# 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
( Result.map_error (Fmt.str "%a" Sdk.Device.Comms.pp_error) sent,
Queue.to_seq outbox |> List.of_seq |> List.map Msg.kind
|> List.for_all (Msg.equal_kind Msg.DP) );;
- : (unit, string) result * bool = (Error "the bus closed", true)

Device.Comms.offer sends the product and returns at once. Device.Comms.closure waits for the flight side's word on the transaction that offer returned. Use the two calls when your app reads the bus between them.

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 an application command and waits for the service's completion report. Service.request sends a payload as a command whose opcode is its first byte, and waits for data. The service answers with a DP frame that opens with the identifier of the request it answers. The call returns the rest of that frame. Use Service.request when the service answers with exactly one frame.

The answer below names the first request that your app sends to the magnetometer, which carries packet sequence count 0:

# let magnetometer_apid = 0x055;;
val magnetometer_apid : int = 85
# let first_request_to apid =
let service =
Space_wire.Command.service (Command { opcode = 0; argument = "" })
in
fst
(Result.get_ok
(Space_wire.Reply.telecommand ~apid ~source:payload_apid
~ack:Pus.ack_none ~sequence_count:0 service));;
val first_request_to : int -> Space_wire.Reply.request = <fun>
# let transport, _ =
bus
[ from_flight Msg.DP ~apid:magnetometer_apid
(Result.get_ok
(Space_wire.Reply.v (first_request_to magnetometer_apid)
"b=48.2,52.1,-14.7")) ]
in
let sdk = Sdk.connect ~self:payload_apid transport in
Sdk.Service.request sdk ~apid:magnetometer_apid "\001read";;
- : (string, Sdk.Fault.t) result = Ok "b=48.2,52.1,-14.7"

Service.command returns Ok () on the TM[1,7] completion report that names its command. It returns Nacked reason when the service reports that the command failed.

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 that refuses the request a pending call sent also makes that call return Fault.Bus_error. The payload of the ERROR names the refused frame. An ERROR that refuses anything else reaches your handler, and the pending call continues to wait for its own answer. A pending call returns Fault.Malformed_error_frame if the payload of an ERROR does not decode.

# 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.Kind.to_int 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 = ()

A flight service that declines a request answers with a request verification failure report. on_refusal registers the handler for those refusals, and Refusal.t names the service, a code to branch on, a sentence and the mission time at which the answer would change.

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 command word it read and the host directives in it 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.

Handle the directives, then pass the word to Shared.ack. ack marks that word as read and does not read the command word again, so a directive that the host sets after your poll stays pending for the next poll.

# 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";
let word, directives = Sdk.Shared.poll supervision in
Sdk.Shared.ack supervision word;
directives;;
- : 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_eio.Transport.unix_socket ~sw ~mono ~net path inside an Eio fiber. Eio is the OCaml 5 I/O library built on effects. A fiber is a lightweight thread that Eio schedules cooperatively, and ~sw is the switch that bounds the lifetime of the fibers and resources it holds, the socket among them. ~mono is the monotonic clock: a spacecraft's wall clock is corrected from the ground, and a correction must not move a deadline that is already running. 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_deadline. It takes a function that reads the current instant and a function that waits for a frame until a given instant on that clock. The second 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.

Shared.mission_time reads again until the seqlock returns a consistent value, up to ?attempts reads (1024 by default). It raises Failure when every attempt finds a writer mid-update.