Skip to content

PAYLOAD — the Science Payload

Reference documentation for nodes/payload_node.c, the science instruments of the simulator. See index.md for the platform overview and dhs.md for the on-board DHS this node reports to.

CAN address 0x5, PUS APID 0x005. One binary, build/bin/payload_node.

The payload is silent by default. config/minscs.conf ships with [payload] log_level = off, so the node emits no console output at all. Set it to info to see the status lines quoted throughout this document.


1. Role and boundaries

Three instruments on one board, run by four tasks:

InstrumentResponsibility
GNSSA mock orbital position fix on a fixed cadence, emitted as science telemetry
CameraCapture triggers, exposure sequences, and clocking a raw frame out line by line
StorageA virtual flash: sector accounting, an overwrite policy, and the segmented readout that splits a stored file into 8-byte CAN frames

Camera → Storage is a stream buffer, not the CAN bus (:85). Both are on the same physical card, so image data never touches the backplane until the DHS asks for it — the same reasoning that keeps the ADCS’s sensor interface off the bus (adcs.md). 8192 bytes of pixels at 7 bytes per frame would otherwise be 1171 frames of backplane traffic for data nobody has asked for yet.

Each instrument is on its own switched channel of the EPS’s instrument rail, and the EPS announces which are powered every housekeeping cycle. This node obeys and does not negotiate: the load switch is on the EPS side of the harness, so an instrument whose channel is open declines its commands rather than pretending. See §4.1 and eps.md §6.1.

The payload is not cold-redundant: redun_t carries no payload pair and shared_can.h defines no payload board identity, so unlike the ADCS and TTC (eps.md §10) this node has no reboot handler and no active-board state. Also deliberately absent: an image content model (the frame is a byte ramp, §5); a power model (the rail is the EPS’s to compute, eps.md §6); a file system beyond a contiguous-run allocator; and any science processing. The card stores and forwards.


2. Boot sequence

main                            :1147
  └─ logInit("PAYLOAD")
  └─ prvLoadConfig              :1102   board straps ONLY: log_level, [spw] link_rate_bps
  └─ prvInitDataPool            :1123
       ├─ dpRegisterCfg x4      :1127   the four parameters of §13
       ├─ dpApplyRomDefaults    :1136   the compiled-in numbers, until the DHS's block arrives
       ├─ dpStart               :1137
       └─ obdbInit(PAYLOAD, NULL)  :1139   no commit callback -- see §12
  └─ xSemaphoreCreateMutex           xFlashMutex
  └─ xStreamBufferCreate(512, 1)     the camera -> storage image pipe
  └─ xQueueCreate(2), (4), (2)       capture triggers, downlink requests, SpaceWire grants
  └─ canInit(CAN_ADDR_PAYLOAD) :1172   adds the canrx task
  └─ spwInit(SPW_LA_PAYLOAD)   :1176   adds the spwrx task; dials the DHS on :20100
  └─ xTaskCreate x4            :1178
  └─ vTaskStartScheduler

3. Tasks

TaskPriorityPeriodRole
dispPRIO_APP (3)blocks on the CAN RX queueCommand dispatch (:760)
cameraPRIO_APP (3)blocks on xCaptureQExposure and readout (:275)
gnssPRIO_TLM (2)gnss_period_ms (2000)Position fixes (:216)
storagePRIO_BULK (1)200 ms poll on the image pipeFlash and readout (:594)
canrxPRIO_CAN_RX (5)Created by canInit, common to every node
spwrxPRIO_CAN_RX (5)Created by spwInit: the SpaceWire link’s receive loop and initialisation state machine, and the RMAP reply callback

Six tasks at ~128 KiB of heap each, ≈ 768 KiB — see porting.md, Heap sizing.

