Added crash-triggered hazard lights, flip confusion mode, and start-up and shutdown sequence.

This commit is contained in:
ThePendulum
2026-10-22 18:59:02 +02:00
parent d14795e216
commit 6f252ff5e5
17 changed files with 49735 additions and 35 deletions
+858
View File
@@ -0,0 +1,858 @@
#include <Button.h>
#include <Adafruit_NeoPixel.h>
#include <Wire.h>
// without USB CDC, Serial falls back to USART1 on PA9/PA10, hijacking the mode and right buttons
#ifndef USBD_USE_CDC
#error "Set Tools > USB support to CDC (generic 'Serial' supersede U(S)ART)"
#endif
/*
Records the IMU to internal flash to work out what a crash looks like, download with tools/crash_dump.py
MODE start/stop recording, starting erases the previous recording, hold 5s to power off
LEFT/RIGHT add marker 1/2 to the recording, e.g. "crash now" and "bump now"
DASH dim blue: empty, green: recording stored (bar shows length), amber: erasing,
red: recording (bar shows storage used), blue: downloading, purple blinking: error
ONBOARD LED fallback for when the dash has no power without the battery
off: idle, fast flicker: erasing, slow blink: recording, short flicker: marker added,
on: downloading, rapid blink: error
serial commands: r record, s stop, d dump, i info, l live sensor reading
*/
constexpr int LEDS_TAIL_PIN = PA6;
constexpr int LEDS_TAIL_LENGTH = 10;
constexpr int LEDS_LEFT_PIN = PA5;
constexpr int LEDS_RIGHT_PIN = PA7;
constexpr int LEDS_HEAD_LENGTH = 39;
constexpr int LEDS_DASH_PIN = PB0;
constexpr int LEDS_DASH_LENGTH = 2;
constexpr int LEDS_DASH_BRIGHTNESS = 100;
constexpr int PIN_ALIVE = PA2;
constexpr int PIN_BUCK = PA1;
constexpr int PIN_PEDAL = PA0;
constexpr int PIN_PEDAL_FORWARD = PA3;
constexpr int PIN_PEDAL_REVERSE = PA4;
constexpr int PIN_BUTTON_MODE = PA9;
constexpr int PIN_BUTTON_LEFT = PA8;
constexpr int PIN_BUTTON_RIGHT = PA10;
constexpr int PIN_IMU_SCL = PB6;
constexpr int PIN_IMU_SDA = PB7;
constexpr int LED_ON = LOW;
constexpr int LED_OFF = HIGH;
constexpr int BUCK_ON = HIGH;
constexpr int BUCK_OFF = LOW;
constexpr unsigned long SHUTDOWN_HOLD = 5000; // ms
constexpr unsigned long IDLE_TIMEOUT = 180000; // ms, not while recording
constexpr unsigned long STATUS_INTERVAL = 50; // ms
constexpr unsigned long MARKER_FLASH = 150; // ms
// MPU9250/9255
constexpr uint8_t IMU_ADDRESSES[] = {0x68, 0x69}; // AD0 low, AD0 high
constexpr uint8_t IMU_REG_SMPLRT_DIV = 0x19;
constexpr uint8_t IMU_REG_CONFIG = 0x1A;
constexpr uint8_t IMU_REG_GYRO_CONFIG = 0x1B;
constexpr uint8_t IMU_REG_ACCEL_CONFIG = 0x1C;
constexpr uint8_t IMU_REG_ACCEL_CONFIG2 = 0x1D;
constexpr uint8_t IMU_REG_FIFO_EN = 0x23;
constexpr uint8_t IMU_REG_INT_PIN_CFG = 0x37;
constexpr uint8_t IMU_REG_INT_ENABLE = 0x38;
constexpr uint8_t IMU_REG_INT_STATUS = 0x3A;
constexpr uint8_t IMU_REG_USER_CTRL = 0x6A;
constexpr uint8_t IMU_REG_PWR_MGMT_1 = 0x6B;
constexpr uint8_t IMU_REG_FIFO_COUNT_H = 0x72;
constexpr uint8_t IMU_REG_FIFO_R_W = 0x74;
constexpr uint8_t IMU_REG_WHO_AM_I = 0x75;
constexpr uint8_t IMU_FIFO_EN_ACCEL_GYRO = 0x78;
constexpr uint8_t IMU_USER_CTRL_FIFO_EN = 0x40;
constexpr uint8_t IMU_USER_CTRL_FIFO_RST = 0x04;
constexpr uint8_t IMU_INT_FIFO_OFLOW = 0x10;
constexpr uint8_t IMU_INT_PIN_CFG_BYPASS = 0x02; // exposes the magnetometer on the main bus
constexpr int SAMPLE_RATE = 500; // Hz
constexpr uint8_t IMU_DLPF_184HZ = 0x01; // 1kHz internal rate, used for gyro and accel
constexpr uint8_t IMU_ACCEL_16G = 0x18;
constexpr uint8_t IMU_GYRO_2000DPS = 0x18;
constexpr uint16_t ACCEL_LSB_PER_G = 2048;
constexpr uint16_t GYRO_LSB_PER_DPS_X10 = 164;
constexpr int FIFO_SIZE = 512;
constexpr int FIFO_SAMPLE_SIZE = 12; // accel xyz + gyro xyz, big-endian
constexpr int FIFO_READ_BATCH = 2; // samples per I2C read, stays within the 32 byte Wire buffer
constexpr unsigned long FIFO_INTERVAL = 10; // ms, the fifo holds ~84ms at 500Hz
// AK8963 magnetometer inside the MPU9250/9255
constexpr uint8_t MAG_ADDRESS = 0x0C;
constexpr uint8_t MAG_REG_WIA = 0x00;
constexpr uint8_t MAG_REG_ST1 = 0x02;
constexpr uint8_t MAG_REG_HXL = 0x03;
constexpr uint8_t MAG_REG_CNTL1 = 0x0A;
constexpr uint8_t MAG_WIA = 0x48;
constexpr uint8_t MAG_ST1_DRDY = 0x01;
constexpr uint8_t MAG_ST2_HOFL = 0x08;
constexpr uint8_t MAG_CNTL1_POWER_DOWN = 0x00;
constexpr uint8_t MAG_CNTL1_16BIT_100HZ = 0x16;
constexpr unsigned long MAG_INTERVAL = 10; // ms
// sectors 0-3 (64KB) hold this sketch, sectors 4-7 (448KB) hold the recording
constexpr uint32_t STORAGE_START = 0x08010000;
constexpr uint32_t STORAGE_END = 0x08080000;
constexpr uint32_t STORAGE_SIZE = STORAGE_END - STORAGE_START;
constexpr uint32_t STORAGE_SECTOR_FIRST = FLASH_SECTOR_4;
constexpr int STORAGE_SECTOR_COUNT = 4;
constexpr float RECORD_BYTES_PER_SECOND = SAMPLE_RATE * (1 + FIFO_SAMPLE_SIZE) + (1000 / MAG_INTERVAL) * (1 + 6);
// recording format, a stream of tagged records, keep in sync with tools/crash_dump.py
enum RecordTag : uint8_t {
TAG_HEADER = 0x01, // 12 bytes: "QLR", version, WHO_AM_I, mag ok, rate, accel LSB/g, gyro LSB/dps * 10 (uint16 LE)
TAG_IMU = 0x02, // 12 bytes: raw fifo sample, accel xyz + gyro xyz, int16 BE
TAG_MAG = 0x03, // 6 bytes: raw magnetometer xyz, int16 LE, 0.15uT/LSB without sensitivity adjustment
TAG_INPUTS = 0x04, // 1 byte: raw pin levels, see INPUT_BIT_*, low is active
TAG_MARKER = 0x05, // 1 byte: marker number
TAG_OVERFLOW = 0x06, // 0 bytes: fifo overflowed, an unknown number of samples was lost
TAG_END = 0xFF, // erased flash
};
constexpr uint8_t RECORD_VERSION = 1;
constexpr uint8_t INPUT_BIT_PEDAL = 0;
constexpr uint8_t INPUT_BIT_FORWARD = 1;
constexpr uint8_t INPUT_BIT_REVERSE = 2;
constexpr uint8_t INPUT_BIT_MODE = 3;
constexpr uint8_t INPUT_BIT_LEFT = 4;
constexpr uint8_t INPUT_BIT_RIGHT = 5;
// end of the program in flash, from the linker script
extern "C" uint32_t _sidata, _sdata, _edata;
Button buttonMode(PIN_BUTTON_MODE);
Button buttonLeft(PIN_BUTTON_LEFT);
Button buttonRight(PIN_BUTTON_RIGHT);
Adafruit_NeoPixel ledsTail(LEDS_TAIL_LENGTH, LEDS_TAIL_PIN, NEO_GRBW + NEO_KHZ800);
Adafruit_NeoPixel ledsHeadLeft(LEDS_HEAD_LENGTH, LEDS_LEFT_PIN, NEO_GRBW + NEO_KHZ800);
Adafruit_NeoPixel ledsHeadRight(LEDS_HEAD_LENGTH, LEDS_RIGHT_PIN, NEO_GRBW + NEO_KHZ800);
Adafruit_NeoPixel ledsDash(LEDS_DASH_LENGTH, LEDS_DASH_PIN, NEO_GRBW + NEO_KHZ800);
const uint32_t COLOR_OFF = ledsDash.Color(0, 0, 0, 0);
const uint32_t COLOR_DIM = ledsDash.Color(0, 0, 0, 10);
const uint32_t COLOR_WHITE = ledsDash.Color(0, 0, 0, 255);
const uint32_t COLOR_EMPTY = ledsDash.Color(0, 0, 40, 0);
const uint32_t COLOR_STORED = ledsDash.Color(0, 255, 0, 0);
const uint32_t COLOR_ERASING = ledsDash.Color(255, 60, 0, 0);
const uint32_t COLOR_RECORDING = ledsDash.Color(255, 0, 0, 0);
const uint32_t COLOR_DUMPING = ledsDash.Color(0, 0, 255, 0);
const uint32_t COLOR_ERROR = ledsDash.Color(255, 0, 255, 0);
enum State {
STATE_IDLE,
STATE_ERASING,
STATE_RECORDING,
STATE_DUMPING,
STATE_ERROR,
STATE_OFF,
};
State state = STATE_IDLE;
const char* errorReason = "";
uint8_t imuAddress = 0; // 0 when not found
uint8_t imuWhoAmI = 0;
bool magFound = false;
uint32_t storageAddress = STORAGE_START; // next word to program
uint32_t storageWord = 0xFFFFFFFF;
int storageWordBytes = 0;
bool storageFull = false;
uint32_t recordedBytes = 0;
int sectorsErased = 0;
unsigned long recordStart = 0;
unsigned long lastFifoRead = 0;
unsigned long lastMagRead = 0;
unsigned long lastStatus = 0;
unsigned long lastActivity = 0;
unsigned long markerFlashUntil = 0;
uint8_t lastInputs = 0;
uint32_t overflowCount = 0;
unsigned long modePressStart = 0;
bool modePressed = false;
// I2C
void writeRegister(uint8_t address, uint8_t reg, uint8_t value) {
Wire.beginTransmission(address);
Wire.write(reg);
Wire.write(value);
Wire.endTransmission();
}
bool readRegisters(uint8_t address, uint8_t reg, uint8_t* buffer, uint8_t length) {
Wire.beginTransmission(address);
Wire.write(reg);
if (Wire.endTransmission(false) != 0 || Wire.requestFrom(address, length) != length) {
return false;
}
for (int i = 0; i < length; i++) {
buffer[i] = Wire.read();
}
return true;
}
uint8_t readRegister(uint8_t address, uint8_t reg) {
uint8_t value = 0;
readRegisters(address, reg, &value, 1);
return value;
}
// IMU
void connectImu() {
for (const uint8_t address : IMU_ADDRESSES) {
uint8_t whoAmI = 0;
if (readRegisters(address, IMU_REG_WHO_AM_I, &whoAmI, 1)) {
imuAddress = address;
imuWhoAmI = whoAmI;
break;
}
}
if (imuAddress == 0) {
return;
}
writeRegister(imuAddress, IMU_REG_PWR_MGMT_1, 0x80); // reset
delay(100);
writeRegister(imuAddress, IMU_REG_PWR_MGMT_1, 0x01); // gyro PLL clock
delay(10);
writeRegister(imuAddress, IMU_REG_CONFIG, IMU_DLPF_184HZ);
writeRegister(imuAddress, IMU_REG_SMPLRT_DIV, 1000 / SAMPLE_RATE - 1);
writeRegister(imuAddress, IMU_REG_GYRO_CONFIG, IMU_GYRO_2000DPS);
writeRegister(imuAddress, IMU_REG_ACCEL_CONFIG, IMU_ACCEL_16G);
writeRegister(imuAddress, IMU_REG_ACCEL_CONFIG2, IMU_DLPF_184HZ);
writeRegister(imuAddress, IMU_REG_INT_ENABLE, IMU_INT_FIFO_OFLOW);
writeRegister(imuAddress, IMU_REG_INT_PIN_CFG, IMU_INT_PIN_CFG_BYPASS);
magFound = readRegister(MAG_ADDRESS, MAG_REG_WIA) == MAG_WIA;
if (magFound) {
writeRegister(MAG_ADDRESS, MAG_REG_CNTL1, MAG_CNTL1_POWER_DOWN);
delay(10);
writeRegister(MAG_ADDRESS, MAG_REG_CNTL1, MAG_CNTL1_16BIT_100HZ);
}
}
void startFifo() {
writeRegister(imuAddress, IMU_REG_FIFO_EN, 0);
writeRegister(imuAddress, IMU_REG_USER_CTRL, IMU_USER_CTRL_FIFO_RST);
readRegister(imuAddress, IMU_REG_INT_STATUS); // clears a stale overflow flag
writeRegister(imuAddress, IMU_REG_USER_CTRL, IMU_USER_CTRL_FIFO_EN);
writeRegister(imuAddress, IMU_REG_FIFO_EN, IMU_FIFO_EN_ACCEL_GYRO);
}
void stopFifo() {
writeRegister(imuAddress, IMU_REG_FIFO_EN, 0);
writeRegister(imuAddress, IMU_REG_USER_CTRL, 0);
}
// storage
uint32_t programEnd() {
return (uint32_t)&_sidata + ((uint32_t)&_edata - (uint32_t)&_sdata);
}
bool hasHeader() {
const uint8_t* storage = (const uint8_t*)STORAGE_START;
return storage[0] == TAG_HEADER && storage[1] == 'Q' && storage[2] == 'L' && storage[3] == 'R';
}
// recordings end at the first erased tag, the last programmed word marks where to stop downloading
uint32_t findRecordedBytes() {
if (!hasHeader()) {
return 0;
}
for (uint32_t address = STORAGE_END - 4; address >= STORAGE_START; address -= 4) {
if (*(const uint32_t*)address != 0xFFFFFFFF) {
return address + 4 - STORAGE_START;
}
}
return 0;
}
void flushWord() {
if (storageWordBytes == 0) {
return;
}
// unwritten bytes stay 0xFF, which reads as the end tag
if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, storageAddress, storageWord) != HAL_OK) {
storageFull = true;
errorReason = "FLASH WRITE FAILED";
}
storageAddress += 4;
storageWord = 0xFFFFFFFF;
storageWordBytes = 0;
}
void storeByte(uint8_t value) {
storageWord &= ~(0xFFu << (storageWordBytes * 8));
storageWord |= (uint32_t)value << (storageWordBytes * 8);
storageWordBytes++;
if (storageWordBytes == 4) {
flushWord();
}
}
// only whole records are stored, so a full recording still parses cleanly
void storeRecord(uint8_t tag, const uint8_t* payload, int length) {
if (storageFull || storageAddress + storageWordBytes + 1 + length > STORAGE_END) {
storageFull = true;
return;
}
storeByte(tag);
for (int i = 0; i < length; i++) {
storeByte(payload[i]);
}
}
void storeHeader() {
const uint8_t header[] = {
'Q', 'L', 'R',
RECORD_VERSION,
imuWhoAmI,
magFound,
SAMPLE_RATE & 0xFF, SAMPLE_RATE >> 8,
ACCEL_LSB_PER_G & 0xFF, ACCEL_LSB_PER_G >> 8,
GYRO_LSB_PER_DPS_X10 & 0xFF, GYRO_LSB_PER_DPS_X10 >> 8,
};
storeRecord(TAG_HEADER, header, sizeof(header));
}
bool eraseStorage() {
HAL_FLASH_Unlock();
__HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_EOP | FLASH_FLAG_OPERR | FLASH_FLAG_WRPERR | FLASH_FLAG_PGAERR | FLASH_FLAG_PGPERR | FLASH_FLAG_PGSERR);
// one sector at a time so the dash can show progress, each 128KB sector takes about a second
for (sectorsErased = 0; sectorsErased < STORAGE_SECTOR_COUNT; sectorsErased++) {
renderStatus(millis());
FLASH_EraseInitTypeDef erase = {};
erase.TypeErase = FLASH_TYPEERASE_SECTORS;
erase.Sector = STORAGE_SECTOR_FIRST + sectorsErased;
erase.NbSectors = 1;
erase.VoltageRange = FLASH_VOLTAGE_RANGE_3;
uint32_t sectorError = 0;
if (HAL_FLASHEx_Erase(&erase, &sectorError) != HAL_OK) {
HAL_FLASH_Lock();
return false;
}
}
return true;
}
// recording
uint8_t readInputs() {
return digitalRead(PIN_PEDAL) << INPUT_BIT_PEDAL
| digitalRead(PIN_PEDAL_FORWARD) << INPUT_BIT_FORWARD
| digitalRead(PIN_PEDAL_REVERSE) << INPUT_BIT_REVERSE
| digitalRead(PIN_BUTTON_MODE) << INPUT_BIT_MODE
| digitalRead(PIN_BUTTON_LEFT) << INPUT_BIT_LEFT
| digitalRead(PIN_BUTTON_RIGHT) << INPUT_BIT_RIGHT;
}
void recordInputs() {
const uint8_t inputs = readInputs();
if (inputs != lastInputs) {
lastInputs = inputs;
storeRecord(TAG_INPUTS, &inputs, 1);
}
}
void recordMarker(uint8_t marker) {
storeRecord(TAG_MARKER, &marker, 1);
markerFlashUntil = millis() + MARKER_FLASH;
Serial.print("MARKER ");
Serial.println(marker);
}
void readFifo() {
const uint8_t status = readRegister(imuAddress, IMU_REG_INT_STATUS);
uint8_t countBytes[2] = {0, 0};
readRegisters(imuAddress, IMU_REG_FIFO_COUNT_H, countBytes, 2);
const int count = (countBytes[0] & 0x1F) << 8 | countBytes[1];
// once full, new samples overwrite the oldest and the stream loses alignment
if (status & IMU_INT_FIFO_OFLOW || count > FIFO_SIZE - FIFO_SAMPLE_SIZE) {
storeRecord(TAG_OVERFLOW, nullptr, 0);
startFifo();
overflowCount++;
return;
}
uint8_t buffer[FIFO_SAMPLE_SIZE * FIFO_READ_BATCH];
for (int samples = count / FIFO_SAMPLE_SIZE; samples > 0; samples -= FIFO_READ_BATCH) {
const int batch = min(samples, FIFO_READ_BATCH);
if (!readRegisters(imuAddress, IMU_REG_FIFO_R_W, buffer, batch * FIFO_SAMPLE_SIZE)) {
return;
}
for (int i = 0; i < batch; i++) {
storeRecord(TAG_IMU, buffer + i * FIFO_SAMPLE_SIZE, FIFO_SAMPLE_SIZE);
}
}
}
void readMag() {
if (!(readRegister(MAG_ADDRESS, MAG_REG_ST1) & MAG_ST1_DRDY)) {
return;
}
// reading through ST2 is required to release the next measurement
uint8_t data[7];
if (readRegisters(MAG_ADDRESS, MAG_REG_HXL, data, sizeof(data)) && !(data[6] & MAG_ST2_HOFL)) {
storeRecord(TAG_MAG, data, 6);
}
}
void printRecordedLength(uint32_t bytes) {
Serial.print(bytes);
Serial.print(" BYTES, ~");
Serial.print(bytes / RECORD_BYTES_PER_SECOND, 1);
Serial.println(" S");
}
void startRecording() {
if (state != STATE_IDLE) {
return;
}
Serial.println("ERASING");
state = STATE_ERASING;
if (!eraseStorage()) {
state = STATE_ERROR;
errorReason = "FLASH ERASE FAILED";
Serial.println(errorReason);
return;
}
storageAddress = STORAGE_START;
storageWord = 0xFFFFFFFF;
storageWordBytes = 0;
storageFull = false;
recordedBytes = 0;
overflowCount = 0;
storeHeader();
lastInputs = readInputs();
storeRecord(TAG_INPUTS, &lastInputs, 1);
startFifo();
recordStart = millis();
lastFifoRead = recordStart;
lastMagRead = recordStart;
state = STATE_RECORDING;
Serial.println("RECORDING");
}
void stopRecording() {
if (state != STATE_RECORDING) {
return;
}
stopFifo();
flushWord();
HAL_FLASH_Lock();
recordedBytes = storageAddress - STORAGE_START;
state = STATE_IDLE;
lastActivity = millis();
Serial.print(storageFull ? "STORAGE FULL, STOPPED, " : "STOPPED, ");
printRecordedLength(recordedBytes);
if (overflowCount > 0) {
Serial.print("FIFO OVERFLOWS ");
Serial.println(overflowCount);
}
}
// serial
void printInfo() {
Serial.print("IMU ");
if (imuAddress == 0) {
Serial.println("NOT FOUND ON PB6/PB7");
} else {
Serial.print("0x");
Serial.print(imuAddress, HEX);
Serial.print(", WHO_AM_I 0x");
Serial.print(imuWhoAmI, HEX);
Serial.println(magFound ? ", MAG OK" : ", MAG NOT FOUND");
}
Serial.print("PROGRAM ENDS 0x");
Serial.print(programEnd(), HEX);
Serial.print(", STORAGE 0x");
Serial.print(STORAGE_START, HEX);
Serial.print(" - 0x");
Serial.println(STORAGE_END, HEX);
if (state == STATE_ERROR) {
Serial.print("ERROR ");
Serial.println(errorReason);
}
Serial.print("RECORDING ");
if (state == STATE_RECORDING) {
Serial.print("IN PROGRESS, ");
printRecordedLength(storageAddress - STORAGE_START);
} else {
printRecordedLength(recordedBytes);
}
Serial.println("INFO END");
}
// reads straight from the data registers instead of the fifo, handy for checking orientation and calibration
void printLive() {
constexpr uint8_t IMU_REG_ACCEL_XOUT_H = 0x3B;
Serial.print("CONFIG 0x");
Serial.print(readRegister(imuAddress, IMU_REG_CONFIG), HEX);
Serial.print(", GYRO_CONFIG 0x");
Serial.print(readRegister(imuAddress, IMU_REG_GYRO_CONFIG), HEX);
Serial.print(", ACCEL_CONFIG 0x");
Serial.print(readRegister(imuAddress, IMU_REG_ACCEL_CONFIG), HEX);
Serial.print(", ACCEL_CONFIG2 0x");
Serial.println(readRegister(imuAddress, IMU_REG_ACCEL_CONFIG2), HEX);
uint8_t data[14];
if (!readRegisters(imuAddress, IMU_REG_ACCEL_XOUT_H, data, sizeof(data))) {
Serial.println("LIVE READ FAILED");
return;
}
// accel xyz, temperature, gyro xyz
const char* names[] = {"AX", "AY", "AZ", "T", "GX", "GY", "GZ"};
const float scales[] = {ACCEL_LSB_PER_G, ACCEL_LSB_PER_G, ACCEL_LSB_PER_G, 1, GYRO_LSB_PER_DPS_X10 / 10.0f, GYRO_LSB_PER_DPS_X10 / 10.0f, GYRO_LSB_PER_DPS_X10 / 10.0f};
for (int i = 0; i < 7; i++) {
const int16_t raw = (int16_t)(data[i * 2] << 8 | data[i * 2 + 1]);
Serial.print(names[i]);
Serial.print(" ");
Serial.print(raw);
if (i == 3) {
Serial.print(" (");
Serial.print(raw / 333.87f + 21, 1); // MPU9250 datasheet
Serial.print("C)");
} else {
Serial.print(" (");
Serial.print(raw / scales[i], 3);
Serial.print(")");
}
Serial.print(i < 6 ? ", " : "\n");
}
}
void dump() {
// also works while off or in error, so a recording can be downloaded after the idle timeout
if (state == STATE_RECORDING || state == STATE_ERASING || state == STATE_DUMPING) {
Serial.println("DUMP UNAVAILABLE");
return;
}
const State previousState = state;
state = STATE_DUMPING;
renderStatus(millis());
Serial.print("DUMP ");
Serial.println(recordedBytes);
for (uint32_t offset = 0; offset < recordedBytes; offset += 256) {
Serial.write((const uint8_t*)(STORAGE_START + offset), min(recordedBytes - offset, (uint32_t)256));
}
Serial.println();
Serial.println("DUMP END");
state = previousState;
}
void handleSerial() {
while (Serial.available() > 0) {
const char command = Serial.read();
lastActivity = millis();
if (command == 'r') {
startRecording();
} else if (command == 's') {
stopRecording();
} else if (command == 'd') {
dump();
} else if (command == 'i') {
printInfo();
} else if (command == 'l' && imuAddress != 0) {
printLive();
}
}
}
// power
void shutdown() {
// when STLink or USB prevents full power cut, loop keeps running, only shut down once
if (state == STATE_OFF) {
return;
}
stopRecording();
Serial.println("SHUTTING DOWN");
state = STATE_OFF;
renderStatus(millis());
digitalWrite(PIN_BUCK, BUCK_OFF);
delay(100);
Serial.println("SELF OFF, GOODBYE");
digitalWrite(PIN_ALIVE, LOW);
}
// only used when STLink or USB prevents full power cut
void restart() {
Serial.println("RESTARTING");
digitalWrite(PIN_BUCK, BUCK_ON);
state = imuAddress == 0 ? STATE_ERROR : STATE_IDLE;
lastActivity = millis();
}
// status
void fillBar(uint32_t color, float fraction) {
const int length = constrain((int)ceil(fraction * LEDS_DASH_LENGTH), 1, LEDS_DASH_LENGTH);
ledsDash.fill(color, 0, length);
}
void renderStatus(unsigned long now) {
ledsDash.fill(COLOR_DIM);
const bool blink = (now / 250) % 2 == 0;
if (state == STATE_IDLE && recordedBytes == 0) {
ledsDash.fill(COLOR_EMPTY);
} else if (state == STATE_IDLE) {
fillBar(COLOR_STORED, (float)recordedBytes / STORAGE_SIZE);
} else if (state == STATE_ERASING) {
fillBar(COLOR_ERASING, (float)(sectorsErased + 1) / STORAGE_SECTOR_COUNT);
} else if (state == STATE_RECORDING) {
fillBar(COLOR_RECORDING, (float)(storageAddress - STORAGE_START) / STORAGE_SIZE);
if (blink) {
ledsDash.setPixelColor(LEDS_DASH_LENGTH - 1, COLOR_RECORDING);
}
} else if (state == STATE_DUMPING) {
ledsDash.fill(COLOR_DUMPING);
} else if (state == STATE_ERROR) {
ledsDash.fill(blink ? COLOR_ERROR : COLOR_OFF);
} else {
ledsDash.fill(COLOR_OFF);
}
if (state != STATE_OFF && now < markerFlashUntil) {
ledsDash.fill(COLOR_WHITE);
}
ledsDash.show();
bool onboard = false;
if (state == STATE_RECORDING && now < markerFlashUntil) {
onboard = (now / 25) % 2 == 0;
} else if (state == STATE_ERASING) {
onboard = (now / 50) % 2 == 0;
} else if (state == STATE_RECORDING) {
onboard = (now / 500) % 2 == 0;
} else if (state == STATE_DUMPING) {
onboard = true;
} else if (state == STATE_ERROR) {
onboard = (now / 100) % 2 == 0;
}
digitalWrite(LED_BUILTIN, onboard ? LED_ON : LED_OFF);
}
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
pinMode(PIN_ALIVE, OUTPUT);
pinMode(PIN_BUCK, OUTPUT);
pinMode(PIN_PEDAL, INPUT_PULLUP);
pinMode(PIN_PEDAL_FORWARD, INPUT_PULLUP);
pinMode(PIN_PEDAL_REVERSE, INPUT_PULLUP);
digitalWrite(PIN_ALIVE, HIGH);
digitalWrite(PIN_BUCK, BUCK_ON);
buttonMode.begin();
buttonLeft.begin();
buttonRight.begin();
Serial.begin(9600);
// the other strips aren't used, make sure they don't keep showing whatever ran before
for (Adafruit_NeoPixel* leds : {&ledsTail, &ledsHeadLeft, &ledsHeadRight}) {
leds->begin();
leds->clear();
leds->show();
}
ledsDash.setBrightness(LEDS_DASH_BRIGHTNESS);
ledsDash.begin();
// setSCL/SDA have to be called before begin()
Wire.setSCL(PIN_IMU_SCL);
Wire.setSDA(PIN_IMU_SDA);
Wire.begin();
Wire.setClock(400000);
connectImu();
recordedBytes = findRecordedBytes();
lastActivity = millis();
if (programEnd() > STORAGE_START) {
state = STATE_ERROR;
errorReason = "PROGRAM OVERLAPS STORAGE";
} else if (imuAddress == 0) {
state = STATE_ERROR;
errorReason = "IMU NOT FOUND";
}
}
void loop() {
const unsigned long now = millis();
if (state == STATE_OFF && (buttonMode.pressed() || buttonLeft.pressed() || buttonRight.pressed())) {
restart();
} else if (state != STATE_OFF) {
if (buttonMode.pressed()) {
modePressed = true;
modePressStart = now;
lastActivity = now;
}
// act on release, so a long press for shutdown doesn't toggle recording first
if (buttonMode.released() && modePressed) {
modePressed = false;
if (state == STATE_RECORDING) {
stopRecording();
} else {
startRecording();
}
}
if (modePressed && now - modePressStart > SHUTDOWN_HOLD) {
modePressed = false;
shutdown();
}
if (buttonLeft.pressed()) {
lastActivity = now;
if (state == STATE_RECORDING) {
recordMarker(1);
}
}
if (buttonRight.pressed()) {
lastActivity = now;
if (state == STATE_RECORDING) {
recordMarker(2);
}
}
}
handleSerial();
if (state == STATE_RECORDING) {
if (now - lastFifoRead >= FIFO_INTERVAL) {
lastFifoRead = now;
readFifo();
}
if (magFound && now - lastMagRead >= MAG_INTERVAL) {
lastMagRead = now;
readMag();
}
recordInputs();
if (storageFull) {
stopRecording();
}
}
if (now - lastStatus >= STATUS_INTERVAL) {
lastStatus = now;
renderStatus(now);
}
if (state != STATE_RECORDING && state != STATE_OFF && now - lastActivity > IDLE_TIMEOUT) {
shutdown();
}
}
+1
View File
@@ -0,0 +1 @@
default_fqbn: STMicroelectronics:stm32:GenF4:pnum=BLACKPILL_F411CE,xserial=generic,usb=CDCgen,xusb=FS,opt=osstd,dbg=none,rtlib=nano,upload_method=swdMethod
+880 -35
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

