Avionics Flight Computer Firmware
Flight code that reads a rocket's barometer and motion sensor, logs the data, and works out when the rocket launches, reaches apogee, and lands. It runs on Silly Goose, Patrick McGuire's custom altimeter board. I wrote it alongside my mentors on AerospaceNU's avionics team in fall 2025.
Flight replay
This is a recorded flight run through my flight logic. The green line is the smoothed altitude the code works with, and the pink line under it is the raw altitude from the barometer. The circles mark where the code called launch, apogee, and landing. Hover over the chart or tap it to see the time, height, and flight state at any point.
The hold timers make the code wait before it trusts a change. It waits 0.1 s before calling launch, 0.5 s before calling apogee, and 5 s before calling landing. Turn them off to see what happens without them.
This is my firmware's logic copied into JavaScript and run on the same recorded data we used for bench testing. The bench replay fed in one reading every 20 ms, a bit slower than real time.
How it works
The main loop runs every 20 ms, so 50 times a second. Each time, it reads the barometer and the motion sensor and converts the raw readings into real units, like pascals for pressure and m/s² for acceleration. Then it turns the pressure into altitude, smooths the data, updates the flight state, and saves a log entry.
Altitude comes from the standard atmosphere formula, which converts air pressure into height above sea level. I smooth it with a low-pass filter where each new reading moves the output 10% of the way toward it. Vertical speed comes from the change in raw altitude between loops, run through a slower filter that moves 5% of the way each time. For acceleration, the code uses the total across all three axes, so it works no matter how the board is mounted in the rocket.
Each log entry is a packed 37-byte record with the time, pressure, temperature, altitude, vertical speed, acceleration, and flight state. The 64 MB flash chip fits about 1.8 million entries, or around 10 hours of logging at 50 Hz.
Four flight states
The code is always in one of four states. Every change needs its condition to stay true for a set time first, which is what the hold timers in the chart control. The code only writes to the flash chip during ascent and descent, so it doesn't fill the log while the rocket sits on the pad.
Pre-flight
The rocket is on the pad and the code is listening for serial commands. It moves to ascent when acceleration goes over 40 m/s² (about 4 g) or the smoothed altitude goes over 100 m above sea level, for at least 0.1 s.
Ascent
Logging onIt moves to descent when the smoothed vertical speed drops below −2 m/s, meaning the rocket is falling, for at least 0.5 s.
Descent
Logging onThe code keeps a reference altitude and resets it whenever the altitude moves more than 3 m away. Once the altitude stays within 3 m of the reference for 5 s, the rocket has landed.
Post-flight
Logging stops and the serial commands work again, so we can download the flight log over USB.
The board
The code runs on Silly Goose, a custom dual-deploy altimeter designed and built by Patrick McGuire, who was also one of my mentors on this project. Patrick made the board and I wrote code for it. You can read about the board in Patrick's write-up.
High-power rockets usually open a small parachute at their highest point and a big one close to the ground. Small black powder charges push each parachute out, and the altimeter board fires them electrically. That's what dual deploy means.
Here's what's on Silly Goose:
- SAMD21 microcontroller
- Barometer
- Accelerometer and gyroscope
- 8 KB of FRAM for settings and state
- 64 MB of flash for black-box logging
- Two switched channels for firing parachute charges
- USB and battery power switching
- Reverse polarity protection
My code talks to three of those parts:
| Part | Connection | What the code does with it |
|---|---|---|
| MS5607 barometer | I²C, address 0x77, 400 kHz | Reads pressure and temperature at an oversampling ratio of 256 |
| ICM-20602 motion sensor | I²C, address 0x69 | Reads acceleration on all three axes at ±16 g. The gyroscope is set to ±2000 °/s and read, but the flight logic doesn't use it yet. |
| S25FL512 flash, 64 MB | SPI, chip select on pin 12 | Stores the flight log, using 4-byte addresses and 512-byte pages |
My code doesn't use the FRAM or fire the parachute charges. It handles the sensing, logging, and flight-state detection that deployment timing depends on.
I built the sensor drivers with my mentors. The barometer driver started from UravuLabs' open-source MS5607 library, and we adapted it for this board.
My custom GPS board uses the same SAMD21 microcontroller.
Tools
I wrote the code in C++ with the Arduino framework, used PlatformIO to build and upload it, and worked in CLion. The PlatformIO build uses the Adafruit Feather M0 board profile, which has the same SAMD21 chip.
Code highlights
The low-pass filter that turns the raw altitude into the smoothed green line on the chart. Each new reading moves the output 10% of the way toward it.
float lowPassPosition(float position) {
constexpr float alpha = 0.1;
static bool initialized = false;
static float output = 0;
if (!initialized) {
output = position;
initialized = true;
return output;
}
output += alpha * (position - output);
return output;
}
The hold timer. check() only returns true once its condition has stayed true for the whole hold time.
class Debounce {
public:
explicit Debounce(const uint32_t time) : m_debounceTimeMs(time) {}
bool check(bool condition) {
uint32_t currentTime = millis();
if (!m_isInitialized) {
m_referenceTime = currentTime;
m_isInitialized = true;
}
if (condition) {
if (currentTime - m_referenceTime >= m_debounceTimeMs) {
return true;
}
} else {
m_referenceTime = currentTime;
}
return false;
}
private:
uint32_t m_debounceTimeMs = 0;
uint32_t m_referenceTime = 0;
bool m_isInitialized = false;
};
The flight state machine. It runs once every loop.
if (state == PRE_FLIGHT) {
runCli();
if (takeoffDebounce.check(altitudeM > 100 || netAccelerationMSS > 40)) {
state = ASCENT;
}
} else if (state == ASCENT) {
logger.writeLogEntry(logData);
if (apogeeDebounce.check(velocityMS < -2.0)) {
state = DESCENT;
}
} else if (state == DESCENT) {
logger.writeLogEntry(logData);
static float referenceAltitude = 0;
bool altitudeChanged = abs(altitudeM - referenceAltitude) > 3.0;
if (altitudeChanged) {
referenceAltitude = altitudeM;
}
if (landingDebounce.check(!altitudeChanged)) {
state = POST_FLIGHT;
}
} else if (state == POST_FLIGHT) {
runCli();
}
Testing
We tested the code on the bench in sim mode. When IS_SIM is on, the loop swaps the live sensor readings for rows from SimData.h, a recording of about 7,200 readings, and steps forward one row every loop. Each loop also prints the current reading over serial, so we could watch the altitude, speed, and state change as the replay ran. The chart above uses the same recording. This code hasn't flown yet.
Before launch and after landing, the code listens for one-letter commands over USB serial. Sending o prints every saved log entry as tab-separated text, e erases the whole flash chip, and p prints the current reading.
Looking ahead to future boards
Working on this code gave me a list of things I'm looking forward to building into the drivers and firmware for my next boards.
-
Measuring height from the pad
This code measures height from standard sea-level pressure, so at a launch site higher than about 100 m it would think the rocket had launched while it was still on the pad. On future boards, I'll save the pad altitude at startup and measure everything from there.
-
Firing the parachute charges
Silly Goose has two switched channels for parachute charges that this code doesn't use yet. The flight states it tracks are what deployment gets built on, and I'm looking forward to adding that step on a future board, with the small parachute's charge firing at apogee and the big one's at a set height on the way down.
-
Starting every filter cleanly
The velocity filter here returns on its first call before it saves the starting altitude and time. Its second call then divides by the whole time since power-up, which gives the speed a made-up starting value that fades over a second or two. In my next drivers, every filter will save its starting values on the first reading.