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;
ERRORframes 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:
| Endpoint | APID |
|---|---|
Space_sdk.camera_apid | 0x00A |
Space_sdk.param_apid | 0x00B |
Space_sdk.downlink_apid | 0x100 |
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.
| Field | What it holds |
|---|---|
width, height | The image dimensions are in pixels. |
geo | The sub-satellite point at capture time is in degrees. |
bits | The sample width is 8, or 16 for an instrument raster. |
acquisition | This is the acquisition that the pixels come from. It is empty when the camera declares none. |
pixels | These 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 / 8exactly.
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.
The downlink
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:
| Directive | What the host is asking for |
|---|---|
Shutdown | The host is reclaiming the partition. Stop cleanly. |
Param_reload | The parameters changed. Read them again. |
Dp_ack | The 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.