CubeSat Building Workshop — Technical Documentation
Full firmware walkthrough for the CubeSat avionics stack built during the SpaceFaculty workshop. Source code: GitHub Repository
1. System Architecture
The CubeSat system is a three-node architecture, each running on an Arduino with LoRa radio modules communicating at 434 MHz:
┌─────────────────┐ LoRa 434 MHz ┌─────────────────┐ LoRa 434 MHz ┌─────────────────┐
│ Ground Station │ ◄──────────────────► │ OBC │ ◄──────────────────► │ ADCS │
│ (GS) │ │ (On-Board Comp)│ │ (Attitude Det. │
│ │ │ │ │ & Control) │
│ • Send commands │ │ • Route commands│ │ • IMU sensing │
│ • Receive telem.│ │ • Camera capture│ │ • Sun sensors │
│ • Token auth │ │ • Sensor aggreg.│ │ • GPS/NMEA │
│ • Image display │ │ • Binary chunked│ │ • PID reaction │
│ │ │ image xmit │ │ wheel control │
└─────────────────┘ └─────────────────┘ └─────────────────┘
All nodes share a common password token (STAFFORDPWROX) for mutual authentication.
2. ADCS Firmware
The ADCS subsystem implements attitude sensing and reaction wheel control:
Sensor Data Structures
struct SunSensorData {
int topLeft;
int topRight;
int bottomLeft;
int bottomRight;
};
struct GPSData {
float latitude;
float longitude;
float altitude;
bool fix;
};
struct IMUData {
float accelX, accelY, accelZ;
float gyroX, gyroY, gyroZ;
};
PID Sun-Pointing Controller
The reaction wheel is driven by a PID controller that uses the sun sensor differential to point the satellite towards the sun:
// PID parameters
float Kp = 2.0, Ki = 0.5, Kd = 1.0;
float integral = 0, previousError = 0;
float computePID(float error) {
integral += error;
float derivative = error - previousError;
previousError = error;
float output = Kp * error + Ki * integral + Kd * derivative;
// Clamp output to motor range
output = constrain(output, -255, 255);
return output;
}
void controlReactionWheel() {
SunSensorData sun = readSunSensors();
// Error = difference between left and right sun sensors
float error = (sun.topLeft + sun.bottomLeft) -
(sun.topRight + sun.bottomRight);
float motorSpeed = computePID(error);
setMotorSpeed(motorSpeed);
}
Shake-Override Safety Logic
If the IMU detects excessive vibration (indicating a physical disturbance), the reaction wheel is immediately stopped:
bool checkShakeOverride() {
IMUData imu = readIMU();
float magnitude = sqrt(
imu.accelX * imu.accelX +
imu.accelY * imu.accelY +
imu.accelZ * imu.accelZ
);
// If acceleration exceeds threshold, disable reaction wheel
if (magnitude > SHAKE_THRESHOLD) {
setMotorSpeed(0);
return true; // Override active
}
return false;
}
GPS NMEA Parsing
GPSData parseNMEA(String sentence) {
GPSData gps;
// Parse $GPGGA sentence
if (sentence.startsWith("$GPGGA")) {
// Extract latitude, longitude, altitude, fix quality
String tokens[15];
int idx = 0;
int start = 0;
for (int i = 0; i < sentence.length(); i++) {
if (sentence.charAt(i) == ',') {
tokens[idx++] = sentence.substring(start, i);
start = i + 1;
}
}
gps.latitude = convertNMEAtoDecimal(tokens[2], tokens[3]);
gps.longitude = convertNMEAtoDecimal(tokens[4], tokens[5]);
gps.altitude = tokens[9].toFloat();
gps.fix = (tokens[6].toInt() > 0);
}
return gps;
}
Telemetry Sender
void sendTelemetry() {
SunSensorData sun = readSunSensors();
IMUData imu = readIMU();
GPSData gps = readGPS();
String telemetry = "ADCS_TELEM|";
telemetry += "SUN:" + String(sun.topLeft) + "," +
String(sun.topRight) + "," +
String(sun.bottomLeft) + "," +
String(sun.bottomRight) + "|";
telemetry += "IMU:" + String(imu.accelX, 2) + "," +
String(imu.accelY, 2) + "," +
String(imu.accelZ, 2) + "|";
telemetry += "GPS:" + String(gps.latitude, 6) + "," +
String(gps.longitude, 6) + "," +
String(gps.altitude, 1);
LoRa.beginPacket();
LoRa.print(telemetry);
LoRa.endPacket();
}
3. OBC Firmware
The On-Board Computer serves as the central hub:
Token-Based Command Routing
#define PASSWORD "STAFFORDPWROX"
bool authenticated = false;
void processIncoming(String message) {
if (!authenticated) {
if (message == PASSWORD) {
authenticated = true;
sendLoRa("AUTH_OK");
} else {
sendLoRa("AUTH_FAIL");
}
return;
}
// Route authenticated commands
if (message.startsWith("ADCS_")) {
// Pass-through to ADCS subsystem
forwardToADCS(message.substring(5));
} else if (message == "GET_IMAGE") {
captureAndTransmitImage();
} else if (message == "GET_TELEMETRY") {
aggregateAndSendTelemetry();
}
}
Chunked Binary Camera Transmission
Camera images are too large for a single LoRa packet (max ~255 bytes). The OBC chunks the binary data:
#define CHUNK_SIZE 200 // bytes per LoRa packet
void captureAndTransmitImage() {
// Capture image from camera module
uint8_t* imageData;
uint32_t imageSize = captureImage(&imageData);
// Send header: total size and number of chunks
uint16_t numChunks = (imageSize + CHUNK_SIZE - 1) / CHUNK_SIZE;
String header = "IMG_START|" + String(imageSize) + "|" +
String(numChunks);
sendLoRa(header);
delay(100);
// Send image in chunks
for (uint16_t i = 0; i < numChunks; i++) {
uint32_t offset = i * CHUNK_SIZE;
uint16_t thisChunkSize = min(CHUNK_SIZE,
imageSize - offset);
LoRa.beginPacket();
LoRa.write(i >> 8); // Chunk index (high byte)
LoRa.write(i & 0xFF); // Chunk index (low byte)
LoRa.write(imageData + offset, thisChunkSize);
LoRa.endPacket();
delay(50); // Inter-packet delay for reliability
}
sendLoRa("IMG_END");
}
Asynchronous Command Pass-Through
void forwardToADCS(String command) {
// Forward command to ADCS via LoRa
String adcsPacket = "CMD|" + command;
sendLoRa(adcsPacket);
// Wait for ADCS acknowledgment (with timeout)
unsigned long startTime = millis();
while (millis() - startTime < 5000) { // 5s timeout
if (LoRa.parsePacket()) {
String response = readLoRa();
if (response.startsWith("ACK|")) {
// Forward ACK back to Ground Station
sendLoRa("ADCS_" + response);
return;
}
}
}
sendLoRa("ADCS_TIMEOUT");
}
4. Ground Station Firmware
The Ground Station handles authentication, command dispatch, and image reassembly:
Authentication Handshake
void authenticate() {
sendLoRa(PASSWORD);
unsigned long startTime = millis();
while (millis() - startTime < 10000) {
if (LoRa.parsePacket()) {
String response = readLoRa();
if (response == "AUTH_OK") {
Serial.println("Authenticated with OBC!");
authenticated = true;
return;
}
}
}
Serial.println("Authentication timeout.");
}
Image Reassembly
uint8_t imageBuffer[65535];
uint32_t expectedSize = 0;
uint16_t receivedChunks = 0;
void receiveImage() {
// Wait for IMG_START header
// Parse chunks and reassemble
while (true) {
if (LoRa.parsePacket()) {
int packetSize = LoRa.available();
if (packetSize > 2) {
// Binary chunk: first 2 bytes = chunk index
uint16_t chunkIdx = (LoRa.read() << 8) | LoRa.read();
uint32_t offset = chunkIdx * CHUNK_SIZE;
int bytesRead = 0;
while (LoRa.available()) {
imageBuffer[offset + bytesRead++] = LoRa.read();
}
receivedChunks++;
}
}
}
// Output raw binary to Serial for host-side display
Serial.write(imageBuffer, expectedSize);
}
5. Communication Protocol Summary
| Message | Direction | Purpose |
|---|---|---|
STAFFORDPWROX | GS → OBC | Authentication token |
AUTH_OK / AUTH_FAIL | OBC → GS | Authentication response |
GET_TELEMETRY | GS → OBC | Request sensor telemetry |
ADCS_TELEM|... | ADCS → OBC → GS | Telemetry data payload |
GET_IMAGE | GS → OBC | Request camera capture |
IMG_START|size|chunks | OBC → GS | Image transfer header |
[binary chunks] | OBC → GS | Chunked image data |
IMG_END | OBC → GS | Image transfer complete |
CMD|... | OBC → ADCS | Pass-through commands |
ACK|... | ADCS → OBC | Command acknowledgments |