Storage runs at PRIO_BULK, below everything: a 1171-frame readout at dispatch priority would hold the CPU for the whole burst, even though the burst is paced (§7). The camera blocks on portMAX_DELAY when the pipe is full (:299, :314): the stream buffer is 512 bytes against an 8192-byte frame (:85), so storage sets the pace and the sensor waits — what a real readout does when the memory controller is behind. Blocking on a FreeRTOS primitive is safe here; blocking on a socket would not be (porting.md).


4. The GNSS instrument

A circular ground track, enough to make the telemetry move plausibly (:216): latitude a sinusoid to ±51.5°, longitude a saw from −180°, altitude 512 ± 4 km. There is no orbit propagator here — the real one lives in the plants and the two are unrelated.

One CAN_FUNC_SCI frame per fix, to the DHS, using all eight octets:

OctetsFieldEncoding
0–1Latitudecenti-degrees, signed big-endian
2–3Longitudecenti-degrees, signed big-endian
4–5Altitudedecametres, unsigned big-endian
6–7GPS timeseconds since 2015-01-01, truncated to its low 16 bits

The timestamp is what pays for that tight budget: the ground reads the packet’s own CDS timestamp, applied by the DHS in prvEmitTm, so the truncated field is decoration and tools/egse_tm.c doesn’t even decode it.

Elapsed time is accumulated in milliseconds

ulElapsedMs += xCfg.gnss_period_ms;
xLastFix.gps_time = GNSS_EPOCH_S + (ulElapsedMs / 1000u);

gnss_period_ms accepts anything from 100 ms up, so the obvious t * (gnss_period_ms / 1000) truncated to zero for every sub-second cadence and pinned the timestamp to the epoch forever, while lat/lon advanced normally. That is this node’s instance of the platform-wide integer-millisecond rule — dyn_engine.md §2.

The receiver stops transmitting when its channel is open

[EPS]     payload gnss OFF -> instrument rail 1.42A
[PAYLOAD] instrument power 0x7 -> 0x5 (camera on, gnss off, memory on)

…and the science frames stop. What does not stop: the task keeps its cadence and xLastFix keeps advancing, so a receiver switched back on reports a fix immediately rather than resuming a ground track from wherever it was interrupted. The switch gates the transmission, because that is the only thing an unpowered box demonstrably cannot do.

The GNSS is powered by default in every shipped mode, SAFE included — a spacecraft in safe mode is exactly when the ground most wants to know where it is.


4.1. Instrument power

The EPS announces which of this card’s three instruments have a rail, once per housekeeping cycle, as a two-octet CMD_PL_PWR (0x2A) whose data[1] is a bit mask (:893):

InstrumentBitGates
Camera0CMD_CAPTURE (§5)
GNSS1The science frame (§4)
Mass memory2CMD_DOWNLINK_FILE, and CMD_CAPTURE — an exposure has nowhere to go without it

Re-sent every cycle, unconditionally, exactly as the EPS re-sends the active board to the ADCS and TTC, and for the same reason: this node is not a housekeeping collector, so it can’t infer a switch the way the DHS infers an OBC change. An unconditional announcement lets the card boot late, miss a frame, or restart alone and still converge within a second. It’s accepted from the EPS alone — a node that isn’t the power system has no business asserting it — and only transitions are logged; the steady state is the EPS’s report to tell the ground about (eps.md §8).

The boot value is GNSS + mass memory, matching the EPS’s shipped SAFE mask: guessing everything off would refuse a downlink of files already on the card for that first second, and guessing everything on would let a capture through onto an unpowered imager.


5. The camera

CMD_CAPTURE puts a trigger on a queue two deep; the camera task takes it, waits camera_exposure_ms (300), then clocks out camera_image_bytes (8192, a database parameter rather than a compile-time constant since the mission data link arrived).

The frame is a 4-byte big-endian length header followed by 64-byte chunks of a byte ramp ((off + i) & 0xFF) — a ramp rather than noise because truncation is then obvious downstream: a short file ends on the wrong value.

A capture has four ways to be refused, and they are not the same refusal:

