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.confships with[payload] log_level = off, so the node emits no console output at all. Set it toinfoto see the status lines quoted throughout this document.
1. Role and boundaries
Three instruments on one board, run by four tasks:
| Instrument | Responsibility |
|---|---|
| GNSS | A mock orbital position fix on a fixed cadence, emitted as science telemetry |
| Camera | Capture triggers, exposure sequences, and clocking a raw frame out line by line |
| Storage | A 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
└─ vTaskStartScheduler3. Tasks
| Task | Priority | Period | Role |
|---|---|---|---|
disp | PRIO_APP (3) | blocks on the CAN RX queue | Command dispatch (:760) |
camera | PRIO_APP (3) | blocks on xCaptureQ | Exposure and readout (:275) |
gnss | PRIO_TLM (2) | gnss_period_ms (2000) | Position fixes (:216) |
storage | PRIO_BULK (1) | 200 ms poll on the image pipe | Flash and readout (:594) |
canrx | PRIO_CAN_RX (5) | — | Created by canInit, common to every node |
spwrx | PRIO_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:
| Octets | Field | Encoding |
|---|---|---|
| 0–1 | Latitude | centi-degrees, signed big-endian |
| 2–3 | Longitude | centi-degrees, signed big-endian |
| 4–5 | Altitude | decametres, unsigned big-endian |
| 6–7 | GPS time | seconds 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):
| Instrument | Bit | Gates |
|---|---|---|
| Camera | 0 | CMD_CAPTURE (§5) |
| GNSS | 1 | The science frame (§4) |
| Mass memory | 2 | CMD_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:
| Condition | Status | Why |
|---|---|---|
| The imager’s channel is off | CAPTURE_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 off | CAPTURE_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 SAFE | CAPTURE_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 out | CAPTURE_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.
| Route | When | |
|---|---|---|
| CAN readout | CAN_FUNC_XFER_FF/_CF, below | the default; the DHS pulls automatically when a file is stored |
| SpaceWire | RMAP writes into the DHS’s mass memory | only 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.
| Function | Layout | |
|---|---|---|
| First Frame | CAN_FUNC_XFER_FF (0x4) | data[0..3] total length, data[4..7] file id, both big-endian |
| Consecutive Frame | CAN_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 partsThe 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_DOWNLINKevent.
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).
| Report | Tag | dlc | Layout |
|---|---|---|---|
| GNSS fix | (untagged) | 8 | lat(2), lon(2) ×100 signed; alt(2) ×10; GPS time low 16 bits(2) |
| Flash status | CMD_FLASH_STATUS 0x12 | 6 | tag(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 id | Value | Type | Source |
|---|---|---|---|
PL_FLASH_USED | 0x0501 | U16 | sectors in use |
PL_FLASH_TOTAL | 0x0502 | U16 | sectors fitted (256) |
PL_FLASH_FILES | 0x0503 | U8 | stored 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.
| Opcode | Action | Acks? |
|---|---|---|
CMD_PING (0x01) | — | 1 octet |
CMD_REQ_HK (0x02) | prvSendFlashStatus | no — the report is the answer |
CMD_SET_MODE (0x03) | Store the mode; gates capture | 2 octets, echoing the mode |
CMD_SET_TIME (0x04) | obtSet, DHS only, dlc >= 7 | no |
CMD_CAPTURE (0x10) | Queue a trigger, unless unpowered, SAFE or busy | 2 octets, status |
CMD_DOWNLINK_FILE (0x11) | Queue a readout request, unless the mass memory is unpowered | 2 octets, after the stream |
CMD_FLASH_STATUS (0x12) | prvSendFlashStatus | no |
CMD_XFER_GRANT (0x13) | Queue a SpaceWire push into the granted mass-memory window; needs dlc == 8 | 2 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/_END | Handled by obdb.c | Ready, 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 callback — obdbInit(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
| Key | Section | Shipped | Meaning |
|---|---|---|---|
log_level | [payload] | off (compiled-in default info) | off|error|warn|info|debug|trace |
link_rate_bps | [spw] | 100000000 | Modelled 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
| Key | Pool id | Default | Range |
|---|---|---|---|
gnss_period_ms | PL_GNSS_PER 0x0580 | 2000 | 100 .. 600000 |
camera_exposure_ms | PL_EXPOSURE_MS 0x0581 | 300 | 1 .. 60000 |
camera_image_bytes | PL_IMAGE_BYTES 0x0582 | 8192 | 256 .. 262144 |
spw_rmap_bytes | PL_RMAP_BYTES 0x0583 | 1024 | 64 .. 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
| Key | Default | Meaning |
|---|---|---|
hk_payload_enabled | 1 | Collect and downlink the payload’s housekeeping |
hk_payload_period_ms | 5000 | Poll cadence (§10) |
Both are pool parameters (HK_PL_ON 0x0123, HK_PL_PER 0x0127), so the collection table is
retunable in flight.