+1
View File
@@ -0,0 +1 @@
default_fqbn: STMicroelectronics:stm32:GenF4:pnum=BLACKPILL_F411CE,xserial=generic,usb=CDCgen,xusb=FS,opt=osstd,dbg=none,rtlib=nano,upload_method=swdMethod
+314
View File
@@ -0,0 +1,314 @@
# /// script
# requires-python = ">=3.10"
# dependencies = ["pyserial", "numpy", "matplotlib"]
# ///
"""Download a recording from CrashRecorder, convert it to CSV and plot it.
uv run tools/crash_dump.py download, save and plot
uv run tools/crash_dump.py --png save the plot as PNG instead of opening a window
uv run tools/crash_dump.py --from-bin FILE.bin re-parse and plot a saved recording
Keep the record format in sync with RecordTag in CrashRecorder/CrashRecorder.ino.
"""
import argparse
import csv
import struct
import sys
import time
from datetime import datetime
from pathlib import Path
import numpy as np
TAG_HEADER = 0x01
TAG_IMU = 0x02
TAG_MAG = 0x03
TAG_INPUTS = 0x04
TAG_MARKER = 0x05
TAG_OVERFLOW = 0x06
TAG_END = 0xFF
PAYLOAD_LENGTHS = {
TAG_HEADER: 12,
TAG_IMU: 12,
TAG_MAG: 6,
TAG_INPUTS: 1,
TAG_MARKER: 1,
TAG_OVERFLOW: 0,
}
INPUT_BITS = {"pedal": 0, "forward": 1, "reverse": 2, "mode": 3, "left": 4, "right": 5}
MAG_UT_PER_LSB = 0.15 # 16-bit mode, without the per-chip sensitivity adjustment
# from the tilt test on 2026-10-21: subtracting these brings |a| to 1g in every pose within ~2mg,
# cross-checked against gyro-integrated tilt angles
ACCEL_OFFSET = np.array([0.490, -0.277, -0.004]) # g, sensor x y z
# sensor mounted on its side: +x up, +y left, +z backward
# vehicle frame accel: forward, left, up (up reads +1g at rest)
# vehicle frame rotation, aviation style: roll + right side down, pitch + nose up, yaw + turning right
def to_vehicle(accel: np.ndarray, gyro: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
corrected = accel - ACCEL_OFFSET
vehicle_accel = np.column_stack([-corrected[:, 2], corrected[:, 1], corrected[:, 0]])
vehicle_rotation = np.column_stack([-gyro[:, 2], -gyro[:, 1], -gyro[:, 0]])
return vehicle_accel, vehicle_rotation
DEFAULT_OUT = Path(__file__).resolve().parent.parent / "recordings"
def download(port: str) -> bytes:
import serial
with serial.Serial(port, timeout=1) as connection:
time.sleep(0.2)
connection.reset_input_buffer()
connection.write(b"i")
while True:
line = connection.readline().decode(errors="replace").strip()
if not line:
sys.exit("no response from the board, is CrashRecorder running?")
if line == "INFO END":
break
print(f" {line}")
connection.write(b"d")
while True:
line = connection.readline().decode(errors="replace").strip()
if not line:
sys.exit("no response to the dump command")
if line == "DUMP UNAVAILABLE":
sys.exit("board is busy, stop the recording first")
if line.startswith("DUMP "):
length = int(line.split()[1])
break
print(f"downloading {length} bytes")
connection.timeout = 10
data = connection.read(length)
if len(data) != length:
sys.exit(f"download incomplete, got {len(data)} of {length} bytes")
return data
def parse(data: bytes) -> dict:
header = None
imu = [] # raw int16 rows: ax ay az gx gy gz
mag_at = [] # (imu index, mx, my, mz)
inputs_at = [] # (imu index, raw levels)
markers = [] # (imu index, marker)
overflows = [] # imu index
position = 0
while position < len(data):
tag = data[position]
if tag == TAG_END:
break
length = PAYLOAD_LENGTHS.get(tag)
if length is None:
print(f"warning: unknown tag 0x{tag:02X} at byte {position}, stopping", file=sys.stderr)
break
payload = data[position + 1 : position + 1 + length]
if len(payload) < length:
break # cut off by power loss or storage full
position += 1 + length
if tag == TAG_HEADER:
if payload[:3] != b"QLR":
sys.exit("not a CrashRecorder recording")
version, who_am_i, mag_ok, rate, accel_lsb, gyro_lsb_x10 = struct.unpack("<BBBHHH", payload[3:])
header = {
"version": version,
"who_am_i": who_am_i,
"mag_ok": bool(mag_ok),
"rate": rate,
"accel_lsb_per_g": accel_lsb,
"gyro_lsb_per_dps": gyro_lsb_x10 / 10,
}
elif tag == TAG_IMU:
imu.append(struct.unpack(">6h", payload))
elif tag == TAG_MAG:
mag_at.append((len(imu), *struct.unpack("<3h", payload)))
elif tag == TAG_INPUTS:
inputs_at.append((len(imu), payload[0]))
elif tag == TAG_MARKER:
markers.append((len(imu), payload[0]))
elif tag == TAG_OVERFLOW:
overflows.append(len(imu))
if header is None:
sys.exit("recording has no header")
raw = np.array(imu, dtype=np.float64).reshape(-1, 6)
count = len(raw)
# mag and inputs are stored when they change, hold them until the next record
mag = np.full((count, 3), np.nan)
for (index, *values), following in zip(mag_at, mag_at[1:] + [(count,)]):
mag[index : following[0]] = np.array(values) * MAG_UT_PER_LSB
inputs = np.zeros((count, len(INPUT_BITS)), dtype=np.int8)
for (index, levels), following in zip(inputs_at, inputs_at[1:] + [(count,)]):
for column, bit in enumerate(INPUT_BITS.values()):
inputs[index : following[0], column] = 0 if levels >> bit & 1 else 1 # low is active
accel = raw[:, :3] / header["accel_lsb_per_g"]
gyro = raw[:, 3:] / header["gyro_lsb_per_dps"]
vehicle_accel, vehicle_rotation = to_vehicle(accel, gyro)
return {
"header": header,
"t": np.arange(count) / header["rate"],
"accel": accel,
"accel_magnitude": np.linalg.norm(accel, axis=1),
"gyro": gyro,
"vehicle_accel": vehicle_accel,
"vehicle_magnitude": np.linalg.norm(vehicle_accel, axis=1),
"horizontal": np.linalg.norm(vehicle_accel[:, :2], axis=1),
"vehicle_rotation": vehicle_rotation,
"mag": mag,
"inputs": inputs,
"markers": markers,
"overflows": overflows,
}
def write_csv(recording: dict, path: Path) -> None:
marker_column = np.zeros(len(recording["t"]), dtype=np.int8)
for index, marker in recording["markers"]:
marker_column[min(index, len(marker_column) - 1)] = marker
overflow_column = np.zeros(len(recording["t"]), dtype=np.int8)
for index in recording["overflows"]:
overflow_column[min(index, len(overflow_column) - 1)] = 1
with path.open("w", newline="") as file:
writer = csv.writer(file)
writer.writerow(
[
"t",
"a_forward", "a_left", "a_up", "a_vehicle_magnitude", "a_horizontal",
"roll_rate", "pitch_rate", "yaw_rate",
"ax", "ay", "az", "a_magnitude", "gx", "gy", "gz",
"mx", "my", "mz",
*INPUT_BITS, "marker", "overflow",
]
)
for i, t in enumerate(recording["t"]):
writer.writerow(
[
f"{t:.3f}",
*(f"{v:.4f}" for v in recording["vehicle_accel"][i]),
f"{recording['vehicle_magnitude'][i]:.4f}",
f"{recording['horizontal'][i]:.4f}",
*(f"{v:.2f}" for v in recording["vehicle_rotation"][i]),
*(f"{v:.4f}" for v in recording["accel"][i]),
f"{recording['accel_magnitude'][i]:.4f}",
*(f"{v:.2f}" for v in recording["gyro"][i]),
*("" if np.isnan(v) else f"{v:.2f}" for v in recording["mag"][i]),
*recording["inputs"][i],
marker_column[i],
overflow_column[i],
]
)
def plot(recording: dict, title: str, png: Path | None) -> None:
import matplotlib
if png:
matplotlib.use("Agg")
import matplotlib.pyplot as plt
t = recording["t"]
figure, axes = plt.subplots(4, 1, sharex=True, figsize=(16, 11), height_ratios=[3, 2, 1, 1])
figure.suptitle(title)
accel_axis, gyro_axis, mag_axis, inputs_axis = axes
for column, name in enumerate(["forward", "left", "up"]):
accel_axis.plot(t, recording["vehicle_accel"][:, column], linewidth=0.7, label=name)
accel_axis.plot(t, recording["horizontal"], linewidth=0.7, color="black", alpha=0.6, label="horizontal")
accel_axis.set_ylabel("acceleration (g)")
for column, name in enumerate(["roll (+ right down)", "pitch (+ nose up)", "yaw (+ right)"]):
gyro_axis.plot(t, recording["vehicle_rotation"][:, column], linewidth=0.7, label=name)
gyro_axis.set_ylabel("rotation (°/s)")
for column, name in enumerate("xyz"):
mag_axis.plot(t, recording["mag"][:, column], linewidth=0.7, label=f"m{name}")
mag_axis.set_ylabel("magnetic (µT)")
# one row per input, filled while active
for column, name in enumerate(INPUT_BITS):
inputs_axis.fill_between(
t, column + 0.1, column + 0.1 + recording["inputs"][:, column] * 0.8, step="post", linewidth=0, label=name
)
inputs_axis.set_yticks(np.arange(len(INPUT_BITS)) + 0.5, list(INPUT_BITS))
inputs_axis.set_ylim(0, len(INPUT_BITS))
inputs_axis.set_xlabel("time (s)")
for axis in axes:
for index, marker in recording["markers"]:
axis.axvline(index / recording["header"]["rate"], color="red" if marker == 1 else "orange", linestyle="--", linewidth=1)
for index in recording["overflows"]:
axis.axvline(index / recording["header"]["rate"], color="grey", linestyle=":", linewidth=1)
axis.grid(alpha=0.3)
axis.legend(loc="upper right", fontsize="small", ncol=7)
figure.tight_layout()
if png:
figure.savefig(png, dpi=120)
print(f"plot saved to {png}")
else:
plt.show()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--port", default="/dev/ttyACM0")
parser.add_argument("--out", type=Path, default=DEFAULT_OUT, help="directory for downloaded recordings")
parser.add_argument("--from-bin", type=Path, help="parse a saved recording instead of downloading")
parser.add_argument("--png", action="store_true", help="save the plot as PNG instead of opening a window")
parser.add_argument("--no-plot", action="store_true")
arguments = parser.parse_args()
if arguments.from_bin:
bin_path = arguments.from_bin
data = bin_path.read_bytes()
else:
data = download(arguments.port)
arguments.out.mkdir(parents=True, exist_ok=True)
bin_path = arguments.out / f"crash-{datetime.now():%Y%m%d-%H%M%S}.bin"
bin_path.write_bytes(data)
print(f"recording saved to {bin_path}")
recording = parse(data)
header = recording["header"]
duration = len(recording["t"]) / header["rate"]
print(
f"{len(recording['t'])} samples ({duration:.1f} s at {header['rate']} Hz), "
f"WHO_AM_I 0x{header['who_am_i']:02X}, {len(recording['markers'])} markers, "
f"{len(recording['overflows'])} fifo overflows"
)
csv_path = bin_path.with_suffix(".csv")
write_csv(recording, csv_path)
print(f"csv saved to {csv_path}")
if not arguments.no_plot:
plot(recording, bin_path.stem, bin_path.with_suffix(".png") if arguments.png else None)
if __name__ == "__main__":
main()