ConditionStatusWhy
The imager’s channel is offCAPTURE_ERR_NOPOWER (0x03)Physical: exposing on an unpowered sensor would fabricate an image the spacecraft could not have taken
The mass memory’s channel is offCAPTURE_ERR_NOPOWER (0x03)An exposure goes straight to flash over the card’s stream buffer; there is no frame store between them
The platform is in SAFECAPTURE_ERR_SAFE (0x02)Operational: being powered in SAFE is not the same as SAFE being a mode this card takes pictures in
The camera is still clocking a frame outCAPTURE_ERR_BUSY (0x01)One exposure at a time

The power check and the mode check are deliberately both present. SAFE’s default channel mask already sheds the imager, so on the shipped parameters the power check fires first — but an operator who switches the channel back on by hand while the platform sits in SAFE has powered the instrument without changing what the spacecraft is flying, and the mode check is what says so. Since config/minscs.conf ships default_mode = SAFE, a capture needs egse_tc DHS MODE NOMINAL or an explicit egse_tc EPS PLPWR CAM ON (eps.md §6.1); §8 shows the whole sequence.


6. The virtual mass memory

256 sectors × 1024 bytes = 256 KiB, in a static array (:65), with 16 file slots. Static deliberately: the geometry sizes the array and configTOTAL_HEAP_SIZE is computed against it, which is why it’s a compile-time constant rather than a database parameter (config.md).

A contiguous-run allocator (:108): scan for the first run of need free sectors, mark them, return the first. Contiguity isn’t cosmetic — it’s the invariant that makes the readout’s addressing valid, since prvStreamFile computes a byte’s sector as first_sector + (offset / FLASH_SECTOR_SIZE) (:378), arithmetic that breaks silently the moment allocation stops being contiguous.

Oldest-first overwrite (:140): full flash evicts the lowest file id, which is the oldest because ids are issued monotonically (flash: evicted file 3 to make room). The steady state on repeated 8 KiB captures is 16 files across 128 sectors — the slot table runs out at half the partition, so the file count bites before the sectors do. A file is evicted without asking whether it was ever downlinked; the DHS’s automatic pull (§8) is what keeps that from mattering here.

The eviction retry is bounded (:632). prvFreeOldestFile is a no-op once nothing valid is left, so retrying until the allocation succeeds is a hang, not a slow success — and a hang holding xFlashMutex, which wedges the dispatch task in prvSendFlashStatus and takes the whole node with it. The loop gives up after one attempt per evictable file and drops the image.

The image length is validated before it is trusted

if ((total == 0) || (total > (uint32_t) (FLASH_SECTOR_COUNT * FLASH_SECTOR_SIZE)))

A length the flash can’t hold isn’t a big image, it’s a lost sync — four bytes of pixel data read as a header. need is a uint16_t truncation of that value, and a need no run can satisfy is precisely what the bounded loop above exists to survive. The check rejects it up front (storage: implausible image length 2147483904 -- resynchronising) and resets the pipe.

The drain never over-reads

A FreeRTOS stream buffer is byte-oriented, not message-oriented: it returns whatever is available up to the size requested. Asking for a full 64-byte chunk when fewer than 64 bytes of this image remain is therefore answered partly from the next capture — which both writes past this file’s last sector (and, for a file allocated near the end of the partition, past the end of ucFlash altogether) and eats the next image’s length header, desynchronising the pipe permanently. The drain sizes its request as total - received (:658) before capping it at one chunk (:663), exactly as prvStreamFile clamps its per-frame count on the way out.

Locking

One mutex, xFlashMutex, guarding xFiles, ucSectorUsed and usSectorsInUse. It’s a leaf, and the readout snapshots length and first_sector and releases it before streaming (:357) rather than holding it for the hundreds of milliseconds a burst takes. That snapshot is safe only because prvStorageTask is both the sole evictor and the sole streamer and never interleaves the two within one iteration — a second requester task would reintroduce a use-after-free read. The configASSERT(slot < FLASH_MAX_FILES) at :650 records the other non-local invariant: xFiles[16] would write straight through into ucSectorUsed.


