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

MessageDirectionPurpose
STAFFORDPWROXGS → OBCAuthentication token
AUTH_OK / AUTH_FAILOBC → GSAuthentication response
GET_TELEMETRYGS → OBCRequest sensor telemetry
ADCS_TELEM|...ADCS → OBC → GSTelemetry data payload
GET_IMAGEGS → OBCRequest camera capture
IMG_START|size|chunksOBC → GSImage transfer header
[binary chunks]OBC → GSChunked image data
IMG_ENDOBC → GSImage transfer complete
CMD|...OBC → ADCSPass-through commands
ACK|...ADCS → OBCCommand acknowledgments