7. The file transport

There are two routes off this card for the same octets, and the storage task runs both — the same task deliberately, since running them at once would read one file out twice. A copy commanded while a CAN readout is in progress simply waits for it.

RouteWhen
CAN readoutCAN_FUNC_XFER_FF/_CF, belowthe default; the DHS pulls automatically when a file is stored
SpaceWireRMAP writes into the DHS’s mass memoryonly when a ground TC[23,14] copy asks for it

The CAN route stays the default and stays working: the mission data link is the fast route, not the only one. What that costs is in spacewire.md §12.

The SpaceWire push is prvSpwPushFile, driven by a CMD_XFER_GRANT frame carrying the window the DHS has reserved (spacewire.md §8). It snapshots the file record under xFlashMutex and releases it before pushing, exactly as prvStreamFile does, so a transfer never holds the flash lock across a whole file. It acknowledges with two octets, never more, for the same reason prvAckDownlink does.

The CAN readout

PAYLOAD→DHS bulk readout, a simplified ISO-TP distinguished by function code — the function code already separates a First Frame from a Consecutive Frame, so no PCI nibble is needed and there are no flow-control frames.

FunctionLayout
First FrameCAN_FUNC_XFER_FF (0x4)data[0..3] total length, data[4..7] file id, both big-endian
Consecutive FrameCAN_FUNC_XFER_CF (0x5)data[0] sequence in the low nibble, rolling 0x0..0xF; data[1..7] up to 7 bytes (dlc = 1 + n)

The First Frame exists so the receiver can size its reassembly buffer before any data arrives. An 8192-byte file is one FF plus 1171 CFs, and the DHS’s REASM_MAX_BYTES is 8192 for exactly that reason: a default capture the fallback route couldn’t carry would make the fallback decorative.

The burst is paced: vTaskDelay(1) every eight frames (:388), so a full readout is about 146 ticks — a second and a half of bus time. Without the pacing, the readout floods the hub and the RX queues of every node between here and the ground; the hub collapses the burst in its own log (route ... 1171 consecutive frame(s)) for the same reason.

Do not confuse this with the whole-packet transport (CAN_FUNC_PKT, function 0x7) that carries PUS packets between the DHS and TTC — different function code, different framing, different direction (can_bus.md).


8. The capture-to-ground chain

One telecommand drives the whole chain, because the DHS pulls a stored file down on its own initiative:

build/bin/egse_tc DHS MODE NOMINAL   # the imager is shed in SAFE -- §5
build/bin/egse_tc DHS CAPTURE
[DHS    ] uplink: TC (8,1) -> PAYLOAD: CAN cmd 0x10
[PAYLOAD] camera: exposure start (300 ms)
[PAYLOAD] camera: readout 8192 bytes -> flash
[PAYLOAD] storage: stored file 1, 8192 bytes across 8 sector(s) from 0
[DHS    ] payload stored file 1 -- requesting downlink
[PAYLOAD] storage: streaming file 1 (8192 bytes) to DHS
[hub    ] route   ... 1171 consecutive frame(s)
[DHS    ] xfer: file 1 complete, 8192/8192 bytes, 0 gap(s)
[DHS    ] xfer: file 1 downlinked in 200-byte parts

The store→pull handshake. The payload announces a stored file by acking the DHS with { CMD_DOWNLINK_FILE, file_id } — five octets (:702). The DHS reads a five-octet ack of that opcode as “there is something worth downlinking”, raises EVT_FILE_STORED, and sends the request straight back. This is why TC DHS CAPTURE alone produces a downlinked file with no operator DOWNLINK step, and why a completion ack for a downlink must stay two octets — §11.

The DHS side. Reassembly is a single 8192-byte buffer, so exactly one file may be in flight; a new First Frame while one is active abandons the old. A sequence mismatch counts a gap and copies the data anyway, but a file that finishes with any gap is dropped, not downlinked. A clean file becomes, under APID 5:

  • one TM(13,1) carrying { total length, file id }, then
  • ⌈length / 200⌉ × TM(13,2) — 41 packets for an 8192-byte image,
  • and an EVT_FILE_DOWNLINK event.

egse_tm prints the first and counts the rest (TM ... 41 large-transfer part(s)), so a readout doesn’t bury the stream.


9. Telemetry

Two reports, both CAN_FUNC_* frames to the DHS, which packetises each as a TM(3,25) under APID 5. The science frame arrives on CAN_FUNC_SCI and the flash frame on CAN_FUNC_HK, but the DHS emits them on one path — the APID says which subsystem produced it (can_bus.md).

ReportTagdlcLayout
GNSS fix(untagged)8lat(2), lon(2) ×100 signed; alt(2) ×10; GPS time low 16 bits(2)
Flash statusCMD_FLASH_STATUS 0x126tag(1), sectors used(2), sectors total(2), file count(1)

On the ground:

TM  2026-07-16T12:00:02.949Z PAYLOAD HK   (3,25) #5   GNSS lat=+15.21 lon=-161.50 alt=515.3km
TM  +7s (unsynced)          PAYLOAD HK   (3,25) #7   flash 8/256 sectors, 1 file(s)

The two are told apart by length, not by tag. egse_tm discriminates on app_len — 8 is a GNSS fix, 6 with d[0] == CMD_FLASH_STATUS is a flash report. The GNSS frame has no room for a tag: it fills all eight octets, the same trade the EPS makes for its state-of-charge frame (eps.md §8).

The flash figures also reach the DHS’s central data pool, so the ground can read them back through service 20 (egse_tc DHS PARAM GET PL_FLASH_USED PL_FLASH_TOTAL PL_FLASH_FILES). They are not in the DHS’s own housekeeping packet, which carries strictly the parameters the DHS itself owns (dhs.md §5); the (3,25) flash report above is where the figures are downlinked.


10. Housekeeping collection

The payload is one of four rows in the DHS’s collection table, and the only row polled with something other than CMD_REQ_HK — it’s polled with CMD_FLASH_STATUS, every [dhs] hk_payload_period_ms (5000):

{ CAN_ADDR_PAYLOAD, 1, CMD_FLASH_STATUS, 5000, 0 },

That’s a historical accident rather than a design: the payload had no CMD_REQ_HK handler, so the table named the one command it did answer. It has one now, both return the same report, and the row is left alone because changing it would alter the DHS’s behaviour to fix nothing.

The DHS parses the reply into three read-only pool identifiers and nothing else:

Pool idValueTypeSource
PL_FLASH_USED0x0501U16sectors in use
PL_FLASH_TOTAL0x0502U16sectors fitted (256)
PL_FLASH_FILES0x0503U8stored files (0..16)

The GNSS fix has no pool identifier at any address. It’s downlinked as telemetry and mirrored nowhere, making it the only measurement on the spacecraft the ground cannot read back through PUS service 20 (§14). The node registers no measurement identifiers in its own pool either — that matches the EPS; the ADCS is the exception, and only because its control law reads its own interface image back (adcs.md).


11. Commands accepted

Dispatch offers every frame to obdbHandleFrame first (:779), so the database protocol stays out of the switch below.

OpcodeActionAcks?
CMD_PING (0x01)1 octet
CMD_REQ_HK (0x02)prvSendFlashStatusno — the report is the answer
CMD_SET_MODE (0x03)Store the mode; gates capture2 octets, echoing the mode
CMD_SET_TIME (0x04)obtSet, DHS only, dlc >= 7no
CMD_CAPTURE (0x10)Queue a trigger, unless unpowered, SAFE or busy2 octets, status
CMD_DOWNLINK_FILE (0x11)Queue a readout request, unless the mass memory is unpowered2 octets, after the stream
CMD_FLASH_STATUS (0x12)prvSendFlashStatusno
CMD_XFER_GRANT (0x13)Queue a SpaceWire push into the granted mass-memory window; needs dlc == 82 octets, status, after the last block
CMD_PL_PWR (0x2A)Adopt the EPS’s instrument power mask (§4.1)no; it is an announcement, not a request
CMD_CFG_BEGIN/_ITEM/_ENDHandled by obdb.cReady, on _END

One report, two callers. CMD_REQ_HK and CMD_FLASH_STATUS both call prvSendFlashStatus, so a one-shot ground request can’t quietly answer with less than the periodic collection does — the rule eps.md §9 states for prvSendAllHk.

The ack’s second octet is a status. Zero accepted, non-zero refused (:51) — every refusal above says so, and acking one with zero would tell the ground an image is coming.

A downlink ack is two octets, never five. Five is the store announcement (§8), so a five-octet completion ack would have the DHS answer by re-requesting the file it has just received. It exists at all because egse_tc PAYLOAD DOWNLINK is addressed to the payload, so the DHS parks a verification entry and waits for an ack to complete it; without one a readout that worked perfectly was reported as TM[1,8] TIMEOUT. CAPTURE and FLASH, by contrast, are addressed to the DHS, which forwards a CAN command and completes them immediately. CMD_SET_MODE is a broadcast the DHS issues when the platform mode changes and once more when every subsystem is configured, so a node that boots into the wrong assumption is corrected within a few ticks.

Length guards. A CMD_SET_MODE too short to carry a mode, or a CMD_PL_PWR too short to carry a mask, is dropped rather than read: data[1] would be a stale octet from the previous frame. Same guard the EPS puts on its own commands.


12. The on-board database

The smallest block on the platform — four parameters against the EPS’s nineteen. They arrive over CMD_CFG_BEGIN/_ITEM/_END and the node runs on its compiled-in ROM defaults until they do:

[PAYLOAD] config: gnss_period=2000ms camera_exposure=300ms image=8192B rmap_block=1024B (rom defaults, awaiting the on-board database)
[PAYLOAD] obdb: 4 parameter(s) applied, epoch 1 (first configuration)

Until the block lands, every GNSS status line carries the (rom) tag from obdbTag().

No commit callbackobdbInit(CAN_ADDR_PAYLOAD, NULL). The EPS and ADCS pass one because their parameters are latched into derived state (rail currents, gains) that a new value must re-derive. All four of the payload’s are read live at the top of each cycle, so a value that lands in the bound field takes effect on the next pass with nothing to re-run.


13. Configuration reference

Board straps — read by this node

KeySectionShippedMeaning
log_level[payload]off (compiled-in default info)off|error|warn|info|debug|trace
link_rate_bps[spw]100000000Modelled SpaceWire signalling rate — a strap, not database content (spacewire.md §9)

That’s the whole list. The payload has no identity strap — no spacecraft id, no active board — so unlike TTC and the EPS it reads nothing else from the file.

Mission parameters — read by the DHS, distributed back

KeyPool idDefaultRange
gnss_period_msPL_GNSS_PER 0x05802000100 .. 600000
camera_exposure_msPL_EXPOSURE_MS 0x05813001 .. 60000
camera_image_bytesPL_IMAGE_BYTES 0x05828192256 .. 262144
spw_rmap_bytesPL_RMAP_BYTES 0x0583102464 .. 4096

camera_image_bytes stopped being a compile-time constant when the mission data link arrived: 8 KiB is deliberately larger than the CAN readout is comfortable with and trivial for SpaceWire, which is the comparison the second bus exists to make. Its upper bound is the mass memory’s capacity — prvStorageTask treats anything larger as a lost stream sync.

The DHS’s own rows that govern this node

KeyDefaultMeaning
hk_payload_enabled1Collect and downlink the payload’s housekeeping
hk_payload_period_ms5000Poll cadence (§10)

Both are pool parameters (HK_PL_ON 0x0123, HK_PL_PER 0x0127), so the collection table is retunable in flight.

Last updated on